1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-02 01:17:58 +02:00

chore: add bulk endpoint for metrics and app registration for edge (#3079)

## About the changes
Implementation of bulk metrics and registration endpoint. This will be
used by edge nodes to send all collected information.

Types around metrics were improved and `IClientApp.bucket` with type
`any` is no longer needed

---------

Co-authored-by: sighphyre <liquidwicked64@gmail.com>
This commit is contained in:
Gastón Fournier 2023-02-15 09:13:32 +01:00 committed by GitHub
parent 232ad28661
commit 7a242ecf2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 286 additions and 25 deletions

View File

@ -23,6 +23,7 @@ import { IUnleashStores } from './types/stores';
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns'; import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
import Timer = NodeJS.Timer; import Timer = NodeJS.Timer;
import { InstanceStatsService } from './services/instance-stats-service'; import { InstanceStatsService } from './services/instance-stats-service';
import { ValidatedClientMetrics } from './services/client-metrics/schema';
export default class MetricsMonitor { export default class MetricsMonitor {
timer?: Timer; timer?: Timer;
@ -268,16 +269,13 @@ export default class MetricsMonitor {
featureToggleUpdateTotal.labels(featureName, project, 'n/a').inc(); featureToggleUpdateTotal.labels(featureName, project, 'n/a').inc();
}); });
eventBus.on(CLIENT_METRICS, (m) => { eventBus.on(CLIENT_METRICS, (m: ValidatedClientMetrics) => {
// eslint-disable-next-line no-restricted-syntax
for (const entry of Object.entries(m.bucket.toggles)) { for (const entry of Object.entries(m.bucket.toggles)) {
featureToggleUsageTotal featureToggleUsageTotal
.labels(entry[0], 'true', m.appName) .labels(entry[0], 'true', m.appName)
// @ts-expect-error
.inc(entry[1].yes); .inc(entry[1].yes);
featureToggleUsageTotal featureToggleUsageTotal
.labels(entry[0], 'false', m.appName) .labels(entry[0], 'false', m.appName)
// @ts-expect-error
.inc(entry[1].no); .inc(entry[1].no);
} }
}); });

View File

