1
0
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:
Mateusz Kwasniewski 2023-04-07 13:31:27 +02:00 committed by GitHub
parent 9c4322d1fb
commit 8d61332543
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 154 additions and 33 deletions

View File

@ -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,
},

View File

@ -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;

View File

@ -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),

View File

@ -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 {

View File

@ -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,
},
};
}

View File

@ -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,

View File

@ -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[]>;
}

View File

@ -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');
}
}