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

feat: duration in stage, add feature lifecycle prometheus metrics (#6973)

Introduce a new concept. Duration in stage.
Also add it into prometheus metric.
This commit is contained in:
Jaanus Sellin 2024-05-08 11:33:51 +03:00 committed by GitHub
parent b30860c12f
commit 02440dfed2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 238 additions and 8 deletions

View File

@ -2,6 +2,7 @@ import type {
FeatureLifecycleStage, FeatureLifecycleStage,
IFeatureLifecycleStore, IFeatureLifecycleStore,
FeatureLifecycleView, FeatureLifecycleView,
FeatureLifecycleFullItem,
} from './feature-lifecycle-store-type'; } from './feature-lifecycle-store-type';
export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore { export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
@ -35,6 +36,17 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
return this.lifecycles[feature] || []; return this.lifecycles[feature] || [];
} }
async getAll(): Promise<FeatureLifecycleFullItem[]> {
const result = Object.entries(this.lifecycles).flatMap(
([key, items]): FeatureLifecycleFullItem[] =>
items.map((item) => ({
...item,
feature: key,
})),
);
return result;
}
async delete(feature: string): Promise<void> { async delete(feature: string): Promise<void> {
this.lifecycles[feature] = []; this.lifecycles[feature] = [];
} }

View File

@ -128,3 +128,71 @@ test('ignores lifecycle state updates when flag disabled', async () => {
expect(lifecycle).toEqual([]); expect(lifecycle).toEqual([]);
}); });
test('can find feature lifecycle stage timings', async () => {
const eventBus = new EventEmitter();
const { featureLifecycleService, eventStore, environmentStore } =
createFakeFeatureLifecycleService({
flagResolver: { isEnabled: () => false },
eventBus,
getLogger: noLoggerProvider,
} as unknown as IUnleashConfig);
const now = new Date();
const minusOneMinute = new Date(now.getTime() - 1 * 60 * 1000);
const minusTenMinutes = new Date(now.getTime() - 10 * 60 * 1000);
const durations = featureLifecycleService.calculateStageDurations([
{
feature: 'a',
stage: 'initial',
enteredStageAt: minusTenMinutes,
},
{
feature: 'b',
stage: 'initial',
enteredStageAt: minusTenMinutes,
},
{
feature: 'a',
stage: 'pre-live',
enteredStageAt: minusOneMinute,
},
{
feature: 'b',
stage: 'live',
enteredStageAt: minusOneMinute,
},
{
feature: 'c',
stage: 'initial',
enteredStageAt: minusTenMinutes,
},
]);
expect(durations).toMatchObject([
{
feature: 'a',
stage: 'initial',
duration: 9,
},
{
feature: 'a',
stage: 'pre-live',
duration: 1,
},
{
feature: 'b',
stage: 'initial',
duration: 9,
},
{
feature: 'b',
stage: 'live',
duration: 1,
},
{
feature: 'c',
stage: 'initial',
duration: 10,
},
]);
});

View File

@ -9,10 +9,12 @@ import {
type IEnvironmentStore, type IEnvironmentStore,
type IEventStore, type IEventStore,
type IFeatureEnvironmentStore, type IFeatureEnvironmentStore,
type IFeatureLifecycleStageDuration,
type IFlagResolver, type IFlagResolver,
type IUnleashConfig, type IUnleashConfig,
} from '../../types'; } from '../../types';
import type { import type {
FeatureLifecycleFullItem,
FeatureLifecycleView, FeatureLifecycleView,
IFeatureLifecycleStore, IFeatureLifecycleStore,
} from './feature-lifecycle-store-type'; } from './feature-lifecycle-store-type';
@ -20,6 +22,7 @@ import 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 { ValidatedClientMetrics } from '../metrics/shared/schema'; import type { ValidatedClientMetrics } from '../metrics/shared/schema';
import { differenceInMinutes } from 'date-fns';
export const STAGE_ENTERED = 'STAGE_ENTERED'; export const STAGE_ENTERED = 'STAGE_ENTERED';
@ -203,4 +206,51 @@ export class FeatureLifecycleService extends EventEmitter {
await this.featureLifecycleStore.delete(feature); await this.featureLifecycleStore.delete(feature);
await this.featureInitialized(feature); await this.featureInitialized(feature);
} }
public async getAllWithStageDuration(): Promise<
IFeatureLifecycleStageDuration[]
> {
const featureLifeCycles = await this.featureLifecycleStore.getAll();
return this.calculateStageDurations(featureLifeCycles);
}
public calculateStageDurations(
featureLifeCycles: FeatureLifecycleFullItem[],
) {
const groupedByFeature = featureLifeCycles.reduce<{
[feature: string]: FeatureLifecycleFullItem[];
}>((acc, curr) => {
if (!acc[curr.feature]) {
acc[curr.feature] = [];
}
acc[curr.feature].push(curr);
return acc;
}, {});
const times: IFeatureLifecycleStageDuration[] = [];
Object.values(groupedByFeature).forEach((stages) => {
stages.sort(
(a, b) =>
a.enteredStageAt.getTime() - b.enteredStageAt.getTime(),
);
stages.forEach((stage, index) => {
const nextStage = stages[index + 1];
const endTime = nextStage
? nextStage.enteredStageAt
: new Date();
const duration = differenceInMinutes(
endTime,
stage.enteredStageAt,
);
times.push({
feature: stage.feature,
stage: stage.stage,
duration,
});
});
});
return times;
}
} }