@ -135,6 +135,8 @@ import { openApiTags } from './util';
import { URL } from 'url'; import { URL } from 'url';
import apiVersion from '../util/version'; import apiVersion from '../util/version';
import { maintenanceSchema } from './spec/maintenance-schema'; 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. // All schemas in `openapi/spec` should be listed here.
export const schemas = { export const schemas = {
@ -147,6 +149,8 @@ export const schemas = {
apiTokensSchema, apiTokensSchema,
applicationSchema, applicationSchema,
applicationsSchema, applicationsSchema,
bulkRegistrationSchema,
bulkMetricsSchema,
changePasswordSchema, changePasswordSchema,
clientApplicationSchema, clientApplicationSchema,
clientFeatureSchema, clientFeatureSchema,

View File

@ -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<typeof bulkMetricsSchema>;

View File

@ -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<typeof bulkRegistrationSchema>;

View File

@ -27,6 +27,21 @@ export const clientMetricsSchema = {
}, },
toggles: { toggles: {
type: 'object', type: 'object',
example: {
myCoolToggle: {
yes: 25,
no: 42,
variants: {
blue: 6,
green: 15,
red: 46,
},
},
myOtherToggle: {
yes: 0,
no: 100,
},
},
additionalProperties: { additionalProperties: {
type: 'object', type: 'object',
properties: { properties: {

View File

@ -4,14 +4,18 @@ import { IUnleashConfig, IUnleashServices } from '../../types';
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import { NONE } from '../../types/permissions'; import { NONE } from '../../types/permissions';
import { createResponseSchema } from '../../openapi/util/create-response-schema'; 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 { createRequestSchema } from '../../openapi/util/create-request-schema';
import { import {
validateEdgeTokensSchema, validateEdgeTokensSchema,
ValidateEdgeTokensSchema, ValidateEdgeTokensSchema,
} from '../../openapi/spec/validate-edge-tokens-schema'; } from '../../openapi/spec/validate-edge-tokens-schema';
import ClientInstanceService from '../../services/client-metrics/instance-service';
import EdgeService from '../../services/edge-service'; import EdgeService from '../../services/edge-service';
import { OpenApiService } from '../../services/openapi-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 { export default class EdgeController extends Controller {
private readonly logger: Logger; private readonly logger: Logger;
@ -20,17 +24,31 @@ export default class EdgeController extends Controller {
private openApiService: OpenApiService; private openApiService: OpenApiService;
private metricsV2: ClientMetricsServiceV2;
private clientInstanceService: ClientInstanceService;
constructor( constructor(
config: IUnleashConfig, config: IUnleashConfig,
{ {
edgeService, edgeService,
openApiService, openApiService,
}: Pick<IUnleashServices, 'edgeService' | 'openApiService'>, clientMetricsServiceV2,
clientInstanceService,
}: Pick<
IUnleashServices,
| 'edgeService'
| 'openApiService'
| 'clientMetricsServiceV2'
| 'clientInstanceService'
>,
) { ) {
super(config); super(config);
this.logger = config.getLogger('edge-api/index.ts'); this.logger = config.getLogger('edge-api/index.ts');
this.edgeService = edgeService; this.edgeService = edgeService;
this.openApiService = openApiService; this.openApiService = openApiService;
this.metricsV2 = clientMetricsServiceV2;
this.clientInstanceService = clientInstanceService;
this.route({ this.route({
method: 'post', 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( async getValidTokens(
@ -66,4 +101,32 @@ export default class EdgeController extends Controller {
tokens, tokens,
); );
} }
async bulkMetrics(
req: IAuthRequest<void, void, BulkMetricsSchema>,
res: Response<void>,
): Promise<void> {
const { body, ip: clientIp } = req;
const { metrics, applications } = body;
try {
let promises: Promise<void>[] = [];
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();
}
}
} }

View File

@ -1,7 +1,6 @@
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import { IUnleashConfig } from '../../server-impl'; import { IUnleashConfig } from '../../server-impl';
import { IUnleashStores } from '../../types'; import { IUnleashStores } from '../../types';
import { IClientApp } from '../../types/model';
import { ToggleMetricsSummary } from '../../types/models/metrics'; import { ToggleMetricsSummary } from '../../types/models/metrics';
import { import {
IClientMetricsEnv, IClientMetricsEnv,
@ -13,7 +12,6 @@ import {
hoursToMilliseconds, hoursToMilliseconds,
secondsToMilliseconds, secondsToMilliseconds,
} from 'date-fns'; } from 'date-fns';
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
import { CLIENT_METRICS } from '../../types/events'; import { CLIENT_METRICS } from '../../types/events';
import ApiUser from '../../types/api-user'; import ApiUser from '../../types/api-user';
import { ALL } from '../../types/models/api-token'; import { ALL } from '../../types/models/api-token';
@ -21,7 +19,7 @@ import User from '../../types/user';
import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics'; import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics';
import { LastSeenService } from './last-seen-service'; import { LastSeenService } from './last-seen-service';
import { generateHourBuckets } from '../../util/time-utils'; import { generateHourBuckets } from '../../util/time-utils';
import { IFlagResolver } from '../../types/experimental'; import { ClientMetricsSchema } from 'lib/openapi';
export default class ClientMetricsServiceV2 { export default class ClientMetricsServiceV2 {
private config: IUnleashConfig; private config: IUnleashConfig;
@ -32,28 +30,19 @@ export default class ClientMetricsServiceV2 {
private clientMetricsStoreV2: IClientMetricsStoreV2; private clientMetricsStoreV2: IClientMetricsStoreV2;
private featureToggleStore: IFeatureToggleStore;
private lastSeenService: LastSeenService; private lastSeenService: LastSeenService;
private flagResolver: IFlagResolver;
private logger: Logger; private logger: Logger;
constructor( constructor(
{ { clientMetricsStoreV2 }: Pick<IUnleashStores, 'clientMetricsStoreV2'>,
featureToggleStore,
clientMetricsStoreV2,
}: Pick<IUnleashStores, 'featureToggleStore' | 'clientMetricsStoreV2'>,
config: IUnleashConfig, config: IUnleashConfig,
lastSeenService: LastSeenService, lastSeenService: LastSeenService,
bulkInterval = secondsToMilliseconds(5), bulkInterval = secondsToMilliseconds(5),
) { ) {
this.featureToggleStore = featureToggleStore;
this.clientMetricsStoreV2 = clientMetricsStoreV2; this.clientMetricsStoreV2 = clientMetricsStoreV2;
this.lastSeenService = lastSeenService; this.lastSeenService = lastSeenService;
this.config = config; this.config = config;
this.flagResolver = config.flagResolver;
this.logger = config.getLogger( this.logger = config.getLogger(
'/services/client-metrics/client-metrics-service-v2.ts', '/services/client-metrics/client-metrics-service-v2.ts',
); );
@ -72,7 +61,7 @@ export default class ClientMetricsServiceV2 {
} }
async registerClientMetrics( async registerClientMetrics(
data: IClientApp, data: ClientMetricsSchema,
clientIp: string, clientIp: string,
): Promise<void> { ): Promise<void> {
const value = await clientMetricsSchema.validateAsync(data); const value = await clientMetricsSchema.validateAsync(data);
@ -100,7 +89,6 @@ export default class ClientMetricsServiceV2 {
...clientMetrics, ...clientMetrics,
]); ]);
this.lastSeenService.updateLastSeen(clientMetrics); this.lastSeenService.updateLastSeen(clientMetrics);
this.config.eventBus.emit(CLIENT_METRICS, value); 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)); 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 instanceof ApiUser) {
if (user.environment !== ALL) { if (user.environment !== ALL) {
return user.environment; return user.environment;

View File

@ -12,6 +12,17 @@ test('clientRegisterSchema should allow empty ("") instanceId', () => {
expect(value.instanceId).toBe('default'); 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', () => { test('clientRegisterSchema should allow undefined instanceId', () => {
const { value } = clientRegisterSchema.validate({ const { value } = clientRegisterSchema.validate({
appName: 'test', appName: 'test',

View File

@ -1,4 +1,5 @@
import joi from 'joi'; import joi from 'joi';
import { IMetricsBucket } from 'lib/types';
const countSchema = joi const countSchema = joi
.object() .object()
@ -9,8 +10,16 @@ const countSchema = joi
variants: joi.object().pattern(joi.string(), joi.number().min(0)), 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 export const clientMetricsSchema = joi
.object() .object<ValidatedClientMetrics>()
.options({ stripUnknown: true }) .options({ stripUnknown: true })
.keys({ .keys({
environment: joi.string().optional(), environment: joi.string().optional(),

View File

@ -307,7 +307,6 @@ export interface IClientApp {
seenToggles?: string[]; seenToggles?: string[];
metricsCount?: number; metricsCount?: number;
strategies?: string[] | Record<string, string>[]; strategies?: string[] | Record<string, string>[];
bucket?: any;
count?: number; count?: number;
started?: string | number | Date; started?: string | number | Date;
interval?: number; interval?: number;
@ -342,7 +341,7 @@ export interface IMetricCounts {
export interface IMetricsBucket { export interface IMetricsBucket {
start: Date; start: Date;
stop: Date; stop: Date;
toggles: IMetricCounts; toggles: { [key: string]: IMetricCounts };
} }
export interface IImportFile extends ImportCommon { export interface IImportFile extends ImportCommon {

View File

@ -328,6 +328,62 @@ exports[`should serve the OpenAPI spec 1`] = `
}, },
"type": "object", "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": { "changePasswordSchema": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@ -534,6 +590,21 @@ exports[`should serve the OpenAPI spec 1`] = `
}, },
"type": "object", "type": "object",
}, },
"example": {
"myCoolToggle": {
"no": 42,
"variants": {
"blue": 6,
"green": 15,
"red": 46,
},
"yes": 25,
},
"myOtherToggle": {
"no": 100,
"yes": 0,
},
},
"type": "object", "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": { "/edge/validate": {
"post": { "post": {
"operationId": "getValidTokens", "operationId": "getValidTokens",