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:
parent
3612884501
commit
fc455811f8
@ -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;
|
||||
|
160
src/lib/db/client-metrics-store-v2.ts
Normal file
160
src/lib/db/client-metrics-store-v2.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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),
|
||||
|
48
src/lib/routes/admin-api/client-metrics.ts
Normal file
48
src/lib/routes/admin-api/client-metrics.ts
Normal 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;
|
@ -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',
|
||||
|
@ -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')
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -49,6 +49,7 @@ async function createApp(
|
||||
metricsMonitor.stopMonitoring();
|
||||
stores.clientInstanceStore.destroy();
|
||||
stores.clientMetricsStore.destroy();
|
||||
services.clientMetricsServiceV2.destroy();
|
||||
await db.destroy();
|
||||
};
|
||||
|
||||
|
111
src/lib/services/client-metrics/client-metrics-service-v2.ts
Normal file
111
src/lib/services/client-metrics/client-metrics-service-v2.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -259,6 +259,7 @@ export interface IClientApp {
|
||||
appName: string;
|
||||
instanceId: string;
|
||||
clientIp?: string;
|
||||
environment?: string;
|
||||
seenToggles?: string[];
|
||||
metricsCount?: number;
|
||||
strategies?: string[] | Record<string, string>[];
|
||||
|
12
src/lib/types/models/metrics.ts
Normal file
12
src/lib/types/models/metrics.ts
Normal 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[];
|
||||
}
|
@ -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;
|
||||
|
27
src/lib/types/stores/client-metrics-store-v2.ts
Normal file
27
src/lib/types/stores/client-metrics-store-v2.ts
Normal 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>;
|
||||
}
|
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) {
|
||||
|
231
src/test/e2e/api/admin/client-metrics.e2e.test.ts
Normal file
231
src/test/e2e/api/admin/client-metrics.e2e.test.ts
Normal 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']);
|
||||
});
|
@ -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/)
|
||||
|
@ -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(
|
||||
|
@ -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({
|
||||
|
100
src/test/e2e/api/client/metricsV2.e2e.test.ts
Normal file
100
src/test/e2e/api/client/metricsV2.e2e.test.ts
Normal 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');
|
||||
});
|
@ -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(
|
||||
|
397
src/test/e2e/stores/client-metrics-store-v2.e2e.test.ts
Normal file
397
src/test/e2e/stores/client-metrics-store-v2.e2e.test.ts
Normal 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');
|
||||
});
|
66
src/test/fixtures/fake-client-metrics-store-v2.ts
vendored
Normal file
66
src/test/fixtures/fake-client-metrics-store-v2.ts
vendored
Normal 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 {}
|
||||
}
|
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