1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01: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 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);
}
});

View File

@ -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,

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: {
type: 'object',
example: {
myCoolToggle: {
yes: 25,
no: 42,
variants: {
blue: 6,
green: 15,
red: 46,
},
},
myOtherToggle: {
yes: 0,
no: 100,
},
},
additionalProperties: {
type: 'object',
properties: {

View File

@ -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<IUnleashServices, 'edgeService' | 'openApiService'>,
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<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 { 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<IUnleashStores, 'featureToggleStore' | 'clientMetricsStoreV2'>,
{ clientMetricsStoreV2 }: Pick<IUnleashStores, 'clientMetricsStoreV2'>,
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<void> {
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;

View File

@ -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',

View File

@ -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<ValidatedClientMetrics>()
.options({ stripUnknown: true })
.keys({
environment: joi.string().optional(),

View File

@ -307,7 +307,6 @@ export interface IClientApp {
seenToggles?: string[];
metricsCount?: number;
strategies?: string[] | Record<string, string>[];
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 {

View File

@ -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",