1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +02:00

feat/metricsV2 (#1005)

Adds a new way of handling usage metrics where we push it directly to the database and performs aggregation on the fly. All metrics are aggregated in to buckets of hours. We will for now store metrics for the 48 hours with the following dimensions:

- featureName
- projectName
- envrionment
- yes (the actual count)
- no (the actual count)
This commit is contained in:
Ivar Conradi Østhus 2021-10-08 10:09:22 +02:00 committed by GitHub
parent 3612884501
commit fc455811f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1282 additions and 29 deletions

View File

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

View File

@ -0,0 +1,160 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger';
import {
IClientMetricsEnv,
IClientMetricsEnvKey,
IClientMetricsStoreV2,
} from '../types/stores/client-metrics-store-v2';
import NotFoundError from '../error/notfound-error';
interface ClientMetricsEnvTable {
feature_name: string;
app_name: string;
environment: string;
timestamp: Date;
yes: number;
no: number;
}
const TABLE = 'client_metrics_env';
export function roundDownToHour(date: Date): Date {
return new Date(date.getTime() - (date.getTime() % 3600000));
}
const fromRow = (row: ClientMetricsEnvTable) => ({
featureName: row.feature_name,
appName: row.app_name,
environment: row.environment,
timestamp: row.timestamp,
yes: row.yes,
no: row.no,
});
const toRow = (metric: IClientMetricsEnv) => ({
feature_name: metric.featureName,
app_name: metric.appName,
environment: metric.environment,
timestamp: roundDownToHour(metric.timestamp),
yes: metric.yes,
no: metric.no,
});
export class ClientMetricsStoreV2 implements IClientMetricsStoreV2 {
private db: Knex;
private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('client-metrics-store-v2.js');
}
async get(key: IClientMetricsEnvKey): Promise<IClientMetricsEnv> {
const row = await this.db<ClientMetricsEnvTable>(TABLE)
.where({
feature_name: key.featureName,
app_name: key.appName,
environment: key.environment,
timestamp: roundDownToHour(key.timestamp),
})
.first();
if (row) {
return fromRow(row);
}
throw new NotFoundError(`Could not find metric`);
}
async getAll(query: Object = {}): Promise<IClientMetricsEnv[]> {
const rows = await this.db<ClientMetricsEnvTable>(TABLE)
.select('*')
.where(query);
return rows.map(fromRow);
}
async exists(key: IClientMetricsEnvKey): Promise<boolean> {
try {
await this.get(key);
return true;
} catch (e) {
return false;
}
}
async delete(key: IClientMetricsEnvKey): Promise<void> {
return this.db<ClientMetricsEnvTable>(TABLE)
.where({
feature_name: key.featureName,
app_name: key.appName,
environment: key.environment,
timestamp: roundDownToHour(key.timestamp),
})
.del();
}
deleteAll(): Promise<void> {
return this.db(TABLE).del();
}
destroy(): void {
// Nothing to do!
}
// this function will collapse metrics before sending it to the database.
async batchInsertMetrics(metrics: IClientMetricsEnv[]): Promise<void> {
if (!metrics || metrics.length == 0) {
return;
}
const rows = metrics.map(toRow);
const batch = rows.reduce((prev, curr) => {
// eslint-disable-next-line prettier/prettier
const key = `${curr.feature_name}_${curr.app_name}_${curr.environment}_${curr.timestamp.getTime()}`;
if (prev[key]) {
prev[key].yes += curr.yes;
prev[key].no += curr.no;
} else {
prev[key] = curr;
}
return prev;
}, {});
// Consider rewriting to SQL batch!
const insert = this.db<ClientMetricsEnvTable>(TABLE)
.insert(Object.values(batch))
.toQuery();
const query = `${insert.toString()} ON CONFLICT (feature_name, app_name, environment, timestamp) DO UPDATE SET "yes" = "client_metrics_env"."yes" + EXCLUDED.yes, "no" = "client_metrics_env"."no" + EXCLUDED.no`;
await this.db.raw(query);
}
async getMetricsForFeatureToggle(
featureName: string,
hoursBack: number = 24,
): Promise<IClientMetricsEnv[]> {
const rows = await this.db<ClientMetricsEnvTable>(TABLE)
.select('*')
.where({ feature_name: featureName })
.andWhereRaw(`timestamp >= NOW() - INTERVAL '${hoursBack} hours'`);
return rows.map(fromRow);
}
async getSeenAppsForFeatureToggle(
featureName: string,
hoursBack: number = 24,
): Promise<string[]> {
return this.db<ClientMetricsEnvTable>(TABLE)
.distinct()
.where({ feature_name: featureName })
.andWhereRaw(`timestamp >= NOW() - INTERVAL '${hoursBack} hours'`)
.pluck('app_name')
.orderBy('app_name');
}
async clearMetrics(hoursAgo: number): Promise<void> {
return this.db<ClientMetricsEnvTable>(TABLE)
.whereRaw(`timestamp <= NOW() - INTERVAL '${hoursAgo} hours'`)
.del();
}
}

