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

feat: lifecycle prometheus metrics per project (#7032)

When we pushed metrics per feature, it had too many datapoints and
grafana could not handle it. Now I am taking median for a project.
This commit is contained in:
Jaanus Sellin 2024-05-10 15:24:27 +03:00 committed by GitHub
parent 56ae53a4da
commit 958ccabb54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 179 additions and 127 deletions

View File

@ -0,0 +1,89 @@
import {
calculateMedians,
calculateStageDurations,
} from './calculate-stage-durations';
test('can find feature lifecycle stage timings', async () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const twentyMinutesAgo = new Date(now.getTime() - 20 * 60 * 1000);
const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000);
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
const durations = calculateStageDurations([
{
feature: 'a',
stage: 'initial',
project: 'default',
enteredStageAt: oneHourAgo,
},
{
feature: 'b',
stage: 'initial',
project: 'default',
enteredStageAt: oneHourAgo,
},
{
feature: 'a',
stage: 'pre-live',
project: 'default',
enteredStageAt: twentyMinutesAgo,
},
{
feature: 'b',
stage: 'live',
project: 'default',
enteredStageAt: tenMinutesAgo,
},
{
feature: 'c',
stage: 'initial',
project: 'default',
enteredStageAt: oneHourAgo,
},
{
feature: 'c',
stage: 'pre-live',
project: 'default',
enteredStageAt: fiveMinutesAgo,
},
]);
expect(durations).toMatchObject([
{
project: 'default',
stage: 'initial',
duration: 50,
},
{
project: 'default',
stage: 'pre-live',
duration: 12.5,
},
{
project: 'default',
stage: 'live',
duration: 10,
},
]);
});
test('should calculate median durations', () => {
const groupedData = {
'Project1/Development': [180, 120, 10],
'Project1/Testing': [240, 60],
};
const medians = calculateMedians(groupedData);
expect(medians).toMatchObject([
{
project: 'Project1',
stage: 'Development',
duration: 120,
},
{
project: 'Project1',
stage: 'Testing',
duration: 150,
},
]);
});

View File

@ -0,0 +1,50 @@
import type { IProjectLifecycleStageDuration, StageName } from '../../types';
import type { FeatureLifecycleProjectItem } from './feature-lifecycle-store-type';
import { differenceInMinutes } from 'date-fns';
import { median } from '../../util/median';
export function calculateStageDurations(
featureLifeCycles: FeatureLifecycleProjectItem[],
) {
const sortedLifeCycles = featureLifeCycles.sort(
(a, b) => a.enteredStageAt.getTime() - b.enteredStageAt.getTime(),
);
const groupedByProjectAndStage = sortedLifeCycles.reduce<{
[key: string]: number[];
}>((acc, curr, index, array) => {
const key = `${curr.project}/${curr.stage}`;
if (!acc[key]) {
acc[key] = [];
}
const nextItem = array
.slice(index + 1)
.find(
(item) =>
item.feature === curr.feature && item.stage !== curr.stage,
);
const endTime = nextItem ? nextItem.enteredStageAt : new Date();
const duration = differenceInMinutes(endTime, curr.enteredStageAt);
acc[key].push(duration);
return acc;
}, {});
return calculateMedians(groupedByProjectAndStage);
}
export const calculateMedians = (groupedByProjectAndStage: {
[key: string]: number[];
}) => {
const medians: IProjectLifecycleStageDuration[] = [];
Object.entries(groupedByProjectAndStage).forEach(([key, durations]) => {
const [project, stage] = key.split('/');
const duration = median(durations);
medians.push({
project,
stage: stage as StageName,
duration,
});
});
return medians;
};

View File

@ -128,76 +128,3 @@ 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',
project: 'default',
enteredStageAt: minusTenMinutes,
},
{
feature: 'b',
stage: 'initial',
project: 'default',
enteredStageAt: minusTenMinutes,
},
{
feature: 'a',
stage: 'pre-live',
project: 'default',
enteredStageAt: minusOneMinute,
},
{
feature: 'b',
stage: 'live',
project: 'default',
enteredStageAt: minusOneMinute,
},
{
feature: 'c',
stage: 'initial',
project: 'default',
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,12 +9,11 @@ import {
type IEnvironmentStore, type IEnvironmentStore,
type IEventStore, type IEventStore,
type IFeatureEnvironmentStore, type IFeatureEnvironmentStore,
type IFeatureLifecycleStageDuration, type IProjectLifecycleStageDuration,
type IFlagResolver, type IFlagResolver,
type IUnleashConfig, type IUnleashConfig,
} from '../../types'; } from '../../types';
import type { import type {
FeatureLifecycleProjectItem,
FeatureLifecycleView, FeatureLifecycleView,
IFeatureLifecycleStore, IFeatureLifecycleStore,
} from './feature-lifecycle-store-type'; } from './feature-lifecycle-store-type';
@ -22,8 +21,8 @@ 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';
import type { FeatureLifecycleCompletedSchema } from '../../openapi'; import type { FeatureLifecycleCompletedSchema } from '../../openapi';
import { calculateStageDurations } from './calculate-stage-durations';
export const STAGE_ENTERED = 'STAGE_ENTERED'; export const STAGE_ENTERED = 'STAGE_ENTERED';
@ -215,50 +214,9 @@ export class FeatureLifecycleService extends EventEmitter {
} }
public async getAllWithStageDuration(): Promise< public async getAllWithStageDuration(): Promise<
IFeatureLifecycleStageDuration[] IProjectLifecycleStageDuration[]
> { > {
const featureLifeCycles = await this.featureLifecycleStore.getAll(); const featureLifeCycles = await this.featureLifecycleStore.getAll();
return this.calculateStageDurations(featureLifeCycles); return calculateStageDurations(featureLifeCycles);
}
public calculateStageDurations(
featureLifeCycles: FeatureLifecycleProjectItem[],
) {
const groupedByFeature = featureLifeCycles.reduce<{
[feature: string]: FeatureLifecycleProjectItem[];
}>((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,
project: stage.project,
duration,
});
});
});
return times;
} }
} }

