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

refactor: batch feature metric inserts (#1928)

* refactor: batch feature metric inserts

* fix: use startOfHour when collapsing metrics

* refactor: avoid extra loop to sum yes/no values

* refactor: add experimental flag for batching metrics
This commit is contained in:
olav 2022-08-19 10:38:26 +02:00 committed by GitHub
parent d91b91b56f
commit d2999d816d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 237 additions and 32 deletions

View File

@ -61,7 +61,10 @@ Object {
Symbol(kCapture): false, Symbol(kCapture): false,
}, },
"eventHook": undefined, "eventHook": undefined,
"experimental": Object {}, "experimental": Object {
"batchMetrics": false,
"embedProxy": false,
},
"frontendApiOrigins": Array [], "frontendApiOrigins": Array [],
"getLogger": [Function], "getLogger": [Function],
"import": Object { "import": Object {

View File

@ -54,8 +54,19 @@ function mergeAll<T>(objects: Partial<T>[]): T {
} }
function loadExperimental(options: IUnleashOptions): IExperimentalOptions { function loadExperimental(options: IUnleashOptions): IExperimentalOptions {
return options.experimental || {}; return {
...options.experimental,
embedProxy: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
Boolean(options.experimental?.embedProxy),
),
batchMetrics: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_BATCH_METRICS,
Boolean(options.experimental?.batchMetrics),
),
};
} }
const defaultClientCachingOptions: IClientCachingOption = { const defaultClientCachingOptions: IClientCachingOption = {
enabled: true, enabled: true,
maxAge: 600, maxAge: 600,

View File

@ -7,6 +7,7 @@ import {
} from '../types/stores/client-metrics-store-v2'; } from '../types/stores/client-metrics-store-v2';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { startOfHour } from 'date-fns'; import { startOfHour } from 'date-fns';
import { collapseHourlyMetrics } from '../util/collapseHourlyMetrics';
interface ClientMetricsEnvTable { interface ClientMetricsEnvTable {
feature_name: string; feature_name: string;
@ -28,7 +29,7 @@ const fromRow = (row: ClientMetricsEnvTable) => ({
no: Number(row.no), no: Number(row.no),
}); });
const toRow = (metric: IClientMetricsEnv) => ({ const toRow = (metric: IClientMetricsEnv): ClientMetricsEnvTable => ({
feature_name: metric.featureName, feature_name: metric.featureName,
app_name: metric.appName, app_name: metric.appName,
environment: metric.environment, environment: metric.environment,
@ -102,22 +103,11 @@ export class ClientMetricsStoreV2 implements IClientMetricsStoreV2 {
if (!metrics || metrics.length == 0) { if (!metrics || metrics.length == 0) {
return; return;
} }
const rows = metrics.map(toRow);
const batch = rows.reduce((prev, curr) => { const rows = collapseHourlyMetrics(metrics).map(toRow);
// 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;
}, {});
// Sort the rows to avoid deadlocks // Sort the rows to avoid deadlocks
const batchRow = Object.values<ClientMetricsEnvTable>(batch).sort( const sortedRows = rows.sort(
(a, b) => (a, b) =>
a.feature_name.localeCompare(b.feature_name) || a.feature_name.localeCompare(b.feature_name) ||
a.app_name.localeCompare(b.app_name) || a.app_name.localeCompare(b.app_name) ||
@ -126,7 +116,7 @@ export class ClientMetricsStoreV2 implements IClientMetricsStoreV2 {
// Consider rewriting to SQL batch! // Consider rewriting to SQL batch!
const insert = this.db<ClientMetricsEnvTable>(TABLE) const insert = this.db<ClientMetricsEnvTable>(TABLE)
.insert(batchRow) .insert(sortedRows)
.toQuery(); .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`; 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`;

View File

@ -4,6 +4,7 @@ export interface IExperimentalOptions {
userGroups?: boolean; userGroups?: boolean;
anonymiseEventLog?: boolean; anonymiseEventLog?: boolean;
embedProxy?: boolean; embedProxy?: boolean;
batchMetrics?: boolean;
} }
export interface IExperimentalToggle { export interface IExperimentalToggle {

View File

@ -8,47 +8,64 @@ import {
IClientMetricsStoreV2, IClientMetricsStoreV2,
} from '../../types/stores/client-metrics-store-v2'; } from '../../types/stores/client-metrics-store-v2';
import { clientMetricsSchema } from './schema'; import { clientMetricsSchema } from './schema';
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns'; import { hoursToMilliseconds, secondsToMilliseconds } from 'date-fns';
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store'; import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
import EventEmitter from 'events'; import EventEmitter from 'events';
import { CLIENT_METRICS } from '../../types/events'; import { CLIENT_METRICS } from '../../types/events';
import ApiUser from '../../types/api-user'; import ApiUser from '../../types/api-user';
import { ALL } from '../../types/models/api-token'; import { ALL } from '../../types/models/api-token';
import User from '../../types/user'; import User from '../../types/user';
import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics';
import { IExperimentalOptions } from '../../experimental';
export default class ClientMetricsServiceV2 { export default class ClientMetricsServiceV2 {
private timer: NodeJS.Timeout; private timers: NodeJS.Timeout[] = [];
private unsavedMetrics: IClientMetricsEnv[] = [];
private clientMetricsStoreV2: IClientMetricsStoreV2; private clientMetricsStoreV2: IClientMetricsStoreV2;
private featureToggleStore: IFeatureToggleStore; private featureToggleStore: IFeatureToggleStore;
private experimental: IExperimentalOptions;
private eventBus: EventEmitter; private eventBus: EventEmitter;
private logger: Logger; private logger: Logger;
private bulkInterval: number;
constructor( constructor(
{ {
featureToggleStore, featureToggleStore,
clientMetricsStoreV2, clientMetricsStoreV2,
}: Pick<IUnleashStores, 'featureToggleStore' | 'clientMetricsStoreV2'>, }: Pick<IUnleashStores, 'featureToggleStore' | 'clientMetricsStoreV2'>,
{ eventBus, getLogger }: Pick<IUnleashConfig, 'eventBus' | 'getLogger'>, {
bulkInterval = minutesToMilliseconds(5), experimental,
eventBus,
getLogger,
}: Pick<IUnleashConfig, 'eventBus' | 'getLogger' | 'experimental'>,
bulkInterval = secondsToMilliseconds(5),
) { ) {
this.featureToggleStore = featureToggleStore; this.featureToggleStore = featureToggleStore;
this.clientMetricsStoreV2 = clientMetricsStoreV2; this.clientMetricsStoreV2 = clientMetricsStoreV2;
this.experimental = experimental;
this.eventBus = eventBus; this.eventBus = eventBus;
this.logger = getLogger( this.logger = getLogger(
'/services/client-metrics/client-metrics-service-v2.ts', '/services/client-metrics/client-metrics-service-v2.ts',
); );
this.bulkInterval = bulkInterval; if (this.experimental.batchMetrics) {
this.timer = setInterval(async () => { this.timers.push(
await this.clientMetricsStoreV2.clearMetrics(48); setInterval(() => {
}, hoursToMilliseconds(12)); this.bulkAdd().catch(console.error);
this.timer.unref(); }, bulkInterval).unref(),
);
}
this.timers.push(
setInterval(() => {
this.clientMetricsStoreV2.clearMetrics(48).catch(console.error);
}, hoursToMilliseconds(12)).unref(),
);
} }
async registerClientMetrics( async registerClientMetrics(
@ -74,11 +91,28 @@ export default class ClientMetricsServiceV2 {
})) }))
.filter((item) => !(item.yes === 0 && item.no === 0)); .filter((item) => !(item.yes === 0 && item.no === 0));
// TODO: should we aggregate for a few minutes (bulkInterval) before pushing to DB? if (this.experimental.batchMetrics) {
this.unsavedMetrics = collapseHourlyMetrics([
...this.unsavedMetrics,
...clientMetrics,
]);
} else {
await this.clientMetricsStoreV2.batchInsertMetrics(clientMetrics); await this.clientMetricsStoreV2.batchInsertMetrics(clientMetrics);
}
this.eventBus.emit(CLIENT_METRICS, value); this.eventBus.emit(CLIENT_METRICS, value);
} }
async bulkAdd(): Promise<void> {
if (this.experimental.batchMetrics && this.unsavedMetrics.length > 0) {
// Make a copy of `unsavedMetrics` in case new metrics
// arrive while awaiting `batchInsertMetrics`.
const copy = [...this.unsavedMetrics];
this.unsavedMetrics = [];
await this.clientMetricsStoreV2.batchInsertMetrics(copy);
}
}
// Overview over usage last "hour" bucket and all applications using the toggle // Overview over usage last "hour" bucket and all applications using the toggle
async getFeatureToggleMetricsSummary( async getFeatureToggleMetricsSummary(
featureName: string, featureName: string,
@ -137,7 +171,6 @@ export default class ClientMetricsServiceV2 {
} }
destroy(): void { destroy(): void {
clearInterval(this.timer); this.timers.forEach(clearInterval);
this.timer = null;
} }
} }

View File

@ -0,0 +1,110 @@
import { collapseHourlyMetrics } from './collapseHourlyMetrics';
import { IClientMetricsEnv } from '../types/stores/client-metrics-store-v2';
import { addMinutes, startOfHour } from 'date-fns';
test('collapseHourlyMetrics', () => {
const timestamp = startOfHour(new Date());
const metricAX1: IClientMetricsEnv = {
featureName: 'a',
appName: 'x',
environment: 'x',
timestamp: addMinutes(timestamp, 1),
yes: 1,
no: 11,
};
const metricAX2: IClientMetricsEnv = {
featureName: 'a',
appName: 'x',
environment: 'x',
timestamp: addMinutes(timestamp, 2),
yes: 2,
no: 12,
};
const metricBX: IClientMetricsEnv = {
featureName: 'b',
appName: 'x',
environment: 'x',
timestamp: addMinutes(timestamp, 3),
yes: 101,
no: 1001,
};
const metricBY: IClientMetricsEnv = {
featureName: 'b',
appName: 'y',
environment: 'y',
timestamp: addMinutes(timestamp, 4),
yes: 102,
no: 1002,
};
expect(
collapseHourlyMetrics([metricAX1, metricAX2, metricBX, metricBY]),
).toEqual([
{
featureName: 'a',
appName: 'x',
environment: 'x',
timestamp,
yes: 3,
no: 23,
},
{
featureName: 'b',
appName: 'x',
environment: 'x',
timestamp,
yes: 101,
no: 1001,
},
{
featureName: 'b',
appName: 'y',
environment: 'y',
timestamp,
yes: 102,
no: 1002,
},
]);
expect(
collapseHourlyMetrics([
metricAX1,
metricAX1,
metricAX2,
metricAX2,
metricBX,
metricBX,
metricBY,
metricBY,
]),
).toEqual([
{
featureName: 'a',
appName: 'x',
environment: 'x',
timestamp,
yes: 6,
no: 46,
},
{
featureName: 'b',
appName: 'x',
environment: 'x',
timestamp,
yes: 202,
no: 2002,
},
{
featureName: 'b',
appName: 'y',
environment: 'y',
timestamp,
yes: 204,
no: 2004,
},
]);
});

View File

@ -0,0 +1,51 @@
import { IClientMetricsEnv } from '../types/stores/client-metrics-store-v2';
import { startOfHour } from 'date-fns';
const groupBy = <T>(list: T[], createKey: (item: T) => string): T[][] => {
const groups = list.reduce((acc, item) => {
const key = createKey(item);
acc[key] = acc[key] ?? [];
acc[key].push(item);
return acc;
}, {} as Record<string, T[]>);
return Object.values(groups);
};
const createMetricKey = (metric: IClientMetricsEnv): string => {
return [
metric.featureName,
metric.appName,
metric.environment,
metric.timestamp.getTime(),
].join();
};
const sumYesNo = (
metrics: IClientMetricsEnv[],
): Pick<IClientMetricsEnv, 'yes' | 'no'> => {
return metrics.reduce(
(acc, metric) => ({
yes: acc.yes + metric.yes,
no: acc.no + metric.no,
}),
{
yes: 0,
no: 0,
},
);
};
export const collapseHourlyMetrics = (
metrics: IClientMetricsEnv[],
): IClientMetricsEnv[] => {
const hourlyMetrics = metrics.map((metric) => ({
...metric,
timestamp: startOfHour(metric.timestamp),
}));
return groupBy(hourlyMetrics, createMetricKey).flatMap((group) => ({
...group[0],
...sumYesNo(group),
}));
};

View File

@ -35,6 +35,7 @@ process.nextTick(async () => {
anonymiseEventLog: false, anonymiseEventLog: false,
userGroups: true, userGroups: true,
embedProxy: true, embedProxy: true,
batchMetrics: true,
}, },
authentication: { authentication: {
initApiTokens: [ initApiTokens: [

View File

@ -25,6 +25,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
experimental: { experimental: {
userGroups: true, userGroups: true,
embedProxy: true, embedProxy: true,
batchMetrics: true,
}, },
}; };
const options = mergeAll<IUnleashOptions>([testConfig, config]); const options = mergeAll<IUnleashOptions>([testConfig, config]);

View File

@ -39,6 +39,7 @@ test('should enrich metrics with environment from api-token', async () => {
.send(metricsExample) .send(metricsExample)
.expect(202); .expect(202);
await app.services.clientMetricsServiceV2.bulkAdd();
const all = await clientMetricsStoreV2.getAll(); const all = await clientMetricsStoreV2.getAll();
expect(all[0].environment).toBe('some'); expect(all[0].environment).toBe('some');
}); });

View File

@ -94,6 +94,7 @@ test('should pick up environment from token', async () => {
}) })
.expect(202); .expect(202);
await app.services.clientMetricsServiceV2.bulkAdd();
const metrics = await db.stores.clientMetricsStoreV2.getAll(); const metrics = await db.stores.clientMetricsStoreV2.getAll();
expect(metrics[0].environment).toBe('test'); expect(metrics[0].environment).toBe('test');
expect(metrics[0].appName).toBe('some-fancy-app'); expect(metrics[0].appName).toBe('some-fancy-app');

View File

@ -284,6 +284,7 @@ test('should store proxy client metrics', async () => {
}) })
.expect(200) .expect(200)
.expect((res) => expect(res.text).toEqual('OK')); .expect((res) => expect(res.text).toEqual('OK'));
await app.services.clientMetricsServiceV2.bulkAdd();
await app.request await app.request
.get(`/api/admin/client-metrics/features/${featureName}`) .get(`/api/admin/client-metrics/features/${featureName}`)
.set('Authorization', adminToken.secret) .set('Authorization', adminToken.secret)

View File

@ -44,6 +44,7 @@ async function createApp(
const destroy = async () => { const destroy = async () => {
services.versionService.destroy(); services.versionService.destroy();
services.clientInstanceService.destroy(); services.clientInstanceService.destroy();
services.clientMetricsServiceV2.destroy();
services.apiTokenService.destroy(); services.apiTokenService.destroy();
}; };