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:
parent
56ae53a4da
commit
958ccabb54
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
@ -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;
|
||||
};
|
@ -128,76 +128,3 @@ test('ignores lifecycle state updates when flag disabled', async () => {
|
||||
|
||||
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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -9,12 +9,11 @@ import {
|
||||
type IEnvironmentStore,
|
||||
type IEventStore,
|
||||
type IFeatureEnvironmentStore,
|
||||
type IFeatureLifecycleStageDuration,
|
||||
type IProjectLifecycleStageDuration,
|
||||
type IFlagResolver,
|
||||
type IUnleashConfig,
|
||||
} from '../../types';
|
||||
import type {
|
||||
FeatureLifecycleProjectItem,
|
||||
FeatureLifecycleView,
|
||||
IFeatureLifecycleStore,
|
||||
} from './feature-lifecycle-store-type';
|
||||
@ -22,8 +21,8 @@ import EventEmitter from 'events';
|
||||
import type { Logger } from '../../logger';
|
||||
import type EventService from '../events/event-service';
|
||||
import type { ValidatedClientMetrics } from '../metrics/shared/schema';
|
||||
import { differenceInMinutes } from 'date-fns';
|
||||
import type { FeatureLifecycleCompletedSchema } from '../../openapi';
|
||||
import { calculateStageDurations } from './calculate-stage-durations';
|
||||
|
||||
export const STAGE_ENTERED = 'STAGE_ENTERED';
|
||||
|
||||
@ -215,50 +214,9 @@ export class FeatureLifecycleService extends EventEmitter {
|
||||
}
|
||||
|
||||
public async getAllWithStageDuration(): Promise<
|
||||
IFeatureLifecycleStageDuration[]
|
||||
IProjectLifecycleStageDuration[]
|
||||
> {
|
||||
const featureLifeCycles = await this.featureLifecycleStore.getAll();
|
||||
return this.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;
|
||||
return calculateStageDurations(featureLifeCycles);
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ import {
|
||||
FEATURES_EXPORTED,
|
||||
FEATURES_IMPORTED,
|
||||
type IApiTokenStore,
|
||||
type IFeatureLifecycleStageDuration,
|
||||
type IProjectLifecycleStageDuration,
|
||||
type IFlagResolver,
|
||||
} from '../../types';
|
||||
import { CUSTOM_ROOT_ROLE_TYPE } from '../../util';
|
||||
@ -63,7 +63,7 @@ export interface InstanceStats {
|
||||
enabledCount: number;
|
||||
variantCount: number;
|
||||
};
|
||||
featureLifeCycles: IFeatureLifecycleStageDuration[];
|
||||
featureLifeCycles: IProjectLifecycleStageDuration[];
|
||||
}
|
||||
|
||||
export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & {
|
||||
@ -349,7 +349,7 @@ export class InstanceStatsService {
|
||||
return { ...instanceStats, sum, projects: totalProjects };
|
||||
}
|
||||
|
||||
async getAllWithStageDuration(): Promise<IFeatureLifecycleStageDuration[]> {
|
||||
async getAllWithStageDuration(): Promise<IProjectLifecycleStageDuration[]> {
|
||||
if (this.flagResolver.isEnabled('featureLifecycleMetrics')) {
|
||||
return this.featureLifecycleService.getAllWithStageDuration();
|
||||
}
|
||||
|
@ -261,7 +261,7 @@ export default class MetricsMonitor {
|
||||
|
||||
const featureLifecycleStageDuration = createHistogram({
|
||||
name: 'feature_lifecycle_stage_duration',
|
||||
labelNames: ['feature_id', 'stage', 'project_id'],
|
||||
labelNames: ['stage', 'project_id'],
|
||||
help: 'Duration of feature lifecycle stages',
|
||||
});
|
||||
|
||||
@ -292,7 +292,6 @@ export default class MetricsMonitor {
|
||||
stats.featureLifeCycles.forEach((stage) => {
|
||||
featureLifecycleStageDuration
|
||||
.labels({
|
||||
feature_id: stage.feature,
|
||||
stage: stage.stage,
|
||||
project_id: stage.project,
|
||||
})
|
||||
|
@ -127,7 +127,6 @@ class InstanceAdminController extends Controller {
|
||||
},
|
||||
featureLifeCycles: [
|
||||
{
|
||||
feature: 'feature1',
|
||||
project: 'default',
|
||||
stage: 'archived',
|
||||
duration: 2000,
|
||||
|
@ -7,7 +7,6 @@ import type { IProjectStats } from '../features/project/project-service';
|
||||
import type { CreateFeatureStrategySchema } from '../openapi';
|
||||
import type { ProjectEnvironment } from '../features/project/project-store-type';
|
||||
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];
|
||||
|
||||
@ -166,9 +165,10 @@ export interface IFeatureLifecycleStage {
|
||||
enteredStageAt: Date;
|
||||
}
|
||||
|
||||
export type IFeatureLifecycleStageDuration = FeatureLifecycleStage & {
|
||||
export type IProjectLifecycleStageDuration = {
|
||||
duration: number;
|
||||
project: string;
|
||||
stage: StageName;
|
||||
};
|
||||
|
||||
export interface IFeatureDependency {
|
||||
|
21
src/lib/util/median.test.ts
Normal file
21
src/lib/util/median.test.ts
Normal 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
9
src/lib/util/median.ts
Normal 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];
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue
Block a user