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:
parent
829c2c5bc3
commit
55997af808
@ -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) },
|
||||
]);
|
||||
});
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user