1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-31 13:47:02 +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, "projectScopedSegments": false,
"projectScopedStickiness": false, "projectScopedStickiness": false,
"projectStatusApi": false, "projectStatusApi": false,
"projectStatusApiImprovements": false,
"responseTimeWithAppNameKillSwitch": false, "responseTimeWithAppNameKillSwitch": false,
"strictSchemaValidation": false, "strictSchemaValidation": false,
}, },
@ -117,6 +118,7 @@ exports[`should create default config 1`] = `
"projectScopedSegments": false, "projectScopedSegments": false,
"projectScopedStickiness": false, "projectScopedStickiness": false,
"projectStatusApi": false, "projectStatusApi": false,
"projectStatusApiImprovements": false,
"responseTimeWithAppNameKillSwitch": false, "responseTimeWithAppNameKillSwitch": false,
"strictSchemaValidation": false, "strictSchemaValidation": false,
}, },

View File

@ -4,7 +4,10 @@ import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import EventEmitter from 'events'; import EventEmitter from 'events';
import { IProjectStats } from 'lib/services/project-service'; 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'; import { Db } from './db';
const TABLE = 'project_stats'; const TABLE = 'project_stats';
@ -107,6 +110,41 @@ class ProjectStatsStore implements IProjectStatsStore {
row.project_members_added_current_window, 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; export default ProjectStatsStore;

View File

@ -93,50 +93,38 @@ const environments = [
const features = [ const features = [
{ {
name: 'average-prod-time', name: 'average-prod-time',
description: null,
type: 'release', type: 'release',
project: 'average-time-to-prod', project: 'average-time-to-prod',
stale: false, stale: false,
createdAt: new Date('2022-12-05T09:37:32.483Z'), createdAt: new Date('2022-12-05T09:37:32.483Z'),
lastSeenAt: null,
impressionData: false, impressionData: false,
archivedAt: null,
archived: false, archived: false,
}, },
{ {
name: 'average-prod-time-4', name: 'average-prod-time-4',
description: null,
type: 'release', type: 'release',
project: 'average-time-to-prod', project: 'average-time-to-prod',
stale: false, stale: false,
createdAt: new Date('2023-01-19T09:37:32.484Z'), createdAt: new Date('2023-01-19T09:37:32.484Z'),
lastSeenAt: null,
impressionData: false, impressionData: false,
archivedAt: null,
archived: false, archived: false,
}, },
{ {
name: 'average-prod-time-2', name: 'average-prod-time-2',
description: null,
type: 'release', type: 'release',
project: 'average-time-to-prod', project: 'average-time-to-prod',
stale: false, stale: false,
createdAt: new Date('2023-01-19T09:37:32.484Z'), createdAt: new Date('2023-01-19T09:37:32.484Z'),
lastSeenAt: null,
impressionData: false, impressionData: false,
archivedAt: null,
archived: false, archived: false,
}, },
{ {
name: 'average-prod-time-3', name: 'average-prod-time-3',
description: null,
type: 'release', type: 'release',
project: 'average-time-to-prod', project: 'average-time-to-prod',
stale: false, stale: false,
createdAt: new Date('2023-01-19T09:37:32.486Z'), createdAt: new Date('2023-01-19T09:37:32.486Z'),
lastSeenAt: null,
impressionData: false, impressionData: false,
archivedAt: null,
archived: false, archived: false,
}, },
]; ];
@ -156,7 +144,7 @@ describe('calculate average time to production', () => {
expect(featureEvents['average-prod-time'].events).toBeInstanceOf(Array); expect(featureEvents['average-prod-time'].events).toBeInstanceOf(Array);
}); });
test('should calculate average correctly', () => { test('[legacy] should calculate average correctly', () => {
const projectStatus = new TimeToProduction( const projectStatus = new TimeToProduction(
features, features,
environments, environments,
@ -168,6 +156,29 @@ describe('calculate average time to production', () => {
expect(timeToProduction).toBe(21); 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', () => { test('should sort events by createdAt', () => {
const projectStatus = new TimeToProduction(features, environments, [ const projectStatus = new TimeToProduction(features, environments, [
...modifyEventCreatedAt(events, 5), ...modifyEventCreatedAt(events, 5),

View File

@ -1,5 +1,6 @@
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { FeatureToggle, IEvent, IProjectEnvironment } from 'lib/types'; import { FeatureToggle, IEvent, IProjectEnvironment } from 'lib/types';
import { ICreateEnabledDates } from '../../types/stores/project-stats-store-type';
interface IFeatureTimeToProdCalculationMap { interface IFeatureTimeToProdCalculationMap {
[index: string]: IFeatureTimeToProdData; [index: string]: IFeatureTimeToProdData;
@ -17,6 +18,7 @@ export class TimeToProduction {
private events: IEvent[]; private events: IEvent[];
// todo: remove
constructor( constructor(
features: FeatureToggle[], features: FeatureToggle[],
productionEnvironments: IProjectEnvironment[], productionEnvironments: IProjectEnvironment[],
@ -27,6 +29,7 @@ export class TimeToProduction {
this.events = events; this.events = events;
} }
// todo: remove
calculateAverageTimeToProd(): number { calculateAverageTimeToProd(): number {
const featureEvents = this.getFeatureEvents(); const featureEvents = this.getFeatureEvents();
const sortedFeatureEvents = const sortedFeatureEvents =
@ -48,6 +51,22 @@ export class TimeToProduction {
return 0; 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 { getFeatureEvents(): IFeatureTimeToProdCalculationMap {
return this.getProductionEvents(this.events).reduce((acc, event) => { return this.getProductionEvents(this.events).reduce((acc, event) => {
if (acc[event.featureName]) { 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) => { return events.filter((event) => {
const found = this.productionEnvironments.find( const found = this.productionEnvironments.find(
(env) => env.name === event.environment, (env) => env.name === event.environment,
@ -78,7 +98,7 @@ export class TimeToProduction {
}); });
} }
calculateTimeToProdForFeatures( private calculateTimeToProdForFeatures(
featureEvents: IFeatureTimeToProdCalculationMap, featureEvents: IFeatureTimeToProdCalculationMap,
): number[] { ): number[] {
return Object.keys(featureEvents).map((featureName) => { 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( sortFeatureEventsByCreatedAt(
featureEvents: IFeatureTimeToProdCalculationMap, featureEvents: IFeatureTimeToProdCalculationMap,
): IFeatureTimeToProdCalculationMap { ): IFeatureTimeToProdCalculationMap {

View File

@ -689,11 +689,13 @@ export default class ProjectService {
async getStatusUpdates(projectId: string): Promise<ICalculateStatus> { async getStatusUpdates(projectId: string): Promise<ICalculateStatus> {
// Get all features for project with type release // Get all features for project with type release
// todo: remove after release of the improved query
const features = await this.featureToggleStore.getAll({ const features = await this.featureToggleStore.getAll({
type: 'release', type: 'release',
project: projectId, project: projectId,
}); });
// todo: remove after release of the improved query
const archivedFeatures = await this.featureToggleStore.getAll({ const archivedFeatures = await this.featureToggleStore.getAll({
archived: true, archived: true,
type: 'release', type: 'release',
@ -703,7 +705,12 @@ export default class ProjectService {
const dateMinusThirtyDays = subDays(new Date(), 30).toISOString(); const dateMinusThirtyDays = subDays(new Date(), 30).toISOString();
const dateMinusSixtyDays = subDays(new Date(), 60).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({ await this.featureToggleStore.countByDate({
project: projectId, project: projectId,
dateAccessor: 'created_at', dateAccessor: 'created_at',
@ -714,9 +721,6 @@ export default class ProjectService {
dateAccessor: 'created_at', dateAccessor: 'created_at',
range: [dateMinusSixtyDays, dateMinusThirtyDays], range: [dateMinusSixtyDays, dateMinusThirtyDays],
}), }),
]);
const [archivedCurrentWindow, archivedPastWindow] = await Promise.all([
await this.featureToggleStore.countByDate({ await this.featureToggleStore.countByDate({
project: projectId, project: projectId,
archived: true, archived: true,
@ -756,6 +760,7 @@ export default class ProjectService {
]); ]);
// Get all project environments with type of production // Get all project environments with type of production
// todo: remove after release of the improved query
const productionEnvironments = const productionEnvironments =
await this.environmentStore.getProjectEnvironments(projectId, { await this.environmentStore.getProjectEnvironments(projectId, {
type: 'production', type: 'production',
@ -763,9 +768,10 @@ export default class ProjectService {
// Get all events for features that correspond to feature toggle environment ON // Get all events for features that correspond to feature toggle environment ON
// Filter out events that are not a production evironment // Filter out events that are not a production evironment
// todo: remove after release of the improved query
const allFeatures = [...features, ...archivedFeatures]; const allFeatures = [...features, ...archivedFeatures];
// todo: remove after release of the improved query
const eventsData = await this.eventStore.query([ const eventsData = await this.eventStore.query([
{ {
op: 'forFeatures', 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, allFeatures,
productionEnvironments, productionEnvironments,
eventsData, 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 = const projectMembersAddedCurrentWindow =
await this.store.getMembersCountByProjectAfterDate( await this.store.getMembersCountByProjectAfterDate(
projectId, projectId,
@ -793,15 +819,13 @@ export default class ProjectService {
return { return {
projectId, projectId,
updates: { updates: {
avgTimeToProdCurrentWindow: avgTimeToProdCurrentWindow,
currentWindowTimeToProdReadModel.calculateAverageTimeToProd(), createdCurrentWindow,
createdCurrentWindow: createdCurrentWindow, createdPastWindow,
createdPastWindow: createdPastWindow, archivedCurrentWindow,
archivedCurrentWindow: archivedCurrentWindow, archivedPastWindow,
archivedPastWindow: archivedPastWindow, projectActivityCurrentWindow,
projectActivityCurrentWindow: projectActivityCurrentWindow, projectActivityPastWindow,
projectActivityPastWindow: projectActivityPastWindow,
projectMembersAddedCurrentWindow:
projectMembersAddedCurrentWindow, projectMembersAddedCurrentWindow,
}, },
}; };

View File

@ -14,6 +14,10 @@ const flags = {
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API, process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API,
false, false,
), ),
projectStatusApiImprovements: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_PROJECT_STATUS_API_IMPROVEMENTS,
false,
),
newProjectOverview: parseEnvVarBoolean( newProjectOverview: parseEnvVarBoolean(
process.env.NEW_PROJECT_OVERVIEW, process.env.NEW_PROJECT_OVERVIEW,
false, false,

View File

@ -1,6 +1,12 @@
import { IProjectStats } from 'lib/services/project-service'; import { IProjectStats } from 'lib/services/project-service';
export interface ICreateEnabledDates {
created: Date;
enabled: Date;
}
export interface IProjectStatsStore { export interface IProjectStatsStore {
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>; updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
getProjectStats(projectId: string): Promise<IProjectStats>; getProjectStats(projectId: string): Promise<IProjectStats>;
getTimeToProdDates(projectId: string): Promise<ICreateEnabledDates[]>;
} }

View File

@ -1,5 +1,8 @@
import { IProjectStats } from 'lib/services/project-service'; 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 */ /* eslint-disable @typescript-eslint/no-unused-vars */
export default class FakeProjectStatsStore implements IProjectStatsStore { export default class FakeProjectStatsStore implements IProjectStatsStore {
@ -13,4 +16,8 @@ export default class FakeProjectStatsStore implements IProjectStatsStore {
getProjectStats(projectId: string): Promise<IProjectStats> { getProjectStats(projectId: string): Promise<IProjectStats> {
throw new Error('not implemented'); throw new Error('not implemented');
} }
getTimeToProdDates(): Promise<ICreateEnabledDates[]> {
throw new Error('not implemented');
}
} }