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..e3ac091369 --- /dev/null +++ b/src/lib/db/client-metrics-store-v2.ts @@ -0,0 +1,160 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Knex } from 'knex'; +import { Logger, LogProvider } from '../logger'; +import { + IClientMetricsEnv, + IClientMetricsEnvKey, + IClientMetricsStoreV2, +} from '../types/stores/client-metrics-store-v2'; +import NotFoundError from '../error/notfound-error'; + +interface ClientMetricsEnvTable { + feature_name: string; + app_name: string; + environment: string; + timestamp: Date; + yes: number; + no: number; +} + +const TABLE = 'client_metrics_env'; + +export function roundDownToHour(date: Date): Date { + return new Date(date.getTime() - (date.getTime() % 3600000)); +} + +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'); + } + + async get(key: IClientMetricsEnvKey): Promise { + const row = await this.db(TABLE) + .where({ + feature_name: key.featureName, + app_name: key.appName, + environment: key.environment, + timestamp: roundDownToHour(key.timestamp), + }) + .first(); + if (row) { + return fromRow(row); + } + throw new NotFoundError(`Could not find metric`); + } + + async getAll(query: Object = {}): Promise { + const rows = await this.db(TABLE) + .select('*') + .where(query); + return rows.map(fromRow); + } + + async exists(key: IClientMetricsEnvKey): Promise { + try { + await this.get(key); + return true; + } catch (e) { + return false; + } + } + + async delete(key: IClientMetricsEnvKey): Promise { + return this.db(TABLE) + .where({ + feature_name: key.featureName, + app_name: key.appName, + environment: key.environment, + timestamp: roundDownToHour(key.timestamp), + }) + .del(); + } + + deleteAll(): Promise { + return this.db(TABLE).del(); + } + + destroy(): void { + // Nothing to do! + } + + // this function will collapse metrics before sending it to the database. + async batchInsertMetrics(metrics: IClientMetricsEnv[]): Promise { + if (!metrics || metrics.length == 0) { + return; + } + const rows = metrics.map(toRow); + + const batch = rows.reduce((prev, curr) => { + // eslint-disable-next-line prettier/prettier + const key = `${curr.feature_name}_${curr.app_name}_${curr.environment}_${curr.timestamp.getTime()}`; + if (prev[key]) { + prev[key].yes += curr.yes; + prev[key].no += curr.no; + } else { + prev[key] = curr; + } + return prev; + }, {}); + + // Consider rewriting to SQL batch! + const insert = this.db(TABLE) + .insert(Object.values(batch)) + .toQuery(); + + const query = `${insert.toString()} ON CONFLICT (feature_name, app_name, environment, timestamp) DO UPDATE SET "yes" = "client_metrics_env"."yes" + EXCLUDED.yes, "no" = "client_metrics_env"."no" + EXCLUDED.no`; + await this.db.raw(query); + } + + 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); + } + + async getSeenAppsForFeatureToggle( + featureName: string, + hoursBack: number = 24, + ): Promise { + return this.db(TABLE) + .distinct() + .where({ feature_name: featureName }) + .andWhereRaw(`timestamp >= NOW() - INTERVAL '${hoursBack} hours'`) + .pluck('app_name') + .orderBy('app_name'); + } + + async clearMetrics(hoursAgo: number): Promise { + return this.db(TABLE) + .whereRaw(`timestamp <= NOW() - INTERVAL '${hoursAgo} hours'`) + .del(); + } +} 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..4998f504a8 --- /dev/null +++ b/src/lib/routes/admin-api/client-metrics.ts @@ -0,0 +1,48 @@ +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/raw', this.getRawToggleMetrics); + this.get('/features/:name', this.getToggleMetricsSummary); + } + + async getRawToggleMetrics(req: Request, res: Response): Promise { + const { name } = req.params; + const data = await this.metrics.getClientMetricsForToggle(name); + res.json({ + version: 1, + maturity: 'experimental', + data, + }); + } + + async getToggleMetricsSummary(req: Request, res: Response): Promise { + const { name } = req.params; + const data = await this.metrics.getFeatureToggleMetricsSummary(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.test.ts b/src/lib/routes/client-api/metrics.test.ts index 4cbb1bb55a..c8b61c5b74 100644 --- a/src/lib/routes/client-api/metrics.test.ts +++ b/src/lib/routes/client-api/metrics.test.ts @@ -6,13 +6,14 @@ import { createTestConfig } from '../../../test/config/test-config'; import { clientMetricsSchema } from '../../services/client-metrics/client-metrics-schema'; import { createServices } from '../../services'; import { IUnleashStores } from '../../types'; +import { IUnleashOptions } from '../../server-impl'; const eventBus = new EventEmitter(); -function getSetup() { +function getSetup(opts?: IUnleashOptions) { const stores = createStores(); - const config = createTestConfig(); + const config = createTestConfig(opts); const services = createServices(stores, config); const app = getApp(config, stores, services, eventBus); @@ -84,6 +85,31 @@ test('should accept client metrics with yes/no', () => { .expect(202); }); +test('should accept client metrics with yes/no with metricsV2', async () => { + const testRunner = getSetup({ + experimental: { metricsV2: { enabled: true } }, + }); + await testRunner.request + .post('/api/client/metrics') + .send({ + appName: 'demo', + instanceId: '1', + bucket: { + start: Date.now(), + stop: Date.now(), + toggles: { + toggleA: { + yes: 200, + no: 0, + }, + }, + }, + }) + .expect(202); + + testRunner.destroy(); +}); + test('should accept client metrics with variants', () => { return request .post('/api/client/metrics') diff --git a/src/lib/routes/client-api/metrics.ts b/src/lib/routes/client-api/metrics.ts index 5b043eb948..4e7f032946 100644 --- a/src/lib/routes/client-api/metrics.ts +++ b/src/lib/routes/client-api/metrics.ts @@ -7,33 +7,63 @@ 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'; +import { User } from '../../server-impl'; +import { IClientApp } from '../../types/model'; 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); } - async registerMetrics(req: IAuthRequest, res: Response): Promise { - const { body: data, ip: clientIp, user } = req; + private resolveEnvironment(user: User, data: IClientApp) { if (user instanceof ApiUser) { if (user.environment !== ALL) { - data.environment = user.environment; + return user.environment; + } else if (user.environment === ALL && data.environment) { + return data.environment; } } + return 'default'; + } + + async registerMetrics(req: IAuthRequest, res: Response): Promise { + const { body: data, ip: clientIp, user } = req; + data.environment = this.resolveEnvironment(user, data); 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/server-impl.ts b/src/lib/server-impl.ts index 1613365242..0c892e6c87 100644 --- a/src/lib/server-impl.ts +++ b/src/lib/server-impl.ts @@ -49,6 +49,7 @@ async function createApp( metricsMonitor.stopMonitoring(); stores.clientInstanceStore.destroy(); stores.clientMetricsStore.destroy(); + services.clientMetricsServiceV2.destroy(); await db.destroy(); }; 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..f54b7d6d96 --- /dev/null +++ b/src/lib/services/client-metrics/client-metrics-service-v2.ts @@ -0,0 +1,111 @@ +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, + IClientMetricsStoreV2, +} from '../../types/stores/client-metrics-store-v2'; +import { clientMetricsSchema } from './client-metrics-schema'; + +const FIVE_MINUTES = 5 * 60 * 1000; +const ONE_DAY = 24 * 60 * 60 * 1000; + +export default class ClientMetricsServiceV2 { + private timer: 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; + this.timer = setInterval(() => { + console.log('Clear metrics'); + this.clientMetricsStoreV2.clearMetrics(48); + }, ONE_DAY); + this.timer.unref(); + } + + 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); + } + + // Overview over usage last "hour" bucket and all applications using the toggle + async getFeatureToggleMetricsSummary( + featureName: string, + ): Promise { + const metrics = + await this.clientMetricsStoreV2.getMetricsForFeatureToggle( + featureName, + 1, + ); + const seenApplications = + await this.clientMetricsStoreV2.getSeenAppsForFeatureToggle( + featureName, + ); + + const groupedMetrics = metrics.reduce((prev, curr) => { + if (prev[curr.environment]) { + prev[curr.environment].yes += curr.yes; + prev[curr.environment].no += curr.no; + } else { + prev[curr.environment] = { + environment: curr.environment, + timestamp: curr.timestamp, + yes: curr.yes, + no: curr.no, + }; + } + return prev; + }, {}); + + return { + featureName, + lastHourUsage: Object.values(groupedMetrics), + seenApplications, + }; + } + + async getClientMetricsForToggle( + toggleName: string, + ): Promise { + return this.clientMetricsStoreV2.getMetricsForFeatureToggle(toggleName); + } + + destroy(): void { + clearInterval(this.timer); + this.timer = null; + } +} 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/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/model.ts b/src/lib/types/model.ts index c1956be47c..034ff00efd 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -259,6 +259,7 @@ export interface IClientApp { appName: string; instanceId: string; clientIp?: string; + environment?: string; seenToggles?: string[]; metricsCount?: number; strategies?: string[] | Record[]; diff --git a/src/lib/types/models/metrics.ts b/src/lib/types/models/metrics.ts new file mode 100644 index 0000000000..efc2558c32 --- /dev/null +++ b/src/lib/types/models/metrics.ts @@ -0,0 +1,12 @@ +export interface GroupedClientMetrics { + environment: string; + timestamp: Date; + yes: number; + no: number; +} + +export interface ToggleMetricsSummary { + featureName: string; + lastHourUsage: GroupedClientMetrics[]; + seenApplications: string[]; +} 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..3856605999 --- /dev/null +++ b/src/lib/types/stores/client-metrics-store-v2.ts @@ -0,0 +1,27 @@ +import { Store } from './store'; + +export interface IClientMetricsEnvKey { + featureName: string; + appName: string; + environment: string; + timestamp: Date; +} + +export interface IClientMetricsEnv extends IClientMetricsEnvKey { + yes: number; + no: number; +} + +export interface IClientMetricsStoreV2 + extends Store { + batchInsertMetrics(metrics: IClientMetricsEnv[]): Promise; + getMetricsForFeatureToggle( + featureName: string, + hoursBack?: number, + ): Promise; + getSeenAppsForFeatureToggle( + featureName: string, + hoursBack?: number, + ): Promise; + clearMetrics(hoursAgo: 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..088cfc7af7 --- /dev/null +++ b/src/test/e2e/api/admin/client-metrics.e2e.test.ts @@ -0,0 +1,231 @@ +import dbInit, { ITestDb } from '../../helpers/database-init'; +import { setupAppWithCustomConfig } from '../../helpers/test-helper'; +import getLogger from '../../../fixtures/no-logger'; +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(); + await db.stores.clientMetricsStoreV2.deleteAll(); +}); + +test('should return raw metrics, aggregated on key', async () => { + const date = 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/raw') + .expect('Content-Type', /json/) + .expect(200); + const { body: t2 } = await app.request + .get('/api/admin/client-metrics/features/t2/raw') + .expect('Content-Type', /json/) + .expect(200); + + expect(demo.data).toHaveLength(2); + expect(demo.data[0].environment).toBe('default'); + expect(demo.data[0].yes).toBe(5); + expect(demo.data[0].no).toBe(4); + expect(demo.data[1].environment).toBe('test'); + expect(demo.data[1].yes).toBe(1); + expect(demo.data[1].no).toBe(3); + + expect(t2.data).toHaveLength(1); + expect(t2.data[0].environment).toBe('default'); + expect(t2.data[0].yes).toBe(7); + expect(t2.data[0].no).toBe(104); +}); + +test('should return toggle summary', async () => { + const date = 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, + }, + { + featureName: 'demo', + appName: 'backend-api', + 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); + + expect(demo.featureName).toBe('demo'); + expect(demo.lastHourUsage).toHaveLength(2); + expect(demo.lastHourUsage[0].environment).toBe('default'); + expect(demo.lastHourUsage[0].yes).toBe(5); + expect(demo.lastHourUsage[0].no).toBe(4); + expect(demo.lastHourUsage[1].environment).toBe('test'); + expect(demo.lastHourUsage[1].yes).toBe(2); + expect(demo.lastHourUsage[1].no).toBe(6); + expect(demo.seenApplications).toStrictEqual(['backend-api', 'web']); +}); + +test('should only include last hour of metrics return toggle summary', async () => { + const date = new Date(); + const dateHoneHourAgo = new Date(); + dateHoneHourAgo.setHours(-1); + 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, + }, + { + featureName: 'demo', + appName: 'backend-api', + environment: 'test', + timestamp: date, + yes: 1, + no: 3, + }, + { + featureName: 'demo', + appName: 'backend-api', + environment: 'test', + timestamp: dateHoneHourAgo, + yes: 55, + no: 55, + }, + ]; + + 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); + + expect(demo.featureName).toBe('demo'); + expect(demo.lastHourUsage).toHaveLength(2); + expect(demo.lastHourUsage[0].environment).toBe('default'); + expect(demo.lastHourUsage[0].yes).toBe(5); + expect(demo.lastHourUsage[0].no).toBe(4); + expect(demo.lastHourUsage[1].environment).toBe('test'); + expect(demo.lastHourUsage[1].yes).toBe(2); + expect(demo.lastHourUsage[1].no).toBe(6); + expect(demo.seenApplications).toStrictEqual(['backend-api', 'web']); +}); diff --git a/src/test/e2e/api/client/feature.e2e.test.ts b/src/test/e2e/api/client/feature.e2e.test.ts index 7625ebaa07..de21d581ee 100644 --- a/src/test/e2e/api/client/feature.e2e.test.ts +++ b/src/test/e2e/api/client/feature.e2e.test.ts @@ -102,7 +102,7 @@ afterAll(async () => { }); test('returns four feature toggles', async () => { - app.request + return app.request .get('/api/client/features') .expect('Content-Type', /json/) .expect(200) @@ -111,19 +111,18 @@ test('returns four feature toggles', async () => { }); }); -test('returns four feature toggles without createdAt', async () => - app.request +test('returns four feature toggles without createdAt', async () => { + return app.request .get('/api/client/features') .expect('Content-Type', /json/) .expect(200) .expect((res) => { expect(res.body.features).toHaveLength(4); expect(res.body.features[0].createdAt).toBeFalsy(); - })); + }); +}); test('gets a feature by name', async () => { - expect.assertions(0); - return app.request .get('/api/client/features/featureX') .expect('Content-Type', /json/) @@ -131,8 +130,6 @@ test('gets a feature by name', async () => { }); test('cant get feature that does not exist', async () => { - expect.assertions(0); - return app.request .get('/api/client/features/myfeature') .expect('Content-Type', /json/) @@ -140,8 +137,6 @@ test('cant get feature that does not exist', async () => { }); test('Can filter features by namePrefix', async () => { - expect.assertions(2); - return app.request .get('/api/client/features?namePrefix=feature.') .expect('Content-Type', /json/) diff --git a/src/test/e2e/api/client/feature.env.disabled.e2e.test.ts b/src/test/e2e/api/client/feature.env.disabled.e2e.test.ts index b28d49bd1b..adb4fa5599 100644 --- a/src/test/e2e/api/client/feature.env.disabled.e2e.test.ts +++ b/src/test/e2e/api/client/feature.env.disabled.e2e.test.ts @@ -8,7 +8,7 @@ let db: ITestDb; const featureName = 'feature.default.1'; beforeAll(async () => { - db = await dbInit('feature_api_client', getLogger); + db = await dbInit('feature_env_api_client', getLogger); app = await setupApp(db.stores); await app.services.featureToggleServiceV2.createFeatureToggle( diff --git a/src/test/e2e/api/client/metrics.e2e.test.ts b/src/test/e2e/api/client/metrics.e2e.test.ts index 30ac24b378..da9e7f07e8 100644 --- a/src/test/e2e/api/client/metrics.e2e.test.ts +++ b/src/test/e2e/api/client/metrics.e2e.test.ts @@ -16,8 +16,7 @@ afterAll(async () => { await db.destroy(); }); -test('should be possble to send metrics', async () => { - expect.assertions(0); +test('should be possible to send metrics', async () => { return app.request .post('/api/client/metrics') .send(metricsExample) @@ -25,7 +24,6 @@ test('should be possble to send metrics', async () => { }); test('should require valid send metrics', async () => { - expect.assertions(0); return app.request .post('/api/client/metrics') .send({ @@ -34,8 +32,7 @@ test('should require valid send metrics', async () => { .expect(400); }); -test('should accept client metrics', async () => { - expect.assertions(0); +test('should accept empty client metrics', async () => { return app.request .post('/api/client/metrics') .send({ diff --git a/src/test/e2e/api/client/metricsV2.e2e.test.ts b/src/test/e2e/api/client/metricsV2.e2e.test.ts new file mode 100644 index 0000000000..dff65b4b50 --- /dev/null +++ b/src/test/e2e/api/client/metricsV2.e2e.test.ts @@ -0,0 +1,100 @@ +import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper'; +import metricsExample from '../../../examples/client-metrics.json'; +import dbInit, { ITestDb } from '../../helpers/database-init'; +import getLogger from '../../../fixtures/no-logger'; +import { ApiTokenType } from '../../../../lib/types/models/api-token'; + +let app: IUnleashTest; +let db: ITestDb; + +let defaultToken; + +beforeAll(async () => { + db = await dbInit('metrics_two_api_client', getLogger); + app = await setupAppWithAuth(db.stores, { + experimental: { metricsV2: { enabled: true } }, + }); + defaultToken = await app.services.apiTokenService.createApiToken({ + type: ApiTokenType.CLIENT, + project: 'default', + environment: 'default', + username: 'tester', + }); +}); + +afterEach(async () => { + await db.stores.clientMetricsStoreV2.deleteAll(); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +test('should be possible to send metrics', async () => { + return app.request + .post('/api/client/metrics') + .set('Authorization', defaultToken.secret) + .send(metricsExample) + .expect(202); +}); + +test('should require valid send metrics', async () => { + return app.request + .post('/api/client/metrics') + .set('Authorization', defaultToken.secret) + .send({ + appName: 'test', + }) + .expect(400); +}); + +test('should accept client metrics', async () => { + return app.request + .post('/api/client/metrics') + .set('Authorization', defaultToken.secret) + .send({ + appName: 'demo', + instanceId: '1', + bucket: { + start: Date.now(), + stop: Date.now(), + toggles: {}, + }, + }) + .expect(202); +}); + +test('should pick up environment from token', async () => { + const environment = 'test'; + await db.stores.environmentStore.create({ name: 'test', type: 'test' }); + const token = await app.services.apiTokenService.createApiToken({ + type: ApiTokenType.CLIENT, + project: 'default', + environment, + username: 'tester', + }); + + await app.request + .post('/api/client/metrics') + .set('Authorization', token.secret) + .send({ + appName: 'some-fancy-app', + instanceId: '1', + bucket: { + start: Date.now(), + stop: Date.now(), + toggles: { + test: { + yes: 100, + no: 50, + }, + }, + }, + }) + .expect(202); + + const metrics = await db.stores.clientMetricsStoreV2.getAll(); + expect(metrics[0].environment).toBe('test'); + expect(metrics[0].appName).toBe('some-fancy-app'); +}); diff --git a/src/test/e2e/helpers/test-helper.ts b/src/test/e2e/helpers/test-helper.ts index 5c4d8870a9..70f6d3f738 100644 --- a/src/test/e2e/helpers/test-helper.ts +++ b/src/test/e2e/helpers/test-helper.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import supertest from 'supertest'; import EventEmitter from 'events'; @@ -62,7 +63,6 @@ export async function setupApp(stores: IUnleashStores): Promise { export async function setupAppWithCustomConfig( stores: IUnleashStores, - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types customOptions: any, ): Promise { return createApp(stores, undefined, undefined, customOptions); @@ -70,8 +70,9 @@ export async function setupAppWithCustomConfig( export async function setupAppWithAuth( stores: IUnleashStores, + customOptions?: any, ): Promise { - return createApp(stores, IAuthType.DEMO); + return createApp(stores, IAuthType.DEMO, undefined, customOptions); } export async function setupAppWithCustomAuth( 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..5afb48edcb --- /dev/null +++ b/src/test/e2e/stores/client-metrics-store-v2.e2e.test.ts @@ -0,0 +1,397 @@ +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'; +import { roundDownToHour } from '../../../lib/db/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); +}); + +test('Should insert 1500 feature toggle metrics', async () => { + const metrics: IClientMetricsEnv[] = []; + + const date = new Date(); + + for (let i = 0; i < 1500; i++) { + metrics.push({ + featureName: `demo-${i}`, + appName: `web`, + environment: 'dev', + timestamp: date, + yes: 2, + no: 2, + }); + } + + await clientMetricsStore.batchInsertMetrics(metrics); + const savedMetrics = await clientMetricsStore.getAll(); + + expect(savedMetrics).toHaveLength(1500); +}); + +test('Should return seen applications using a feature toggle', async () => { + const metrics: IClientMetricsEnv[] = [ + { + featureName: 'demo', + appName: 'web', + environment: 'dev', + timestamp: new Date(), + yes: 2, + no: 2, + }, + { + featureName: 'demo', + appName: 'backend-api', + environment: 'dev', + timestamp: new Date(), + yes: 1, + no: 3, + }, + { + featureName: 'demo', + appName: 'backend-api', + environment: 'dev', + timestamp: new Date(), + yes: 1, + no: 3, + }, + ]; + await clientMetricsStore.batchInsertMetrics(metrics); + const apps = await clientMetricsStore.getSeenAppsForFeatureToggle('demo'); + + expect(apps).toHaveLength(2); + expect(apps).toStrictEqual(['backend-api', 'web']); +}); + +test('Should not fail on empty list of metrics', async () => { + await clientMetricsStore.batchInsertMetrics([]); + const all = await clientMetricsStore.getAll(); + + expect(all).toHaveLength(0); +}); + +test('Should not fail on undefined list of metrics', async () => { + await clientMetricsStore.batchInsertMetrics(undefined); + const all = await clientMetricsStore.getAll(); + + expect(all).toHaveLength(0); +}); + +test('Should return delete old metric', async () => { + const twoDaysAgo = new Date(); + twoDaysAgo.setHours(-48); + + const metrics: IClientMetricsEnv[] = [ + { + featureName: 'demo1', + appName: 'web', + environment: 'dev', + timestamp: new Date(), + yes: 2, + no: 2, + }, + { + featureName: 'demo2', + appName: 'backend-api', + environment: 'dev', + timestamp: new Date(), + yes: 1, + no: 3, + }, + { + featureName: 'demo3', + appName: 'backend-api', + environment: 'dev', + timestamp: twoDaysAgo, + yes: 1, + no: 3, + }, + { + featureName: 'demo4', + appName: 'backend-api', + environment: 'dev', + timestamp: twoDaysAgo, + yes: 1, + no: 3, + }, + ]; + await clientMetricsStore.batchInsertMetrics(metrics); + await clientMetricsStore.clearMetrics(24); + const all = await clientMetricsStore.getAll(); + + expect(all).toHaveLength(2); + expect(all[0].featureName).toBe('demo1'); + expect(all[1].featureName).toBe('demo2'); +}); + +test('Should get metric', async () => { + const twoDaysAgo = new Date(); + twoDaysAgo.setHours(-48); + + const metrics: IClientMetricsEnv[] = [ + { + featureName: 'demo1', + appName: 'web', + environment: 'dev', + timestamp: new Date(), + yes: 2, + no: 2, + }, + { + featureName: 'demo2', + appName: 'backend-api', + environment: 'dev', + timestamp: new Date(), + yes: 1, + no: 3, + }, + { + featureName: 'demo3', + appName: 'backend-api', + environment: 'dev', + timestamp: twoDaysAgo, + yes: 1, + no: 3, + }, + { + featureName: 'demo4', + appName: 'backend-api', + environment: 'dev', + timestamp: twoDaysAgo, + yes: 41, + no: 42, + }, + ]; + await clientMetricsStore.batchInsertMetrics(metrics); + const metric = await clientMetricsStore.get({ + featureName: 'demo4', + timestamp: twoDaysAgo, + appName: 'backend-api', + environment: 'dev', + }); + + expect(metric.featureName).toBe('demo4'); + expect(metric.yes).toBe(41); + expect(metric.no).toBe(42); +}); + +test('Should not exists after delete', async () => { + const metric = { + featureName: 'demo4', + appName: 'backend-api', + environment: 'dev', + timestamp: new Date(), + yes: 41, + no: 42, + }; + + const metrics: IClientMetricsEnv[] = [metric]; + await clientMetricsStore.batchInsertMetrics(metrics); + + const existBefore = await clientMetricsStore.exists(metric); + expect(existBefore).toBe(true); + + await clientMetricsStore.delete(metric); + + const existAfter = await clientMetricsStore.exists(metric); + expect(existAfter).toBe(false); +}); + +test('should floor hours as expected', () => { + expect( + roundDownToHour(new Date('2019-11-12T08:44:32.499Z')).toISOString(), + ).toBe('2019-11-12T08:00:00.000Z'); + expect( + roundDownToHour(new Date('2019-11-12T08:59:59.999Z')).toISOString(), + ).toBe('2019-11-12T08:00:00.000Z'); + expect( + roundDownToHour(new Date('2019-11-12T09:01:00.999Z')).toISOString(), + ).toBe('2019-11-12T09:00:00.000Z'); +}); 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..5e919a1d81 --- /dev/null +++ b/src/test/fixtures/fake-client-metrics-store-v2.ts @@ -0,0 +1,66 @@ +/* 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: IClientMetricsEnv[] = []; + + constructor() { + super(); + this.setMaxListeners(0); + } + clearMetrics(hoursBack: number): Promise { + return Promise.resolve(); + } + getSeenAppsForFeatureToggle( + featureName: string, + hoursBack?: number, + ): Promise { + throw new Error('Method not implemented.'); + } + getMetricsForFeatureToggle( + featureName: string, + hoursBack?: number, + ): Promise { + throw new Error('Method not implemented.'); + } + batchInsertMetrics(metrics: IClientMetricsEnv[]): Promise { + metrics.forEach((m) => this.metrics.push(m)); + return Promise.resolve(); + } + 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(),