diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 813935e151..288af655e9 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -23,6 +23,7 @@ import { IUnleashStores } from './types/stores'; import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns'; import Timer = NodeJS.Timer; import { InstanceStatsService } from './services/instance-stats-service'; +import { ValidatedClientMetrics } from './services/client-metrics/schema'; export default class MetricsMonitor { timer?: Timer; @@ -268,16 +269,13 @@ export default class MetricsMonitor { featureToggleUpdateTotal.labels(featureName, project, 'n/a').inc(); }); - eventBus.on(CLIENT_METRICS, (m) => { - // eslint-disable-next-line no-restricted-syntax + eventBus.on(CLIENT_METRICS, (m: ValidatedClientMetrics) => { for (const entry of Object.entries(m.bucket.toggles)) { featureToggleUsageTotal .labels(entry[0], 'true', m.appName) - // @ts-expect-error .inc(entry[1].yes); featureToggleUsageTotal .labels(entry[0], 'false', m.appName) - // @ts-expect-error .inc(entry[1].no); } }); diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index e4fd75a3a9..24436bf6f2 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -135,6 +135,8 @@ import { openApiTags } from './util'; import { URL } from 'url'; import apiVersion from '../util/version'; import { maintenanceSchema } from './spec/maintenance-schema'; +import { bulkRegistrationSchema } from './spec/bulk-registration-schema'; +import { bulkMetricsSchema } from './spec/bulk-metrics-schema'; // All schemas in `openapi/spec` should be listed here. export const schemas = { @@ -147,6 +149,8 @@ export const schemas = { apiTokensSchema, applicationSchema, applicationsSchema, + bulkRegistrationSchema, + bulkMetricsSchema, changePasswordSchema, clientApplicationSchema, clientFeatureSchema, diff --git a/src/lib/openapi/spec/bulk-metrics-schema.ts b/src/lib/openapi/spec/bulk-metrics-schema.ts new file mode 100644 index 0000000000..da2e651ab1 --- /dev/null +++ b/src/lib/openapi/spec/bulk-metrics-schema.ts @@ -0,0 +1,32 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { bulkRegistrationSchema } from './bulk-registration-schema'; +import { clientMetricsSchema } from './client-metrics-schema'; +import { dateSchema } from './date-schema'; + +export const bulkMetricsSchema = { + $id: '#/components/schemas/bulkMetricsSchema', + type: 'object', + properties: { + applications: { + type: 'array', + items: { + $ref: '#/components/schemas/bulkRegistrationSchema', + }, + }, + metrics: { + type: 'array', + items: { + $ref: '#/components/schemas/clientMetricsSchema', + }, + }, + }, + components: { + schemas: { + bulkRegistrationSchema, + dateSchema, + clientMetricsSchema, + }, + }, +} as const; + +export type BulkMetricsSchema = FromSchema; diff --git a/src/lib/openapi/spec/bulk-registration-schema.ts b/src/lib/openapi/spec/bulk-registration-schema.ts new file mode 100644 index 0000000000..71197c8e62 --- /dev/null +++ b/src/lib/openapi/spec/bulk-registration-schema.ts @@ -0,0 +1,44 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const bulkRegistrationSchema = { + $id: '#/components/schemas/bulkRegistrationSchema', + type: 'object', + required: ['appName', 'instanceId'], + properties: { + connectVia: { + type: 'array', + items: { + type: 'string', + }, + }, + appName: { + type: 'string', + }, + environment: { + type: 'string', + }, + instanceId: { + type: 'string', + }, + interval: { + type: 'number', + }, + started: { + type: 'number', + }, + strategies: { + type: 'array', + items: { + type: 'string', + }, + }, + sdkVersion: { + type: 'string', + }, + }, + components: { + schemas: {}, + }, +} as const; + +export type BulkRegistrationSchema = FromSchema; diff --git a/src/lib/openapi/spec/client-metrics-schema.ts b/src/lib/openapi/spec/client-metrics-schema.ts index 106e4b5437..7d615b604e 100644 --- a/src/lib/openapi/spec/client-metrics-schema.ts +++ b/src/lib/openapi/spec/client-metrics-schema.ts @@ -27,6 +27,21 @@ export const clientMetricsSchema = { }, toggles: { type: 'object', + example: { + myCoolToggle: { + yes: 25, + no: 42, + variants: { + blue: 6, + green: 15, + red: 46, + }, + }, + myOtherToggle: { + yes: 0, + no: 100, + }, + }, additionalProperties: { type: 'object', properties: { diff --git a/src/lib/routes/edge-api/index.ts b/src/lib/routes/edge-api/index.ts index 8926f50174..81b698c046 100644 --- a/src/lib/routes/edge-api/index.ts +++ b/src/lib/routes/edge-api/index.ts @@ -4,14 +4,18 @@ import { IUnleashConfig, IUnleashServices } from '../../types'; import { Logger } from '../../logger'; import { NONE } from '../../types/permissions'; import { createResponseSchema } from '../../openapi/util/create-response-schema'; -import { RequestBody } from '../unleash-types'; +import { IAuthRequest, RequestBody } from '../unleash-types'; import { createRequestSchema } from '../../openapi/util/create-request-schema'; import { validateEdgeTokensSchema, ValidateEdgeTokensSchema, } from '../../openapi/spec/validate-edge-tokens-schema'; +import ClientInstanceService from '../../services/client-metrics/instance-service'; import EdgeService from '../../services/edge-service'; import { OpenApiService } from '../../services/openapi-service'; +import { emptyResponse } from '../../openapi/util/standard-responses'; +import { BulkMetricsSchema } from '../../openapi/spec/bulk-metrics-schema'; +import ClientMetricsServiceV2 from '../../services/client-metrics/metrics-service-v2'; export default class EdgeController extends Controller { private readonly logger: Logger; @@ -20,17 +24,31 @@ export default class EdgeController extends Controller { private openApiService: OpenApiService; + private metricsV2: ClientMetricsServiceV2; + + private clientInstanceService: ClientInstanceService; + constructor( config: IUnleashConfig, { edgeService, openApiService, - }: Pick, + clientMetricsServiceV2, + clientInstanceService, + }: Pick< + IUnleashServices, + | 'edgeService' + | 'openApiService' + | 'clientMetricsServiceV2' + | 'clientInstanceService' + >, ) { super(config); this.logger = config.getLogger('edge-api/index.ts'); this.edgeService = edgeService; this.openApiService = openApiService; + this.metricsV2 = clientMetricsServiceV2; + this.clientInstanceService = clientInstanceService; this.route({ method: 'post', @@ -50,6 +68,23 @@ export default class EdgeController extends Controller { }), ], }); + + this.route({ + method: 'post', + path: '/metrics', + handler: this.bulkMetrics, + permission: NONE, // should have a permission but not bound to any environment + middleware: [ + this.openApiService.validPath({ + tags: ['Edge'], + operationId: 'bulkMetrics', + requestBody: createRequestSchema('bulkMetricsSchema'), + responses: { + 202: emptyResponse, + }, + }), + ], + }); } async getValidTokens( @@ -66,4 +101,32 @@ export default class EdgeController extends Controller { tokens, ); } + + async bulkMetrics( + req: IAuthRequest, + res: Response, + ): Promise { + const { body, ip: clientIp } = req; + const { metrics, applications } = body; + + try { + let promises: Promise[] = []; + for (const app of applications) { + promises.push( + this.clientInstanceService.registerClient(app, clientIp), + ); + } + if (metrics) { + for (const metric of metrics) { + promises.push( + this.metricsV2.registerClientMetrics(metric, clientIp), + ); + } + } + await Promise.all(promises); + res.status(202).end(); + } catch (e) { + res.status(400).end(); + } + } } diff --git a/src/lib/services/client-metrics/metrics-service-v2.ts b/src/lib/services/client-metrics/metrics-service-v2.ts index 33c304fa5f..c16b2e73d5 100644 --- a/src/lib/services/client-metrics/metrics-service-v2.ts +++ b/src/lib/services/client-metrics/metrics-service-v2.ts @@ -1,7 +1,6 @@ import { Logger } from '../../logger'; import { IUnleashConfig } from '../../server-impl'; import { IUnleashStores } from '../../types'; -import { IClientApp } from '../../types/model'; import { ToggleMetricsSummary } from '../../types/models/metrics'; import { IClientMetricsEnv, @@ -13,7 +12,6 @@ import { hoursToMilliseconds, secondsToMilliseconds, } from 'date-fns'; -import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store'; import { CLIENT_METRICS } from '../../types/events'; import ApiUser from '../../types/api-user'; import { ALL } from '../../types/models/api-token'; @@ -21,7 +19,7 @@ import User from '../../types/user'; import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics'; import { LastSeenService } from './last-seen-service'; import { generateHourBuckets } from '../../util/time-utils'; -import { IFlagResolver } from '../../types/experimental'; +import { ClientMetricsSchema } from 'lib/openapi'; export default class ClientMetricsServiceV2 { private config: IUnleashConfig; @@ -32,28 +30,19 @@ export default class ClientMetricsServiceV2 { private clientMetricsStoreV2: IClientMetricsStoreV2; - private featureToggleStore: IFeatureToggleStore; - private lastSeenService: LastSeenService; - private flagResolver: IFlagResolver; - private logger: Logger; constructor( - { - featureToggleStore, - clientMetricsStoreV2, - }: Pick, + { clientMetricsStoreV2 }: Pick, config: IUnleashConfig, lastSeenService: LastSeenService, bulkInterval = secondsToMilliseconds(5), ) { - this.featureToggleStore = featureToggleStore; this.clientMetricsStoreV2 = clientMetricsStoreV2; this.lastSeenService = lastSeenService; this.config = config; - this.flagResolver = config.flagResolver; this.logger = config.getLogger( '/services/client-metrics/client-metrics-service-v2.ts', ); @@ -72,7 +61,7 @@ export default class ClientMetricsServiceV2 { } async registerClientMetrics( - data: IClientApp, + data: ClientMetricsSchema, clientIp: string, ): Promise { const value = await clientMetricsSchema.validateAsync(data); @@ -100,7 +89,6 @@ export default class ClientMetricsServiceV2 { ...clientMetrics, ]); this.lastSeenService.updateLastSeen(clientMetrics); - this.config.eventBus.emit(CLIENT_METRICS, value); } @@ -196,7 +184,10 @@ export default class ClientMetricsServiceV2 { return result.sort((a, b) => compareAsc(a.timestamp, b.timestamp)); } - resolveMetricsEnvironment(user: User | ApiUser, data: IClientApp): string { + resolveMetricsEnvironment( + user: User | ApiUser, + data: { environment?: string }, + ): string { if (user instanceof ApiUser) { if (user.environment !== ALL) { return user.environment; diff --git a/src/lib/services/client-metrics/schema.test.ts b/src/lib/services/client-metrics/schema.test.ts index 1a97efc95d..6c50620bee 100644 --- a/src/lib/services/client-metrics/schema.test.ts +++ b/src/lib/services/client-metrics/schema.test.ts @@ -12,6 +12,17 @@ test('clientRegisterSchema should allow empty ("") instanceId', () => { expect(value.instanceId).toBe('default'); }); +test('clientRegisterSchema should allow string dates', () => { + const date = new Date(); + const { value } = clientRegisterSchema.validate({ + appName: 'test', + strategies: ['default'], + started: date.toISOString(), + interval: 100, + }); + expect(value.started).toStrictEqual(date); +}); + test('clientRegisterSchema should allow undefined instanceId', () => { const { value } = clientRegisterSchema.validate({ appName: 'test', diff --git a/src/lib/services/client-metrics/schema.ts b/src/lib/services/client-metrics/schema.ts index edf8fce2e7..b399711d1c 100644 --- a/src/lib/services/client-metrics/schema.ts +++ b/src/lib/services/client-metrics/schema.ts @@ -1,4 +1,5 @@ import joi from 'joi'; +import { IMetricsBucket } from 'lib/types'; const countSchema = joi .object() @@ -9,8 +10,16 @@ const countSchema = joi variants: joi.object().pattern(joi.string(), joi.number().min(0)), }); +// validated type from client-metrics-schema.ts with default values +export type ValidatedClientMetrics = { + environment?: string; + appName: string; + instanceId: string; + bucket: IMetricsBucket; +}; + export const clientMetricsSchema = joi - .object() + .object() .options({ stripUnknown: true }) .keys({ environment: joi.string().optional(), diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index c528a57abd..bae7afda68 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -307,7 +307,6 @@ export interface IClientApp { seenToggles?: string[]; metricsCount?: number; strategies?: string[] | Record[]; - bucket?: any; count?: number; started?: string | number | Date; interval?: number; @@ -342,7 +341,7 @@ export interface IMetricCounts { export interface IMetricsBucket { start: Date; stop: Date; - toggles: IMetricCounts; + toggles: { [key: string]: IMetricCounts }; } export interface IImportFile extends ImportCommon { diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 2e69d7fa02..64a8fdbdf5 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -328,6 +328,62 @@ exports[`should serve the OpenAPI spec 1`] = ` }, "type": "object", }, + "bulkMetricsSchema": { + "properties": { + "applications": { + "items": { + "$ref": "#/components/schemas/bulkRegistrationSchema", + }, + "type": "array", + }, + "metrics": { + "items": { + "$ref": "#/components/schemas/clientMetricsSchema", + }, + "type": "array", + }, + }, + "type": "object", + }, + "bulkRegistrationSchema": { + "properties": { + "appName": { + "type": "string", + }, + "connectVia": { + "items": { + "type": "string", + }, + "type": "array", + }, + "environment": { + "type": "string", + }, + "instanceId": { + "type": "string", + }, + "interval": { + "type": "number", + }, + "sdkVersion": { + "type": "string", + }, + "started": { + "type": "number", + }, + "strategies": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "appName", + "instanceId", + ], + "type": "object", + }, "changePasswordSchema": { "additionalProperties": false, "properties": { @@ -534,6 +590,21 @@ exports[`should serve the OpenAPI spec 1`] = ` }, "type": "object", }, + "example": { + "myCoolToggle": { + "no": 42, + "variants": { + "blue": 6, + "green": 15, + "red": 46, + }, + "yes": 25, + }, + "myOtherToggle": { + "no": 100, + "yes": 0, + }, + }, "type": "object", }, }, @@ -8326,6 +8397,30 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/edge/metrics": { + "post": { + "operationId": "bulkMetrics", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bulkMetricsSchema", + }, + }, + }, + "description": "bulkMetricsSchema", + "required": true, + }, + "responses": { + "202": { + "description": "This response has no body.", + }, + }, + "tags": [ + "Edge", + ], + }, + }, "/edge/validate": { "post": { "operationId": "getValidTokens",