mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
feat: lifecycle stage entered counter (#7449)
This commit is contained in:
parent
5d0fc071e7
commit
3a3b6a29ff
@ -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<void> {
|
||||
await Promise.all(
|
||||
featureLifecycleStages.map((stage) => this.insertOne(stage)),
|
||||
): Promise<NewStage[]> {
|
||||
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<void> {
|
||||
): Promise<boolean> {
|
||||
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<FeatureLifecycleView> {
|
||||
|
@ -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);
|
||||
}),
|
||||
|
@ -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<IUnleashConfig, 'flagResolver' | 'eventBus' | 'getLogger'>,
|
||||
) {
|
||||
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) {
|
||||
|
@ -14,8 +14,12 @@ export type FeatureLifecycleProjectItem = FeatureLifecycleStage & {
|
||||
project: string;
|
||||
};
|
||||
|
||||
export type NewStage = Pick<FeatureLifecycleStage, 'feature' | 'stage'>;
|
||||
|
||||
export interface IFeatureLifecycleStore {
|
||||
insert(featureLifecycleStages: FeatureLifecycleStage[]): Promise<void>;
|
||||
insert(
|
||||
featureLifecycleStages: FeatureLifecycleStage[],
|
||||
): Promise<NewStage[]>;
|
||||
get(feature: string): Promise<FeatureLifecycleView>;
|
||||
stageExists(stage: FeatureLifecycleStage): Promise<boolean>;
|
||||
delete(feature: string): Promise<void>;
|
||||
|
@ -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<void> {
|
||||
): Promise<NewStage[]> {
|
||||
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<FeatureLifecycleView> {
|
||||
|
@ -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);
|
||||
}),
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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/);
|
||||
});
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user