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) {
|
||||
return envVar === 'true' || envVar === '1' || envVar === 't';
|
||||
}
|
||||
@ -224,6 +224,10 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
|
||||
|
||||
const experimental = options.experimental || {};
|
||||
|
||||
if (safeBoolean(process.env.EXP_METRICS_V2, false)) {
|
||||
experimental.metricsV2 = { enabled: true };
|
||||
}
|
||||
|
||||
const email: IEmailOption = mergeAll([defaultEmail, options.email]);
|
||||
|
||||
let listen: IListeningPipe | IListeningHost;
|
||||
|
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 FeatureTagStore from './feature-tag-store';
|
||||
import { FeatureEnvironmentStore } from './feature-environment-store';
|
||||
import { ClientMetricsStoreV2 } from './client-metrics-store-v2';
|
||||
|
||||
export const createStores = (
|
||||
config: IUnleashConfig,
|
||||
@ -54,6 +55,7 @@ export const createStores = (
|
||||
eventBus,
|
||||
getLogger,
|
||||
),
|
||||
clientMetricsStoreV2: new ClientMetricsStoreV2(db, getLogger),
|
||||
contextFieldStore: new ContextFieldStore(db, getLogger),
|
||||
settingStore: new SettingStore(db, getLogger),
|
||||
userStore: new UserStore(db, getLogger),
|
||||
|
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 ConfigController from './config';
|
||||
import ContextController from './context';
|
||||
import ClientMetricsController from './client-metrics';
|
||||
import BootstrapController from './bootstrap-controller';
|
||||
import StateController from './state';
|
||||
import TagController from './tag';
|
||||
@ -49,6 +50,10 @@ class AdminApi extends Controller {
|
||||
'/metrics',
|
||||
new MetricsController(config, services).router,
|
||||
);
|
||||
this.app.use(
|
||||
'/client-metrics',
|
||||
new ClientMetricsController(config, services).router,
|
||||
);
|
||||
this.app.use('/user', new UserController(config, services).router);
|
||||
this.app.use(
|
||||
'/ui-config',
|
||||
|
@ -7,21 +7,37 @@ import { Logger } from '../../logger';
|
||||
import { IAuthRequest } from '../unleash-types';
|
||||
import ApiUser from '../../types/api-user';
|
||||
import { ALL } from '../../types/models/api-token';
|
||||
import ClientMetricsServiceV2 from '../../services/client-metrics/client-metrics-service-v2';
|
||||
|
||||
export default class ClientMetricsController extends Controller {
|
||||
logger: Logger;
|
||||
|
||||
metrics: ClientMetricsService;
|
||||
|
||||
metricsV2: ClientMetricsServiceV2;
|
||||
|
||||
newServiceEnabled: boolean = false;
|
||||
|
||||
constructor(
|
||||
{
|
||||
clientMetricsService,
|
||||
}: Pick<IUnleashServices, 'clientMetricsService'>,
|
||||
clientMetricsServiceV2,
|
||||
}: Pick<
|
||||
IUnleashServices,
|
||||
'clientMetricsService' | 'clientMetricsServiceV2'
|
||||
>,
|
||||
config: IUnleashConfig,
|
||||
) {
|
||||
super(config);
|
||||
this.logger = config.getLogger('/api/client/metrics');
|
||||
const { experimental, getLogger } = config;
|
||||
if (experimental && experimental.metricsV2) {
|
||||
//@ts-ignore
|
||||
this.newServiceEnabled = experimental.metricsV2.enabled;
|
||||
}
|
||||
|
||||
this.logger = getLogger('/api/client/metrics');
|
||||
this.metrics = clientMetricsService;
|
||||
this.metricsV2 = clientMetricsServiceV2;
|
||||
|
||||
this.post('/', this.registerMetrics);
|
||||
}
|
||||
@ -34,6 +50,11 @@ export default class ClientMetricsController extends Controller {
|
||||
}
|
||||
}
|
||||
await this.metrics.registerClientMetrics(data, clientIp);
|
||||
|
||||
if (this.newServiceEnabled) {
|
||||
await this.metricsV2.registerClientMetrics(data, clientIp);
|
||||
}
|
||||
|
||||
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 { Projection } from './projection';
|
||||
import { clientMetricsSchema } from './client-metrics-schema';
|
||||
@ -66,8 +65,6 @@ export default class ClientMetricsService {
|
||||
|
||||
private eventStore: IEventStore;
|
||||
|
||||
private getLogger: LogProvider;
|
||||
|
||||
private bulkInterval: number;
|
||||
|
||||
private announcementInterval: number;
|
||||
|
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 StateService from './state-service';
|
||||
import ClientMetricsService from './client-metrics';
|
||||
import ClientMetricsServiceV2 from './client-metrics/client-metrics-service-v2';
|
||||
import TagTypeService from './tag-type-service';
|
||||
import TagService from './tag-service';
|
||||
import StrategyService from './strategy-service';
|
||||
@ -34,6 +35,7 @@ export const createServices = (
|
||||
const accessService = new AccessService(stores, config);
|
||||
const apiTokenService = new ApiTokenService(stores, config);
|
||||
const clientMetricsService = new ClientMetricsService(stores, config);
|
||||
const clientMetricsServiceV2 = new ClientMetricsServiceV2(stores, config);
|
||||
const contextService = new ContextService(stores, config);
|
||||
const emailService = new EmailService(config.email, config.getLogger);
|
||||
const eventService = new EventService(stores, config);
|
||||
@ -82,6 +84,7 @@ export const createServices = (
|
||||
tagTypeService,
|
||||
tagService,
|
||||
clientMetricsService,
|
||||
clientMetricsServiceV2,
|
||||
contextService,
|
||||
versionService,
|
||||
apiTokenService,
|
||||
|
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 FeatureTagService from '../services/feature-tag-service';
|
||||
import ProjectHealthService from '../services/project-health-service';
|
||||
import ClientMetricsServiceV2 from '../services/client-metrics/client-metrics-service-v2';
|
||||
|
||||
export interface IUnleashServices {
|
||||
accessService: AccessService;
|
||||
addonService: AddonService;
|
||||
apiTokenService: ApiTokenService;
|
||||
clientMetricsService: ClientMetricsService;
|
||||
clientMetricsServiceV2: ClientMetricsServiceV2;
|
||||
contextService: ContextService;
|
||||
emailService: EmailService;
|
||||
environmentService: EnvironmentService;
|
||||
|
@ -22,6 +22,7 @@ import { IFeatureEnvironmentStore } from './stores/feature-environment-store';
|
||||
import { IFeatureStrategiesStore } from './stores/feature-strategies-store';
|
||||
import { IEnvironmentStore } from './stores/environment-store';
|
||||
import { IFeatureToggleClientStore } from './stores/feature-toggle-client-store';
|
||||
import { IClientMetricsStoreV2 } from './stores/client-metrics-store-v2';
|
||||
|
||||
export interface IUnleashStores {
|
||||
accessStore: IAccessStore;
|
||||
@ -30,6 +31,7 @@ export interface IUnleashStores {
|
||||
clientApplicationsStore: IClientApplicationsStore;
|
||||
clientInstanceStore: IClientInstanceStore;
|
||||
clientMetricsStore: IClientMetricsStore;
|
||||
clientMetricsStoreV2: IClientMetricsStoreV2;
|
||||
contextFieldStore: IContextFieldStore;
|
||||
environmentStore: IEnvironmentStore;
|
||||
eventStore: IEventStore;
|
||||
|
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: {
|
||||
enable: false,
|
||||
},
|
||||
experimental: {
|
||||
metricsV2: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} 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 FakeResetTokenStore from './fake-reset-token-store';
|
||||
import FakeFeatureToggleClientStore from './fake-feature-toggle-client-store';
|
||||
import FakeClientMetricsStoreV2 from './fake-client-metrics-store-v2';
|
||||
|
||||
const createStores: () => IUnleashStores = () => {
|
||||
const db = {
|
||||
@ -35,6 +36,7 @@ const createStores: () => IUnleashStores = () => {
|
||||
db,
|
||||
clientApplicationsStore: new FakeClientApplicationsStore(),
|
||||
clientMetricsStore: new FakeClientMetricsStore(),
|
||||
clientMetricsStoreV2: new FakeClientMetricsStoreV2(),
|
||||
clientInstanceStore: new FakeClientInstanceStore(),
|
||||
featureToggleStore: new FakeFeatureToggleStore(),
|
||||
featureToggleClientStore: new FakeFeatureToggleClientStore(),
|
||||
|
Loading…
Reference in New Issue
Block a user