View File

@ -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),

View File

@ -0,0 +1,48 @@
import { Request, Response } from 'express';
import Controller from '../controller';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types/services';
import { Logger } from '../../logger';
import ClientMetricsServiceV2 from '../../services/client-metrics/client-metrics-service-v2';
class ClientMetricsController extends Controller {
private logger: Logger;
private metrics: ClientMetricsServiceV2;
constructor(
config: IUnleashConfig,
{
clientMetricsServiceV2,
}: Pick<IUnleashServices, 'clientMetricsServiceV2'>,
) {
super(config);
this.logger = config.getLogger('/admin-api/client-metrics.ts');
this.metrics = clientMetricsServiceV2;
this.get('/features/:name/raw', this.getRawToggleMetrics);
this.get('/features/:name', this.getToggleMetricsSummary);
}
async getRawToggleMetrics(req: Request, res: Response): Promise<void> {
const { name } = req.params;
const data = await this.metrics.getClientMetricsForToggle(name);
res.json({
version: 1,
maturity: 'experimental',
data,
});
}
async getToggleMetricsSummary(req: Request, res: Response): Promise<void> {
const { name } = req.params;
const data = await this.metrics.getFeatureToggleMetricsSummary(name);
res.json({
version: 1,
maturity: 'experimental',
...data,
});
}
}
export default ClientMetricsController;

View File

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

View File

