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