mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
refactor: Improved lead time calculation (#3475)
This commit is contained in:
parent
9c4322d1fb
commit
8d61332543
@ -89,6 +89,7 @@ exports[`should create default config 1`] = `
|
||||
"projectScopedSegments": false,
|
||||
"projectScopedStickiness": false,
|
||||
"projectStatusApi": false,
|
||||
"projectStatusApiImprovements": false,
|
||||
"responseTimeWithAppNameKillSwitch": false,
|
||||
"strictSchemaValidation": false,
|
||||
},
|
||||
@ -117,6 +118,7 @@ exports[`should create default config 1`] = `
|
||||
"projectScopedSegments": false,
|
||||
"projectScopedStickiness": false,
|
||||
"projectStatusApi": false,
|
||||
"projectStatusApiImprovements": false,
|
||||
"responseTimeWithAppNameKillSwitch": false,
|
||||
"strictSchemaValidation": false,
|
||||
},
|
||||
|
@ -4,7 +4,10 @@ import metricsHelper from '../util/metrics-helper';
|
||||
import { DB_TIME } from '../metric-events';
|
||||
import EventEmitter from 'events';
|
||||
import { IProjectStats } from 'lib/services/project-service';
|
||||
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||
import {
|
||||
ICreateEnabledDates,
|
||||
IProjectStatsStore,
|
||||
} from 'lib/types/stores/project-stats-store-type';
|
||||
import { Db } from './db';
|
||||
|
||||
const TABLE = 'project_stats';
|
||||
@ -107,6 +110,41 @@ class ProjectStatsStore implements IProjectStatsStore {
|
||||
row.project_members_added_current_window,
|
||||
};
|
||||
}
|
||||
|
||||
// we're not calculating time difference in a DB as it requires specialized
|
||||
// time aware libraries
|
||||
async getTimeToProdDates(
|
||||
projectId: string,
|
||||
): Promise<ICreateEnabledDates[]> {
|
||||
const result = await this.db
|
||||
.select('events.feature_name')
|
||||
// select only first enabled event, distinct works with orderBy
|
||||
.distinctOn('events.feature_name')
|
||||
.select(
|
||||
this.db.raw(
|
||||
'events.created_at as enabled, features.created_at as created',
|
||||
),
|
||||
)
|
||||
.from('events')
|
||||
.innerJoin(
|
||||
'environments',
|
||||
'environments.name',
|
||||
'=',
|
||||
'events.environment',
|
||||
)
|
||||
.innerJoin('features', 'features.name', '=', 'events.feature_name')
|
||||
.where('events.type', '=', 'feature-environment-enabled')
|
||||
.where('environments.type', '=', 'production')
|
||||
// kill-switch is long lived
|
||||
.where('features.type', '=', 'release')
|
||||
// exclude events for features that were previously deleted
|
||||
.where(this.db.raw('events.created_at > features.created_at'))
|
||||
.where('features.project', '=', projectId)
|
||||
.orderBy('events.feature_name')
|
||||
// first enabled event
|
||||
.orderBy('events.created_at', 'asc');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default ProjectStatsStore;
|
||||
|
@ -93,50 +93,38 @@ const environments = [
|
||||
const features = [
|
||||
{
|
||||
name: 'average-prod-time',
|
||||
description: null,
|
||||
type: 'release',
|
||||
project: 'average-time-to-prod',
|
||||
stale: false,
|
||||
createdAt: new Date('2022-12-05T09:37:32.483Z'),
|
||||
lastSeenAt: null,
|
||||
impressionData: false,
|
||||
archivedAt: null,
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
name: 'average-prod-time-4',
|
||||
description: null,
|
||||
type: 'release',
|
||||
project: 'average-time-to-prod',
|
||||
stale: false,
|
||||
createdAt: new Date('2023-01-19T09:37:32.484Z'),
|
||||
lastSeenAt: null,
|
||||
impressionData: false,
|
||||
archivedAt: null,
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
name: 'average-prod-time-2',
|
||||
description: null,
|
||||
type: 'release',
|
||||
project: 'average-time-to-prod',
|
||||
stale: false,
|
||||
createdAt: new Date('2023-01-19T09:37:32.484Z'),
|
||||
lastSeenAt: null,
|
||||
impressionData: false,
|
||||
archivedAt: null,
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
name: 'average-prod-time-3',
|
||||
description: null,
|
||||
type: 'release',
|
||||
project: 'average-time-to-prod',
|
||||
stale: false,
|
||||
createdAt: new Date('2023-01-19T09:37:32.486Z'),
|
||||
lastSeenAt: null,
|
||||
impressionData: false,
|
||||
archivedAt: null,
|
||||
archived: false,
|
||||
},
|
||||
];
|
||||
@ -156,7 +144,7 @@ describe('calculate average time to production', () => {
|
||||
expect(featureEvents['average-prod-time'].events).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
test('should calculate average correctly', () => {
|
||||
test('[legacy] should calculate average correctly', () => {
|
||||
const projectStatus = new TimeToProduction(
|
||||
features,
|
||||
environments,
|
||||
@ -168,6 +156,29 @@ describe('calculate average time to production', () => {
|
||||
expect(timeToProduction).toBe(21);
|
||||
});
|
||||
|
||||
test('should calculate average correctly', () => {
|
||||
const timeToProduction = TimeToProduction.calculateAverageTimeToProd([
|
||||
{
|
||||
created: new Date('2022-12-05T09:37:32.483Z'),
|
||||
enabled: new Date('2023-01-25T09:37:32.504Z'),
|
||||
},
|
||||
{
|
||||
created: new Date('2023-01-19T09:37:32.484Z'),
|
||||
enabled: new Date('2023-01-31T09:37:32.506Z'),
|
||||
},
|
||||
{
|
||||
created: new Date('2023-01-19T09:37:32.484Z'),
|
||||
enabled: new Date('2023-02-02T09:37:32.509Z'),
|
||||
},
|
||||
{
|
||||
created: new Date('2023-01-19T09:37:32.486Z'),
|
||||
enabled: new Date('2023-01-26T09:37:32.508Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(timeToProduction).toBe(21);
|
||||
});
|
||||
|
||||
test('should sort events by createdAt', () => {
|
||||
const projectStatus = new TimeToProduction(features, environments, [
|
||||
...modifyEventCreatedAt(events, 5),
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { FeatureToggle, IEvent, IProjectEnvironment } from 'lib/types';
|
||||
import { ICreateEnabledDates } from '../../types/stores/project-stats-store-type';
|
||||
|
||||
interface IFeatureTimeToProdCalculationMap {
|
||||
[index: string]: IFeatureTimeToProdData;
|
||||
@ -17,6 +18,7 @@ export class TimeToProduction {
|
||||
|
||||
private events: IEvent[];
|
||||
|
||||
// todo: remove
|
||||
constructor(
|
||||
features: FeatureToggle[],
|
||||
productionEnvironments: IProjectEnvironment[],
|
||||
@ -27,6 +29,7 @@ export class TimeToProduction {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
// todo: remove
|
||||
calculateAverageTimeToProd(): number {
|
||||
const featureEvents = this.getFeatureEvents();
|
||||
const sortedFeatureEvents =
|
||||
@ -48,6 +51,22 @@ export class TimeToProduction {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static calculateAverageTimeToProd(items: ICreateEnabledDates[]): number {
|
||||
const timeToProdPerFeature =
|
||||
TimeToProduction.calculateTimeToProdForFeatures(items);
|
||||
if (timeToProdPerFeature.length) {
|
||||
const sum = timeToProdPerFeature.reduce(
|
||||
(acc, curr) => acc + curr,
|
||||
0,
|
||||
);
|
||||
|
||||
return Number((sum / Object.keys(items).length).toFixed(1));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// todo: remove, as DB query can handle it
|
||||
getFeatureEvents(): IFeatureTimeToProdCalculationMap {
|
||||
return this.getProductionEvents(this.events).reduce((acc, event) => {
|
||||
if (acc[event.featureName]) {
|
||||
@ -64,7 +83,8 @@ export class TimeToProduction {
|
||||
}, {});
|
||||
}
|
||||
|
||||
getProductionEvents(events: IEvent[]): IEvent[] {
|
||||
// todo: remove it as DB query can handle it
|
||||
private getProductionEvents(events: IEvent[]): IEvent[] {
|
||||
return events.filter((event) => {
|
||||
const found = this.productionEnvironments.find(
|
||||
(env) => env.name === event.environment,
|
||||
@ -78,7 +98,7 @@ export class TimeToProduction {
|
||||
});
|
||||
}
|
||||
|
||||
calculateTimeToProdForFeatures(
|
||||
private calculateTimeToProdForFeatures(
|
||||
featureEvents: IFeatureTimeToProdCalculationMap,
|
||||
): number[] {
|
||||
return Object.keys(featureEvents).map((featureName) => {
|
||||
@ -94,6 +114,15 @@ export class TimeToProduction {
|
||||
});
|
||||
}
|
||||
|
||||
private static calculateTimeToProdForFeatures(
|
||||
items: ICreateEnabledDates[],
|
||||
): number[] {
|
||||
return items.map((item) =>
|
||||
differenceInDays(item.enabled, item.created),
|
||||
);
|
||||
}
|
||||
|
||||
// todo: remove as DB query can handle it
|
||||
sortFeatureEventsByCreatedAt(
|
||||
featureEvents: IFeatureTimeToProdCalculationMap,
|
||||
): IFeatureTimeToProdCalculationMap {
|
||||
|
@ -689,11 +689,13 @@ export default class ProjectService {
|
||||
|
||||
async getStatusUpdates(projectId: string): Promise<ICalculateStatus> {
|
||||
// Get all features for project with type release
|
||||
// todo: remove after release of the improved query
|
||||
const features = await this.featureToggleStore.getAll({
|
||||
type: 'release',
|
||||
project: projectId,
|
||||
});
|
||||
|
||||
// todo: remove after release of the improved query
|
||||
const archivedFeatures = await this.featureToggleStore.getAll({
|
||||
archived: true,
|
||||
type: 'release',
|
||||
@ -703,7 +705,12 @@ export default class ProjectService {
|
||||
const dateMinusThirtyDays = subDays(new Date(), 30).toISOString();
|
||||
const dateMinusSixtyDays = subDays(new Date(), 60).toISOString();
|
||||
|
||||
const [createdCurrentWindow, createdPastWindow] = await Promise.all([
|
||||
const [
|
||||
createdCurrentWindow,
|
||||
createdPastWindow,
|
||||
archivedCurrentWindow,
|
||||
archivedPastWindow,
|
||||
] = await Promise.all([
|
||||
await this.featureToggleStore.countByDate({
|
||||
project: projectId,
|
||||
dateAccessor: 'created_at',
|
||||
@ -714,9 +721,6 @@ export default class ProjectService {
|
||||
dateAccessor: 'created_at',
|
||||
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
||||
}),
|
||||
]);
|
||||
|
||||
const [archivedCurrentWindow, archivedPastWindow] = await Promise.all([
|
||||
await this.featureToggleStore.countByDate({
|
||||
project: projectId,
|
||||
archived: true,
|
||||
@ -756,6 +760,7 @@ export default class ProjectService {
|
||||
]);
|
||||
|
||||
// Get all project environments with type of production
|
||||
// todo: remove after release of the improved query
|
||||
const productionEnvironments =
|
||||
await this.environmentStore.getProjectEnvironments(projectId, {
|
||||
type: 'production',
|
||||
@ -763,9 +768,10 @@ export default class ProjectService {
|
||||
|
||||
// Get all events for features that correspond to feature toggle environment ON
|
||||
// Filter out events that are not a production evironment
|
||||
|
||||
// todo: remove after release of the improved query
|
||||
const allFeatures = [...features, ...archivedFeatures];
|
||||
|
||||
// todo: remove after release of the improved query
|
||||
const eventsData = await this.eventStore.query([
|
||||
{
|
||||
op: 'forFeatures',
|
||||
@ -778,12 +784,32 @@ export default class ProjectService {
|
||||
},
|
||||
]);
|
||||
|
||||
const currentWindowTimeToProdReadModel = new TimeToProduction(
|
||||
// todo: remove after release of the improved query
|
||||
const timeToProduction = new TimeToProduction(
|
||||
allFeatures,
|
||||
productionEnvironments,
|
||||
eventsData,
|
||||
);
|
||||
|
||||
const avgTimeToProdCurrentWindowFast =
|
||||
TimeToProduction.calculateAverageTimeToProd(
|
||||
await this.projectStatsStore.getTimeToProdDates(projectId),
|
||||
);
|
||||
const avgTimeToProdCurrentWindowSlow =
|
||||
timeToProduction.calculateAverageTimeToProd();
|
||||
|
||||
const avgTimeToProdCurrentWindow = this.flagResolver.isEnabled(
|
||||
'projectStatusApiImprovements',
|
||||
)
|
||||
? avgTimeToProdCurrentWindowFast
|
||||
: avgTimeToProdCurrentWindowSlow;
|
||||
|
||||
if (avgTimeToProdCurrentWindowFast != avgTimeToProdCurrentWindowSlow) {
|
||||
this.logger.warn(
|
||||
`Lead time calculation difference, old ${avgTimeToProdCurrentWindowSlow}, new ${avgTimeToProdCurrentWindowFast}`,
|
||||
);
|
||||
}
|
||||
|
||||
const projectMembersAddedCurrentWindow =
|
||||
await this.store.getMembersCountByProjectAfterDate(
|
||||
projectId,
|
||||
@ -793,16 +819,14 @@ export default class ProjectService {
|
||||
return {
|
||||
projectId,
|
||||
updates: {
|
||||
avgTimeToProdCurrentWindow:
|
||||
currentWindowTimeToProdReadModel.calculateAverageTimeToProd(),
|
||||
createdCurrentWindow: createdCurrentWindow,
|
||||
createdPastWindow: createdPastWindow,
|
||||
archivedCurrentWindow: archivedCurrentWindow,
|
||||
archivedPastWindow: archivedPastWindow,
|
||||
projectActivityCurrentWindow: projectActivityCurrentWindow,
|
||||
projectActivityPastWindow: projectActivityPastWindow,
|
||||
projectMembersAddedCurrentWindow:
|
||||
projectMembersAddedCurrentWindow,
|
||||
avgTimeToProdCurrentWindow,
|
||||
createdCurrentWindow,
|
||||
createdPastWindow,
|
||||
archivedCurrentWindow,
|
||||
archivedPastWindow,
|
||||
projectActivityCurrentWindow,
|
||||
projectActivityPastWindow,
|
||||
projectMembersAddedCurrentWindow,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -14,6 +14,10 @@ const flags = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API,
|
||||
false,
|
||||
),
|
||||
projectStatusApiImprovements: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API_IMPROVEMENTS,
|
||||
false,
|
||||
),
|
||||
newProjectOverview: parseEnvVarBoolean(
|
||||
process.env.NEW_PROJECT_OVERVIEW,
|
||||
false,
|
||||
|
@ -1,6 +1,12 @@
|
||||
import { IProjectStats } from 'lib/services/project-service';
|
||||
|
||||
export interface ICreateEnabledDates {
|
||||
created: Date;
|
||||
enabled: Date;
|
||||
}
|
||||
|
||||
export interface IProjectStatsStore {
|
||||
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
|
||||
getProjectStats(projectId: string): Promise<IProjectStats>;
|
||||
getTimeToProdDates(projectId: string): Promise<ICreateEnabledDates[]>;
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { IProjectStats } from 'lib/services/project-service';
|
||||
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||
import {
|
||||
ICreateEnabledDates,
|
||||
IProjectStatsStore,
|
||||
} from 'lib/types/stores/project-stats-store-type';
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
export default class FakeProjectStatsStore implements IProjectStatsStore {
|
||||
@ -13,4 +16,8 @@ export default class FakeProjectStatsStore implements IProjectStatsStore {
|
||||
getProjectStats(projectId: string): Promise<IProjectStats> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
|
||||
getTimeToProdDates(): Promise<ICreateEnabledDates[]> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user