@ -6,13 +6,14 @@ import { createTestConfig } from '../../../test/config/test-config';
import { clientMetricsSchema } from '../../services/client-metrics/client-metrics-schema';
import { createServices } from '../../services';
import { IUnleashStores } from '../../types';
import { IUnleashOptions } from '../../server-impl';
const eventBus = new EventEmitter();
function getSetup() {
function getSetup(opts?: IUnleashOptions) {
const stores = createStores();
const config = createTestConfig();
const config = createTestConfig(opts);
const services = createServices(stores, config);
const app = getApp(config, stores, services, eventBus);
@ -84,6 +85,31 @@ test('should accept client metrics with yes/no', () => {
.expect(202);
});
test('should accept client metrics with yes/no with metricsV2', async () => {
const testRunner = getSetup({
experimental: { metricsV2: { enabled: true } },
});
await testRunner.request
.post('/api/client/metrics')
.send({
appName: 'demo',
instanceId: '1',
bucket: {
start: Date.now(),
stop: Date.now(),
toggles: {
toggleA: {
yes: 200,
no: 0,
},
},
},
})
.expect(202);
testRunner.destroy();
});
test('should accept client metrics with variants', () => {
return request
.post('/api/client/metrics')

View File

@ -7,33 +7,63 @@ import { Logger } from '../../logger';
import { IAuthRequest } from '../unleash-types';
import ApiUser from '../../types/api-user';
import { ALL } from '../../types/models/api-token';
import ClientMetricsServiceV2 from '../../services/client-metrics/client-metrics-service-v2';
import { User } from '../../server-impl';
import { IClientApp } from '../../types/model';
export default class ClientMetricsController extends Controller {
logger: Logger;
metrics: ClientMetricsService;
metricsV2: ClientMetricsServiceV2;
newServiceEnabled: boolean = false;
constructor(
{
clientMetricsService,
}: Pick<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);
}
async registerMetrics(req: IAuthRequest, res: Response): Promise<void> {
const { body: data, ip: clientIp, user } = req;
private resolveEnvironment(user: User, data: IClientApp) {
if (user instanceof ApiUser) {
if (user.environment !== ALL) {
data.environment = user.environment;
return user.environment;
} else if (user.environment === ALL && data.environment) {
return data.environment;
}
}
return 'default';
}
async registerMetrics(req: IAuthRequest, res: Response): Promise<void> {
const { body: data, ip: clientIp, user } = req;
data.environment = this.resolveEnvironment(user, data);
await this.metrics.registerClientMetrics(data, clientIp);
if (this.newServiceEnabled) {
await this.metricsV2.registerClientMetrics(data, clientIp);
}
return res.status(202).end();
}
}

View File

@ -49,6 +49,7 @@ async function createApp(
metricsMonitor.stopMonitoring();
stores.clientInstanceStore.destroy();
stores.clientMetricsStore.destroy();
services.clientMetricsServiceV2.destroy();
await db.destroy();
};

View File

@ -0,0 +1,111 @@
import { Logger } from '../../logger';
import { IUnleashConfig } from '../../server-impl';
import { IUnleashStores } from '../../types';
import { IClientApp } from '../../types/model';
import { ToggleMetricsSummary } from '../../types/models/metrics';
import {
IClientMetricsEnv,
IClientMetricsStoreV2,
} from '../../types/stores/client-metrics-store-v2';
import { clientMetricsSchema } from './client-metrics-schema';
const FIVE_MINUTES = 5 * 60 * 1000;
const ONE_DAY = 24 * 60 * 60 * 1000;
export default class ClientMetricsServiceV2 {
private timer: NodeJS.Timeout;
private clientMetricsStoreV2: IClientMetricsStoreV2;
private logger: Logger;
private bulkInterval: number;
constructor(
{ clientMetricsStoreV2 }: Pick<IUnleashStores, 'clientMetricsStoreV2'>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
bulkInterval = FIVE_MINUTES,
) {
this.clientMetricsStoreV2 = clientMetricsStoreV2;
this.logger = getLogger('/services/client-metrics/index.ts');
this.bulkInterval = bulkInterval;
this.timer = setInterval(() => {
console.log('Clear metrics');
this.clientMetricsStoreV2.clearMetrics(48);
}, ONE_DAY);
this.timer.unref();
}
async registerClientMetrics(
data: IClientApp,
clientIp: string,
): Promise<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);
}
// Overview over usage last "hour" bucket and all applications using the toggle
async getFeatureToggleMetricsSummary(
featureName: string,
): Promise<ToggleMetricsSummary> {
const metrics =
await this.clientMetricsStoreV2.getMetricsForFeatureToggle(
featureName,
1,
);
const seenApplications =
await this.clientMetricsStoreV2.getSeenAppsForFeatureToggle(
featureName,
);
const groupedMetrics = metrics.reduce((prev, curr) => {
if (prev[curr.environment]) {
prev[curr.environment].yes += curr.yes;
prev[curr.environment].no += curr.no;
} else {
prev[curr.environment] = {
environment: curr.environment,
timestamp: curr.timestamp,
yes: curr.yes,
no: curr.no,
};
}
return prev;
}, {});
return {
featureName,
lastHourUsage: Object.values(groupedMetrics),
seenApplications,
};
}
async getClientMetricsForToggle(
toggleName: string,
): Promise<IClientMetricsEnv[]> {
return this.clientMetricsStoreV2.getMetricsForFeatureToggle(toggleName);
}
destroy(): void {
clearInterval(this.timer);
this.timer = null;
}
}

View File

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

View File

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

View File

@ -259,6 +259,7 @@ export interface IClientApp {
appName: string;
instanceId: string;
clientIp?: string;
environment?: string;
seenToggles?: string[];
metricsCount?: number;
strategies?: string[] | Record<string, string>[];

View File

@ -0,0 +1,12 @@
export interface GroupedClientMetrics {
environment: string;
timestamp: Date;
yes: number;
no: number;
}
export interface ToggleMetricsSummary {
featureName: string;
lastHourUsage: GroupedClientMetrics[];
seenApplications: string[];
}

View File

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

View File

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

View File

@ -0,0 +1,27 @@
import { Store } from './store';
export interface IClientMetricsEnvKey {
featureName: string;
appName: string;
environment: string;
timestamp: Date;
}
export interface IClientMetricsEnv extends IClientMetricsEnvKey {
yes: number;
no: number;
}
export interface IClientMetricsStoreV2
extends Store<IClientMetricsEnv, IClientMetricsEnvKey> {
batchInsertMetrics(metrics: IClientMetricsEnv[]): Promise<void>;
getMetricsForFeatureToggle(
featureName: string,
hoursBack?: number,
): Promise<IClientMetricsEnv[]>;
getSeenAppsForFeatureToggle(
featureName: string,
hoursBack?: number,
): Promise<string[]>;
clearMetrics(hoursAgo: number): Promise<void>;
}

View 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,
);
};

View File

@ -25,6 +25,11 @@ process.nextTick(async () => {
versionCheck: {
enable: false,
},
experimental: {
metricsV2: {
enabled: true,
},
},
}),
);
} catch (error) {

View File

@ -0,0 +1,231 @@
import dbInit, { ITestDb } from '../../helpers/database-init';
import { setupAppWithCustomConfig } from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger';
import { IClientMetricsEnv } from '../../../../lib/types/stores/client-metrics-store-v2';
let app;
let db: ITestDb;
beforeAll(async () => {
db = await dbInit('client_metrics_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: { metricsV2: { enabled: true } },
});
});
afterAll(async () => {
if (db) {
await db.destroy();
}
});
afterEach(async () => {
await db.reset();
await db.stores.clientMetricsStoreV2.deleteAll();
});
test('should return raw metrics, aggregated on key', async () => {
const date = new Date();
const metrics: IClientMetricsEnv[] = [
{
featureName: 'demo',
appName: 'web',
environment: 'default',
timestamp: date,
yes: 2,
no: 2,
},
{
featureName: 't2',
appName: 'web',
environment: 'default',
timestamp: date,
yes: 5,
no: 5,
},
{
featureName: 't2',
appName: 'web',
environment: 'default',
timestamp: date,
yes: 2,
no: 99,
},
{
featureName: 'demo',
appName: 'web',
environment: 'default',
timestamp: date,
yes: 3,
no: 2,
},
{
featureName: 'demo',
appName: 'web',
environment: 'test',
timestamp: date,
yes: 1,
no: 3,
},
];
await db.stores.clientMetricsStoreV2.batchInsertMetrics(metrics);
const { body: demo } = await app.request
.get('/api/admin/client-metrics/features/demo/raw')
.expect('Content-Type', /json/)
.expect(200);
const { body: t2 } = await app.request
.get('/api/admin/client-metrics/features/t2/raw')
.expect('Content-Type', /json/)
.expect(200);
expect(demo.data).toHaveLength(2);
expect(demo.data[0].environment).toBe('default');
expect(demo.data[0].yes).toBe(5);
expect(demo.data[0].no).toBe(4);
expect(demo.data[1].environment).toBe('test');
expect(demo.data[1].yes).toBe(1);
expect(demo.data[1].no).toBe(3);
expect(t2.data).toHaveLength(1);
expect(t2.data[0].environment).toBe('default');
expect(t2.data[0].yes).toBe(7);
expect(t2.data[0].no).toBe(104);
});
test('should return toggle summary', async () => {
const date = new Date();
const metrics: IClientMetricsEnv[] = [
{
featureName: 'demo',
appName: 'web',
environment: 'default',
timestamp: date,
yes: 2,
no: 2,
},
{
featureName: 't2',
appName: 'web',
environment: 'default',
timestamp: date,
yes: 5,
no: 5,
},
{
featureName: 't2',
appName: 'web',
environment: 'default',
timestamp: date,
yes: 2,
no: 99,
},
{
featureName: 'demo',
appName: 'web',
environment: 'default',
timestamp: date,
yes: 3,
no: 2,
},
{
featureName: 'demo',
appName: 'web',
environment: 'test',
timestamp: date,
yes: 1,
no: 3,
},
{
featureName: 'demo',
appName: 'backend-api',
environment: 'test',
timestamp: date,
yes: 1,
no: 3,
},
];
await db.stores.clientMetricsStoreV2.batchInsertMetrics(metrics);
const { body: demo } = await app.request
.get('/api/admin/client-metrics/features/demo')
.expect('Content-Type', /json/)
.expect(200);
expect(demo.featureName).toBe('demo');
expect(demo.lastHourUsage).toHaveLength(2);
expect(demo.lastHourUsage[0].environment).toBe('default');
expect(demo.lastHourUsage[0].yes).toBe(5);
expect(demo.lastHourUsage[0].no).toBe(4);
expect(demo.lastHourUsage[1].environment).toBe('test');
expect(demo.lastHourUsage[1].yes).toBe(2);
expect(demo.lastHourUsage[1].no).toBe(6);
expect(demo.seenApplications).toStrictEqual(['backend-api', 'web']);
});
test('should only include last hour of metrics return toggle summary', async () => {
const date = new Date();
const dateHoneHourAgo = new Date();
dateHoneHourAgo.setHours(-1);
const metrics: IClientMetricsEnv[] = [
{
featureName: 'demo',
appName: 'web',
environment: 'default',
timestamp: date,
yes: 2,
no: 2,
},
{
featureName: 'demo',
appName: 'web',
environment: 'default',
timestamp: date,
yes: 3,
no: 2,
},
{
featureName: 'demo',
appName: 'web',
environment: 'test',
timestamp: date,
yes: 1,
no: 3,
},
{
featureName: 'demo',
appName: 'backend-api',
environment: 'test',
timestamp: date,
yes: 1,
no: 3,
},
{
featureName: 'demo',
appName: 'backend-api',
environment: 'test',
timestamp: dateHoneHourAgo,
yes: 55,
no: 55,
},
];
await db.stores.clientMetricsStoreV2.batchInsertMetrics(metrics);
const { body: demo } = await app.request
.get('/api/admin/client-metrics/features/demo')
.expect('Content-Type', /json/)
.expect(200);
expect(demo.featureName).toBe('demo');
expect(demo.lastHourUsage).toHaveLength(2);
expect(demo.lastHourUsage[0].environment).toBe('default');
expect(demo.lastHourUsage[0].yes).toBe(5);
expect(demo.lastHourUsage[0].no).toBe(4);
expect(demo.lastHourUsage[1].environment).toBe('test');
expect(demo.lastHourUsage[1].yes).toBe(2);
expect(demo.lastHourUsage[1].no).toBe(6);
expect(demo.seenApplications).toStrictEqual(['backend-api', 'web']);
});

