From 55997af8084c888c763941d9cbdb300dfe377526 Mon Sep 17 00:00:00 2001 From: Jaanus Date: Mon, 1 Sep 2025 15:11:47 +0300 Subject: [PATCH] fix: lifecycle metrics will now be posted across all environments --- .../feature-lifecycle-service.test.ts | 259 ++++++++++++++++-- .../feature-lifecycle-service.ts | 128 ++++++--- 2 files changed, 316 insertions(+), 71 deletions(-) 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 0b5507cdac..5839e04a36 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts @@ -14,6 +14,24 @@ import EventEmitter from 'events'; import noLoggerProvider from '../../../test/fixtures/no-logger.js'; import { STAGE_ENTERED } from '../../metric-events.js'; +function emitMetricsEvent(eventBus: EventEmitter, featureName: string, environment: string) { + eventBus.emit(CLIENT_METRICS_ADDED, [ + { + featureName, + environment, + }, + ]); +} + +function reachedStage(eventBus: EventEmitter, feature: string, name: StageName) { + return new Promise((resolve) => + eventBus.on(STAGE_ENTERED, (event) => { + if (event.stage === name && event.feature === feature) + resolve(name); + }), + ); +} + test('can insert and read lifecycle stages', async () => { const eventBus = new EventEmitter(); const { @@ -33,22 +51,7 @@ test('can insert and read lifecycle stages', async () => { true, ); - function emitMetricsEvent(environment: string) { - eventBus.emit(CLIENT_METRICS_ADDED, [ - { - featureName, - environment, - }, - ]); - } - function reachedStage(feature: string, name: StageName) { - return new Promise((resolve) => - eventBus.on(STAGE_ENTERED, (event) => { - if (event.stage === name && event.feature === feature) - resolve(name); - }), - ); - } + await environmentStore.create({ name: 'my-dev-environment', @@ -69,20 +72,20 @@ test('can insert and read lifecycle stages', async () => { featureLifecycleService.listen(); eventStore.emit(FEATURE_CREATED, { featureName }); - await reachedStage(featureName, 'initial'); + await reachedStage(eventBus, featureName, 'initial'); - emitMetricsEvent('unknown-environment'); - emitMetricsEvent('my-dev-environment'); - await reachedStage(featureName, 'pre-live'); - emitMetricsEvent('my-dev-environment'); - emitMetricsEvent('my-another-dev-environment'); - emitMetricsEvent('my-prod-environment'); - await reachedStage(featureName, 'live'); - emitMetricsEvent('my-prod-environment'); - emitMetricsEvent('my-another-prod-environment'); + emitMetricsEvent(eventBus, featureName, 'unknown-environment'); + emitMetricsEvent(eventBus, featureName, 'my-dev-environment'); + await reachedStage(eventBus, featureName, 'pre-live'); + emitMetricsEvent(eventBus, featureName, 'my-dev-environment'); + emitMetricsEvent(eventBus, featureName, 'my-another-dev-environment'); + emitMetricsEvent(eventBus, featureName, 'my-prod-environment'); + await reachedStage(eventBus, featureName, 'live'); + emitMetricsEvent(eventBus, featureName, 'my-prod-environment'); + emitMetricsEvent(eventBus, featureName, 'my-another-prod-environment'); eventStore.emit(FEATURE_ARCHIVED, { featureName }); - await reachedStage(featureName, 'archived'); + await reachedStage(eventBus, featureName, 'archived'); const lifecycle = await featureLifecycleService.getFeatureLifecycle(featureName); @@ -95,10 +98,210 @@ test('can insert and read lifecycle stages', async () => { ]); eventStore.emit(FEATURE_REVIVED, { featureName }); - await reachedStage(featureName, 'initial'); + await reachedStage(eventBus, featureName, 'initial'); const initialLifecycle = await featureLifecycleService.getFeatureLifecycle(featureName); expect(initialLifecycle).toEqual([ { stage: 'initial', enteredStageAt: expect.any(Date) }, ]); }); + +test('handles bulk metrics efficiently with multiple environments and features', async () => { + const eventBus = new EventEmitter(); + const { + featureLifecycleService, + eventStore, + environmentStore, + featureEnvironmentStore, + } = createFakeFeatureLifecycleService({ + flagResolver: { isEnabled: () => true }, + eventBus, + getLogger: noLoggerProvider, + } as unknown as IUnleashConfig); + + await environmentStore.create({ + name: 'dev-env', + type: 'development', + } as IEnvironment); + + await environmentStore.create({ + name: 'staging-env', + type: 'staging', + } as IEnvironment); + + await environmentStore.create({ + name: 'prod-env', + type: 'production', + } as IEnvironment); + + const feature1 = 'feature-1'; + const feature2 = 'feature-2'; + const feature3 = 'feature-3'; + + await featureEnvironmentStore.addEnvironmentToFeature(feature1, 'dev-env', false); + await featureEnvironmentStore.addEnvironmentToFeature(feature1, 'staging-env', false); + await featureEnvironmentStore.addEnvironmentToFeature(feature1, 'prod-env', true); + + await featureEnvironmentStore.addEnvironmentToFeature(feature2, 'dev-env', true); + await featureEnvironmentStore.addEnvironmentToFeature(feature2, 'staging-env', false); + await featureEnvironmentStore.addEnvironmentToFeature(feature2, 'prod-env', false); + + await featureEnvironmentStore.addEnvironmentToFeature(feature3, 'dev-env', false); + await featureEnvironmentStore.addEnvironmentToFeature(feature3, 'staging-env', true); + await featureEnvironmentStore.addEnvironmentToFeature(feature3, 'prod-env', true); + + + featureLifecycleService.listen(); + + eventStore.emit(FEATURE_CREATED, { featureName: feature1 }); + eventStore.emit(FEATURE_CREATED, { featureName: feature2 }); + eventStore.emit(FEATURE_CREATED, { featureName: feature3 }); + + const bulkMetricsEvent = [ + { + featureName: feature1, + environment: 'dev-env', + appName: 'test-app', + timestamp: new Date(), + yes: 10, + no: 5, + }, + { + featureName: feature2, + environment: 'dev-env', + appName: 'test-app', + timestamp: new Date(), + yes: 15, + no: 3, + }, + { + featureName: feature3, + environment: 'dev-env', + appName: 'test-app', + timestamp: new Date(), + yes: 8, + no: 2, + }, + { + featureName: feature1, + environment: 'staging-env', + appName: 'test-app', + timestamp: new Date(), + yes: 20, + no: 10, + }, + { + featureName: feature2, + environment: 'staging-env', + appName: 'test-app', + timestamp: new Date(), + yes: 12, + no: 6, + }, + { + featureName: feature3, + environment: 'staging-env', + appName: 'test-app', + timestamp: new Date(), + yes: 25, + no: 5, + }, + { + featureName: feature1, + environment: 'prod-env', + appName: 'test-app', + timestamp: new Date(), + yes: 100, + no: 20, + }, + { + featureName: feature2, + environment: 'prod-env', + appName: 'test-app', + timestamp: new Date(), + yes: 80, + no: 15, + }, + { + featureName: feature3, + environment: 'prod-env', + appName: 'test-app', + timestamp: new Date(), + yes: 150, + no: 30, + }, + ]; + + eventBus.emit(CLIENT_METRICS_ADDED, bulkMetricsEvent); + + await new Promise(resolve => setTimeout(resolve, 50)); + + const lifecycle1 = await featureLifecycleService.getFeatureLifecycle(feature1); + const lifecycle2 = await featureLifecycleService.getFeatureLifecycle(feature2); + const lifecycle3 = await featureLifecycleService.getFeatureLifecycle(feature3); + + expect(lifecycle1.some(stage => stage.stage === 'initial')).toBe(true); + expect(lifecycle2.some(stage => stage.stage === 'initial')).toBe(true); + expect(lifecycle3.some(stage => stage.stage === 'initial')).toBe(true); + + expect(lifecycle1.length).toBeGreaterThan(1); + expect(lifecycle2.length).toBeGreaterThan(1); + expect(lifecycle3.length).toBeGreaterThan(1); +}); + +test('handles bulk metrics with unknown environments gracefully', async () => { + const eventBus = new EventEmitter(); + const { + featureLifecycleService, + eventStore, + environmentStore, + featureEnvironmentStore, + } = createFakeFeatureLifecycleService({ + flagResolver: { isEnabled: () => true }, + eventBus, + getLogger: noLoggerProvider, + } as unknown as IUnleashConfig); + + + + await environmentStore.create({ + name: 'known-env', + type: 'development', + } as IEnvironment); + + const feature = 'test-feature'; + await featureEnvironmentStore.addEnvironmentToFeature(feature, 'known-env', false); + + featureLifecycleService.listen(); + eventStore.emit(FEATURE_CREATED, { featureName: feature }); + await reachedStage(eventBus, feature, 'initial'); + + const metricsWithUnknownEnv = [ + { + featureName: feature, + environment: 'known-env', + appName: 'test-app', + timestamp: new Date(), + yes: 10, + no: 5, + }, + { + featureName: feature, + environment: 'unknown-env', + appName: 'test-app', + timestamp: new Date(), + yes: 5, + no: 2, + }, + ]; + + eventBus.emit(CLIENT_METRICS_ADDED, metricsWithUnknownEnv); + + await reachedStage(eventBus, feature, 'pre-live'); + + const lifecycle = await featureLifecycleService.getFeatureLifecycle(feature); + expect(lifecycle).toEqual([ + { stage: 'initial', enteredStageAt: expect.any(Date) }, + { stage: 'pre-live', enteredStageAt: expect.any(Date) }, + ]); +}); diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts index c8b27ea9be..cc6ee4506e 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts @@ -88,19 +88,7 @@ export class FeatureLifecycleService { CLIENT_METRICS_ADDED, async (events: IClientMetricsEnv[]) => { if (events.length > 0) { - const groupedByEnvironment = groupBy(events, 'environment'); - - for (const [environment, metrics] of Object.entries( - groupedByEnvironment, - )) { - const features = metrics.map( - (metric) => metric.featureName, - ); - await this.featuresReceivedMetrics( - features, - environment, - ); - } + await this.handleBulkMetrics(events); } }, ); @@ -123,52 +111,106 @@ export class FeatureLifecycleService { this.recordStagesEntered(result); } - private async stageReceivedMetrics( - features: string[], - stage: 'live' | 'pre-live', - ) { - const newlyEnteredStages = await this.featureLifecycleStore.insert( - features.map((feature) => ({ feature, stage })), - ); - this.recordStagesEntered(newlyEnteredStages); - } - private recordStagesEntered(newlyEnteredStages: NewStage[]) { newlyEnteredStages.forEach(({ stage, feature }) => { this.eventBus.emit(STAGE_ENTERED, { stage, feature }); }); } - private async featuresReceivedMetrics( - features: string[], - environment: string, - ) { + private async handleBulkMetrics(events: IClientMetricsEnv[]) { try { - const env = await this.environmentStore.get(environment); + const { environments, allFeatures } = this.extractUniqueEnvironmentsAndFeatures(events); + const envMap = await this.buildEnvironmentMap(); + const featureEnvMap = await this.buildFeatureEnvironmentMap(allFeatures); + const allStagesToInsert = this.determineLifecycleStages(events, environments, envMap, featureEnvMap); - if (!env) { - return; - } - await this.stageReceivedMetrics(features, 'pre-live'); - if (env.type === 'production') { - const featureEnv = - await this.featureEnvironmentStore.getAllByFeatures( - features, - env.name, - ); - const enabledFeatures = featureEnv - .filter((feature) => feature.enabled) - .map((feature) => feature.featureName); - await this.stageReceivedMetrics(enabledFeatures, 'live'); + if (allStagesToInsert.length > 0) { + const newlyEnteredStages = await this.featureLifecycleStore.insert(allStagesToInsert); + this.recordStagesEntered(newlyEnteredStages); } } catch (e) { this.logger.warn( - `Error handling ${features.length} metrics in ${environment}`, + `Error handling bulk metrics for ${events.length} events`, e, ); } } + private extractUniqueEnvironmentsAndFeatures(events: IClientMetricsEnv[]) { + const environments = [...new Set(events.map(e => e.environment))]; + const allFeatures = [...new Set(events.map(e => e.featureName))]; + return { environments, allFeatures }; + } + + private async buildEnvironmentMap() { + const allEnvs = await this.environmentStore.getAll(); + return new Map(allEnvs.map(env => [env.name, env])); + } + + private async buildFeatureEnvironmentMap(allFeatures: string[]) { + const allFeatureEnvs = await this.featureEnvironmentStore.getAllByFeatures(allFeatures); + const featureEnvMap = new Map>(); + + allFeatureEnvs.forEach(fe => { + if (!featureEnvMap.has(fe.environment)) { + featureEnvMap.set(fe.environment, new Map()); + } + featureEnvMap.get(fe.environment)!.set(fe.featureName, fe); + }); + + return featureEnvMap; + } + + private determineLifecycleStages( + events: IClientMetricsEnv[], + environments: string[], + envMap: Map, + featureEnvMap: Map> + ): Array<{ feature: string; stage: 'pre-live' | 'live' }> { + const allStagesToInsert: Array<{ feature: string; stage: 'pre-live' | 'live' }> = []; + + for (const environment of environments) { + const env = envMap.get(environment); + if (!env) continue; + + const envFeatures = this.getFeaturesForEnvironment(events, environment); + allStagesToInsert.push(...this.createPreLiveStages(envFeatures)); + + if (env.type === 'production') { + const enabledFeatures = this.getEnabledFeaturesForEnvironment(envFeatures, environment, featureEnvMap); + allStagesToInsert.push(...this.createLiveStages(enabledFeatures)); + } + } + + return allStagesToInsert; + } + + private getFeaturesForEnvironment(events: IClientMetricsEnv[], environment: string): string[] { + return events + .filter(e => e.environment === environment) + .map(e => e.featureName); + } + + private createPreLiveStages(features: string[]): Array<{ feature: string; stage: 'pre-live' }> { + return features.map(feature => ({ feature, stage: 'pre-live' as const })); + } + + private createLiveStages(features: string[]): Array<{ feature: string; stage: 'live' }> { + return features.map(feature => ({ feature, stage: 'live' as const })); + } + + private getEnabledFeaturesForEnvironment( + features: string[], + environment: string, + featureEnvMap: Map> + ): string[] { + const envFeatureEnvs = featureEnvMap.get(environment) || new Map(); + return features.filter(feature => { + const fe = envFeatureEnvs.get(feature); + return fe && fe.enabled; + }); + } + public async featureCompleted( feature: string, projectId: string,