View File

@ -22,7 +22,7 @@ import {
FEATURES_EXPORTED, FEATURES_EXPORTED,
FEATURES_IMPORTED, FEATURES_IMPORTED,
type IApiTokenStore, type IApiTokenStore,
type IFeatureLifecycleStageDuration, type IProjectLifecycleStageDuration,
type IFlagResolver, type IFlagResolver,
} from '../../types'; } from '../../types';
import { CUSTOM_ROOT_ROLE_TYPE } from '../../util'; import { CUSTOM_ROOT_ROLE_TYPE } from '../../util';
@ -63,7 +63,7 @@ export interface InstanceStats {
enabledCount: number; enabledCount: number;
variantCount: number; variantCount: number;
}; };
featureLifeCycles: IFeatureLifecycleStageDuration[]; featureLifeCycles: IProjectLifecycleStageDuration[];
} }
export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & { export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & {
@ -349,7 +349,7 @@ export class InstanceStatsService {
return { ...instanceStats, sum, projects: totalProjects }; return { ...instanceStats, sum, projects: totalProjects };
} }
async getAllWithStageDuration(): Promise<IFeatureLifecycleStageDuration[]> { async getAllWithStageDuration(): Promise<IProjectLifecycleStageDuration[]> {
if (this.flagResolver.isEnabled('featureLifecycleMetrics')) { if (this.flagResolver.isEnabled('featureLifecycleMetrics')) {
return this.featureLifecycleService.getAllWithStageDuration(); return this.featureLifecycleService.getAllWithStageDuration();
} }

View File

@ -261,7 +261,7 @@ export default class MetricsMonitor {
const featureLifecycleStageDuration = createHistogram({ const featureLifecycleStageDuration = createHistogram({
name: 'feature_lifecycle_stage_duration', name: 'feature_lifecycle_stage_duration',
labelNames: ['feature_id', 'stage', 'project_id'], labelNames: ['stage', 'project_id'],
help: 'Duration of feature lifecycle stages', help: 'Duration of feature lifecycle stages',
}); });
@ -292,7 +292,6 @@ export default class MetricsMonitor {
stats.featureLifeCycles.forEach((stage) => { stats.featureLifeCycles.forEach((stage) => {
featureLifecycleStageDuration featureLifecycleStageDuration
.labels({ .labels({
feature_id: stage.feature,
stage: stage.stage, stage: stage.stage,
project_id: stage.project, project_id: stage.project,
}) })

View File

@ -127,7 +127,6 @@ class InstanceAdminController extends Controller {
}, },
featureLifeCycles: [ featureLifeCycles: [
{ {
feature: 'feature1',
project: 'default', project: 'default',
stage: 'archived', stage: 'archived',
duration: 2000, duration: 2000,

View File

@ -7,7 +7,6 @@ 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];
@ -166,9 +165,10 @@ export interface IFeatureLifecycleStage {
enteredStageAt: Date; enteredStageAt: Date;
} }
export type IFeatureLifecycleStageDuration = FeatureLifecycleStage & { export type IProjectLifecycleStageDuration = {
duration: number; duration: number;
project: string; project: string;
stage: StageName;
}; };
export interface IFeatureDependency { export interface IFeatureDependency {

View File

@ -0,0 +1,21 @@
import { median } from './median';
test('calculateMedian with an odd number of elements', () => {
expect(median([1, 3, 5])).toBe(3);
});
test('calculateMedian with an even number of elements', () => {
expect(median([1, 2, 3, 4])).toBe(2.5);
});
test('calculateMedian with negative numbers', () => {
expect(median([-5, -1, -3, -2, -4])).toBe(-3);
});
test('calculateMedian with one element', () => {
expect(median([42])).toBe(42);
});
test('calculateMedian with an empty array', () => {
expect(median([])).toBe(Number.NaN);
});

9
src/lib/util/median.ts Normal file
View File

@ -0,0 +1,9 @@
export const median = (numbers: number[]): number => {
numbers.sort((a, b) => a - b);
const midIndex = Math.floor(numbers.length / 2);
if (numbers.length % 2 === 0) {
return (numbers[midIndex - 1] + numbers[midIndex]) / 2;
} else {
return numbers[midIndex];
}
};