From fc455811f88ddd074f4223d04e079cee221ac5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20Conradi=20=C3=98sthus?= Date: Fri, 8 Oct 2021 10:09:22 +0200 Subject: [PATCH] feat/metricsV2 (#1005) Adds a new way of handling usage metrics where we push it directly to the database and performs aggregation on the fly. All metrics are aggregated in to buckets of hours. We will for now store metrics for the 48 hours with the following dimensions: - featureName - projectName - envrionment - yes (the actual count) - no (the actual count) --- src/lib/create-config.ts | 6 +- src/lib/db/client-metrics-store-v2.ts | 160 +++++++ src/lib/db/index.ts | 2 + src/lib/routes/admin-api/client-metrics.ts | 48 +++ src/lib/routes/admin-api/index.ts | 5 + src/lib/routes/client-api/metrics.test.ts | 30 +- src/lib/routes/client-api/metrics.ts | 40 +- src/lib/server-impl.ts | 1 + .../client-metrics-service-v2.ts | 111 +++++ src/lib/services/client-metrics/index.ts | 3 - src/lib/services/index.ts | 3 + src/lib/types/model.ts | 1 + src/lib/types/models/metrics.ts | 12 + src/lib/types/services.ts | 2 + src/lib/types/stores.ts | 2 + .../types/stores/client-metrics-store-v2.ts | 27 ++ .../20211004104917-client-metrics-env.js | 28 ++ src/server-dev.ts | 5 + .../e2e/api/admin/client-metrics.e2e.test.ts | 231 ++++++++++ src/test/e2e/api/client/feature.e2e.test.ts | 15 +- .../client/feature.env.disabled.e2e.test.ts | 2 +- src/test/e2e/api/client/metrics.e2e.test.ts | 7 +- src/test/e2e/api/client/metricsV2.e2e.test.ts | 100 +++++ src/test/e2e/helpers/test-helper.ts | 5 +- .../client-metrics-store-v2.e2e.test.ts | 397 ++++++++++++++++++ .../fixtures/fake-client-metrics-store-v2.ts | 66 +++ src/test/fixtures/store.ts | 2 + 27 files changed, 1282 insertions(+), 29 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/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/api/client/metricsV2.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..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(),