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

fix: filter empty metrics before we collect last seen toggles. (#2172)

* fix: filter empty metrics before we collect last seen toggles.

fixes: #2104

* fix: add a last-seen service to batch last-seen toggle updates

Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
Ivar Conradi Østhus 2022-10-17 09:06:59 +02:00 committed by GitHub
parent b82d2b22d7
commit 1f0fa6abfe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 279 additions and 16 deletions

View File

@ -4,7 +4,7 @@ import getApp from '../../app';
import { createTestConfig } from '../../../test/config/test-config'; import { createTestConfig } from '../../../test/config/test-config';
import { clientMetricsSchema } from '../../services/client-metrics/schema'; import { clientMetricsSchema } from '../../services/client-metrics/schema';
import { createServices } from '../../services'; import { createServices } from '../../services';
import { IUnleashOptions, IUnleashStores } from '../../types'; import { IUnleashOptions, IUnleashServices, IUnleashStores } from '../../types';
async function getSetup(opts?: IUnleashOptions) { async function getSetup(opts?: IUnleashOptions) {
const stores = createStores(); const stores = createStores();
@ -16,6 +16,7 @@ async function getSetup(opts?: IUnleashOptions) {
return { return {
request: supertest(app), request: supertest(app),
stores, stores,
services,
destroy: () => { destroy: () => {
services.versionService.destroy(); services.versionService.destroy();
services.clientInstanceService.destroy(); services.clientInstanceService.destroy();
@ -26,6 +27,7 @@ async function getSetup(opts?: IUnleashOptions) {
let request; let request;
let stores: IUnleashStores; let stores: IUnleashStores;
let services: IUnleashServices;
let destroy; let destroy;
beforeEach(async () => { beforeEach(async () => {
@ -33,6 +35,7 @@ beforeEach(async () => {
request = setup.request; request = setup.request;
stores = setup.stores; stores = setup.stores;
destroy = setup.destroy; destroy = setup.destroy;
services = setup.services;
}); });
afterEach(() => { afterEach(() => {
@ -202,6 +205,7 @@ test('should set lastSeen on toggle', async () => {
}) })
.expect(202); .expect(202);
await services.lastSeenService.store();
const toggle = await stores.featureToggleStore.get('toggleLastSeen'); const toggle = await stores.featureToggleStore.get('toggleLastSeen');
expect(toggle.lastSeenAt).toBeTruthy(); expect(toggle.lastSeenAt).toBeTruthy();

View File

@ -0,0 +1,58 @@
import { secondsToMilliseconds } from 'date-fns';
import { Logger } from '../../logger';
import { IUnleashConfig } from '../../server-impl';
import { IUnleashStores } from '../../types';
import { IClientMetricsEnv } from '../../types/stores/client-metrics-store-v2';
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
export class LastSeenService {
private timers: NodeJS.Timeout[] = [];
private lastSeenToggles: Set<string> = new Set();
private logger: Logger;
private featureToggleStore: IFeatureToggleStore;
constructor(
{ featureToggleStore }: Pick<IUnleashStores, 'featureToggleStore'>,
config: IUnleashConfig,
lastSeenInterval = secondsToMilliseconds(30),
) {
this.featureToggleStore = featureToggleStore;
this.logger = config.getLogger(
'/services/client-metrics/last-seen-service.ts',
);
this.timers.push(
setInterval(() => this.store(), lastSeenInterval).unref(),
);
}
async store(): Promise<number> {
const count = this.lastSeenToggles.size;
if (count > 0) {
const lastSeenToggles = [...this.lastSeenToggles];
this.lastSeenToggles = new Set();
this.logger.debug(
`Updating last seen for ${lastSeenToggles.length} toggles`,
);
await this.featureToggleStore.setLastSeen(lastSeenToggles);
}
return count;
}
updateLastSeen(clientMetrics: IClientMetricsEnv[]): void {
clientMetrics
.filter(
(clientMetric) => clientMetric.yes > 0 || clientMetric.no > 0,
)
.forEach((clientMetric) =>
this.lastSeenToggles.add(clientMetric.featureName),
);
}
destroy(): void {
this.timers.forEach(clearInterval);
}
}

View File

@ -15,6 +15,7 @@ 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 { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics';
import { LastSeenService } from './last-seen-service';
export default class ClientMetricsServiceV2 { export default class ClientMetricsServiceV2 {
private config: IUnleashConfig; private config: IUnleashConfig;
@ -27,6 +28,8 @@ export default class ClientMetricsServiceV2 {
private featureToggleStore: IFeatureToggleStore; private featureToggleStore: IFeatureToggleStore;
private lastSeenService: LastSeenService;
private logger: Logger; private logger: Logger;
constructor( constructor(
@ -35,10 +38,12 @@ export default class ClientMetricsServiceV2 {
clientMetricsStoreV2, clientMetricsStoreV2,
}: Pick<IUnleashStores, 'featureToggleStore' | 'clientMetricsStoreV2'>, }: Pick<IUnleashStores, 'featureToggleStore' | 'clientMetricsStoreV2'>,
config: IUnleashConfig, config: IUnleashConfig,
lastSeenService: LastSeenService,
bulkInterval = secondsToMilliseconds(5), bulkInterval = secondsToMilliseconds(5),
) { ) {
this.featureToggleStore = featureToggleStore; this.featureToggleStore = featureToggleStore;
this.clientMetricsStoreV2 = clientMetricsStoreV2; this.clientMetricsStoreV2 = clientMetricsStoreV2;
this.lastSeenService = lastSeenService;
this.config = config; this.config = config;
this.logger = config.getLogger( this.logger = config.getLogger(
'/services/client-metrics/client-metrics-service-v2.ts', '/services/client-metrics/client-metrics-service-v2.ts',
@ -62,30 +67,35 @@ export default class ClientMetricsServiceV2 {
clientIp: string, clientIp: string,
): Promise<void> { ): Promise<void> {
const value = await clientMetricsSchema.validateAsync(data); const value = await clientMetricsSchema.validateAsync(data);
const toggleNames = Object.keys(value.bucket.toggles); const toggleNames = Object.keys(value.bucket.toggles).filter(
if (toggleNames.length > 0) { (name) =>
await this.featureToggleStore.setLastSeen(toggleNames); !(
} value.bucket.toggles[name].yes === 0 &&
value.bucket.toggles[name].no === 0
),
);
this.logger.debug(`got metrics from ${clientIp}`); this.logger.debug(`got metrics from ${clientIp}`);
const clientMetrics: IClientMetricsEnv[] = toggleNames const clientMetrics: IClientMetricsEnv[] = toggleNames.map((name) => ({
.map((name) => ({
featureName: name, featureName: name,
appName: value.appName, appName: value.appName,
environment: value.environment, environment: value.environment,
timestamp: value.bucket.start, //we might need to approximate between start/stop... timestamp: value.bucket.start, //we might need to approximate between start/stop...
yes: value.bucket.toggles[name].yes, yes: value.bucket.toggles[name].yes,
no: value.bucket.toggles[name].no, no: value.bucket.toggles[name].no,
})) }));
.filter((item) => !(item.yes === 0 && item.no === 0));
if (this.config.flagResolver.isEnabled('batchMetrics')) { if (this.config.flagResolver.isEnabled('batchMetrics')) {
this.unsavedMetrics = collapseHourlyMetrics([ this.unsavedMetrics = collapseHourlyMetrics([
...this.unsavedMetrics, ...this.unsavedMetrics,
...clientMetrics, ...clientMetrics,
]); ]);
this.lastSeenService.updateLastSeen(clientMetrics);
} else { } else {
if (toggleNames.length > 0) {
await this.featureToggleStore.setLastSeen(toggleNames);
}
await this.clientMetricsStoreV2.batchInsertMetrics(clientMetrics); await this.clientMetricsStoreV2.batchInsertMetrics(clientMetrics);
} }
@ -161,5 +171,6 @@ export default class ClientMetricsServiceV2 {
destroy(): void { destroy(): void {
this.timers.forEach(clearInterval); this.timers.forEach(clearInterval);
this.lastSeenService.destroy();
} }
} }

View File

@ -35,6 +35,7 @@ import { ProxyService } from './proxy-service';
import EdgeService from './edge-service'; import EdgeService from './edge-service';
import PatService from './pat-service'; import PatService from './pat-service';
import { PublicSignupTokenService } from './public-signup-token-service'; import { PublicSignupTokenService } from './public-signup-token-service';
import { LastSeenService } from './client-metrics/last-seen-service';
export const createServices = ( export const createServices = (
stores: IUnleashStores, stores: IUnleashStores,
config: IUnleashConfig, config: IUnleashConfig,
@ -43,7 +44,12 @@ export const createServices = (
const accessService = new AccessService(stores, config, groupService); const accessService = new AccessService(stores, config, groupService);
const apiTokenService = new ApiTokenService(stores, config); const apiTokenService = new ApiTokenService(stores, config);
const clientInstanceService = new ClientInstanceService(stores, config); const clientInstanceService = new ClientInstanceService(stores, config);
const clientMetricsServiceV2 = new ClientMetricsServiceV2(stores, config); const lastSeenService = new LastSeenService(stores, config);
const clientMetricsServiceV2 = new ClientMetricsServiceV2(
stores,
config,
lastSeenService,
);
const contextService = new ContextService(stores, config); const contextService = new ContextService(stores, config);
const emailService = new EmailService(config.email, config.getLogger); const emailService = new EmailService(config.email, config.getLogger);
const eventService = new EventService(stores, config); const eventService = new EventService(stores, config);
@ -147,6 +153,7 @@ export const createServices = (
edgeService, edgeService,
patService, patService,
publicSignupTokenService, publicSignupTokenService,
lastSeenService,
}; };
}; };

View File

@ -33,6 +33,7 @@ import { ProxyService } from '../services/proxy-service';
import EdgeService from '../services/edge-service'; import EdgeService from '../services/edge-service';
import PatService from '../services/pat-service'; import PatService from '../services/pat-service';
import { PublicSignupTokenService } from '../services/public-signup-token-service'; import { PublicSignupTokenService } from '../services/public-signup-token-service';
import { LastSeenService } from '../services/client-metrics/last-seen-service';
export interface IUnleashServices { export interface IUnleashServices {
accessService: AccessService; accessService: AccessService;
@ -71,4 +72,5 @@ export interface IUnleashServices {
openApiService: OpenApiService; openApiService: OpenApiService;
clientSpecService: ClientSpecService; clientSpecService: ClientSpecService;
patService: PatService; patService: PatService;
lastSeenService: LastSeenService;
} }

View File

@ -97,3 +97,53 @@ test('should pick up environment from token', async () => {
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');
}); });
test('should set lastSeen for toggles with metrics', async () => {
const start = Date.now();
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{ name: 't1' },
'tester',
);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{ name: 't2' },
'tester',
);
const token = await app.services.apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
project: 'default',
environment: 'default',
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: {
t1: {
yes: 100,
no: 50,
},
t2: {
yes: 0,
no: 0,
},
},
},
})
.expect(202);
await app.services.clientMetricsServiceV2.bulkAdd();
await app.services.lastSeenService.store();
const t1 = await db.stores.featureToggleStore.get('t1');
const t2 = await db.stores.featureToggleStore.get('t2');
expect(t1.lastSeenAt.getTime()).toBeGreaterThanOrEqual(start);
expect(t2.lastSeenAt).toBeDefined();
});

View File

@ -0,0 +1,131 @@
import { createTestConfig } from '../../config/test-config';
import dbInit from '../helpers/database-init';
import { IUnleashStores } from '../../../lib/types/stores';
import { LastSeenService } from '../../../lib/services/client-metrics/last-seen-service';
import { IClientMetricsEnv } from '../../../lib/types/stores/client-metrics-store-v2';
let stores: IUnleashStores;
let db;
let config;
beforeAll(async () => {
config = createTestConfig();
db = await dbInit('last_seen_service_serial', config.getLogger);
stores = db.stores;
});
beforeEach(async () => {
await stores.featureToggleStore.deleteAll();
});
afterAll(async () => {
await db.destroy();
});
test('Should update last seen for known toggles', async () => {
const service = new LastSeenService(stores, config);
const time = Date.now();
await stores.featureToggleStore.create('default', { name: 'ta1' });
const metrics: IClientMetricsEnv[] = [
{
featureName: 'ta1',
appName: 'some-App',
environment: 'default',
timestamp: new Date(time),
yes: 1,
no: 0,
},
{
featureName: 'ta2',
appName: 'some-App',
environment: 'default',
timestamp: new Date(time),
yes: 1,
no: 0,
},
];
service.updateLastSeen(metrics);
await service.store();
const t1 = await stores.featureToggleStore.get('ta1');
expect(t1.lastSeenAt.getTime()).toBeGreaterThan(time);
service.destroy();
});
test('Should not update last seen toggles with 0 metrics', async () => {
// jest.useFakeTimers();
const service = new LastSeenService(stores, config, 30);
const time = Date.now();
await stores.featureToggleStore.create('default', { name: 'tb1' });
await stores.featureToggleStore.create('default', { name: 'tb2' });
const metrics: IClientMetricsEnv[] = [
{
featureName: 'tb1',
appName: 'some-App',
environment: 'default',
timestamp: new Date(time),
yes: 1,
no: 0,
},
{
featureName: 'tb2',
appName: 'some-App',
environment: 'default',
timestamp: new Date(time),
yes: 0,
no: 0,
},
];
service.updateLastSeen(metrics);
// bypass interval waiting
await service.store();
const t1 = await stores.featureToggleStore.get('tb1');
const t2 = await stores.featureToggleStore.get('tb2');
expect(t2.lastSeenAt).toBeNull();
expect(t1.lastSeenAt.getTime()).toBeGreaterThanOrEqual(time);
service.destroy();
});
test('Should not update anything for 0 toggles', async () => {
// jest.useFakeTimers();
const service = new LastSeenService(stores, config, 30);
const time = Date.now();
await stores.featureToggleStore.create('default', { name: 'tb1' });
await stores.featureToggleStore.create('default', { name: 'tb2' });
const metrics: IClientMetricsEnv[] = [
{
featureName: 'tb1',
appName: 'some-App',
environment: 'default',
timestamp: new Date(time),
yes: 0,
no: 0,
},
{
featureName: 'tb2',
appName: 'some-App',
environment: 'default',
timestamp: new Date(time),
yes: 0,
no: 0,
},
];
service.updateLastSeen(metrics);
// bypass interval waiting
const count = await service.store();
expect(count).toBe(0);
service.destroy();
});