Skip to content

Commit

Permalink
refactor: Improved lead time calculation (#3475)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Apr 7, 2023
1 parent 9c4322d commit 8d61332
Show file tree
Hide file tree
Showing 8 changed files with 154 additions and 33 deletions.
2 changes: 2 additions & 0 deletions src/lib/__snapshots__/create-config.test.ts.snap
Expand Up @@ -89,6 +89,7 @@ exports[`should create default config 1`] = `
"projectScopedSegments": false,
"projectScopedStickiness": false,
"projectStatusApi": false,
"projectStatusApiImprovements": false,
"responseTimeWithAppNameKillSwitch": false,
"strictSchemaValidation": false,
},
Expand Down Expand Up @@ -117,6 +118,7 @@ exports[`should create default config 1`] = `
"projectScopedSegments": false,
"projectScopedStickiness": false,
"projectStatusApi": false,
"projectStatusApiImprovements": false,
"responseTimeWithAppNameKillSwitch": false,
"strictSchemaValidation": false,
},
Expand Down
40 changes: 39 additions & 1 deletion src/lib/db/project-stats-store.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
37 changes: 24 additions & 13 deletions src/lib/read-models/time-to-production/time-to-production.test.ts
Expand Up @@ -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,
},
];
Expand All @@ -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,
Expand All @@ -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),
Expand Down
33 changes: 31 additions & 2 deletions src/lib/read-models/time-to-production/time-to-production.ts
@@ -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;
Expand All @@ -17,6 +18,7 @@ export class TimeToProduction {

private events: IEvent[];

// todo: remove
constructor(
features: FeatureToggle[],
productionEnvironments: IProjectEnvironment[],
Expand All @@ -27,6 +29,7 @@ export class TimeToProduction {
this.events = events;
}

// todo: remove
calculateAverageTimeToProd(): number {
const featureEvents = this.getFeatureEvents();
const sortedFeatureEvents =
Expand All @@ -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]) {
Expand All @@ -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,
Expand All @@ -78,7 +98,7 @@ export class TimeToProduction {
});
}

calculateTimeToProdForFeatures(
private calculateTimeToProdForFeatures(
featureEvents: IFeatureTimeToProdCalculationMap,
): number[] {
return Object.keys(featureEvents).map((featureName) => {
Expand All @@ -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 {
Expand Down
56 changes: 40 additions & 16 deletions src/lib/services/project-service.ts
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -756,16 +760,18 @@ 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',
});

// 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',
Expand All @@ -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,
Expand All @@ -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,
},
};
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/types/experimental.ts
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/lib/types/stores/project-stats-store-type.ts
@@ -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[]>;
}

0 comments on commit 8d61332

Please sign in to comment.