View File

@ -7,9 +7,14 @@ export type FeatureLifecycleStage = {
export type FeatureLifecycleView = IFeatureLifecycleStage[]; export type FeatureLifecycleView = IFeatureLifecycleStage[];
export type FeatureLifecycleFullItem = FeatureLifecycleStage & {
enteredStageAt: Date;
};
export interface IFeatureLifecycleStore { export interface IFeatureLifecycleStore {
insert(featureLifecycleStages: FeatureLifecycleStage[]): Promise<void>; insert(featureLifecycleStages: FeatureLifecycleStage[]): Promise<void>;
get(feature: string): Promise<FeatureLifecycleView>; get(feature: string): Promise<FeatureLifecycleView>;
getAll(): Promise<FeatureLifecycleFullItem[]>;
stageExists(stage: FeatureLifecycleStage): Promise<boolean>; stageExists(stage: FeatureLifecycleStage): Promise<boolean>;
delete(feature: string): Promise<void>; delete(feature: string): Promise<void>;
deleteStage(stage: FeatureLifecycleStage): Promise<void>; deleteStage(stage: FeatureLifecycleStage): Promise<void>;

View File

@ -2,6 +2,7 @@ import type {
FeatureLifecycleStage, FeatureLifecycleStage,
IFeatureLifecycleStore, IFeatureLifecycleStore,
FeatureLifecycleView, FeatureLifecycleView,
FeatureLifecycleFullItem,
} 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';
@ -9,7 +10,7 @@ import type { StageName } from '../../types';
type DBType = { type DBType = {
feature: string; feature: string;
stage: StageName; stage: StageName;
created_at: Date; created_at: string;
}; };
export class FeatureLifecycleStore implements IFeatureLifecycleStore { export class FeatureLifecycleStore implements IFeatureLifecycleStore {
@ -53,7 +54,20 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore {
return results.map(({ stage, created_at }: DBType) => ({ return results.map(({ stage, created_at }: DBType) => ({
stage, stage,
enteredStageAt: created_at, enteredStageAt: new Date(created_at),
}));
}
async getAll(): Promise<FeatureLifecycleFullItem[]> {
const results = await this.db('feature_lifecycles').orderBy(
'created_at',
'asc',
);
return results.map(({ feature, stage, created_at }: DBType) => ({
feature,
stage,
enteredStageAt: new Date(created_at),
})); }));
} }

View File

@ -40,6 +40,10 @@ import FakeSettingStore from '../../../test/fixtures/fake-setting-store';
import FakeSegmentStore from '../../../test/fixtures/fake-segment-store'; import FakeSegmentStore from '../../../test/fixtures/fake-segment-store';
import FakeStrategiesStore from '../../../test/fixtures/fake-strategies-store'; import FakeStrategiesStore from '../../../test/fixtures/fake-strategies-store';
import FakeFeatureStrategiesStore from '../feature-toggle/fakes/fake-feature-strategies-store'; import FakeFeatureStrategiesStore from '../feature-toggle/fakes/fake-feature-strategies-store';
import {
createFakeFeatureLifecycleService,
createFeatureLifecycleService,
} from '../feature-lifecycle/createFeatureLifecycle';
export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => { export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => {
const { eventBus, getLogger, flagResolver } = config; const { eventBus, getLogger, flagResolver } = config;
@ -84,6 +88,10 @@ export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => {
getLogger, getLogger,
flagResolver, flagResolver,
); );
const { featureLifecycleService } = createFeatureLifecycleService(
db,
config,
);
const instanceStatsServiceStores = { const instanceStatsServiceStores = {
featureToggleStore, featureToggleStore,
userStore, userStore,
@ -125,6 +133,7 @@ export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => {
versionService, versionService,
getActiveUsers, getActiveUsers,
getProductionChanges, getProductionChanges,
featureLifecycleService,
); );
return instanceStatsService; return instanceStatsService;
@ -146,6 +155,9 @@ export const createFakeInstanceStatsService = (config: IUnleashConfig) => {
const eventStore = new FakeEventStore(); const eventStore = new FakeEventStore();
const apiTokenStore = new FakeApiTokenStore(); const apiTokenStore = new FakeApiTokenStore();
const clientMetricsStoreV2 = new FakeClientMetricsStoreV2(); const clientMetricsStoreV2 = new FakeClientMetricsStoreV2();
const { featureLifecycleService } =
createFakeFeatureLifecycleService(config);
const instanceStatsServiceStores = { const instanceStatsServiceStores = {
featureToggleStore, featureToggleStore,
userStore, userStore,
@ -182,6 +194,7 @@ export const createFakeInstanceStatsService = (config: IUnleashConfig) => {
versionService, versionService,
getActiveUsers, getActiveUsers,
getProductionChanges, getProductionChanges,
featureLifecycleService,
); );
return instanceStatsService; return instanceStatsService;

View File

@ -4,6 +4,7 @@ import createStores from '../../../test/fixtures/store';
import VersionService from '../../services/version-service'; import VersionService from '../../services/version-service';
import { createFakeGetActiveUsers } from './getActiveUsers'; import { createFakeGetActiveUsers } from './getActiveUsers';
import { createFakeGetProductionChanges } from './getProductionChanges'; import { createFakeGetProductionChanges } from './getProductionChanges';
import { createFakeFeatureLifecycleService } from '../feature-lifecycle/createFeatureLifecycle';
let instanceStatsService: InstanceStatsService; let instanceStatsService: InstanceStatsService;
let versionService: VersionService; let versionService: VersionService;
@ -23,6 +24,7 @@ beforeEach(() => {
versionService, versionService,
createFakeGetActiveUsers(), createFakeGetActiveUsers(),
createFakeGetProductionChanges(), createFakeGetProductionChanges(),
createFakeFeatureLifecycleService(config).featureLifecycleService,
); );
jest.spyOn(instanceStatsService, 'refreshAppCountSnapshot'); jest.spyOn(instanceStatsService, 'refreshAppCountSnapshot');

View File

@ -22,11 +22,13 @@ import {
FEATURES_EXPORTED, FEATURES_EXPORTED,
FEATURES_IMPORTED, FEATURES_IMPORTED,
type IApiTokenStore, type IApiTokenStore,
type IFeatureLifecycleStageDuration,
} from '../../types'; } from '../../types';
import { CUSTOM_ROOT_ROLE_TYPE } from '../../util'; import { CUSTOM_ROOT_ROLE_TYPE } from '../../util';
import type { GetActiveUsers } from './getActiveUsers'; import type { GetActiveUsers } from './getActiveUsers';
import type { ProjectModeCount } from '../project/project-store'; import type { ProjectModeCount } from '../project/project-store';
import type { GetProductionChanges } from './getProductionChanges'; import type { GetProductionChanges } from './getProductionChanges';
import type { FeatureLifecycleService } from '../feature-lifecycle/feature-lifecycle-service';
export type TimeRange = 'allTime' | '30d' | '7d'; export type TimeRange = 'allTime' | '30d' | '7d';
@ -60,6 +62,7 @@ export interface InstanceStats {
enabledCount: number; enabledCount: number;
variantCount: number; variantCount: number;
}; };
featureLifeCycles: IFeatureLifecycleStageDuration[];
} }
export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & { export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & {
@ -90,6 +93,8 @@ export class InstanceStatsService {
private eventStore: IEventStore; private eventStore: IEventStore;
private featureLifecycleService: FeatureLifecycleService;
private apiTokenStore: IApiTokenStore; private apiTokenStore: IApiTokenStore;
private versionService: VersionService; private versionService: VersionService;
@ -143,6 +148,7 @@ export class InstanceStatsService {
versionService: VersionService, versionService: VersionService,
getActiveUsers: GetActiveUsers, getActiveUsers: GetActiveUsers,
getProductionChanges: GetProductionChanges, getProductionChanges: GetProductionChanges,
featureLifecycleService: FeatureLifecycleService,
) { ) {
this.strategyStore = strategyStore; this.strategyStore = strategyStore;
this.userStore = userStore; this.userStore = userStore;
@ -157,6 +163,7 @@ export class InstanceStatsService {
this.settingStore = settingStore; this.settingStore = settingStore;
this.eventStore = eventStore; this.eventStore = eventStore;
this.clientInstanceStore = clientInstanceStore; this.clientInstanceStore = clientInstanceStore;
this.featureLifecycleService = featureLifecycleService;
this.logger = getLogger('services/stats-service.js'); this.logger = getLogger('services/stats-service.js');
this.getActiveUsers = getActiveUsers; this.getActiveUsers = getActiveUsers;
this.getProductionChanges = getProductionChanges; this.getProductionChanges = getProductionChanges;
@ -243,6 +250,7 @@ export class InstanceStatsService {
featureImports, featureImports,
productionChanges, productionChanges,
previousDayMetricsBucketsCount, previousDayMetricsBucketsCount,
featureLifeCycles,
] = await Promise.all([ ] = await Promise.all([
this.getToggleCount(), this.getToggleCount(),
this.getArchivedToggleCount(), this.getArchivedToggleCount(),
@ -266,6 +274,7 @@ export class InstanceStatsService {
this.eventStore.filteredCount({ type: FEATURES_IMPORTED }), this.eventStore.filteredCount({ type: FEATURES_IMPORTED }),
this.getProductionChanges(), this.getProductionChanges(),
this.clientMetricsStore.countPreviousDayHourlyMetricsBuckets(), this.clientMetricsStore.countPreviousDayHourlyMetricsBuckets(),
this.featureLifecycleService.getAllWithStageDuration(),
]); ]);
return { return {
@ -298,6 +307,7 @@ export class InstanceStatsService {
featureImports, featureImports,
productionChanges, productionChanges,
previousDayMetricsBucketsCount, previousDayMetricsBucketsCount,
featureLifeCycles,
}; };
} }

View File

@ -20,6 +20,7 @@ import type { IEnvironmentStore, IUnleashStores } from './types';
import FakeEnvironmentStore from './features/project-environments/fake-environment-store'; import FakeEnvironmentStore from './features/project-environments/fake-environment-store';
import { SchedulerService } from './services'; import { SchedulerService } from './services';
import noLogger from '../test/fixtures/no-logger'; import noLogger from '../test/fixtures/no-logger';
import { createFakeFeatureLifecycleService } from './features';
const monitor = createMetricsMonitor(); const monitor = createMetricsMonitor();
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
@ -45,12 +46,15 @@ beforeAll(async () => {
createFakeGetActiveUsers(), createFakeGetActiveUsers(),
createFakeGetProductionChanges(), createFakeGetProductionChanges(),
); );
const { featureLifecycleService } =
createFakeFeatureLifecycleService(config);
statsService = new InstanceStatsService( statsService = new InstanceStatsService(
stores, stores,
config, config,
versionService, versionService,
createFakeGetActiveUsers(), createFakeGetActiveUsers(),
createFakeGetProductionChanges(), createFakeGetProductionChanges(),
featureLifecycleService,
); );
schedulerService = new SchedulerService( schedulerService = new SchedulerService(
@ -275,3 +279,8 @@ test('should collect metrics for project disabled numbers', async () => {
/project_environments_disabled{project_id=\"default\"} 1/, /project_environments_disabled{project_id=\"default\"} 1/,
); );
}); });
test('should collect metrics for lifecycle', async () => {
const metrics = await prometheusRegister.metrics();
expect(metrics).toMatch(/feature_lifecycle_stage_duration/);
});

View File

@ -259,6 +259,12 @@ export default class MetricsMonitor {
help: 'Duration of mapFeaturesForClient function', help: 'Duration of mapFeaturesForClient function',
}); });
const featureLifecycleStageDuration = createHistogram({
name: 'feature_lifecycle_stage_duration',
labelNames: ['feature_id', 'stage'],
help: 'Duration of feature lifecycle stages',
});
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',
@ -283,6 +289,15 @@ export default class MetricsMonitor {
serviceAccounts.reset(); serviceAccounts.reset();
serviceAccounts.set(stats.serviceAccounts); serviceAccounts.set(stats.serviceAccounts);
stats.featureLifeCycles.forEach((stage) => {
featureLifecycleStageDuration
.labels({
feature_id: stage.feature,
stage: stage.stage,
})
.observe(stage.duration);
});
apiTokens.reset(); apiTokens.reset();
for (const [type, value] of stats.apiTokens) { for (const [type, value] of stats.apiTokens) {
@ -358,7 +373,10 @@ export default class MetricsMonitor {
rateLimits.reset(); rateLimits.reset();
rateLimits rateLimits
.labels({ endpoint: '/api/client/metrics', method: 'POST' }) .labels({
endpoint: '/api/client/metrics',
method: 'POST',
})
.set(config.metricsRateLimiting.clientMetricsMaxPerMinute); .set(config.metricsRateLimiting.clientMetricsMaxPerMinute);
rateLimits rateLimits
.labels({ .labels({
@ -389,7 +407,10 @@ export default class MetricsMonitor {
}) })
.set(config.rateLimiting.createUserMaxPerMinute); .set(config.rateLimiting.createUserMaxPerMinute);
rateLimits rateLimits
.labels({ endpoint: '/auth/simple', method: 'POST' }) .labels({
endpoint: '/auth/simple',
method: 'POST',
})
.set(config.rateLimiting.simpleLoginMaxPerMinute); .set(config.rateLimiting.simpleLoginMaxPerMinute);
rateLimits rateLimits
.labels({ .labels({
@ -407,7 +428,6 @@ export default class MetricsMonitor {
); );
} catch (e) {} } catch (e) {}
} }
await schedulerService.schedule( await schedulerService.schedule(
collectStaticCounters.bind(this), collectStaticCounters.bind(this),
hoursToMilliseconds(2), hoursToMilliseconds(2),
@ -419,7 +439,12 @@ export default class MetricsMonitor {
events.REQUEST_TIME, events.REQUEST_TIME,
({ path, method, time, statusCode, appName }) => { ({ path, method, time, statusCode, appName }) => {
requestDuration requestDuration
.labels({ path, method, status: statusCode, appName }) .labels({
path,
method,
status: statusCode,
appName,
})
.observe(time); .observe(time);
}, },
); );
@ -432,7 +457,10 @@ export default class MetricsMonitor {
events.FUNCTION_TIME, events.FUNCTION_TIME,
({ functionName, className, time }) => { ({ functionName, className, time }) => {
functionDuration functionDuration
.labels({ functionName, className }) .labels({
functionName,
className,
})
.observe(time); .observe(time);
}, },
); );
@ -446,7 +474,12 @@ export default class MetricsMonitor {
}); });
eventBus.on(events.DB_TIME, ({ store, action, time }) => { eventBus.on(events.DB_TIME, ({ store, action, time }) => {
dbDuration.labels({ store, action }).observe(time); dbDuration
.labels({
store,
action,
})
.observe(time);
}); });
eventBus.on(events.PROXY_REPOSITORY_CREATED, () => { eventBus.on(events.PROXY_REPOSITORY_CREATED, () => {
@ -704,6 +737,7 @@ export default class MetricsMonitor {
} }
} }
} }
export function createMetricsMonitor(): MetricsMonitor { export function createMetricsMonitor(): MetricsMonitor {
return new MetricsMonitor(); return new MetricsMonitor();
} }

View File

@ -125,6 +125,13 @@ class InstanceAdminController extends Controller {
variantCount: 100, variantCount: 100,
enabledCount: 200, enabledCount: 200,
}, },
featureLifeCycles: [
{
feature: 'feature1',
stage: 'archived',
duration: 2000,
},
],
}; };
} }

