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([]);
|
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 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
})
|
})
|
||||||
|
@ -127,7 +127,6 @@ class InstanceAdminController extends Controller {
|
|||||||
},
|
},
|
||||||
featureLifeCycles: [
|
featureLifeCycles: [
|
||||||
{
|
{
|
||||||
feature: 'feature1',
|
|
||||||
project: 'default',
|
project: 'default',
|
||||||
stage: 'archived',
|
stage: 'archived',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
|
@ -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 {
|
||||||
|
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