Skip to content

Commit

Permalink
feat: lifecycle stage entered counter (#7449)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew authored Jun 25, 2024
1 parent 5d0fc07 commit 3a3b6a2
Show file tree
Hide file tree
Showing 9 changed files with 78 additions and 35 deletions.
23 changes: 18 additions & 5 deletions src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,39 @@ import type {
FeatureLifecycleStage,
IFeatureLifecycleStore,
FeatureLifecycleView,
NewStage,
} from './feature-lifecycle-store-type';

export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
private lifecycles: Record<string, FeatureLifecycleView> = {};

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,
Expand All @@ -34,6 +46,7 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
enteredStageAt: new Date(),
},
];
return true;
}

async get(feature: string): Promise<FeatureLifecycleView> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}),
Expand Down
32 changes: 18 additions & 14 deletions src/lib/features/feature-lifecycle/feature-lifecycle-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -182,14 +185,15 @@ export class FeatureLifecycleService extends EventEmitter {
status: FeatureLifecycleCompletedSchema,
auditUser: IAuditUser,
) {
await this.featureLifecycleStore.insert([
const result = await this.featureLifecycleStore.insert([
{
feature,
stage: 'completed',
status: status.status,
statusValue: status.statusValue,
},
]);
this.recordStagesEntered(result);
await this.eventService.storeEvent(
new FeatureCompletedEvent({
project: projectId,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
12 changes: 9 additions & 3 deletions src/lib/features/feature-lifecycle/feature-lifecycle-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}),
Expand Down
2 changes: 2 additions & 0 deletions src/lib/metric-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,4 +19,5 @@ export {
FRONTEND_API_REPOSITORY_CREATED,
PROXY_REPOSITORY_CREATED,
PROXY_FEATURES_FOR_TOKEN_TIME,
STAGE_ENTERED,
};
3 changes: 2 additions & 1 deletion src/lib/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
23 changes: 19 additions & 4 deletions src/lib/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 3a3b6a2

Please sign in to comment.