From 4cf6258209a458cef275c073ffa9c998ccefc9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20Conradi=20=C3=98sthus?= Date: Wed, 6 Oct 2021 12:01:53 +0200 Subject: [PATCH] feat/metricsV2 --- src/lib/create-config.ts | 6 +- src/lib/db/client-metrics-store-v2.ts | 109 ++++++++++ src/lib/db/index.ts | 2 + src/lib/routes/admin-api/client-metrics.ts | 37 ++++ src/lib/routes/admin-api/index.ts | 5 + src/lib/routes/client-api/metrics.ts | 25 ++- .../client-metrics-service-v2.ts | 70 +++++++ src/lib/services/client-metrics/index.ts | 3 - src/lib/services/client-metrics/util.test.ts | 57 +++++ src/lib/services/client-metrics/util.ts | 48 +++++ src/lib/services/index.ts | 3 + src/lib/types/models/metrics.ts | 6 + src/lib/types/services.ts | 2 + src/lib/types/stores.ts | 2 + .../types/stores/client-metrics-store-v2.ts | 22 ++ .../20211004104917-client-metrics-env.js | 28 +++ src/server-dev.ts | 5 + .../e2e/api/admin/client-metrics.e2e.test.ts | 95 +++++++++ .../client-metrics-store-v2.e2e.test.ts | 194 ++++++++++++++++++ .../fixtures/fake-client-metrics-store-v2.ts | 56 +++++ src/test/fixtures/store.ts | 2 + 21 files changed, 771 insertions(+), 6 deletions(-) create mode 100644 src/lib/db/client-metrics-store-v2.ts create mode 100644 src/lib/routes/admin-api/client-metrics.ts create mode 100644 src/lib/services/client-metrics/client-metrics-service-v2.ts create mode 100644 src/lib/services/client-metrics/util.test.ts create mode 100644 src/lib/services/client-metrics/util.ts create mode 100644 src/lib/types/models/metrics.ts create mode 100644 src/lib/types/stores/client-metrics-store-v2.ts create mode 100644 src/migrations/20211004104917-client-metrics-env.js create mode 100644 src/test/e2e/api/admin/client-metrics.e2e.test.ts create mode 100644 src/test/e2e/stores/client-metrics-store-v2.e2e.test.ts create mode 100644 src/test/fixtures/fake-client-metrics-store-v2.ts diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index a2c84fa780..6704cc34e9 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -40,7 +40,7 @@ function safeNumber(envVar, defaultVal): number { } } -function safeBoolean(envVar, defaultVal) { +function safeBoolean(envVar: string, defaultVal: boolean): boolean { if (envVar) { return envVar === 'true' || envVar === '1' || envVar === 't'; } @@ -224,6 +224,10 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig { const experimental = options.experimental || {}; + if (safeBoolean(process.env.EXP_METRICS_V2, false)) { + experimental.metricsV2 = { enabled: true }; + } + const email: IEmailOption = mergeAll([defaultEmail, options.email]); let listen: IListeningPipe | IListeningHost; diff --git a/src/lib/db/client-metrics-store-v2.ts b/src/lib/db/client-metrics-store-v2.ts new file mode 100644 index 0000000000..c5e294f8e5 --- /dev/null +++ b/src/lib/db/client-metrics-store-v2.ts @@ -0,0 +1,109 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import util from 'util'; +import { Knex } from 'knex'; +import { Logger, LogProvider } from '../logger'; +import { + IClientMetricsEnv, + IClientMetricsEnvKey, + IClientMetricsStoreV2, +} from '../types/stores/client-metrics-store-v2'; + +interface ClientMetricsEnvTable { + feature_name: string; + app_name: string; + environment: string; + timestamp: Date; + yes: number; + no: number; +} + +const TABLE = 'client_metrics_env'; + +function roundDownToHour(date) { + let p = 60 * 60 * 1000; // milliseconds in an hour + return new Date(Math.floor(date.getTime() / p) * p); +} + +const fromRow = (row: ClientMetricsEnvTable) => ({ + featureName: row.feature_name, + appName: row.app_name, + environment: row.environment, + timestamp: row.timestamp, + yes: row.yes, + no: row.no, +}); + +const toRow = (metric: IClientMetricsEnv) => ({ + feature_name: metric.featureName, + app_name: metric.appName, + environment: metric.environment, + timestamp: roundDownToHour(metric.timestamp), + yes: metric.yes, + no: metric.no, +}); + +export class ClientMetricsStoreV2 implements IClientMetricsStoreV2 { + private db: Knex; + + private logger: Logger; + + constructor(db: Knex, getLogger: LogProvider) { + this.db = db; + this.logger = getLogger('client-metrics-store-v2.js'); + } + + get(key: IClientMetricsEnvKey): Promise { + throw new Error('Method not implemented.'); + } + + async getAll(query: Object = {}): Promise { + const rows = await this.db(TABLE) + .select('*') + .where(query); + return rows.map(fromRow); + } + + exists(key: IClientMetricsEnvKey): Promise { + throw new Error('Method not implemented.'); + } + + delete(key: IClientMetricsEnvKey): Promise { + throw new Error('Method not implemented.'); + } + + deleteAll(): Promise { + throw new Error('Method not implemented.'); + } + + destroy(): void { + // Nothing to do! + } + + async batchInsertMetrics(metrics: IClientMetricsEnv[]): Promise { + const rows = metrics.map(toRow); + + // Consider rewriting to SQL batch! + for (const row of rows) { + const insert = this.db(TABLE) + .insert(row) + .toQuery(); + + const query = `${insert.toString()} ON CONFLICT (feature_name, app_name, environment, timestamp) + DO UPDATE SET + "yes" = "client_metrics_env"."yes" + ?, + "no" = "client_metrics_env"."no" + ?`; + await this.db.raw(query, [row.yes, row.no]); + } + } + + async getMetricsForFeatureToggle( + featureName: string, + hoursBack: number = 24, + ): Promise { + const rows = await this.db(TABLE) + .select('*') + .where({ feature_name: featureName }) + .andWhereRaw(`timestamp >= NOW() - INTERVAL '${hoursBack} hours'`); + return rows.map(fromRow); + } +} diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 02cadfa0d0..a7d0c07d04 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -28,6 +28,7 @@ import FeatureToggleClientStore from './feature-toggle-client-store'; import EnvironmentStore from './environment-store'; import FeatureTagStore from './feature-tag-store'; import { FeatureEnvironmentStore } from './feature-environment-store'; +import { ClientMetricsStoreV2 } from './client-metrics-store-v2'; export const createStores = ( config: IUnleashConfig, @@ -54,6 +55,7 @@ export const createStores = ( eventBus, getLogger, ), + clientMetricsStoreV2: new ClientMetricsStoreV2(db, getLogger), contextFieldStore: new ContextFieldStore(db, getLogger), settingStore: new SettingStore(db, getLogger), userStore: new UserStore(db, getLogger), diff --git a/src/lib/routes/admin-api/client-metrics.ts b/src/lib/routes/admin-api/client-metrics.ts new file mode 100644 index 0000000000..ccac1dd1e7 --- /dev/null +++ b/src/lib/routes/admin-api/client-metrics.ts @@ -0,0 +1,37 @@ +import { Request, Response } from 'express'; +import Controller from '../controller'; +import { IUnleashConfig } from '../../types/option'; +import { IUnleashServices } from '../../types/services'; +import { Logger } from '../../logger'; +import ClientMetricsServiceV2 from '../../services/client-metrics/client-metrics-service-v2'; + +class ClientMetricsController extends Controller { + private logger: Logger; + + private metrics: ClientMetricsServiceV2; + + constructor( + config: IUnleashConfig, + { + clientMetricsServiceV2, + }: Pick, + ) { + super(config); + this.logger = config.getLogger('/admin-api/client-metrics.ts'); + + this.metrics = clientMetricsServiceV2; + + this.get('/features/:name', this.getFeatureToggleMetrics); + } + + async getFeatureToggleMetrics(req: Request, res: Response): Promise { + const { name } = req.params; + const data = await this.metrics.getClientMetricsForToggle(name); + res.json({ + version: 1, + maturity: 'experimental', + data, + }); + } +} +export default ClientMetricsController; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 49e979da0f..3e0edf6114 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -11,6 +11,7 @@ import MetricsController from './metrics'; import UserController from './user'; import ConfigController from './config'; import ContextController from './context'; +import ClientMetricsController from './client-metrics'; import BootstrapController from './bootstrap-controller'; import StateController from './state'; import TagController from './tag'; @@ -49,6 +50,10 @@ class AdminApi extends Controller { '/metrics', new MetricsController(config, services).router, ); + this.app.use( + '/client-metrics', + new ClientMetricsController(config, services).router, + ); this.app.use('/user', new UserController(config, services).router); this.app.use( '/ui-config', diff --git a/src/lib/routes/client-api/metrics.ts b/src/lib/routes/client-api/metrics.ts index 5b043eb948..8a136ce78c 100644 --- a/src/lib/routes/client-api/metrics.ts +++ b/src/lib/routes/client-api/metrics.ts @@ -7,21 +7,37 @@ import { Logger } from '../../logger'; import { IAuthRequest } from '../unleash-types'; import ApiUser from '../../types/api-user'; import { ALL } from '../../types/models/api-token'; +import ClientMetricsServiceV2 from '../../services/client-metrics/client-metrics-service-v2'; export default class ClientMetricsController extends Controller { logger: Logger; metrics: ClientMetricsService; + metricsV2: ClientMetricsServiceV2; + + newServiceEnabled: boolean = false; + constructor( { clientMetricsService, - }: Pick, + clientMetricsServiceV2, + }: Pick< + IUnleashServices, + 'clientMetricsService' | 'clientMetricsServiceV2' + >, config: IUnleashConfig, ) { super(config); - this.logger = config.getLogger('/api/client/metrics'); + const { experimental, getLogger } = config; + if (experimental && experimental.metricsV2) { + //@ts-ignore + this.newServiceEnabled = experimental.metricsV2.enabled; + } + + this.logger = getLogger('/api/client/metrics'); this.metrics = clientMetricsService; + this.metricsV2 = clientMetricsServiceV2; this.post('/', this.registerMetrics); } @@ -34,6 +50,11 @@ export default class ClientMetricsController extends Controller { } } await this.metrics.registerClientMetrics(data, clientIp); + + if (this.newServiceEnabled) { + await this.metricsV2.registerClientMetrics(data, clientIp); + } + return res.status(202).end(); } } diff --git a/src/lib/services/client-metrics/client-metrics-service-v2.ts b/src/lib/services/client-metrics/client-metrics-service-v2.ts new file mode 100644 index 0000000000..0f8bef6db3 --- /dev/null +++ b/src/lib/services/client-metrics/client-metrics-service-v2.ts @@ -0,0 +1,70 @@ +import { Logger } from '../../logger'; +import { IUnleashConfig } from '../../server-impl'; +import { IUnleashStores } from '../../types'; +import { IClientApp } from '../../types/model'; +import { GroupedClientMetrics } from '../../types/models/metrics'; +import { + IClientMetricsEnv, + IClientMetricsStoreV2, +} from '../../types/stores/client-metrics-store-v2'; +import { clientMetricsSchema } from './client-metrics-schema'; +import { groupMetricsOnEnv } from './util'; + +const FIVE_MINUTES = 5 * 60 * 1000; + +export default class ClientMetricsServiceV2 { + private timers: NodeJS.Timeout[] = []; + + private clientMetricsStoreV2: IClientMetricsStoreV2; + + private logger: Logger; + + private bulkInterval: number; + + constructor( + { clientMetricsStoreV2 }: Pick, + { getLogger }: Pick, + bulkInterval = FIVE_MINUTES, + ) { + this.clientMetricsStoreV2 = clientMetricsStoreV2; + + this.logger = getLogger('/services/client-metrics/index.ts'); + + this.bulkInterval = bulkInterval; + } + + async registerClientMetrics( + data: IClientApp, + clientIp: string, + ): Promise { + const value = await clientMetricsSchema.validateAsync(data); + const toggleNames = Object.keys(value.bucket.toggles); + + this.logger.debug(`got metrics from ${clientIp}`); + + const clientMetrics: IClientMetricsEnv[] = toggleNames + .map((name) => ({ + featureName: name, + appName: value.appName, + environment: value.environment, + timestamp: value.bucket.start, //we might need to approximate between start/stop... + yes: value.bucket.toggles[name].yes, + no: value.bucket.toggles[name].no, + })) + .filter((item) => !(item.yes === 0 && item.no === 0)); + + // TODO: should we aggregate for a few minutes (bulkInterval) before pushing to DB? + await this.clientMetricsStoreV2.batchInsertMetrics(clientMetrics); + } + + async getClientMetricsForToggle( + toggleName: string, + ): Promise { + const metrics = + await this.clientMetricsStoreV2.getMetricsForFeatureToggle( + toggleName, + ); + + return groupMetricsOnEnv(metrics); + } +} diff --git a/src/lib/services/client-metrics/index.ts b/src/lib/services/client-metrics/index.ts index 7fa99422e7..f853667985 100644 --- a/src/lib/services/client-metrics/index.ts +++ b/src/lib/services/client-metrics/index.ts @@ -1,4 +1,3 @@ -import { LogProvider } from '../../logger'; import { applicationSchema } from './metrics-schema'; import { Projection } from './projection'; import { clientMetricsSchema } from './client-metrics-schema'; @@ -66,8 +65,6 @@ export default class ClientMetricsService { private eventStore: IEventStore; - private getLogger: LogProvider; - private bulkInterval: number; private announcementInterval: number; diff --git a/src/lib/services/client-metrics/util.test.ts b/src/lib/services/client-metrics/util.test.ts new file mode 100644 index 0000000000..e746e29e1d --- /dev/null +++ b/src/lib/services/client-metrics/util.test.ts @@ -0,0 +1,57 @@ +import { IClientMetricsEnv } from '../../types/stores/client-metrics-store-v2'; +import { generateLastNHours, groupMetricsOnEnv, roundDownToHour } from './util'; + +test('should return list of 24 horus', () => { + const hours = generateLastNHours(24, new Date(2021, 10, 10, 15, 30, 1, 0)); + + expect(hours).toHaveLength(24); + expect(hours[0]).toStrictEqual(new Date(2021, 10, 10, 15, 0, 0)); + expect(hours[1]).toStrictEqual(new Date(2021, 10, 10, 14, 0, 0)); + expect(hours[2]).toStrictEqual(new Date(2021, 10, 10, 13, 0, 0)); + expect(hours[23]).toStrictEqual(new Date(2021, 10, 9, 16, 0, 0)); +}); + +test('should group metrics together', () => { + const date = roundDownToHour(new Date()); + const metrics: IClientMetricsEnv[] = [ + { + featureName: 'demo', + appName: 'web', + environment: 'default', + timestamp: date, + yes: 2, + no: 2, + }, + { + featureName: 'demo', + appName: 'web', + environment: 'default', + timestamp: date, + yes: 3, + no: 2, + }, + { + featureName: 'demo', + appName: 'web', + environment: 'test', + timestamp: date, + yes: 1, + no: 3, + }, + ]; + + const grouped = groupMetricsOnEnv(metrics); + + expect(grouped[0]).toStrictEqual({ + timestamp: date, + environment: 'default', + yes_count: 5, + no_count: 4, + }); + expect(grouped[1]).toStrictEqual({ + timestamp: date, + environment: 'test', + yes_count: 1, + no_count: 3, + }); +}); diff --git a/src/lib/services/client-metrics/util.ts b/src/lib/services/client-metrics/util.ts new file mode 100644 index 0000000000..0ee1dfa5e9 --- /dev/null +++ b/src/lib/services/client-metrics/util.ts @@ -0,0 +1,48 @@ +import { GroupedClientMetrics } from '../../types/models/metrics'; +import { IClientMetricsEnv } from '../../types/stores/client-metrics-store-v2'; + +//duplicate from client-metrics-store-v2.ts +export function roundDownToHour(date: Date): Date { + let p = 60 * 60 * 1000; // milliseconds in an hour + return new Date(Math.floor(date.getTime() / p) * p); +} + +export function generateLastNHours(n: number, start: Date): Date[] { + const nHours: Date[] = []; + nHours.push(roundDownToHour(start)); + for (let i = 1; i < n; i++) { + const prev = nHours[i - 1]; + const next = new Date(prev); + next.setHours(prev.getHours() - 1); + nHours.push(next); + } + + return nHours; +} + +export function groupMetricsOnEnv( + metrics: IClientMetricsEnv[], +): GroupedClientMetrics[] { + const hours = generateLastNHours(24, new Date()); + const environments = metrics.map((m) => m.environment); + + const grouped = {}; + + hours.forEach((time) => { + environments.forEach((environment) => { + grouped[`${time}:${environment}`] = { + timestamp: time, + environment, + yes_count: 0, + no_count: 0, + }; + }); + }); + + metrics.forEach((m) => { + grouped[`${m.timestamp}:${m.environment}`].yes_count += m.yes; + grouped[`${m.timestamp}:${m.environment}`].no_count += m.no; + }); + + return Object.values(grouped); +} diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index e47e66ea81..8832d78f9c 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -8,6 +8,7 @@ import HealthService from './health-service'; import ProjectService from './project-service'; import StateService from './state-service'; import ClientMetricsService from './client-metrics'; +import ClientMetricsServiceV2 from './client-metrics/client-metrics-service-v2'; import TagTypeService from './tag-type-service'; import TagService from './tag-service'; import StrategyService from './strategy-service'; @@ -34,6 +35,7 @@ export const createServices = ( const accessService = new AccessService(stores, config); const apiTokenService = new ApiTokenService(stores, config); const clientMetricsService = new ClientMetricsService(stores, config); + const clientMetricsServiceV2 = new ClientMetricsServiceV2(stores, config); const contextService = new ContextService(stores, config); const emailService = new EmailService(config.email, config.getLogger); const eventService = new EventService(stores, config); @@ -82,6 +84,7 @@ export const createServices = ( tagTypeService, tagService, clientMetricsService, + clientMetricsServiceV2, contextService, versionService, apiTokenService, diff --git a/src/lib/types/models/metrics.ts b/src/lib/types/models/metrics.ts new file mode 100644 index 0000000000..22d14aa9f8 --- /dev/null +++ b/src/lib/types/models/metrics.ts @@ -0,0 +1,6 @@ +export interface GroupedClientMetrics { + environment: string; + timestamp: Date; + yes_count: number; + no_count: number; +} diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 2c7bb6849f..3ae887a687 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -22,12 +22,14 @@ import FeatureToggleServiceV2 from '../services/feature-toggle-service-v2'; import EnvironmentService from '../services/environment-service'; import FeatureTagService from '../services/feature-tag-service'; import ProjectHealthService from '../services/project-health-service'; +import ClientMetricsServiceV2 from '../services/client-metrics/client-metrics-service-v2'; export interface IUnleashServices { accessService: AccessService; addonService: AddonService; apiTokenService: ApiTokenService; clientMetricsService: ClientMetricsService; + clientMetricsServiceV2: ClientMetricsServiceV2; contextService: ContextService; emailService: EmailService; environmentService: EnvironmentService; diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 00709e4c4e..ff22f24b3f 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -22,6 +22,7 @@ import { IFeatureEnvironmentStore } from './stores/feature-environment-store'; import { IFeatureStrategiesStore } from './stores/feature-strategies-store'; import { IEnvironmentStore } from './stores/environment-store'; import { IFeatureToggleClientStore } from './stores/feature-toggle-client-store'; +import { IClientMetricsStoreV2 } from './stores/client-metrics-store-v2'; export interface IUnleashStores { accessStore: IAccessStore; @@ -30,6 +31,7 @@ export interface IUnleashStores { clientApplicationsStore: IClientApplicationsStore; clientInstanceStore: IClientInstanceStore; clientMetricsStore: IClientMetricsStore; + clientMetricsStoreV2: IClientMetricsStoreV2; contextFieldStore: IContextFieldStore; environmentStore: IEnvironmentStore; eventStore: IEventStore; diff --git a/src/lib/types/stores/client-metrics-store-v2.ts b/src/lib/types/stores/client-metrics-store-v2.ts new file mode 100644 index 0000000000..642ba63396 --- /dev/null +++ b/src/lib/types/stores/client-metrics-store-v2.ts @@ -0,0 +1,22 @@ +import { Store } from './store'; + +export interface IClientMetricsEnvKey { + featureName: string; + appName: string; + environment: string; +} + +export interface IClientMetricsEnv extends IClientMetricsEnvKey { + timestamp: Date; + yes: number; + no: number; +} + +export interface IClientMetricsStoreV2 + extends Store { + batchInsertMetrics(metrics: IClientMetricsEnv[]): Promise; + getMetricsForFeatureToggle( + featureName: string, + hoursBack?: number, + ): Promise; +} diff --git a/src/migrations/20211004104917-client-metrics-env.js b/src/migrations/20211004104917-client-metrics-env.js new file mode 100644 index 0000000000..1d7b777853 --- /dev/null +++ b/src/migrations/20211004104917-client-metrics-env.js @@ -0,0 +1,28 @@ +exports.up = function (db, cb) { + // TODO: foreign key on env. + db.runSql( + ` + CREATE TABLE client_metrics_env( + feature_name VARCHAR(255), + app_name VARCHAR(255), + environment VARCHAR(100), + timestamp TIMESTAMP WITH TIME ZONE, + yes INTEGER DEFAULT 0, + no INTEGER DEFAULT 0, + PRIMARY KEY (feature_name, app_name, environment, timestamp) + ); + CREATE INDEX idx_client_metrics_f_name ON client_metrics_env(feature_name); + + `, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql( + ` + DROP TABLE client_metrics_env; + `, + cb, + ); +}; diff --git a/src/server-dev.ts b/src/server-dev.ts index 40981a32f3..bf1cea9c95 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -25,6 +25,11 @@ process.nextTick(async () => { versionCheck: { enable: false, }, + experimental: { + metricsV2: { + enabled: true, + }, + }, }), ); } catch (error) { diff --git a/src/test/e2e/api/admin/client-metrics.e2e.test.ts b/src/test/e2e/api/admin/client-metrics.e2e.test.ts new file mode 100644 index 0000000000..6e54010d98 --- /dev/null +++ b/src/test/e2e/api/admin/client-metrics.e2e.test.ts @@ -0,0 +1,95 @@ +import dbInit, { ITestDb } from '../../helpers/database-init'; +import { setupAppWithCustomConfig } from '../../helpers/test-helper'; +import getLogger from '../../../fixtures/no-logger'; +import { roundDownToHour } from '../../../../lib/services/client-metrics/util'; +import { IClientMetricsEnv } from '../../../../lib/types/stores/client-metrics-store-v2'; + +let app; +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('client_metrics_serial', getLogger); + app = await setupAppWithCustomConfig(db.stores, { + experimental: { metricsV2: { enabled: true } }, + }); +}); + +afterAll(async () => { + if (db) { + await db.destroy(); + } +}); + +afterEach(async () => { + await db.reset(); +}); + +test('should return grouped metrics', async () => { + const date = roundDownToHour(new Date()); + const metrics: IClientMetricsEnv[] = [ + { + featureName: 'demo', + appName: 'web', + environment: 'default', + timestamp: date, + yes: 2, + no: 2, + }, + { + featureName: 't2', + appName: 'web', + environment: 'default', + timestamp: date, + yes: 5, + no: 5, + }, + { + featureName: 't2', + appName: 'web', + environment: 'default', + timestamp: date, + yes: 2, + no: 99, + }, + { + featureName: 'demo', + appName: 'web', + environment: 'default', + timestamp: date, + yes: 3, + no: 2, + }, + { + featureName: 'demo', + appName: 'web', + environment: 'test', + timestamp: date, + yes: 1, + no: 3, + }, + ]; + + await db.stores.clientMetricsStoreV2.batchInsertMetrics(metrics); + + const { body: demo } = await app.request + .get('/api/admin/client-metrics/features/demo') + .expect('Content-Type', /json/) + .expect(200); + const { body: t2 } = await app.request + .get('/api/admin/client-metrics/features/t2') + .expect('Content-Type', /json/) + .expect(200); + + expect(demo.data).toHaveLength(48); + expect(demo.data[0].environment).toBe('default'); + expect(demo.data[0].yes_count).toBe(5); + expect(demo.data[0].no_count).toBe(4); + expect(demo.data[1].environment).toBe('test'); + expect(demo.data[1].yes_count).toBe(1); + expect(demo.data[1].no_count).toBe(3); + + expect(t2.data).toHaveLength(24); + expect(t2.data[0].environment).toBe('default'); + expect(t2.data[0].yes_count).toBe(7); + expect(t2.data[0].no_count).toBe(104); +}); diff --git a/src/test/e2e/stores/client-metrics-store-v2.e2e.test.ts b/src/test/e2e/stores/client-metrics-store-v2.e2e.test.ts new file mode 100644 index 0000000000..b9d8c6d9b8 --- /dev/null +++ b/src/test/e2e/stores/client-metrics-store-v2.e2e.test.ts @@ -0,0 +1,194 @@ +import dbInit from '../helpers/database-init'; +import getLogger from '../../fixtures/no-logger'; +import { IUnleashStores } from '../../../lib/types'; +import { + IClientMetricsEnv, + IClientMetricsStoreV2, +} from '../../../lib/types/stores/client-metrics-store-v2'; + +let db; +let stores: IUnleashStores; +let clientMetricsStore: IClientMetricsStoreV2; + +beforeEach(async () => { + db = await dbInit('client_metrics_store_v2_e2e_serial', getLogger); + stores = db.stores; + clientMetricsStore = stores.clientMetricsStoreV2; +}); + +afterEach(async () => { + await db.destroy(); +}); + +test('Should store single list of metrics', async () => { + const metrics: IClientMetricsEnv[] = [ + { + featureName: 'demo', + appName: 'web', + environment: 'dev', + timestamp: new Date(), + yes: 2, + no: 2, + }, + ]; + await clientMetricsStore.batchInsertMetrics(metrics); + const savedMetrics = await clientMetricsStore.getAll(); + + expect(savedMetrics).toHaveLength(1); +}); + +test('Should "increment" metrics within same hour', async () => { + const metrics: IClientMetricsEnv[] = [ + { + featureName: 'demo', + appName: 'web', + environment: 'dev', + timestamp: new Date(), + yes: 2, + no: 2, + }, + { + featureName: 'demo', + appName: 'web', + environment: 'dev', + timestamp: new Date(), + yes: 1, + no: 3, + }, + ]; + await clientMetricsStore.batchInsertMetrics(metrics); + const savedMetrics = await clientMetricsStore.getAll(); + + expect(savedMetrics).toHaveLength(1); + expect(savedMetrics[0].yes).toBe(3); + expect(savedMetrics[0].no).toBe(5); +}); + +test('Should get individual metrics outside same hour', async () => { + const d1 = new Date(); + const d2 = new Date(); + d1.setHours(10, 10, 11); + d2.setHours(11, 10, 11); + const metrics: IClientMetricsEnv[] = [ + { + featureName: 'demo', + appName: 'web', + environment: 'dev', + timestamp: d1, + yes: 2, + no: 2, + }, + { + featureName: 'demo', + appName: 'web', + environment: 'dev', + timestamp: d2, + yes: 1, + no: 3, + }, + ]; + await clientMetricsStore.batchInsertMetrics(metrics); + const savedMetrics = await clientMetricsStore.getAll(); + + expect(savedMetrics).toHaveLength(2); + expect(savedMetrics[0].yes).toBe(2); + expect(savedMetrics[0].no).toBe(2); +}); + +test('Should insert hundred metrics in a row', async () => { + const metrics: IClientMetricsEnv[] = []; + + const date = new Date(); + + for (let i = 0; i < 100; i++) { + metrics.push({ + featureName: 'demo', + appName: 'web', + environment: 'dev', + timestamp: date, + yes: i, + no: i + 1, + }); + } + + await clientMetricsStore.batchInsertMetrics(metrics); + const savedMetrics = await clientMetricsStore.getAll(); + + expect(savedMetrics).toHaveLength(1); + expect(savedMetrics[0].yes).toBe(4950); + expect(savedMetrics[0].no).toBe(5050); +}); + +test('Should insert individual rows for different apps', async () => { + const metrics: IClientMetricsEnv[] = []; + + const date = new Date(); + + for (let i = 0; i < 10; i++) { + metrics.push({ + featureName: 'demo', + appName: `web-${i}`, + environment: 'dev', + timestamp: date, + yes: 2, + no: 2, + }); + } + + await clientMetricsStore.batchInsertMetrics(metrics); + const savedMetrics = await clientMetricsStore.getAll(); + + expect(savedMetrics).toHaveLength(10); + expect(savedMetrics[0].yes).toBe(2); + expect(savedMetrics[0].no).toBe(2); +}); + +test('Should insert individual rows for different toggles', async () => { + const metrics: IClientMetricsEnv[] = []; + + const date = new Date(); + + for (let i = 0; i < 10; i++) { + metrics.push({ + featureName: `app-${i}`, + appName: `web`, + environment: 'dev', + timestamp: date, + yes: 2, + no: 2, + }); + } + + await clientMetricsStore.batchInsertMetrics(metrics); + const savedMetrics = await clientMetricsStore.getAll(); + + expect(savedMetrics).toHaveLength(10); + expect(savedMetrics[0].yes).toBe(2); + expect(savedMetrics[0].no).toBe(2); +}); + +test('Should get toggle metrics', async () => { + const metrics: IClientMetricsEnv[] = []; + + const date = new Date(); + + for (let i = 0; i < 100; i++) { + metrics.push({ + featureName: 'demo', + appName: 'web', + environment: 'dev', + timestamp: date, + yes: i, + no: i + 1, + }); + } + + await clientMetricsStore.batchInsertMetrics(metrics); + const savedMetrics = await clientMetricsStore.getMetricsForFeatureToggle( + 'demo', + ); + + expect(savedMetrics).toHaveLength(1); + expect(savedMetrics[0].yes).toBe(4950); + expect(savedMetrics[0].no).toBe(5050); +}); diff --git a/src/test/fixtures/fake-client-metrics-store-v2.ts b/src/test/fixtures/fake-client-metrics-store-v2.ts new file mode 100644 index 0000000000..94e17375ef --- /dev/null +++ b/src/test/fixtures/fake-client-metrics-store-v2.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/lines-between-class-members */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import EventEmitter from 'events'; +import { IClientMetric } from '../../lib/types/stores/client-metrics-db'; +import { + IClientMetricsEnv, + IClientMetricsEnvKey, + IClientMetricsStoreV2, +} from '../../lib/types/stores/client-metrics-store-v2'; + +export default class FakeClientMetricsStoreV2 + extends EventEmitter + implements IClientMetricsStoreV2 +{ + metrics: IClientMetric[] = []; + + constructor() { + super(); + this.setMaxListeners(0); + } + getMetricsForFeatureToggle( + featureName: string, + hoursBack?: number, + ): Promise { + throw new Error('Method not implemented.'); + } + batchInsertMetrics(metrics: IClientMetricsEnv[]): Promise { + throw new Error('Method not implemented.'); + } + get(key: IClientMetricsEnvKey): Promise { + throw new Error('Method not implemented.'); + } + getAll(query?: Object): Promise { + throw new Error('Method not implemented.'); + } + exists(key: IClientMetricsEnvKey): Promise { + throw new Error('Method not implemented.'); + } + delete(key: IClientMetricsEnvKey): Promise { + throw new Error('Method not implemented.'); + } + + async getMetricsLastHour(): Promise { + return Promise.resolve([]); + } + + async insert(): Promise { + return Promise.resolve(); + } + + async deleteAll(): Promise { + return Promise.resolve(undefined); + } + + destroy(): void {} +} diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 68e9007fbe..5e36e35ff0 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -23,6 +23,7 @@ import FakeApiTokenStore from './fake-api-token-store'; import FakeFeatureTypeStore from './fake-feature-type-store'; import FakeResetTokenStore from './fake-reset-token-store'; import FakeFeatureToggleClientStore from './fake-feature-toggle-client-store'; +import FakeClientMetricsStoreV2 from './fake-client-metrics-store-v2'; const createStores: () => IUnleashStores = () => { const db = { @@ -35,6 +36,7 @@ const createStores: () => IUnleashStores = () => { db, clientApplicationsStore: new FakeClientApplicationsStore(), clientMetricsStore: new FakeClientMetricsStore(), + clientMetricsStoreV2: new FakeClientMetricsStoreV2(), clientInstanceStore: new FakeClientInstanceStore(), featureToggleStore: new FakeFeatureToggleStore(), featureToggleClientStore: new FakeFeatureToggleClientStore(),