View File

@ -7,6 +7,7 @@ import type { IProjectStats } from '../features/project/project-service';
import type { CreateFeatureStrategySchema } from '../openapi'; import type { CreateFeatureStrategySchema } from '../openapi';
import type { ProjectEnvironment } from '../features/project/project-store-type'; import type { ProjectEnvironment } from '../features/project/project-store-type';
import type { FeatureSearchEnvironmentSchema } from '../openapi/spec/feature-search-environment-schema'; import type { FeatureSearchEnvironmentSchema } from '../openapi/spec/feature-search-environment-schema';
import type { FeatureLifecycleStage } from '../features/feature-lifecycle/feature-lifecycle-store-type';
export type Operator = (typeof ALL_OPERATORS)[number]; export type Operator = (typeof ALL_OPERATORS)[number];
@ -159,11 +160,16 @@ export type StageName =
| 'live' | 'live'
| 'completed' | 'completed'
| 'archived'; | 'archived';
export interface IFeatureLifecycleStage { export interface IFeatureLifecycleStage {
stage: StageName; stage: StageName;
enteredStageAt: Date; enteredStageAt: Date;
} }
export type IFeatureLifecycleStageDuration = FeatureLifecycleStage & {
duration: number;
};
export interface IFeatureDependency { export interface IFeatureDependency {
feature: string; feature: string;
dependency: IDependency; dependency: IDependency;