View File

@ -102,7 +102,7 @@ afterAll(async () => {
});
test('returns four feature toggles', async () => {
app.request
return app.request
.get('/api/client/features')
.expect('Content-Type', /json/)
.expect(200)
@ -111,19 +111,18 @@ test('returns four feature toggles', async () => {
});
});
test('returns four feature toggles without createdAt', async () =>
app.request
test('returns four feature toggles without createdAt', async () => {
return app.request
.get('/api/client/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features).toHaveLength(4);
expect(res.body.features[0].createdAt).toBeFalsy();
}));
});
});
test('gets a feature by name', async () => {
expect.assertions(0);
return app.request
.get('/api/client/features/featureX')
.expect('Content-Type', /json/)
@ -131,8 +130,6 @@ test('gets a feature by name', async () => {
});
test('cant get feature that does not exist', async () => {
expect.assertions(0);
return app.request
.get('/api/client/features/myfeature')
.expect('Content-Type', /json/)
@ -140,8 +137,6 @@ test('cant get feature that does not exist', async () => {
});
test('Can filter features by namePrefix', async () => {
expect.assertions(2);
return app.request
.get('/api/client/features?namePrefix=feature.')
.expect('Content-Type', /json/)

View File

@ -8,7 +8,7 @@ let db: ITestDb;
const featureName = 'feature.default.1';
beforeAll(async () => {
db = await dbInit('feature_api_client', getLogger);
db = await dbInit('feature_env_api_client', getLogger);
app = await setupApp(db.stores);
await app.services.featureToggleServiceV2.createFeatureToggle(

View File

@ -16,8 +16,7 @@ afterAll(async () => {
await db.destroy();
});
test('should be possble to send metrics', async () => {
expect.assertions(0);
test('should be possible to send metrics', async () => {
return app.request
.post('/api/client/metrics')
.send(metricsExample)
@ -25,7 +24,6 @@ test('should be possble to send metrics', async () => {
});
test('should require valid send metrics', async () => {
expect.assertions(0);
return app.request
.post('/api/client/metrics')
.send({
@ -34,8 +32,7 @@ test('should require valid send metrics', async () => {
.expect(400);
});
test('should accept client metrics', async () => {
expect.assertions(0);
test('should accept empty client metrics', async () => {
return app.request
.post('/api/client/metrics')
.send({

View File

@ -0,0 +1,100 @@
import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper';
import metricsExample from '../../../examples/client-metrics.json';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { ApiTokenType } from '../../../../lib/types/models/api-token';
let app: IUnleashTest;
let db: ITestDb;
let defaultToken;
beforeAll(async () => {
db = await dbInit('metrics_two_api_client', getLogger);
app = await setupAppWithAuth(db.stores, {
experimental: { metricsV2: { enabled: true } },
});
defaultToken = await app.services.apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
project: 'default',
environment: 'default',
username: 'tester',
});
});
afterEach(async () => {
await db.stores.clientMetricsStoreV2.deleteAll();
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('should be possible to send metrics', async () => {
return app.request
.post('/api/client/metrics')
.set('Authorization', defaultToken.secret)
.send(metricsExample)
.expect(202);
});
test('should require valid send metrics', async () => {
return app.request
.post('/api/client/metrics')
.set('Authorization', defaultToken.secret)
.send({
appName: 'test',
})
.expect(400);
});
test('should accept client metrics', async () => {
return app.request
.post('/api/client/metrics')
.set('Authorization', defaultToken.secret)
.send({
appName: 'demo',
instanceId: '1',
bucket: {
start: Date.now(),
stop: Date.now(),
toggles: {},
},
})
.expect(202);
});
test('should pick up environment from token', async () => {
const environment = 'test';
await db.stores.environmentStore.create({ name: 'test', type: 'test' });
const token = await app.services.apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
project: 'default',
environment,
username: 'tester',
});
await app.request
.post('/api/client/metrics')
.set('Authorization', token.secret)
.send({
appName: 'some-fancy-app',
instanceId: '1',
bucket: {
start: Date.now(),
stop: Date.now(),
toggles: {
test: {
yes: 100,
no: 50,
},
},
},
})
.expect(202);
const metrics = await db.stores.clientMetricsStoreV2.getAll();
expect(metrics[0].environment).toBe('test');
expect(metrics[0].appName).toBe('some-fancy-app');
});

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import supertest from 'supertest';
import EventEmitter from 'events';
@ -62,7 +63,6 @@ export async function setupApp(stores: IUnleashStores): Promise<IUnleashTest> {
export async function setupAppWithCustomConfig(
stores: IUnleashStores,
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
customOptions: any,
): Promise<IUnleashTest> {
return createApp(stores, undefined, undefined, customOptions);
@ -70,8 +70,9 @@ export async function setupAppWithCustomConfig(
export async function setupAppWithAuth(
stores: IUnleashStores,
customOptions?: any,
): Promise<IUnleashTest> {
return createApp(stores, IAuthType.DEMO);
return createApp(stores, IAuthType.DEMO, undefined, customOptions);
}
export async function setupAppWithCustomAuth(

View File

@ -0,0 +1,397 @@
import dbInit from '../helpers/database-init';
import getLogger from '../../fixtures/no-logger';
import { IUnleashStores } from '../../../lib/types';
import {
IClientMetricsEnv,
IClientMetricsStoreV2,
} from '../../../lib/types/stores/client-metrics-store-v2';
import { roundDownToHour } from '../../../lib/db/client-metrics-store-v2';
let db;
let stores: IUnleashStores;
let clientMetricsStore: IClientMetricsStoreV2;
beforeEach(async () => {
db = await dbInit('client_metrics_store_v2_e2e_serial', getLogger);
stores = db.stores;
clientMetricsStore = stores.clientMetricsStoreV2;
});
afterEach(async () => {
await db.destroy();
});
test('Should store single list of metrics', async () => {
const metrics: IClientMetricsEnv[] = [
{
featureName: 'demo',
appName: 'web',
environment: 'dev',
timestamp: new Date(),
yes: 2,
no: 2,
},
];
await clientMetricsStore.batchInsertMetrics(metrics);
const savedMetrics = await clientMetricsStore.getAll();
expect(savedMetrics).toHaveLength(1);
});
test('Should "increment" metrics within same hour', async () => {
const metrics: IClientMetricsEnv[] = [
{
featureName: 'demo',
appName: 'web',
environment: 'dev',
timestamp: new Date(),
yes: 2,
no: 2,
},
{
featureName: 'demo',
appName: 'web',
environment: 'dev',
timestamp: new Date(),
yes: 1,
no: 3,
},
];
await clientMetricsStore.batchInsertMetrics(metrics);
const savedMetrics = await clientMetricsStore.getAll();
expect(savedMetrics).toHaveLength(1);
expect(savedMetrics[0].yes).toBe(3);
expect(savedMetrics[0].no).toBe(5);
});
test('Should get individual metrics outside same hour', async () => {
const d1 = new Date();
const d2 = new Date();
d1.setHours(10, 10, 11);
d2.setHours(11, 10, 11);
const metrics: IClientMetricsEnv[] = [
{
featureName: 'demo',
appName: 'web',
environment: 'dev',
timestamp: d1,
yes: 2,
no: 2,
},
{
featureName: 'demo',
appName: 'web',
environment: 'dev',
timestamp: d2,
yes: 1,
no: 3,
},
];
await clientMetricsStore.batchInsertMetrics(metrics);
const savedMetrics = await clientMetricsStore.getAll();
expect(savedMetrics).toHaveLength(2);
expect(savedMetrics[0].yes).toBe(2);
expect(savedMetrics[0].no).toBe(2);
});
test('Should insert hundred metrics in a row', async () => {
const metrics: IClientMetricsEnv[] = [];
const date = new Date();
for (let i = 0; i < 100; i++) {
metrics.push({
featureName: 'demo',
appName: 'web',
environment: 'dev',
timestamp: date,
yes: i,
no: i + 1,
});
}
await clientMetricsStore.batchInsertMetrics(metrics);
const savedMetrics = await clientMetricsStore.getAll();
expect(savedMetrics).toHaveLength(1);
expect(savedMetrics[0].yes).toBe(4950);
expect(savedMetrics[0].no).toBe(5050);
});
test('Should insert individual rows for different apps', async () => {
const metrics: IClientMetricsEnv[] = [];
const date = new Date();
for (let i = 0; i < 10; i++) {
metrics.push({
featureName: 'demo',
appName: `web-${i}`,
environment: 'dev',
timestamp: date,
yes: 2,
no: 2,
});
}
await clientMetricsStore.batchInsertMetrics(metrics);
const savedMetrics = await clientMetricsStore.getAll();
expect(savedMetrics).toHaveLength(10);
expect(savedMetrics[0].yes).toBe(2);
expect(savedMetrics[0].no).toBe(2);
});
test('Should insert individual rows for different toggles', async () => {
const metrics: IClientMetricsEnv[] = [];
const date = new Date();
for (let i = 0; i < 10; i++) {
metrics.push({
featureName: `app-${i}`,
appName: `web`,
environment: 'dev',
timestamp: date,
yes: 2,
no: 2,
});
}
await clientMetricsStore.batchInsertMetrics(metrics);
const savedMetrics = await clientMetricsStore.getAll();
expect(savedMetrics).toHaveLength(10);
expect(savedMetrics[0].yes).toBe(2);
expect(savedMetrics[0].no).toBe(2);
});
test('Should get toggle metrics', async () => {
const metrics: IClientMetricsEnv[] = [];
const date = new Date();
for (let i = 0; i < 100; i++) {
metrics.push({
featureName: 'demo',
appName: 'web',
environment: 'dev',
timestamp: date,
yes: i,
no: i + 1,
});
}
await clientMetricsStore.batchInsertMetrics(metrics);
const savedMetrics = await clientMetricsStore.getMetricsForFeatureToggle(
'demo',
);
expect(savedMetrics).toHaveLength(1);
expect(savedMetrics[0].yes).toBe(4950);
expect(savedMetrics[0].no).toBe(5050);
});
test('Should insert 1500 feature toggle metrics', async () => {
const metrics: IClientMetricsEnv[] = [];
const date = new Date();
for (let i = 0; i < 1500; i++) {
metrics.push({
featureName: `demo-${i}`,
appName: `web`,
environment: 'dev',
timestamp: date,
yes: 2,
no: 2,
});
}
await clientMetricsStore.batchInsertMetrics(metrics);
const savedMetrics = await clientMetricsStore.getAll();
expect(savedMetrics).toHaveLength(1500);
});
test('Should return seen applications using a feature toggle', async () => {
const metrics: IClientMetricsEnv[] = [
{
featureName: 'demo',
appName: 'web',
environment: 'dev',
timestamp: new Date(),
yes: 2,
no: 2,
},
{
featureName: 'demo',
appName: 'backend-api',
environment: 'dev',
timestamp: new Date(),
yes: 1,
no: 3,
},
{
featureName: 'demo',
appName: 'backend-api',
environment: 'dev',
timestamp: new Date(),
yes: 1,
no: 3,
},
];
await clientMetricsStore.batchInsertMetrics(metrics);
const apps = await clientMetricsStore.getSeenAppsForFeatureToggle('demo');
expect(apps).toHaveLength(2);
expect(apps).toStrictEqual(['backend-api', 'web']);
});
test('Should not fail on empty list of metrics', async () => {
await clientMetricsStore.batchInsertMetrics([]);
const all = await clientMetricsStore.getAll();
expect(all).toHaveLength(0);
});
test('Should not fail on undefined list of metrics', async () => {
await clientMetricsStore.batchInsertMetrics(undefined);
const all = await clientMetricsStore.getAll();
expect(all).toHaveLength(0);
});
test('Should return delete old metric', async () => {
const twoDaysAgo = new Date();
twoDaysAgo.setHours(-48);
const metrics: IClientMetricsEnv[] = [
{
featureName: 'demo1',
appName: 'web',
environment: 'dev',
timestamp: new Date(),
yes: 2,
no: 2,
},
{
featureName: 'demo2',
appName: 'backend-api',
environment: 'dev',
timestamp: new Date(),
yes: 1,
no: 3,
},
{
featureName: 'demo3',
appName: 'backend-api',
environment: 'dev',
timestamp: twoDaysAgo,
yes: 1,
no: 3,
},
{
featureName: 'demo4',
appName: 'backend-api',
environment: 'dev',
timestamp: twoDaysAgo,
yes: 1,
no: 3,
},
];
await clientMetricsStore.batchInsertMetrics(metrics);
await clientMetricsStore.clearMetrics(24);
const all = await clientMetricsStore.getAll();
expect(all).toHaveLength(2);
expect(all[0].featureName).toBe('demo1');
expect(all[1].featureName).toBe('demo2');
});
test('Should get metric', async () => {
const twoDaysAgo = new Date();
twoDaysAgo.setHours(-48);
const metrics: IClientMetricsEnv[] = [
{
featureName: 'demo1',
appName: 'web',
environment: 'dev',
timestamp: new Date(),
yes: 2,
no: 2,
},
{
featureName: 'demo2',
appName: 'backend-api',
environment: 'dev',
timestamp: new Date(),
yes: 1,
no: 3,
},
{
featureName: 'demo3',
appName: 'backend-api',
environment: 'dev',
timestamp: twoDaysAgo,
yes: 1,
no: 3,
},
{
featureName: 'demo4',
appName: 'backend-api',
environment: 'dev',
timestamp: twoDaysAgo,
yes: 41,
no: 42,
},
];
await clientMetricsStore.batchInsertMetrics(metrics);
const metric = await clientMetricsStore.get({
featureName: 'demo4',
timestamp: twoDaysAgo,
appName: 'backend-api',
environment: 'dev',
});
expect(metric.featureName).toBe('demo4');
expect(metric.yes).toBe(41);
expect(metric.no).toBe(42);
});
test('Should not exists after delete', async () => {
const metric = {
featureName: 'demo4',
appName: 'backend-api',
environment: 'dev',
timestamp: new Date(),
yes: 41,
no: 42,
};
const metrics: IClientMetricsEnv[] = [metric];
await clientMetricsStore.batchInsertMetrics(metrics);
const existBefore = await clientMetricsStore.exists(metric);
expect(existBefore).toBe(true);
await clientMetricsStore.delete(metric);
const existAfter = await clientMetricsStore.exists(metric);
expect(existAfter).toBe(false);
});
test('should floor hours as expected', () => {
expect(
roundDownToHour(new Date('2019-11-12T08:44:32.499Z')).toISOString(),
).toBe('2019-11-12T08:00:00.000Z');
expect(
roundDownToHour(new Date('2019-11-12T08:59:59.999Z')).toISOString(),
).toBe('2019-11-12T08:00:00.000Z');
expect(
roundDownToHour(new Date('2019-11-12T09:01:00.999Z')).toISOString(),
).toBe('2019-11-12T09:00:00.000Z');
});

View File

@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/lines-between-class-members */
/* eslint-disable @typescript-eslint/no-unused-vars */
import EventEmitter from 'events';
import { IClientMetric } from '../../lib/types/stores/client-metrics-db';
import {
IClientMetricsEnv,
IClientMetricsEnvKey,
IClientMetricsStoreV2,
} from '../../lib/types/stores/client-metrics-store-v2';
export default class FakeClientMetricsStoreV2
extends EventEmitter
implements IClientMetricsStoreV2
{
metrics: IClientMetricsEnv[] = [];
constructor() {
super();
this.setMaxListeners(0);
}
clearMetrics(hoursBack: number): Promise<void> {
return Promise.resolve();
}
getSeenAppsForFeatureToggle(
featureName: string,
hoursBack?: number,
): Promise<string[]> {
throw new Error('Method not implemented.');
}
getMetricsForFeatureToggle(
featureName: string,
hoursBack?: number,
): Promise<IClientMetricsEnv[]> {
throw new Error('Method not implemented.');
}
batchInsertMetrics(metrics: IClientMetricsEnv[]): Promise<void> {
metrics.forEach((m) => this.metrics.push(m));
return Promise.resolve();
}
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 {}
}

View File

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