From 2f013bacbf724744a3bbfc6a5bfa00998d859879 Mon Sep 17 00:00:00 2001 From: checketts Date: Thu, 24 Jun 2021 11:22:12 -0600 Subject: [PATCH] chore: Convert client metrics controller to typescript (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ivar Conradi Ă˜sthus --- ...ent-metrics-db.js => client-metrics-db.ts} | 32 ++- ...e.test.js => client-metrics-store.test.ts} | 13 +- ...trics-store.js => client-metrics-store.ts} | 40 ++- src/lib/db/event-store.ts | 2 +- src/lib/db/index.ts | 4 +- src/lib/routes/admin-api/metrics.ts | 5 +- ...ics-schema.js => client-metrics-schema.ts} | 8 +- ...metrics.test.js => client-metrics.test.ts} | 203 +++++++------ .../client-metrics/{index.js => index.ts} | 266 ++++++++++++------ .../{metrics-schema.js => metrics-schema.ts} | 8 +- src/lib/services/client-metrics/models.ts | 27 ++ ...{projection.test.js => projection.test.ts} | 3 +- .../{projection.js => projection.ts} | 16 +- src/lib/services/user-service.ts | 1 - src/lib/types/stores.ts | 2 +- ....js => client-metrics-service.e2e.test.ts} | 18 +- 16 files changed, 406 insertions(+), 242 deletions(-) rename src/lib/db/{client-metrics-db.js => client-metrics-db.ts} (74%) rename src/lib/db/{client-metrics-store.test.js => client-metrics-store.test.ts} (86%) rename src/lib/db/{client-metrics-store.js => client-metrics-store.ts} (62%) rename src/lib/services/client-metrics/{client-metrics-schema.js => client-metrics-schema.ts} (87%) rename src/lib/services/client-metrics/{client-metrics.test.js => client-metrics.test.ts} (75%) rename src/lib/services/client-metrics/{index.js => index.ts} (54%) rename src/lib/services/client-metrics/{metrics-schema.js => metrics-schema.ts} (85%) create mode 100644 src/lib/services/client-metrics/models.ts rename src/lib/services/client-metrics/{projection.test.js => projection.test.ts} (94%) rename src/lib/services/client-metrics/{projection.js => projection.ts} (67%) rename src/test/e2e/services/{client-metrics-service.e2e.test.js => client-metrics-service.e2e.test.ts} (87%) diff --git a/src/lib/db/client-metrics-db.js b/src/lib/db/client-metrics-db.ts similarity index 74% rename from src/lib/db/client-metrics-db.js rename to src/lib/db/client-metrics-db.ts index 6687bd9374..81d718ce82 100644 --- a/src/lib/db/client-metrics-db.js +++ b/src/lib/db/client-metrics-db.ts @@ -1,4 +1,5 @@ -'use strict'; +import { Knex } from 'knex'; +import { Logger, LogProvider } from '../logger'; const METRICS_COLUMNS = ['id', 'created_at', 'metrics']; const TABLE = 'client_metrics'; @@ -11,18 +12,27 @@ const mapRow = row => ({ metrics: row.metrics, }); -class ClientMetricsDb { - constructor(db, getLogger) { - this.db = db; +export interface IClientMetric { + id: number; + createdAt: Date; + metrics: any; +} + +export class ClientMetricsDb { + private readonly logger: Logger; + + private readonly timer: NodeJS.Timeout; + + constructor(private db: Knex, getLogger: LogProvider) { this.logger = getLogger('client-metrics-db.js'); - // Clear old metrics regulary + // Clear old metrics regularly const clearer = () => this.removeMetricsOlderThanOneHour(); setTimeout(clearer, 10).unref(); this.timer = setInterval(clearer, ONE_MINUTE).unref(); } - async removeMetricsOlderThanOneHour() { + async removeMetricsOlderThanOneHour(): Promise { try { const rows = await this.db(TABLE) .whereRaw("created_at < now() - interval '1 hour'") @@ -36,12 +46,12 @@ class ClientMetricsDb { } // Insert new client metrics - async insert(metrics) { + async insert(metrics: IClientMetric): Promise { return this.db(TABLE).insert({ metrics }); } // Used at startup to load all metrics last week into memory! - async getMetricsLastHour() { + async getMetricsLastHour(): Promise { try { const result = await this.db .select(METRICS_COLUMNS) @@ -57,7 +67,7 @@ class ClientMetricsDb { } // Used to poll for new metrics - async getNewMetrics(lastKnownId) { + async getNewMetrics(lastKnownId: number): Promise { try { const res = await this.db .select(METRICS_COLUMNS) @@ -72,9 +82,7 @@ class ClientMetricsDb { return []; } - destroy() { + destroy(): void { clearInterval(this.timer); } } - -module.exports = ClientMetricsDb; diff --git a/src/lib/db/client-metrics-store.test.js b/src/lib/db/client-metrics-store.test.ts similarity index 86% rename from src/lib/db/client-metrics-store.test.js rename to src/lib/db/client-metrics-store.test.ts index c264b2104d..040b3bd661 100644 --- a/src/lib/db/client-metrics-store.test.js +++ b/src/lib/db/client-metrics-store.test.ts @@ -1,8 +1,7 @@ -'use strict'; +import EventEmitter from 'events'; -const { EventEmitter } = require('events'); -const ClientMetricStore = require('./client-metrics-store'); -const getLogger = require('../../test/fixtures/no-logger'); +import { ClientMetricsStore } from './client-metrics-store'; +import getLogger from '../../test/fixtures/no-logger'; function getMockDb() { const list = [ @@ -28,7 +27,7 @@ test('should call database on startup', done => { jest.useFakeTimers('modern'); const mock = getMockDb(); const ee = new EventEmitter(); - const store = new ClientMetricStore(mock, ee, getLogger); + const store = new ClientMetricsStore(mock as any, ee, getLogger); jest.runAllTicks(); @@ -49,7 +48,7 @@ test('should start poller even if initial database fetch fails', done => { const mock = getMockDb(); mock.getMetricsLastHour = () => Promise.reject(new Error('oops')); const ee = new EventEmitter(); - const store = new ClientMetricStore(mock, ee, getLogger, 100); + const store = new ClientMetricsStore(mock as any, ee, getLogger, 100); jest.runAllTicks(); const metrics = []; @@ -74,7 +73,7 @@ test('should poll for updates', done => { jest.useFakeTimers('modern'); const mock = getMockDb(); const ee = new EventEmitter(); - const store = new ClientMetricStore(mock, ee, getLogger, 100); + const store = new ClientMetricsStore(mock as any, ee, getLogger, 100); jest.runAllTicks(); const metrics = []; diff --git a/src/lib/db/client-metrics-store.js b/src/lib/db/client-metrics-store.ts similarity index 62% rename from src/lib/db/client-metrics-store.js rename to src/lib/db/client-metrics-store.ts index 68ef06e0fc..5aaf7523fb 100644 --- a/src/lib/db/client-metrics-store.js +++ b/src/lib/db/client-metrics-store.ts @@ -1,17 +1,31 @@ 'use strict'; -const { EventEmitter } = require('events'); -const metricsHelper = require('../util/metrics-helper'); -const { DB_TIME } = require('../metric-events'); +import EventEmitter from 'events'; +import { ClientMetricsDb, IClientMetric } from './client-metrics-db'; +import { Logger, LogProvider } from '../logger'; +import metricsHelper from '../util/metrics-helper'; +import { DB_TIME } from '../metric-events'; const TEN_SECONDS = 10 * 1000; -class ClientMetricsStore extends EventEmitter { - constructor(metricsDb, eventBus, getLogger, pollInterval = TEN_SECONDS) { +export class ClientMetricsStore extends EventEmitter { + private logger: Logger; + + highestIdSeen = 0; + + private startTimer: Function; + + private timer: NodeJS.Timeout; + + constructor( + private metricsDb: ClientMetricsDb, + eventBus: EventEmitter, + getLogger: LogProvider, + pollInterval = TEN_SECONDS, + ) { super(); this.logger = getLogger('client-metrics-store.js'); this.metricsDb = metricsDb; - this.eventBus = eventBus; this.highestIdSeen = 0; this.startTimer = action => @@ -25,7 +39,7 @@ class ClientMetricsStore extends EventEmitter { }); } - async _init(pollInterval) { + async _init(pollInterval: number): Promise { try { const metrics = await this.metricsDb.getMetricsLastHour(); this._emitMetrics(metrics); @@ -36,18 +50,18 @@ class ClientMetricsStore extends EventEmitter { this.emit('ready'); } - _startPoller(pollInterval) { + _startPoller(pollInterval: number): void { this.timer = setInterval(() => this._fetchNewAndEmit(), pollInterval); this.timer.unref(); } - _fetchNewAndEmit() { + _fetchNewAndEmit(): void { this.metricsDb .getNewMetrics(this.highestIdSeen) .then(metrics => this._emitMetrics(metrics)); } - _emitMetrics(metrics) { + _emitMetrics(metrics: IClientMetric[]): void { if (metrics && metrics.length > 0) { this.highestIdSeen = metrics[metrics.length - 1].id; metrics.forEach(m => this.emit('metrics', m.metrics)); @@ -55,7 +69,7 @@ class ClientMetricsStore extends EventEmitter { } // Insert new client metrics - async insert(metrics) { + async insert(metrics: IClientMetric): Promise { const stopTimer = this.startTimer('insert'); await this.metricsDb.insert(metrics); @@ -63,10 +77,8 @@ class ClientMetricsStore extends EventEmitter { stopTimer(); } - destroy() { + destroy(): void { clearInterval(this.timer); this.metricsDb.destroy(); } } - -module.exports = ClientMetricsStore; diff --git a/src/lib/db/event-store.ts b/src/lib/db/event-store.ts index df29a0bd64..a7b5bf7d85 100644 --- a/src/lib/db/event-store.ts +++ b/src/lib/db/event-store.ts @@ -21,7 +21,7 @@ interface IEventTable { tags: []; } -interface ICreateEvent { +export interface ICreateEvent { type: string; createdBy: string; data?: any; diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 40d02ffc41..416cb30743 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -11,8 +11,8 @@ import FeatureToggleStore from './feature-toggle-store'; import FeatureTypeStore from './feature-type-store'; import StrategyStore from './strategy-store'; import ClientInstanceStore from './client-instance-store'; -import ClientMetricsDb from './client-metrics-db'; -import ClientMetricsStore from './client-metrics-store'; +import { ClientMetricsDb } from './client-metrics-db'; +import { ClientMetricsStore } from './client-metrics-store'; import ClientApplicationsStore from './client-applications-store'; import ContextFieldStore from './context-field-store'; import SettingStore from './setting-store'; diff --git a/src/lib/routes/admin-api/metrics.ts b/src/lib/routes/admin-api/metrics.ts index 31e4bb5f16..21eb1a8b46 100644 --- a/src/lib/routes/admin-api/metrics.ts +++ b/src/lib/routes/admin-api/metrics.ts @@ -106,7 +106,10 @@ class MetricsController extends Controller { async getApplications(req: Request, res: Response): Promise { try { - const applications = await this.metrics.getApplications(req.query); + const query = req.query.strategyName + ? { strategyName: req.query.strategyName as string } + : {}; + const applications = await this.metrics.getApplications(query); res.json({ applications }); } catch (err) { handleErrors(res, this.logger, err); diff --git a/src/lib/services/client-metrics/client-metrics-schema.js b/src/lib/services/client-metrics/client-metrics-schema.ts similarity index 87% rename from src/lib/services/client-metrics/client-metrics-schema.js rename to src/lib/services/client-metrics/client-metrics-schema.ts index 425b9b754a..c66a7ebb52 100644 --- a/src/lib/services/client-metrics/client-metrics-schema.js +++ b/src/lib/services/client-metrics/client-metrics-schema.ts @@ -1,6 +1,4 @@ -'use strict'; - -const joi = require('joi'); +import joi from 'joi'; const countSchema = joi .object() @@ -19,7 +17,7 @@ const countSchema = joi variants: joi.object().pattern(joi.string(), joi.number().min(0)), }); -const clientMetricsSchema = joi +export const clientMetricsSchema = joi .object() .options({ stripUnknown: true }) .keys({ @@ -34,5 +32,3 @@ const clientMetricsSchema = joi toggles: joi.object().pattern(/.*/, countSchema), }), }); - -module.exports = { clientMetricsSchema }; diff --git a/src/lib/services/client-metrics/client-metrics.test.js b/src/lib/services/client-metrics/client-metrics.test.ts similarity index 75% rename from src/lib/services/client-metrics/client-metrics.test.js rename to src/lib/services/client-metrics/client-metrics.test.ts index 3ce8beb59c..3be7bac14a 100644 --- a/src/lib/services/client-metrics/client-metrics.test.js +++ b/src/lib/services/client-metrics/client-metrics.test.ts @@ -1,21 +1,27 @@ -'use strict'; - -const moment = require('moment'); - -const { EventEmitter } = require('events'); -const UnleashClientMetrics = require('./index'); +import EventEmitter from 'events'; +import moment from 'moment'; +import ClientMetricsService, { IClientApp } from './index'; +import getLogger from '../../../test/fixtures/no-logger'; const appName = 'appName'; const instanceId = 'instanceId'; -const getLogger = require('../../../test/fixtures/no-logger'); +const createMetricsService = cms => + new ClientMetricsService( + { + clientMetricsStore: cms, + strategyStore: null, + featureToggleStore: null, + clientApplicationsStore: null, + clientInstanceStore: null, + eventStore: null, + }, + { getLogger }, + ); test('should work without state', () => { const clientMetricsStore = new EventEmitter(); - const metrics = new UnleashClientMetrics( - { clientMetricsStore }, - { getLogger }, - ); + const metrics = createMetricsService(clientMetricsStore); expect(metrics.getAppsWithToggles()).toBeTruthy(); expect(metrics.getTogglesMetrics()).toBeTruthy(); @@ -27,10 +33,7 @@ test('data should expire', () => { jest.useFakeTimers('modern'); const clientMetricsStore = new EventEmitter(); - const metrics = new UnleashClientMetrics( - { clientMetricsStore }, - { getLogger }, - ); + const metrics = createMetricsService(clientMetricsStore); metrics.addPayload({ appName, @@ -58,22 +61,20 @@ test('data should expire', () => { }); jest.advanceTimersByTime(60 * 1000); - expect(lastMinExpires === 1).toBe(true); - expect(lastHourExpires === 0).toBe(true); + expect(lastMinExpires).toBe(1); + expect(lastHourExpires).toBe(0); jest.advanceTimersByTime(60 * 60 * 1000); - expect(lastMinExpires === 1).toBe(true); - expect(lastHourExpires === 1).toBe(true); + expect(lastMinExpires).toBe(1); + expect(lastHourExpires).toBe(1); jest.useRealTimers(); + metrics.destroy(); }); test('should listen to metrics from store', () => { const clientMetricsStore = new EventEmitter(); - const metrics = new UnleashClientMetrics( - { clientMetricsStore }, - { getLogger }, - ); + const metrics = createMetricsService(clientMetricsStore); clientMetricsStore.emit('metrics', { appName, instanceId, @@ -89,8 +90,8 @@ test('should listen to metrics from store', () => { }, }); - expect(metrics.apps[appName].count === 123).toBeTruthy(); - expect(metrics.globalCount === 123).toBeTruthy(); + expect(metrics.apps[appName].count).toBe(123); + expect(metrics.globalCount).toBe(123); expect(metrics.getTogglesMetrics().lastHour.toggleX).toEqual({ yes: 123, @@ -116,7 +117,7 @@ test('should listen to metrics from store', () => { }, }); - expect(metrics.globalCount === 143).toBeTruthy(); + expect(metrics.globalCount).toBe(143); expect(metrics.getTogglesMetrics().lastHour.toggleX).toEqual({ yes: 133, no: 10, @@ -131,10 +132,7 @@ test('should listen to metrics from store', () => { test('should build up list of seen toggles when new metrics arrives', () => { const clientMetricsStore = new EventEmitter(); - const metrics = new UnleashClientMetrics( - { clientMetricsStore }, - { getLogger }, - ); + const metrics = createMetricsService(clientMetricsStore); clientMetricsStore.emit('metrics', { appName, instanceId, @@ -157,12 +155,12 @@ test('should build up list of seen toggles when new metrics arrives', () => { const appToggles = metrics.getAppsWithToggles(); const togglesForApp = metrics.getSeenTogglesByAppName(appName); - expect(appToggles).toHaveLength(1); - expect(appToggles[0].seenToggles).toHaveLength(2); + expect(appToggles.length).toBe(1); + expect(appToggles[0].seenToggles.length).toBe(2); expect(appToggles[0].seenToggles).toContain('toggleX'); expect(appToggles[0].seenToggles).toContain('toggleY'); - expect(togglesForApp).toHaveLength(2); + expect(togglesForApp.length === 2); expect(togglesForApp).toContain('toggleX'); expect(togglesForApp).toContain('toggleY'); metrics.destroy(); @@ -170,10 +168,7 @@ test('should build up list of seen toggles when new metrics arrives', () => { test('should handle a lot of toggles', () => { const clientMetricsStore = new EventEmitter(); - const metrics = new UnleashClientMetrics( - { clientMetricsStore }, - { getLogger }, - ); + const metrics = createMetricsService(clientMetricsStore); const toggleCounts = {}; for (let i = 0; i < 100; i++) { @@ -192,7 +187,7 @@ test('should handle a lot of toggles', () => { const seenToggles = metrics.getSeenTogglesByAppName(appName); - expect(seenToggles).toHaveLength(100); + expect(seenToggles.length).toBe(100); metrics.destroy(); }); @@ -200,10 +195,7 @@ test('should have correct values for lastMinute', () => { jest.useFakeTimers('modern'); const clientMetricsStore = new EventEmitter(); - const metrics = new UnleashClientMetrics( - { clientMetricsStore }, - { getLogger }, - ); + const metrics = createMetricsService(clientMetricsStore); const now = new Date(); const input = [ @@ -253,7 +245,7 @@ test('should have correct values for lastMinute', () => { }); const seenToggles = metrics.getSeenTogglesByAppName(appName); - expect(seenToggles.length === 1).toBeTruthy(); + expect(seenToggles.length).toBe(1); // metrics.se let c = metrics.getTogglesMetrics(); @@ -275,10 +267,7 @@ test('should have correct values for lastHour', () => { jest.useFakeTimers('modern'); const clientMetricsStore = new EventEmitter(); - const metrics = new UnleashClientMetrics( - { clientMetricsStore }, - { getLogger }, - ); + const metrics = createMetricsService(clientMetricsStore); const now = new Date(); const input = [ @@ -322,7 +311,7 @@ test('should have correct values for lastHour', () => { const seenToggles = metrics.getSeenTogglesByAppName(appName); - expect(seenToggles.length === 1).toBeTruthy(); + expect(seenToggles.length).toBe(1); // metrics.se let c = metrics.getTogglesMetrics(); @@ -358,10 +347,7 @@ test('should have correct values for lastHour', () => { test('should not fail when toggle metrics is missing yes/no field', () => { const clientMetricsStore = new EventEmitter(); - const metrics = new UnleashClientMetrics( - { clientMetricsStore }, - { getLogger }, - ); + const metrics = createMetricsService(clientMetricsStore); clientMetricsStore.emit('metrics', { appName, instanceId, @@ -403,20 +389,27 @@ test('should not fail when toggle metrics is missing yes/no field', () => { test('Multiple registrations of same appname and instanceid within same time period should only cause one registration', async () => { jest.useFakeTimers('modern'); - const clientMetricsStore = new EventEmitter(); + const clientMetricsStore: any = new EventEmitter(); const appStoreSpy = jest.fn(); const bulkSpy = jest.fn(); - const clientApplicationsStore = { + const clientApplicationsStore: any = { bulkUpsert: appStoreSpy, }; - const clientInstanceStore = { + const clientInstanceStore: any = { bulkUpsert: bulkSpy, }; - const clientMetrics = new UnleashClientMetrics( - { clientMetricsStore, clientApplicationsStore, clientInstanceStore }, + const clientMetrics = new ClientMetricsService( + { + clientMetricsStore, + strategyStore: null, + featureToggleStore: null, + clientApplicationsStore, + clientInstanceStore, + eventStore: null, + }, { getLogger }, ); - const client1 = { + const client1: IClientApp = { appName: 'test_app', instanceId: 'ava', strategies: [{ name: 'defaullt' }], @@ -427,30 +420,42 @@ test('Multiple registrations of same appname and instanceid within same time per await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); - jest.advanceTimersByTime(7 * 1000); + await jest.advanceTimersByTime(7 * 1000); + expect(appStoreSpy).toHaveBeenCalledTimes(1); + expect(bulkSpy).toHaveBeenCalledTimes(1); + const registrations = appStoreSpy.mock.calls[0][0]; - expect(registrations).toHaveLength(1); + + expect(registrations.length).toBe(1); expect(registrations[0].appName).toBe(client1.appName); expect(registrations[0].instanceId).toBe(client1.instanceId); expect(registrations[0].started).toBe(client1.started); expect(registrations[0].interval).toBe(client1.interval); + jest.useRealTimers(); }); test('Multiple unique clients causes multiple registrations', async () => { jest.useFakeTimers('modern'); - const clientMetricsStore = new EventEmitter(); + const clientMetricsStore: any = new EventEmitter(); const appStoreSpy = jest.fn(); const bulkSpy = jest.fn(); - const clientApplicationsStore = { + const clientApplicationsStore: any = { bulkUpsert: appStoreSpy, }; - const clientInstanceStore = { + const clientInstanceStore: any = { bulkUpsert: bulkSpy, }; - const clientMetrics = new UnleashClientMetrics( - { clientMetricsStore, clientApplicationsStore, clientInstanceStore }, + const clientMetrics = new ClientMetricsService( + { + clientMetricsStore, + strategyStore: null, + featureToggleStore: null, + clientApplicationsStore, + clientInstanceStore, + eventStore: null, + }, { getLogger }, ); const client1 = { @@ -473,70 +478,92 @@ test('Multiple unique clients causes multiple registrations', async () => { await clientMetrics.registerClient(client2, '127.0.0.1'); await clientMetrics.registerClient(client2, '127.0.0.1'); await clientMetrics.registerClient(client2, '127.0.0.1'); - jest.advanceTimersByTime(7 * 1000); + await jest.advanceTimersByTime(7 * 1000); + expect(appStoreSpy).toHaveBeenCalledTimes(1); + expect(bulkSpy).toHaveBeenCalledTimes(1); + const registrations = appStoreSpy.mock.calls[0][0]; - expect(registrations).toHaveLength(2); + + expect(registrations.length).toBe(2); jest.useRealTimers(); }); test('Same client registered outside of dedup interval will be registered twice', async () => { jest.useFakeTimers('modern'); - const clientMetricsStore = new EventEmitter(); + const clientMetricsStore: any = new EventEmitter(); const appStoreSpy = jest.fn(); const bulkSpy = jest.fn(); - const clientApplicationsStore = { + const clientApplicationsStore: any = { bulkUpsert: appStoreSpy, }; - const clientInstanceStore = { + const clientInstanceStore: any = { bulkUpsert: bulkSpy, }; + const bulkInterval = 2000; - const clientMetrics = new UnleashClientMetrics( - { clientMetricsStore, clientApplicationsStore, clientInstanceStore }, - { getLogger, bulkInterval }, + + const clientMetrics = new ClientMetricsService( + { + clientMetricsStore, + strategyStore: null, + featureToggleStore: null, + clientApplicationsStore, + clientInstanceStore, + eventStore: null, + }, + { getLogger }, + bulkInterval, ); const client1 = { appName: 'test_app', instanceId: 'client1', - strategies: [{ name: 'default' }], + strategies: [{ name: 'defaullt' }], started: new Date(), interval: 10, }; await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); - jest.advanceTimersByTime(3 * 1000); + await jest.advanceTimersByTime(3 * 1000); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); - jest.advanceTimersByTime(3 * 1000); + await jest.advanceTimersByTime(3 * 1000); expect(appStoreSpy).toHaveBeenCalledTimes(2); - const firstRegistrations = appStoreSpy.mock.calls[0][0]; - const secondRegistrations = appStoreSpy.mock.calls[1][0]; - expect(firstRegistrations[0].appName).toBe(secondRegistrations[0].appName); - expect(firstRegistrations[0].instanceId).toBe( - secondRegistrations[0].instanceId, - ); + expect(bulkSpy).toHaveBeenCalledTimes(2); + + const firstRegistrations = appStoreSpy.mock.calls[0][0][0]; + const secondRegistrations = appStoreSpy.mock.calls[1][0][0]; + + expect(firstRegistrations.appName).toBe(secondRegistrations.appName); + expect(firstRegistrations.instanceId).toBe(secondRegistrations.instanceId); jest.useRealTimers(); }); test('No registrations during a time period will not call stores', async () => { jest.useFakeTimers('modern'); - const clientMetricsStore = new EventEmitter(); + const clientMetricsStore: any = new EventEmitter(); const appStoreSpy = jest.fn(); const bulkSpy = jest.fn(); - const clientApplicationsStore = { + const clientApplicationsStore: any = { bulkUpsert: appStoreSpy, }; - const clientInstanceStore = { + const clientInstanceStore: any = { bulkUpsert: bulkSpy, }; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const metrics = new UnleashClientMetrics( - { clientMetricsStore, clientApplicationsStore, clientInstanceStore }, + const clientMetrics = new ClientMetricsService( + { + clientMetricsStore, + strategyStore: null, + featureToggleStore: null, + clientApplicationsStore, + clientInstanceStore, + eventStore: null, + }, { getLogger }, ); - jest.advanceTimersByTime(6 * 1000); + await jest.advanceTimersByTime(6 * 1000); expect(appStoreSpy).toHaveBeenCalledTimes(0); expect(bulkSpy).toHaveBeenCalledTimes(0); jest.useRealTimers(); diff --git a/src/lib/services/client-metrics/index.js b/src/lib/services/client-metrics/index.ts similarity index 54% rename from src/lib/services/client-metrics/index.js rename to src/lib/services/client-metrics/index.ts index ba47597be8..ec4d1c6fcc 100644 --- a/src/lib/services/client-metrics/index.js +++ b/src/lib/services/client-metrics/index.ts @@ -1,54 +1,148 @@ /* eslint-disable no-param-reassign */ +import EventStore, { ICreateEvent } from '../../db/event-store'; +import StrategyStore from '../../db/strategy-store'; +import ClientApplicationsDb from '../../db/client-applications-store'; +import ClientInstanceStore from '../../db/client-instance-store'; +import { ClientMetricsStore } from '../../db/client-metrics-store'; +import FeatureToggleStore from '../../db/feature-toggle-store'; +import { LogProvider } from '../../logger'; +import { applicationSchema } from './metrics-schema'; +import { Projection } from './projection'; +import { clientMetricsSchema } from './client-metrics-schema'; +import { APPLICATION_CREATED } from '../../types/events'; +import { IApplication, IYesNoCount } from './models'; +import { IUnleashStores } from '../../types/stores'; +import { IUnleashConfig } from '../../types/option'; -'use strict'; - -const Projection = require('./projection'); const TTLList = require('./ttl-list'); -const appSchema = require('./metrics-schema'); -const { clientMetricsSchema } = require('./client-metrics-schema'); const { clientRegisterSchema } = require('./register-schema'); -const { APPLICATION_CREATED } = require('../../types/events'); const FIVE_SECONDS = 5 * 1000; const FIVE_MINUTES = 5 * 60 * 1000; -module.exports = class ClientMetricsService { +export interface IClientApp { + appName: string; + instanceId: string; + clientIp?: string; + seenToggles?: string[]; + metricsCount?: number; + strategies?: string[] | Record[]; + bucket?: any; + count?: number; + started?: number | Date; + interval?: number; + icon?: string; + description?: string; + color?: string; +} + +export interface IAppFeature { + name: string; + description: string; + type: string; + project: string; + enabled: boolean; + stale: boolean; + strategies: any; + variants: any[]; + createdAt: Date; + lastSeenAt: Date; +} + +export interface IApplicationQuery { + strategyName?: string; +} + +export interface IAppName { + appName: string; +} + +export interface IMetricCounts { + yes?: number; + no?: number; + variants?: Record; +} + +export interface IMetricsBucket { + start: Date; + stop: Date; + toggles: IMetricCounts; +} + +export default class ClientMetricsService { + globalCount = 0; + + apps = {}; + + lastHourProjection = new Projection(); + + lastMinuteProjection = new Projection(); + + lastHourList = new TTLList({ + interval: 10000, + }); + + logger = null; + + lastMinuteList = new TTLList({ + interval: 10000, + expireType: 'minutes', + expireAmount: 1, + }); + + seenClients: Record = {}; + + private timers: NodeJS.Timeout[] = []; + + private clientMetricsStore: ClientMetricsStore; + + private strategyStore: StrategyStore; + + private featureToggleStore: FeatureToggleStore; + + private clientApplicationsStore: ClientApplicationsDb; + + private clientInstanceStore: ClientInstanceStore; + + private eventStore: EventStore; + + private getLogger: LogProvider; + + private bulkInterval: number; + + private announcementInterval: number; + constructor( { clientMetricsStore, strategyStore, featureToggleStore, - clientApplicationsStore, clientInstanceStore, + clientApplicationsStore, eventStore, - }, - { - getLogger, - bulkInterval = FIVE_SECONDS, - announcementInterval: appAnnouncementInterval = FIVE_MINUTES, - }, + }: Pick, + { getLogger }: Pick, + bulkInterval = FIVE_SECONDS, + announcementInterval = FIVE_MINUTES, ) { - this.globalCount = 0; - this.apps = {}; - this.strategyStore = strategyStore; - this.toggleStore = featureToggleStore; - this.clientAppStore = clientApplicationsStore; - this.clientInstanceStore = clientInstanceStore; this.clientMetricsStore = clientMetricsStore; - this.lastHourProjection = new Projection(); - this.lastMinuteProjection = new Projection(); + this.strategyStore = strategyStore; + this.featureToggleStore = featureToggleStore; + this.clientApplicationsStore = clientApplicationsStore; + this.clientInstanceStore = clientInstanceStore; this.eventStore = eventStore; - this.lastHourList = new TTLList({ - interval: 10000, - }); - this.logger = getLogger('services/client-metrics/index.ts'); + this.logger = getLogger('/services/client-metrics/index.ts'); - this.lastMinuteList = new TTLList({ - interval: 10000, - expireType: 'minutes', - expireAmount: 1, - }); + this.bulkInterval = bulkInterval; + this.announcementInterval = announcementInterval; this.lastHourList.on('expire', toggles => { Object.keys(toggles).forEach(toggleName => { @@ -66,21 +160,26 @@ module.exports = class ClientMetricsService { ); }); }); - this.seenClients = {}; - this.bulkAddTimer = setInterval(() => this.bulkAdd(), bulkInterval); - this.bulkAddTimer.unref(); - this.announceTimer = setInterval( - () => this.announceUnannounced(), - appAnnouncementInterval, + + this.timers.push( + setInterval(() => this.bulkAdd(), this.bulkInterval).unref(), + ); + this.timers.push( + setInterval( + () => this.announceUnannounced(), + this.announcementInterval, + ).unref(), ); - this.announceTimer.unref(); clientMetricsStore.on('metrics', m => this.addPayload(m)); } - async registerClientMetrics(data, clientIp) { + async registerClientMetrics( + data: IClientApp, + clientIp: string, + ): Promise { const value = await clientMetricsSchema.validateAsync(data); const toggleNames = Object.keys(value.bucket.toggles); - await this.toggleStore.lastSeenToggles(toggleNames); + await this.featureToggleStore.lastSeenToggles(toggleNames); await this.clientMetricsStore.insert(value); await this.clientInstanceStore.insert({ appName: value.appName, @@ -89,40 +188,36 @@ module.exports = class ClientMetricsService { }); } - async announceUnannounced() { - if (this.clientAppStore) { - try { - const appsToAnnounce = await this.clientAppStore.setUnannouncedToAnnounced(); - if (appsToAnnounce.length > 0) { - const events = appsToAnnounce.map(app => ({ - type: APPLICATION_CREATED, - createdBy: app.createdBy || 'unknown', - data: app, - })); - await this.eventStore.batchStore(events); - } - } catch (e) { - this.logger.warn(e); + async announceUnannounced(): Promise { + if (this.clientApplicationsStore) { + const appsToAnnounce = await this.clientApplicationsStore.setUnannouncedToAnnounced(); + if (appsToAnnounce.length > 0) { + const events = appsToAnnounce.map(app => ({ + type: APPLICATION_CREATED, + createdBy: app.createdBy || 'unknown', + data: app, + })); + await this.eventStore.batchStore(events); } } } - async registerClient(data, clientIp) { + async registerClient(data: IClientApp, clientIp: string): Promise { const value = await clientRegisterSchema.validateAsync(data); value.clientIp = clientIp; value.createdBy = clientIp; this.seenClients[this.clientKey(value)] = value; } - clientKey(client) { + clientKey(client: IClientApp): string { return `${client.appName}_${client.instanceId}`; } - async bulkAdd() { + async bulkAdd(): Promise { if ( this && this.seenClients && - this.clientAppStore && + this.clientApplicationsStore && this.clientInstanceStore ) { const uniqueRegistrations = Object.values(this.seenClients); @@ -135,7 +230,7 @@ module.exports = class ClientMetricsService { this.seenClients = {}; try { if (uniqueRegistrations.length > 0) { - await this.clientAppStore.bulkUpsert(uniqueApps); + await this.clientApplicationsStore.bulkUpsert(uniqueApps); await this.clientInstanceStore.bulkUpsert( uniqueRegistrations, ); @@ -146,7 +241,7 @@ module.exports = class ClientMetricsService { } } - appToEvent(app) { + appToEvent(app: IClientApp): ICreateEvent { return { type: APPLICATION_CREATED, createdBy: app.clientIp, @@ -154,7 +249,7 @@ module.exports = class ClientMetricsService { }; } - getAppsWithToggles() { + getAppsWithToggles(): IClientApp[] { const apps = []; Object.keys(this.apps).forEach(appName => { const seenToggles = Object.keys(this.apps[appName].seenToggles); @@ -164,15 +259,15 @@ module.exports = class ClientMetricsService { return apps; } - getSeenTogglesByAppName(appName) { + getSeenTogglesByAppName(appName: string): string[] { return this.apps[appName] ? Object.keys(this.apps[appName].seenToggles) : []; } - async getSeenApps() { + async getSeenApps(): Promise> { const seenApps = this.getSeenAppsPerToggle(); - const applications = await this.clientAppStore.getApplications(); + const applications: IApplication[] = await this.clientApplicationsStore.getApplications(); const metaData = applications.reduce((result, entry) => { // eslint-disable-next-line no-param-reassign result[entry.appName] = entry; @@ -190,11 +285,13 @@ module.exports = class ClientMetricsService { return seenApps; } - async getApplications(query) { - return this.clientAppStore.getApplications(query); + async getApplications( + query: IApplicationQuery, + ): Promise> { + return this.clientApplicationsStore.getApplications(query); } - async getApplication(appName) { + async getApplication(appName: string): Promise { const seenToggles = this.getSeenTogglesByAppName(appName); const [ application, @@ -202,10 +299,10 @@ module.exports = class ClientMetricsService { strategies, features, ] = await Promise.all([ - this.clientAppStore.getApplication(appName), + this.clientApplicationsStore.getApplication(appName), this.clientInstanceStore.getByAppName(appName), this.strategyStore.getStrategies(), - this.toggleStore.getFeatures(), + this.featureToggleStore.getFeatures(), ]); return { @@ -230,7 +327,7 @@ module.exports = class ClientMetricsService { }; } - getSeenAppsPerToggle() { + getSeenAppsPerToggle(): Record { const toggles = {}; Object.keys(this.apps).forEach(appName => { Object.keys(this.apps[appName].seenToggles).forEach( @@ -245,20 +342,20 @@ module.exports = class ClientMetricsService { return toggles; } - getTogglesMetrics() { + getTogglesMetrics(): Record> { return { lastHour: this.lastHourProjection.getProjection(), lastMinute: this.lastMinuteProjection.getProjection(), }; } - addPayload(data) { + addPayload(data: IClientApp): void { const { appName, bucket } = data; const app = this.getApp(appName); this.addBucket(app, bucket); } - getApp(appName) { + getApp(appName: string): IClientApp { this.apps[appName] = this.apps[appName] || { seenToggles: {}, count: 0, @@ -266,7 +363,7 @@ module.exports = class ClientMetricsService { return this.apps[appName]; } - createCountObject(entry) { + createCountObject(entry: IMetricCounts): IYesNoCount { let yes = typeof entry.yes === 'number' ? entry.yes : 0; let no = typeof entry.no === 'number' ? entry.no : 0; @@ -283,7 +380,7 @@ module.exports = class ClientMetricsService { return { yes, no }; } - addBucket(app, bucket) { + addBucket(app: IClientApp, bucket: IMetricsBucket): void { let count = 0; // TODO stop should be createdAt const { stop, toggles } = bucket; @@ -305,26 +402,25 @@ module.exports = class ClientMetricsService { this.addSeenToggles(app, toggleNames); } - addSeenToggles(app, toggleNames) { + addSeenToggles(app: IClientApp, toggleNames: string[]): void { toggleNames.forEach(t => { app.seenToggles[t] = true; }); } - async deleteApplication(appName) { + async deleteApplication(appName: string): Promise { await this.clientInstanceStore.deleteForApplication(appName); - await this.clientAppStore.deleteApplication(appName); + await this.clientApplicationsStore.deleteApplication(appName); } - async createApplication(input) { - const applicationData = await appSchema.validateAsync(input); - await this.clientAppStore.upsert(applicationData); + async createApplication(input: IApplication): Promise { + const applicationData = await applicationSchema.validateAsync(input); + await this.clientApplicationsStore.upsert(applicationData); } - destroy() { + destroy(): void { this.lastHourList.destroy(); this.lastMinuteList.destroy(); - clearInterval(this.announceTimer); - clearInterval(this.bulkAddTimer); + this.timers.forEach(clearInterval); } -}; +} diff --git a/src/lib/services/client-metrics/metrics-schema.js b/src/lib/services/client-metrics/metrics-schema.ts similarity index 85% rename from src/lib/services/client-metrics/metrics-schema.js rename to src/lib/services/client-metrics/metrics-schema.ts index 95f074c721..76ffe2575c 100644 --- a/src/lib/services/client-metrics/metrics-schema.js +++ b/src/lib/services/client-metrics/metrics-schema.ts @@ -1,8 +1,6 @@ -'use strict'; +import joi from 'joi'; -const joi = require('joi'); - -const applicationSchema = joi +export const applicationSchema = joi .object() .options({ stripUnknown: false }) .keys({ @@ -29,5 +27,3 @@ const applicationSchema = joi .allow('') .optional(), }); - -module.exports = applicationSchema; diff --git a/src/lib/services/client-metrics/models.ts b/src/lib/services/client-metrics/models.ts new file mode 100644 index 0000000000..e745e9ff93 --- /dev/null +++ b/src/lib/services/client-metrics/models.ts @@ -0,0 +1,27 @@ +export interface IYesNoCount { + yes: number; + no: number; +} + +export interface IAppInstance { + appName: string; + instanceId: string; + sdkVersion: string; + clientIp: string; + lastSeen: Date; + createdAt: Date; +} + +export interface IApplication { + appName: string; + sdkVersion?: string; + strategies?: string[] | any[]; + description?: string; + url?: string; + color?: string; + icon?: string; + createdAt: Date; + instances?: IAppInstance; + seenToggles: Record; + links: Record; +} diff --git a/src/lib/services/client-metrics/projection.test.js b/src/lib/services/client-metrics/projection.test.ts similarity index 94% rename from src/lib/services/client-metrics/projection.test.js rename to src/lib/services/client-metrics/projection.test.ts index 315bb7e307..3eacb0ae38 100644 --- a/src/lib/services/client-metrics/projection.test.js +++ b/src/lib/services/client-metrics/projection.test.ts @@ -1,6 +1,5 @@ -'use strict'; +import { Projection } from './projection'; -const Projection = require('./projection'); test('should return set empty if missing', () => { const projection = new Projection(); diff --git a/src/lib/services/client-metrics/projection.js b/src/lib/services/client-metrics/projection.ts similarity index 67% rename from src/lib/services/client-metrics/projection.js rename to src/lib/services/client-metrics/projection.ts index edfe84a417..00a821fb02 100644 --- a/src/lib/services/client-metrics/projection.js +++ b/src/lib/services/client-metrics/projection.ts @@ -1,15 +1,13 @@ -'use strict'; +import { IYesNoCount } from './models'; -module.exports = class Projection { - constructor() { - this.store = {}; - } +export class Projection { + store: Record = {}; - getProjection() { + getProjection(): Record { return this.store; } - add(name, countObj) { + add(name: string, countObj: IYesNoCount): void { if (this.store[name]) { this.store[name].yes += countObj.yes; this.store[name].no += countObj.no; @@ -21,7 +19,7 @@ module.exports = class Projection { } } - substract(name, countObj) { + substract(name: string, countObj: IYesNoCount): void { if (this.store[name]) { this.store[name].yes -= countObj.yes; this.store[name].no -= countObj.no; @@ -32,4 +30,4 @@ module.exports = class Projection { }; } } -}; +} diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index e0caf8e8b6..38deec2065 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -21,7 +21,6 @@ import { IUnleashStores } from '../types/stores'; import PasswordUndefinedError from '../error/password-undefined'; import EventStore from '../db/event-store'; import { USER_UPDATED, USER_CREATED, USER_DELETED } from '../types/events'; -import { IRole } from '../db/access-store'; const systemUser = new User({ id: -1, username: 'system' }); diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 7e7ab010e7..9ba6f5fc4e 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -5,7 +5,7 @@ import FeatureTypeStore from '../db/feature-type-store'; import StrategyStore from '../db/strategy-store'; import ClientApplicationsDb from '../db/client-applications-store'; import ClientInstanceStore from '../db/client-instance-store'; -import ClientMetricsStore from '../db/client-metrics-store'; +import { ClientMetricsStore } from '../db/client-metrics-store'; import FeatureToggleStore from '../db/feature-toggle-store'; import ContextFieldStore from '../db/context-field-store'; import SettingStore from '../db/setting-store'; diff --git a/src/test/e2e/services/client-metrics-service.e2e.test.js b/src/test/e2e/services/client-metrics-service.e2e.test.ts similarity index 87% rename from src/test/e2e/services/client-metrics-service.e2e.test.js rename to src/test/e2e/services/client-metrics-service.e2e.test.ts index dc72117f70..9bb592ba4e 100644 --- a/src/test/e2e/services/client-metrics-service.e2e.test.js +++ b/src/test/e2e/services/client-metrics-service.e2e.test.ts @@ -1,7 +1,10 @@ +import ClientMetricsService, { + IClientApp, +} from '../../../lib/services/client-metrics'; + const faker = require('faker'); const dbInit = require('../helpers/database-init'); const getLogger = require('../../fixtures/no-logger'); -const ClientMetricsService = require('../../../lib/services/client-metrics'); const { APPLICATION_CREATED } = require('../../../lib/types/events'); let stores; @@ -11,11 +14,12 @@ let clientMetricsService; beforeAll(async () => { db = await dbInit('client_metrics_service_serial', getLogger); stores = db.stores; - clientMetricsService = new ClientMetricsService(stores, { - getLogger, - bulkInterval: 500, - announcementInterval: 2000, - }); + clientMetricsService = new ClientMetricsService( + stores, + { getLogger }, + 500, + 2000, + ); }); afterAll(async () => { @@ -24,7 +28,7 @@ afterAll(async () => { }); test('Apps registered should be announced', async () => { expect.assertions(3); - const clientRegistration = { + const clientRegistration: IClientApp = { appName: faker.internet.domainName(), instanceId: faker.datatype.uuid(), strategies: ['default'],