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

feat: lifecycle stage entered counter (#7449)

This commit is contained in:
Mateusz Kwasniewski 2024-06-25 14:40:16 +02:00 committed by GitHub
parent 5d0fc071e7
commit 3a3b6a29ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 78 additions and 35 deletions

View File

@ -2,6 +2,7 @@ import type {
FeatureLifecycleStage, FeatureLifecycleStage,
IFeatureLifecycleStore, IFeatureLifecycleStore,
FeatureLifecycleView, FeatureLifecycleView,
NewStage,
} from './feature-lifecycle-store-type'; } from './feature-lifecycle-store-type';
export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore { export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
@ -9,20 +10,31 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
async insert( async insert(
featureLifecycleStages: FeatureLifecycleStage[], featureLifecycleStages: FeatureLifecycleStage[],
): Promise<void> { ): Promise<NewStage[]> {
await Promise.all( const results = await Promise.all(
featureLifecycleStages.map((stage) => this.insertOne(stage)), 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() {} async backfill() {}
private async insertOne( private async insertOne(
featureLifecycleStage: FeatureLifecycleStage, featureLifecycleStage: FeatureLifecycleStage,
): Promise<void> { ): Promise<boolean> {
if (await this.stageExists(featureLifecycleStage)) { if (await this.stageExists(featureLifecycleStage)) {
return; return false;
} }
const newStages: NewStage[] = [];
const existingStages = await this.get(featureLifecycleStage.feature); const existingStages = await this.get(featureLifecycleStage.feature);
this.lifecycles[featureLifecycleStage.feature] = [ this.lifecycles[featureLifecycleStage.feature] = [
...existingStages, ...existingStages,
@ -34,6 +46,7 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
enteredStageAt: new Date(), enteredStageAt: new Date(),
}, },
]; ];
return true;
} }
async get(feature: string): Promise<FeatureLifecycleView> { async get(feature: string): Promise<FeatureLifecycleView> {

View File

@ -10,8 +10,8 @@ import {
} from '../../types'; } from '../../types';
import { createFakeFeatureLifecycleService } from './createFeatureLifecycle'; import { createFakeFeatureLifecycleService } from './createFeatureLifecycle';
import EventEmitter from 'events'; import EventEmitter from 'events';
import { STAGE_ENTERED } from './feature-lifecycle-service';
import noLoggerProvider from '../../../test/fixtures/no-logger'; import noLoggerProvider from '../../../test/fixtures/no-logger';
import { STAGE_ENTERED } from '../../metric-events';
test('can insert and read lifecycle stages', async () => { test('can insert and read lifecycle stages', async () => {
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
@ -42,7 +42,7 @@ test('can insert and read lifecycle stages', async () => {
} }
function reachedStage(feature: string, name: StageName) { function reachedStage(feature: string, name: StageName) {
return new Promise((resolve) => return new Promise((resolve) =>
featureLifecycleService.on(STAGE_ENTERED, (event) => { eventBus.on(STAGE_ENTERED, (event) => {
if (event.stage === name && event.feature === feature) if (event.stage === name && event.feature === feature)
resolve(name); resolve(name);
}), }),

View File

@ -15,17 +15,17 @@ import {
import type { import type {
FeatureLifecycleView, FeatureLifecycleView,
IFeatureLifecycleStore, IFeatureLifecycleStore,
NewStage,
} from './feature-lifecycle-store-type'; } from './feature-lifecycle-store-type';
import EventEmitter from 'events'; import type EventEmitter from 'events';
import type { Logger } from '../../logger'; import type { Logger } from '../../logger';
import type EventService from '../events/event-service'; import type EventService from '../events/event-service';
import type { FeatureLifecycleCompletedSchema } from '../../openapi'; import type { FeatureLifecycleCompletedSchema } from '../../openapi';
import type { IClientMetricsEnv } from '../metrics/client-metrics/client-metrics-store-v2-type'; import type { IClientMetricsEnv } from '../metrics/client-metrics/client-metrics-store-v2-type';
import groupBy from 'lodash.groupby'; import groupBy from 'lodash.groupby';
import { STAGE_ENTERED } from '../../metric-events';
export const STAGE_ENTERED = 'STAGE_ENTERED'; export class FeatureLifecycleService {
export class FeatureLifecycleService extends EventEmitter {
private eventStore: IEventStore; private eventStore: IEventStore;
private featureLifecycleStore: IFeatureLifecycleStore; private featureLifecycleStore: IFeatureLifecycleStore;
@ -65,7 +65,6 @@ export class FeatureLifecycleService extends EventEmitter {
getLogger, getLogger,
}: Pick<IUnleashConfig, 'flagResolver' | 'eventBus' | 'getLogger'>, }: Pick<IUnleashConfig, 'flagResolver' | 'eventBus' | 'getLogger'>,
) { ) {
super();
this.eventStore = eventStore; this.eventStore = eventStore;
this.featureLifecycleStore = featureLifecycleStore; this.featureLifecycleStore = featureLifecycleStore;
this.environmentStore = environmentStore; this.environmentStore = environmentStore;
@ -128,22 +127,26 @@ export class FeatureLifecycleService extends EventEmitter {
} }
private async featureInitialized(feature: string) { private async featureInitialized(feature: string) {
await this.featureLifecycleStore.insert([ const result = await this.featureLifecycleStore.insert([
{ feature, stage: 'initial' }, { feature, stage: 'initial' },
]); ]);
this.emit(STAGE_ENTERED, { stage: 'initial', feature }); this.recordStagesEntered(result);
} }
private async stageReceivedMetrics( private async stageReceivedMetrics(
features: string[], features: string[],
stage: 'live' | 'pre-live', stage: 'live' | 'pre-live',
) { ) {
await this.featureLifecycleStore.insert( const newlyEnteredStages = await this.featureLifecycleStore.insert(
features.map((feature) => ({ feature, stage })), features.map((feature) => ({ feature, stage })),
); );
features.forEach((feature) => this.recordStagesEntered(newlyEnteredStages);
this.emit(STAGE_ENTERED, { stage, feature }), }
);
private recordStagesEntered(newlyEnteredStages: NewStage[]) {
newlyEnteredStages.forEach(({ stage, feature }) => {
this.eventBus.emit(STAGE_ENTERED, { stage, feature });
});
} }
private async featuresReceivedMetrics( private async featuresReceivedMetrics(
@ -182,7 +185,7 @@ export class FeatureLifecycleService extends EventEmitter {
status: FeatureLifecycleCompletedSchema, status: FeatureLifecycleCompletedSchema,
auditUser: IAuditUser, auditUser: IAuditUser,
) { ) {
await this.featureLifecycleStore.insert([ const result = await this.featureLifecycleStore.insert([
{ {
feature, feature,
stage: 'completed', stage: 'completed',
@ -190,6 +193,7 @@ export class FeatureLifecycleService extends EventEmitter {
statusValue: status.statusValue, statusValue: status.statusValue,
}, },
]); ]);
this.recordStagesEntered(result);
await this.eventService.storeEvent( await this.eventService.storeEvent(
new FeatureCompletedEvent({ new FeatureCompletedEvent({
project: projectId, project: projectId,
@ -219,10 +223,10 @@ export class FeatureLifecycleService extends EventEmitter {
} }
private async featureArchived(feature: string) { private async featureArchived(feature: string) {
await this.featureLifecycleStore.insert([ const result = await this.featureLifecycleStore.insert([
{ feature, stage: 'archived' }, { feature, stage: 'archived' },
]); ]);
this.emit(STAGE_ENTERED, { stage: 'archived', feature }); this.recordStagesEntered(result);
} }
private async featureRevived(feature: string) { private async featureRevived(feature: string) {

View File

@ -14,8 +14,12 @@ export type FeatureLifecycleProjectItem = FeatureLifecycleStage & {
project: string; project: string;
}; };
export type NewStage = Pick<FeatureLifecycleStage, 'feature' | 'stage'>;
export interface IFeatureLifecycleStore { export interface IFeatureLifecycleStore {
insert(featureLifecycleStages: FeatureLifecycleStage[]): Promise<void>; insert(
featureLifecycleStages: FeatureLifecycleStage[],
): Promise<NewStage[]>;
get(feature: string): Promise<FeatureLifecycleView>; get(feature: string): Promise<FeatureLifecycleView>;
stageExists(stage: FeatureLifecycleStage): Promise<boolean>; stageExists(stage: FeatureLifecycleStage): Promise<boolean>;
delete(feature: string): Promise<void>; delete(feature: string): Promise<void>;

View File

@ -3,6 +3,7 @@ import type {
IFeatureLifecycleStore, IFeatureLifecycleStore,
FeatureLifecycleView, FeatureLifecycleView,
FeatureLifecycleProjectItem, FeatureLifecycleProjectItem,
NewStage,
} from './feature-lifecycle-store-type'; } from './feature-lifecycle-store-type';
import type { Db } from '../../db/db'; import type { Db } from '../../db/db';
import type { StageName } from '../../types'; import type { StageName } from '../../types';
@ -38,7 +39,7 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore {
async insert( async insert(
featureLifecycleStages: FeatureLifecycleStage[], featureLifecycleStages: FeatureLifecycleStage[],
): Promise<void> { ): Promise<NewStage[]> {
const existingFeatures = await this.db('features') const existingFeatures = await this.db('features')
.select('name') .select('name')
.whereIn( .whereIn(
@ -53,9 +54,9 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore {
); );
if (validStages.length === 0) { if (validStages.length === 0) {
return; return [];
} }
await this.db('feature_lifecycles') const result = await this.db('feature_lifecycles')
.insert( .insert(
validStages.map((stage) => ({ validStages.map((stage) => ({
feature: stage.feature, feature: stage.feature,
@ -67,6 +68,11 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore {
.returning('*') .returning('*')
.onConflict(['feature', 'stage']) .onConflict(['feature', 'stage'])
.ignore(); .ignore();
return result.map((row) => ({
stage: row.stage,
feature: row.feature,
}));
} }
async get(feature: string): Promise<FeatureLifecycleView> { async get(feature: string): Promise<FeatureLifecycleView> {

View File

@ -14,13 +14,11 @@ import {
type StageName, type StageName,
} from '../../types'; } from '../../types';
import type EventEmitter from 'events'; import type EventEmitter from 'events';
import { import type { FeatureLifecycleService } from './feature-lifecycle-service';
type FeatureLifecycleService,
STAGE_ENTERED,
} from './feature-lifecycle-service';
import type { FeatureLifecycleCompletedSchema } from '../../openapi'; import type { FeatureLifecycleCompletedSchema } from '../../openapi';
import { FeatureLifecycleReadModel } from './feature-lifecycle-read-model'; import { FeatureLifecycleReadModel } from './feature-lifecycle-read-model';
import type { IFeatureLifecycleReadModel } from './feature-lifecycle-read-model-type'; import type { IFeatureLifecycleReadModel } from './feature-lifecycle-read-model-type';
import { STAGE_ENTERED } from '../../metric-events';
let app: IUnleashTest; let app: IUnleashTest;
let db: ITestDb; let db: ITestDb;
@ -102,7 +100,7 @@ const uncompleteFeature = async (featureName: string, expectedCode = 200) => {
function reachedStage(feature: string, stage: StageName) { function reachedStage(feature: string, stage: StageName) {
return new Promise((resolve) => return new Promise((resolve) =>
featureLifecycleService.on(STAGE_ENTERED, (event) => { eventBus.on(STAGE_ENTERED, (event) => {
if (event.stage === stage && event.feature === feature) if (event.stage === stage && event.feature === feature)
resolve(stage); resolve(stage);
}), }),

View File

@ -7,6 +7,7 @@ const EVENTS_CREATED_BY_PROCESSED = 'events_created_by_processed';
const FRONTEND_API_REPOSITORY_CREATED = 'frontend_api_repository_created'; const FRONTEND_API_REPOSITORY_CREATED = 'frontend_api_repository_created';
const PROXY_REPOSITORY_CREATED = 'proxy_repository_created'; const PROXY_REPOSITORY_CREATED = 'proxy_repository_created';
const PROXY_FEATURES_FOR_TOKEN_TIME = 'proxy_features_for_token_time'; const PROXY_FEATURES_FOR_TOKEN_TIME = 'proxy_features_for_token_time';
const STAGE_ENTERED = 'stage-entered' as const;
export { export {
REQUEST_TIME, REQUEST_TIME,
@ -18,4 +19,5 @@ export {
FRONTEND_API_REPOSITORY_CREATED, FRONTEND_API_REPOSITORY_CREATED,
PROXY_REPOSITORY_CREATED, PROXY_REPOSITORY_CREATED,
PROXY_FEATURES_FOR_TOKEN_TIME, PROXY_FEATURES_FOR_TOKEN_TIME,
STAGE_ENTERED,
}; };

View File

@ -320,5 +320,6 @@ test('should collect metrics for lifecycle', async () => {
const metrics = await prometheusRegister.metrics(); const metrics = await prometheusRegister.metrics();
expect(metrics).toMatch(/feature_lifecycle_stage_duration/); 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/);
}); });

View File

@ -285,12 +285,18 @@ export default class MetricsMonitor {
help: 'Duration of feature lifecycle stages', help: 'Duration of feature lifecycle stages',
}); });
const stageCountByProject = createGauge({ const featureLifecycleStageCountByProject = createGauge({
name: 'stage_count_by_project', name: 'feature_lifecycle_stage_count_by_project',
help: 'Count features in a given stage by project id', help: 'Count features in a given stage by project id',
labelNames: ['stage', '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({ const projectEnvironmentsDisabled = createCounter({
name: 'project_environments_disabled', name: 'project_environments_disabled',
help: 'How many "environment disabled" events we have received for each project', help: 'How many "environment disabled" events we have received for each project',
@ -337,9 +343,18 @@ export default class MetricsMonitor {
.set(stage.duration); .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) => stageCountByProjectResult.forEach((stageResult) =>
stageCountByProject featureLifecycleStageCountByProject
.labels({ .labels({
project_id: stageResult.project, project_id: stageResult.project,
stage: stageResult.stage, stage: stageResult.stage,