From 3a3b6a29fff3d990df9bcaac306445f3747dc449 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 25 Jun 2024 14:40:16 +0200 Subject: [PATCH] feat: lifecycle stage entered counter (#7449) --- .../fake-feature-lifecycle-store.ts | 23 ++++++++++--- .../feature-lifecycle-service.test.ts | 4 +-- .../feature-lifecycle-service.ts | 32 +++++++++++-------- .../feature-lifecycle-store-type.ts | 6 +++- .../feature-lifecycle-store.ts | 12 +++++-- .../feature-lifecycle.e2e.test.ts | 8 ++--- src/lib/metric-events.ts | 2 ++ src/lib/metrics.test.ts | 3 +- src/lib/metrics.ts | 23 ++++++++++--- 9 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts index 70bfd5186474..2e7e2790bda5 100644 --- a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts +++ b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts @@ -2,6 +2,7 @@ import type { FeatureLifecycleStage, IFeatureLifecycleStore, FeatureLifecycleView, + NewStage, } from './feature-lifecycle-store-type'; export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore { @@ -9,20 +10,31 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore { async insert( featureLifecycleStages: FeatureLifecycleStage[], - ): Promise { - await Promise.all( - featureLifecycleStages.map((stage) => this.insertOne(stage)), + ): Promise { + const results = await Promise.all( + featureLifecycleStages.map(async (stage) => { + const success = await this.insertOne(stage); + if (success) { + return { + feature: stage.feature, + stage: stage.stage, + }; + } + return null; + }), ); + return results.filter((result) => result !== null) as NewStage[]; } async backfill() {} private async insertOne( featureLifecycleStage: FeatureLifecycleStage, - ): Promise { + ): Promise { if (await this.stageExists(featureLifecycleStage)) { - return; + return false; } + const newStages: NewStage[] = []; const existingStages = await this.get(featureLifecycleStage.feature); this.lifecycles[featureLifecycleStage.feature] = [ ...existingStages, @@ -34,6 +46,7 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore { enteredStageAt: new Date(), }, ]; + return true; } async get(feature: string): Promise { diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts index ed428d934fb0..9b3a2de61da3 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts @@ -10,8 +10,8 @@ import { } from '../../types'; import { createFakeFeatureLifecycleService } from './createFeatureLifecycle'; import EventEmitter from 'events'; -import { STAGE_ENTERED } from './feature-lifecycle-service'; import noLoggerProvider from '../../../test/fixtures/no-logger'; +import { STAGE_ENTERED } from '../../metric-events'; test('can insert and read lifecycle stages', async () => { const eventBus = new EventEmitter(); @@ -42,7 +42,7 @@ test('can insert and read lifecycle stages', async () => { } function reachedStage(feature: string, name: StageName) { return new Promise((resolve) => - featureLifecycleService.on(STAGE_ENTERED, (event) => { + eventBus.on(STAGE_ENTERED, (event) => { if (event.stage === name && event.feature === feature) resolve(name); }), diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts index 08736c278a7e..0b2d44aa5a91 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts @@ -15,17 +15,17 @@ import { import type { FeatureLifecycleView, IFeatureLifecycleStore, + NewStage, } from './feature-lifecycle-store-type'; -import EventEmitter from 'events'; +import type EventEmitter from 'events'; import type { Logger } from '../../logger'; import type EventService from '../events/event-service'; import type { FeatureLifecycleCompletedSchema } from '../../openapi'; import type { IClientMetricsEnv } from '../metrics/client-metrics/client-metrics-store-v2-type'; import groupBy from 'lodash.groupby'; +import { STAGE_ENTERED } from '../../metric-events'; -export const STAGE_ENTERED = 'STAGE_ENTERED'; - -export class FeatureLifecycleService extends EventEmitter { +export class FeatureLifecycleService { private eventStore: IEventStore; private featureLifecycleStore: IFeatureLifecycleStore; @@ -65,7 +65,6 @@ export class FeatureLifecycleService extends EventEmitter { getLogger, }: Pick, ) { - super(); this.eventStore = eventStore; this.featureLifecycleStore = featureLifecycleStore; this.environmentStore = environmentStore; @@ -128,22 +127,26 @@ export class FeatureLifecycleService extends EventEmitter { } private async featureInitialized(feature: string) { - await this.featureLifecycleStore.insert([ + const result = await this.featureLifecycleStore.insert([ { feature, stage: 'initial' }, ]); - this.emit(STAGE_ENTERED, { stage: 'initial', feature }); + this.recordStagesEntered(result); } private async stageReceivedMetrics( features: string[], stage: 'live' | 'pre-live', ) { - await this.featureLifecycleStore.insert( + const newlyEnteredStages = await this.featureLifecycleStore.insert( features.map((feature) => ({ feature, stage })), ); - features.forEach((feature) => - this.emit(STAGE_ENTERED, { stage, feature }), - ); + this.recordStagesEntered(newlyEnteredStages); + } + + private recordStagesEntered(newlyEnteredStages: NewStage[]) { + newlyEnteredStages.forEach(({ stage, feature }) => { + this.eventBus.emit(STAGE_ENTERED, { stage, feature }); + }); } private async featuresReceivedMetrics( @@ -182,7 +185,7 @@ export class FeatureLifecycleService extends EventEmitter { status: FeatureLifecycleCompletedSchema, auditUser: IAuditUser, ) { - await this.featureLifecycleStore.insert([ + const result = await this.featureLifecycleStore.insert([ { feature, stage: 'completed', @@ -190,6 +193,7 @@ export class FeatureLifecycleService extends EventEmitter { statusValue: status.statusValue, }, ]); + this.recordStagesEntered(result); await this.eventService.storeEvent( new FeatureCompletedEvent({ project: projectId, @@ -219,10 +223,10 @@ export class FeatureLifecycleService extends EventEmitter { } private async featureArchived(feature: string) { - await this.featureLifecycleStore.insert([ + const result = await this.featureLifecycleStore.insert([ { feature, stage: 'archived' }, ]); - this.emit(STAGE_ENTERED, { stage: 'archived', feature }); + this.recordStagesEntered(result); } private async featureRevived(feature: string) { diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts index 2511a7155c00..ac73cbd575d4 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts @@ -14,8 +14,12 @@ export type FeatureLifecycleProjectItem = FeatureLifecycleStage & { project: string; }; +export type NewStage = Pick; + export interface IFeatureLifecycleStore { - insert(featureLifecycleStages: FeatureLifecycleStage[]): Promise; + insert( + featureLifecycleStages: FeatureLifecycleStage[], + ): Promise; get(feature: string): Promise; stageExists(stage: FeatureLifecycleStage): Promise; delete(feature: string): Promise; diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts index 6ba80f90a31f..b6d866119565 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts @@ -3,6 +3,7 @@ import type { IFeatureLifecycleStore, FeatureLifecycleView, FeatureLifecycleProjectItem, + NewStage, } from './feature-lifecycle-store-type'; import type { Db } from '../../db/db'; import type { StageName } from '../../types'; @@ -38,7 +39,7 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore { async insert( featureLifecycleStages: FeatureLifecycleStage[], - ): Promise { + ): Promise { const existingFeatures = await this.db('features') .select('name') .whereIn( @@ -53,9 +54,9 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore { ); if (validStages.length === 0) { - return; + return []; } - await this.db('feature_lifecycles') + const result = await this.db('feature_lifecycles') .insert( validStages.map((stage) => ({ feature: stage.feature, @@ -67,6 +68,11 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore { .returning('*') .onConflict(['feature', 'stage']) .ignore(); + + return result.map((row) => ({ + stage: row.stage, + feature: row.feature, + })); } async get(feature: string): Promise { diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts index ddbe30f18d00..70941b91462a 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts @@ -14,13 +14,11 @@ import { type StageName, } from '../../types'; import type EventEmitter from 'events'; -import { - type FeatureLifecycleService, - STAGE_ENTERED, -} from './feature-lifecycle-service'; +import type { FeatureLifecycleService } from './feature-lifecycle-service'; import type { FeatureLifecycleCompletedSchema } from '../../openapi'; import { FeatureLifecycleReadModel } from './feature-lifecycle-read-model'; import type { IFeatureLifecycleReadModel } from './feature-lifecycle-read-model-type'; +import { STAGE_ENTERED } from '../../metric-events'; let app: IUnleashTest; let db: ITestDb; @@ -102,7 +100,7 @@ const uncompleteFeature = async (featureName: string, expectedCode = 200) => { function reachedStage(feature: string, stage: StageName) { return new Promise((resolve) => - featureLifecycleService.on(STAGE_ENTERED, (event) => { + eventBus.on(STAGE_ENTERED, (event) => { if (event.stage === stage && event.feature === feature) resolve(stage); }), diff --git a/src/lib/metric-events.ts b/src/lib/metric-events.ts index 326ba69ea5fe..d92d06d9a2f2 100644 --- a/src/lib/metric-events.ts +++ b/src/lib/metric-events.ts @@ -7,6 +7,7 @@ const EVENTS_CREATED_BY_PROCESSED = 'events_created_by_processed'; const FRONTEND_API_REPOSITORY_CREATED = 'frontend_api_repository_created'; const PROXY_REPOSITORY_CREATED = 'proxy_repository_created'; const PROXY_FEATURES_FOR_TOKEN_TIME = 'proxy_features_for_token_time'; +const STAGE_ENTERED = 'stage-entered' as const; export { REQUEST_TIME, @@ -18,4 +19,5 @@ export { FRONTEND_API_REPOSITORY_CREATED, PROXY_REPOSITORY_CREATED, PROXY_FEATURES_FOR_TOKEN_TIME, + STAGE_ENTERED, }; diff --git a/src/lib/metrics.test.ts b/src/lib/metrics.test.ts index d897a3adef1d..94ef576511e4 100644 --- a/src/lib/metrics.test.ts +++ b/src/lib/metrics.test.ts @@ -320,5 +320,6 @@ test('should collect metrics for lifecycle', async () => { const metrics = await prometheusRegister.metrics(); expect(metrics).toMatch(/feature_lifecycle_stage_duration/); - expect(metrics).toMatch(/stage_count_by_project/); + expect(metrics).toMatch(/feature_lifecycle_stage_count_by_project/); + expect(metrics).toMatch(/feature_lifecycle_stage_entered/); }); diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 300c70bb7965..861a547d4ec7 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -285,12 +285,18 @@ export default class MetricsMonitor { help: 'Duration of feature lifecycle stages', }); - const stageCountByProject = createGauge({ - name: 'stage_count_by_project', + const featureLifecycleStageCountByProject = createGauge({ + name: 'feature_lifecycle_stage_count_by_project', help: 'Count features in a given stage by project id', labelNames: ['stage', 'project_id'], }); + const featureLifecycleStageEnteredCounter = createCounter({ + name: 'feature_lifecycle_stage_entered', + help: 'Count how many features entered a given stage', + labelNames: ['stage'], + }); + const projectEnvironmentsDisabled = createCounter({ name: 'project_environments_disabled', help: 'How many "environment disabled" events we have received for each project', @@ -337,9 +343,18 @@ export default class MetricsMonitor { .set(stage.duration); }); - stageCountByProject.reset(); + eventBus.on( + events.STAGE_ENTERED, + (entered: { stage: string; feature: string }) => { + featureLifecycleStageEnteredCounter + .labels({ stage: entered.stage }) + .inc(); + }, + ); + + featureLifecycleStageCountByProject.reset(); stageCountByProjectResult.forEach((stageResult) => - stageCountByProject + featureLifecycleStageCountByProject .labels({ project_id: stageResult.project, stage: stageResult.stage,