1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

chore: Convert client metrics controller to typescript (#831)

Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com>
This commit is contained in:
checketts 2021-06-24 11:22:12 -06:00 committed by GitHub
parent 00f2c7312d
commit 2f013bacbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 406 additions and 242 deletions

View File

@ -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<void> {
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<void> {
return this.db(TABLE).insert({ metrics });
}
// Used at startup to load all metrics last week into memory!
async getMetricsLastHour() {
async getMetricsLastHour(): Promise<IClientMetric[]> {
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<IClientMetric[]> {
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;

View File

@ -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 = [];

View File

@ -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<void> {
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<void> {
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;

View File

@ -21,7 +21,7 @@ interface IEventTable {
tags: [];
}
interface ICreateEvent {
export interface ICreateEvent {
type: string;
createdBy: string;
data?: any;

View File

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

View File

@ -106,7 +106,10 @@ class MetricsController extends Controller {
async getApplications(req: Request, res: Response): Promise<void> {
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);

View File

@ -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 };

View File

@ -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();

View File

@ -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<string, string>[];
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<string, number>;
}
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<string, IClientApp> = {};
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<IUnleashStores,
| 'clientMetricsStore'
| 'strategyStore'
| 'featureToggleStore'
| 'clientApplicationsStore'
| 'clientInstanceStore'
| 'eventStore'
>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
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<void> {
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<void> {
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<void> {
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<void> {
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<Record<string, IApplication[]>> {
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<Record<string, IApplication>> {
return this.clientApplicationsStore.getApplications(query);
}
async getApplication(appName) {
async getApplication(appName: string): Promise<IApplication> {
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<string, IApplication[]> {
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<string, Record<string, IYesNoCount>> {
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<void> {
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<void> {
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);
}
};
}

View File

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

View File

@ -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<string, any>;
links: Record<string, string>;
}

View File

@ -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();

View File

@ -1,15 +1,13 @@
'use strict';
import { IYesNoCount } from './models';
module.exports = class Projection {
constructor() {
this.store = {};
}
export class Projection {
store: Record<string, IYesNoCount> = {};
getProjection() {
getProjection(): Record<string, IYesNoCount> {
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 {
};
}
}
};
}

View File

@ -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' });

View File

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

View File

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