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:
parent
5d0fc071e7
commit
3a3b6a29ff
@ -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> {
|
||||||
|
@ -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);
|
||||||
}),
|
}),
|
||||||
|
@ -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) {
|
||||||
|
@ -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>;
|
||||||
|
@ -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> {
|
||||||
|
@ -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);
|
||||||
}),
|
}),
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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/);
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user