1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-24 17:51:14 +02:00

fix: lifecycle metrics will now be posted across all environments

This commit is contained in:
Jaanus 2025-09-01 15:11:47 +03:00
parent 829c2c5bc3
commit 55997af808
No known key found for this signature in database
2 changed files with 316 additions and 71 deletions

View File

@ -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) },
]);
});

View File

@ -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<string, Map<string, any>>();
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<string, any>,
featureEnvMap: Map<string, Map<string, any>>
): 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, Map<string, any>>
): 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,