diff --git a/.changeset/new-donkeys-protect.md b/.changeset/new-donkeys-protect.md new file mode 100644 index 00000000..945c908a --- /dev/null +++ b/.changeset/new-donkeys-protect.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/api': minor +'@hyperdx/app': minor +--- + +feat: external api v1 route (REQUIRES db migration) + Mongo DB migration script diff --git a/Makefile b/Makefile index 17e3b103..87783c17 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,10 @@ dev-unit: ci-unit: npx nx run-many -t ci:unit +.PHONY: dev-migrate-db +dev-migrate-db: + npx nx run @hyperdx/api:dev:migrate-db + .PHONY: build-local build-local: docker build ./docker/hostmetrics -t ${IMAGE_NAME}:${LATEST_VERSION}-hostmetrics --target prod & diff --git a/README.md b/README.md index 153f25aa..36063f5d 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,12 @@ can change this by updating `HYPERDX_APP_**` and `HYPERDX_API_**` variables in the `.env` file. After making your changes, rebuild images with `make build-local`. +**DB Migration** + +You can initiate the DB migration process by executing `make dev-migrate-db`. +This will run the migration scripts in `/packages/api/migrations` against the +local DB. + ### Hosted Cloud HyperDX is also available as a hosted cloud service at diff --git a/packages/api/.eslintignore b/packages/api/.eslintignore index e6b6fdca..90985cad 100644 --- a/packages/api/.eslintignore +++ b/packages/api/.eslintignore @@ -1,3 +1,5 @@ keys node_modules archive +migrations +migrate-mongo-config.ts diff --git a/packages/api/migrate-mongo-config.ts b/packages/api/migrate-mongo-config.ts new file mode 100644 index 00000000..4303f05a --- /dev/null +++ b/packages/api/migrate-mongo-config.ts @@ -0,0 +1,32 @@ +export = { + mongodb: { + // TODO Change (or review) the url to your MongoDB: + url: 'mongodb://localhost:27017', + + // TODO Change this to your database name: + databaseName: 'hyperdx', + + options: { + // useNewUrlParser: true, // removes a deprecation warning when connecting + // useUnifiedTopology: true, // removes a deprecating warning when connecting + // connectTimeoutMS: 3600000, // increase connection timeout to 1 hour + // socketTimeoutMS: 3600000, // increase socket timeout to 1 hour + }, + }, + + // The migrations dir, can be an relative or absolute path. Only edit this when really necessary. + migrationsDir: 'migrations', + + // The mongodb collection where the applied changes are stored. Only edit this when really necessary. + changelogCollectionName: 'changelog', + + // The file extension to create migrations and search for in migration dir + migrationFileExtension: '.ts', + + // Enable the algorithm to create a checksum of the file contents and use that in the comparison to determine + // if the file should be run. Requires that scripts are coded to be run multiple times. + useFileHash: false, + + // Don't change this, unless you know what you're doing + moduleSystem: 'commonjs', +}; diff --git a/packages/api/migrations/20231130053610-add_accessKey_field_to_user_collection.ts b/packages/api/migrations/20231130053610-add_accessKey_field_to_user_collection.ts new file mode 100644 index 00000000..94712698 --- /dev/null +++ b/packages/api/migrations/20231130053610-add_accessKey_field_to_user_collection.ts @@ -0,0 +1,13 @@ +import { Db, MongoClient } from 'mongodb'; +import { v4 as uuidv4 } from 'uuid'; + +module.exports = { + async up(db: Db, client: MongoClient) { + await db + .collection('users') + .updateMany({}, { $set: { accessKey: uuidv4() } }); + }, + async down(db: Db, client: MongoClient) { + await db.collection('users').updateMany({}, { $unset: { accessKey: '' } }); + }, +}; diff --git a/packages/api/package.json b/packages/api/package.json index fead12a2..fba73d44 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -72,6 +72,8 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", "jest": "^28.1.1", + "migrate-mongo": "^11.0.0", + "mongodb": "^6.3.0", "nodemon": "^2.0.20", "rimraf": "^4.4.1", "supertest": "^6.3.1", @@ -89,6 +91,8 @@ "lint": "eslint . --ext .ts", "ci:lint": "yarn lint && yarn tsc --noEmit", "ci:int": "jest --runInBand --ci --forceExit --coverage", - "dev:int": "jest --watchAll --runInBand --detectOpenHandles" + "dev:int": "jest --watchAll --runInBand --detectOpenHandles", + "dev:migrate-db-create": "ts-node node_modules/.bin/migrate-mongo create -f migrate-mongo-config.ts", + "dev:migrate-db": "ts-node node_modules/.bin/migrate-mongo up -f migrate-mongo-config.ts" } } diff --git a/packages/api/src/api-app.ts b/packages/api/src/api-app.ts index 1b4d4b45..053d59c6 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -7,6 +7,7 @@ import session from 'express-session'; import * as config from './config'; import defaultCors from './middleware/cors'; +import externalRoutersV1 from './routers/external-api/v1'; import passport from './utils/passport'; import routers from './routers/api'; import usageStats from './tasks/usageStats'; @@ -82,6 +83,14 @@ app.use('/team', routers.teamRouter); app.use('/webhooks', routers.webhooksRouter); // --------------------------------------------------------------------- +// TODO: Separate external API routers from internal routers +// --------------------------------------------------------------------- +// ----------------------- External Routers ---------------------------- +// --------------------------------------------------------------------- +// API v1 +app.use('/api/v1', externalRoutersV1); +// --------------------------------------------------------------------- + // error handling app.use(appErrorHandler); diff --git a/packages/api/src/controllers/user.ts b/packages/api/src/controllers/user.ts index 50b32500..294bd435 100644 --- a/packages/api/src/controllers/user.ts +++ b/packages/api/src/controllers/user.ts @@ -2,6 +2,10 @@ import User from '@/models/user'; import type { ObjectId } from '@/models'; +export function findUserByAccessKey(accessKey: string) { + return User.findOne({ accessKey }); +} + export function findUserById(id: string) { return User.findById(id); } diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index b34c1b59..108c9dfb 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -5,6 +5,7 @@ import { setTraceAttributes } from '@hyperdx/node-opentelemetry'; import * as config from '@/config'; import logger from '@/utils/logger'; +import { findUserByAccessKey } from '@/controllers/user'; import type { UserDocument } from '@/models/user'; @@ -58,6 +59,30 @@ export function handleAuthError( res.redirect(`${config.FRONTEND_URL}/login?err=${returnErr}`); } +export async function validateUserAccessKey( + req: Request, + res: Response, + next: NextFunction, +) { + const authHeader = req.headers.authorization; + if (!authHeader) { + return res.sendStatus(401); + } + const key = authHeader.split('Bearer ')[1]; + if (!key) { + return res.sendStatus(401); + } + + const user = await findUserByAccessKey(key); + if (!user) { + return res.sendStatus(401); + } + + req.user = user; + + next(); +} + export function isUserAuthenticated( req: Request, res: Response, diff --git a/packages/api/src/models/user.ts b/packages/api/src/models/user.ts index 0d4cb522..e297da5d 100644 --- a/packages/api/src/models/user.ts +++ b/packages/api/src/models/user.ts @@ -7,6 +7,7 @@ type ObjectId = mongoose.Types.ObjectId; export interface IUser { _id: ObjectId; + accessKey: string; createdAt: Date; email: string; name: string; @@ -23,6 +24,12 @@ const UserSchema = new Schema( required: true, }, team: { type: mongoose.Schema.Types.ObjectId, ref: 'Team' }, + accessKey: { + type: String, + default: function genUUID() { + return uuidv4(); + }, + }, }, { timestamps: true, diff --git a/packages/api/src/routers/api/root.ts b/packages/api/src/routers/api/root.ts index 7aa26fb8..8a97da4b 100644 --- a/packages/api/src/routers/api/root.ts +++ b/packages/api/src/routers/api/root.ts @@ -52,11 +52,19 @@ router.get('/me', isUserAuthenticated, async (req, res, next) => { throw new Api404Error('Request without user found'); } - const { _id: id, team: teamId, email, name, createdAt } = req.user; + const { + _id: id, + accessKey, + createdAt, + email, + name, + team: teamId, + } = req.user; const team = await getTeam(teamId); return res.json({ + accessKey, createdAt, email, id, diff --git a/packages/api/src/routers/external-api/__tests__/v1.test.ts b/packages/api/src/routers/external-api/__tests__/v1.test.ts new file mode 100644 index 00000000..a5112d21 --- /dev/null +++ b/packages/api/src/routers/external-api/__tests__/v1.test.ts @@ -0,0 +1,205 @@ +import * as clickhouse from '@/clickhouse'; +import { + clearDBCollections, + closeDB, + getLoggedInAgent, + getServer, +} from '@/fixtures'; + +describe('external api v1', () => { + const server = getServer(); + + beforeAll(async () => { + await server.start(); + }); + + afterEach(async () => { + await clearDBCollections(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await server.closeHttpServer(); + await closeDB(); + }); + + it('GET /api/v1', async () => { + const { agent, user } = await getLoggedInAgent(server); + const resp = await agent + .get(`/api/v1`) + .set('Authorization', `Bearer ${user?.accessKey}`) + .expect(200); + expect(resp.body.version).toEqual('v1'); + expect(resp.body.user._id).toEqual(user?._id.toString()); + }); + + it('GET /api/v1/metrics/tags', async () => { + jest.spyOn(clickhouse, 'getMetricsTags').mockResolvedValueOnce({ + data: [ + { + name: 'system.filesystem.usage - Sum', + tags: [ + { + device: '/dev/vda1', + host: 'unknown', + mode: 'rw', + mountpoint: '/etc/resolv.conf', + state: 'reserved', + type: 'ext4', + }, + ], + data_type: 'Sum', + }, + ], + meta: [ + { + name: 'name', + type: 'String', + }, + { + name: 'data_type', + type: 'LowCardinality(String)', + }, + { + name: 'tags', + type: 'Array(Map(String, String))', + }, + ], + rows: 1, + }); + const { agent, user } = await getLoggedInAgent(server); + const resp = await agent + .get(`/api/v1/metrics/tags`) + .set('Authorization', `Bearer ${user?.accessKey}`) + .expect(200); + + expect(clickhouse.getMetricsTags).toBeCalledTimes(1); + expect(resp.body).toEqual({ + data: [ + { + name: 'system.filesystem.usage', + tags: [ + { + device: '/dev/vda1', + host: 'unknown', + mode: 'rw', + mountpoint: '/etc/resolv.conf', + state: 'reserved', + type: 'ext4', + }, + ], + type: 'Sum', + }, + ], + meta: [ + { + name: 'name', + type: 'String', + }, + { + name: 'data_type', + type: 'LowCardinality(String)', + }, + { + name: 'tags', + type: 'Array(Map(String, String))', + }, + ], + rows: 1, + }); + }); + + describe('POST /api/v1/metrics/chart', () => { + it('should return 400 if startTime is greater than endTime', async () => { + const { agent, user } = await getLoggedInAgent(server); + await agent + .post(`/api/v1/metrics/chart`) + .set('Authorization', `Bearer ${user?.accessKey}`) + .send({ + aggFn: 'max_rate', + endTime: 1701224193940, + granularity: '30 second', + name: 'http.server.active_requests', + startTime: 1701233593940, + type: 'Sum', + }) + .expect(400); + }); + + it('suucess', async () => { + jest.spyOn(clickhouse, 'getMetricsChart').mockResolvedValueOnce({ + data: [ + { + ts_bucket: 1701223590, + data: 10, + group: 'http.server.active_requests', + }, + ], + meta: [ + { + name: 'ts_bucket', + type: 'UInt32', + }, + { + name: 'data', + type: 'Float64', + }, + { + name: 'group', + type: 'LowCardinality(String)', + }, + ], + rows: 1, + }); + const { agent, user, team } = await getLoggedInAgent(server); + const resp = await agent + .post(`/api/v1/metrics/chart`) + .set('Authorization', `Bearer ${user?.accessKey}`) + .send({ + aggFn: 'max_rate', + endTime: 1701224193940, + granularity: '30 second', + name: 'http.server.active_requests', + startTime: 1701223593940, + type: 'Sum', + }) + .expect(200); + + expect(clickhouse.getMetricsChart).toHaveBeenNthCalledWith(1, { + aggFn: 'max_rate', + dataType: 'Sum', + endTime: 1701224193940, + granularity: '30 second', + groupBy: undefined, + name: 'http.server.active_requests', + q: undefined, + startTime: 1701223593940, + teamId: team?._id.toString(), + }); + expect(resp.body).toEqual({ + data: [ + { + ts_bucket: 1701223590, + data: 10, + group: 'http.server.active_requests', + }, + ], + meta: [ + { + name: 'ts_bucket', + type: 'UInt32', + }, + { + name: 'data', + type: 'Float64', + }, + { + name: 'group', + type: 'LowCardinality(String)', + }, + ], + rows: 1, + }); + }); + }); +}); diff --git a/packages/api/src/routers/external-api/v1/index.ts b/packages/api/src/routers/external-api/v1/index.ts new file mode 100644 index 00000000..5b66cba9 --- /dev/null +++ b/packages/api/src/routers/external-api/v1/index.ts @@ -0,0 +1,230 @@ +import express from 'express'; +import ms from 'ms'; +import opentelemetry, { SpanStatusCode } from '@opentelemetry/api'; +import { isNumber, parseInt } from 'lodash'; +import { validateRequest } from 'zod-express-middleware'; +import { z } from 'zod'; + +import * as clickhouse from '@/clickhouse'; +import rateLimiter from '@/utils/rateLimiter'; +import { Api400Error, Api403Error } from '@/utils/errors'; +import { getTeam } from '@/controllers/team'; +import { validateUserAccessKey } from '@/middleware/auth'; + +const router = express.Router(); + +const rateLimiterKeyGenerator = (req: express.Request) => { + return req.headers.authorization || req.ip; +}; + +const getDefaultRateLimiter = () => + rateLimiter({ + windowMs: 60 * 1000, // 1 minute + max: 100, // Limit each IP to 100 requests per `window` + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + keyGenerator: rateLimiterKeyGenerator, + }); + +router.get('/', validateUserAccessKey, (req, res, next) => { + res.json({ + version: 'v1', + user: req.user?.toJSON(), + }); +}); + +router.get( + '/logs/properties', + getDefaultRateLimiter(), + validateUserAccessKey, + async (req, res, next) => { + try { + const teamId = req.user?.team.toString(); + if (teamId == null) { + throw new Api403Error('Forbidden'); + } + + const team = await getTeam(teamId); + if (team == null) { + throw new Api403Error('Forbidden'); + } + + const nowInMs = Date.now(); + const propertyTypeMappingsModel = + await clickhouse.buildLogsPropertyTypeMappingsModel( + team.logStreamTableVersion, + teamId, + nowInMs - ms('1d'), + nowInMs, + ); + + const data = [...propertyTypeMappingsModel.currentPropertyTypeMappings]; + res.json({ + data, + rows: data.length, + }); + } catch (e) { + const span = opentelemetry.trace.getActiveSpan(); + span?.recordException(e as Error); + span?.setStatus({ code: SpanStatusCode.ERROR }); + next(e); + } + }, +); + +router.get( + '/logs/chart', + getDefaultRateLimiter(), + validateRequest({ + query: z.object({ + aggFn: z.nativeEnum(clickhouse.AggFn), + endTime: z.string(), + field: z.string().optional(), + granularity: z.nativeEnum(clickhouse.Granularity).optional(), + groupBy: z.string().optional(), + q: z.string().optional(), + startTime: z.string(), + }), + }), + validateUserAccessKey, + async (req, res, next) => { + try { + const teamId = req.user?.team.toString(); + const { aggFn, endTime, field, granularity, groupBy, q, startTime } = + req.query; + if (teamId == null) { + throw new Api403Error('Forbidden'); + } + const startTimeNum = parseInt(startTime); + const endTimeNum = parseInt(endTime); + if (!isNumber(startTimeNum) || !isNumber(endTimeNum)) { + throw new Api400Error('startTime and endTime must be numbers'); + } + + const team = await getTeam(teamId); + if (team == null) { + throw new Api403Error('Forbidden'); + } + + const propertyTypeMappingsModel = + await clickhouse.buildLogsPropertyTypeMappingsModel( + team.logStreamTableVersion, + teamId, + startTimeNum, + endTimeNum, + ); + + // TODO: expose this to the frontend ? + const MAX_NUM_GROUPS = 20; + + res.json( + await clickhouse.getLogsChart({ + aggFn, + endTime: endTimeNum, + // @ts-expect-error + field, + granularity, + // @ts-expect-error + groupBy, + maxNumGroups: MAX_NUM_GROUPS, + propertyTypeMappingsModel, + // @ts-expect-error + q, + startTime: startTimeNum, + tableVersion: team.logStreamTableVersion, + teamId, + }), + ); + } catch (e) { + const span = opentelemetry.trace.getActiveSpan(); + span?.recordException(e as Error); + span?.setStatus({ code: SpanStatusCode.ERROR }); + next(e); + } + }, +); + +router.get( + '/metrics/tags', + getDefaultRateLimiter(), + validateUserAccessKey, + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + throw new Api403Error('Forbidden'); + } + const tags = await clickhouse.getMetricsTags(teamId.toString()); + res.json({ + data: tags.data.map(tag => ({ + // FIXME: unify the return type of both internal and external APIs + name: tag.name.split(' - ')[0], // FIXME: we want to separate name and data type into two columns + type: tag.data_type, + tags: tag.tags, + })), + meta: tags.meta, + rows: tags.rows, + }); + } catch (e) { + const span = opentelemetry.trace.getActiveSpan(); + span?.recordException(e as Error); + span?.setStatus({ code: SpanStatusCode.ERROR }); + next(e); + } + }, +); + +router.post( + '/metrics/chart', + getDefaultRateLimiter(), + validateRequest({ + body: z.object({ + aggFn: z.nativeEnum(clickhouse.AggFn), + endTime: z.number().int().min(0), + granularity: z.nativeEnum(clickhouse.Granularity), + groupBy: z.string().optional(), + name: z.string().min(1), + type: z.nativeEnum(clickhouse.MetricsDataType), + q: z.string().optional(), + startTime: z.number().int().min(0), + }), + }), + validateUserAccessKey, + async (req, res, next) => { + try { + const teamId = req.user?.team; + const { aggFn, endTime, granularity, groupBy, name, q, startTime, type } = + req.body; + + if (teamId == null) { + throw new Api403Error('Forbidden'); + } + + if (startTime > endTime) { + throw new Api400Error('startTime must be less than endTime'); + } + + res.json( + await clickhouse.getMetricsChart({ + aggFn, + dataType: type, + endTime, + granularity, + groupBy, + name, + // @ts-expect-error + q, + startTime, + teamId: teamId.toString(), + }), + ); + } catch (e) { + const span = opentelemetry.trace.getActiveSpan(); + span?.recordException(e as Error); + span?.setStatus({ code: SpanStatusCode.ERROR }); + next(e); + } + }, +); + +export default router; diff --git a/packages/api/src/utils/errors.ts b/packages/api/src/utils/errors.ts index 148f6304..489b13a8 100644 --- a/packages/api/src/utils/errors.ts +++ b/packages/api/src/utils/errors.ts @@ -1,10 +1,11 @@ export enum StatusCode { - OK = 200, BAD_REQUEST = 400, - UNAUTHORIZED = 401, + CONFLICT = 409, FORBIDDEN = 403, - NOT_FOUND = 404, INTERNAL_SERVER = 500, + NOT_FOUND = 404, + OK = 200, + UNAUTHORIZED = 401, } export class BaseError extends Error { @@ -48,6 +49,24 @@ export class Api404Error extends BaseError { } } +export class Api401Error extends BaseError { + constructor(name: string) { + super(name, StatusCode.UNAUTHORIZED, true, 'Unauthorized'); + } +} + +export class Api403Error extends BaseError { + constructor(name: string) { + super(name, StatusCode.FORBIDDEN, true, 'Forbidden'); + } +} + +export class Api409Error extends BaseError { + constructor(name: string) { + super(name, StatusCode.CONFLICT, true, 'Conflict'); + } +} + export const isOperationalError = (error: Error) => { if (error instanceof BaseError) { return error.isOperational; diff --git a/packages/app/src/TeamPage.tsx b/packages/app/src/TeamPage.tsx index 2f65915c..10a4af1b 100644 --- a/packages/app/src/TeamPage.tsx +++ b/packages/app/src/TeamPage.tsx @@ -262,6 +262,22 @@ export default function TeamPage() { + {!isLoadingMe && me != null && ( +