1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

refactor: switching to new stats calculations (#3477)

This commit is contained in:
Mateusz Kwasniewski 2023-04-10 09:50:39 +02:00 committed by GitHub
parent 52cc633a0a
commit f1133556bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 138 additions and 444 deletions

View File

@ -135,7 +135,6 @@ class ProjectStatsStore implements IProjectStatsStore {
.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'))

View File

@ -0,0 +1,26 @@
import { calculateAverageTimeToProd } from './time-to-production';
describe('calculate average time to production', () => {
test('should calculate average correctly', () => {
const 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);
});
});

View File

@ -0,0 +1,20 @@
import { differenceInDays } from 'date-fns';
import { ICreateEnabledDates } from '../../../types/stores/project-stats-store-type';
const calculateTimeToProdForFeatures = (
items: ICreateEnabledDates[],
): number[] =>
items.map((item) => differenceInDays(item.enabled, item.created));
export const calculateAverageTimeToProd = (
items: ICreateEnabledDates[],
): number => {
const timeToProdPerFeature = 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;
};

View File

@ -1,227 +0,0 @@
import { addDays, subDays } from 'date-fns';
import { IEvent } from 'lib/types';
import { TimeToProduction } from './time-to-production';
const modifyEventCreatedAt = (events: IEvent[], days: number): IEvent[] => {
return events.map((event) => {
const newEvent = { ...event };
newEvent.createdAt = addDays(newEvent.createdAt, days);
newEvent.id = newEvent.id + days;
return newEvent;
});
};
const createEvent = (env: string, overrides: Partial<IEvent>) => {
return {
id: Math.floor(Math.random() * 1000),
type: 'feature-environment-enabled',
createdBy: 'Fredrik',
createdAt: new Date('2023-01-25T09:37:32.504Z'),
data: null,
preData: null,
tags: [],
featureName: 'average-prod-time',
project: 'average-time-to-prod',
environment: env,
...overrides,
};
};
const events = [
{
id: 65,
type: 'feature-environment-enabled',
createdBy: 'Fredrik',
createdAt: new Date('2023-01-25T09:37:32.504Z'),
data: null,
preData: null,
tags: [],
featureName: 'average-prod-time',
project: 'average-time-to-prod',
environment: 'default',
},
{
id: 66,
type: 'feature-environment-enabled',
createdBy: 'Fredrik',
createdAt: new Date('2023-01-31T09:37:32.506Z'),
data: null,
preData: null,
tags: [],
featureName: 'average-prod-time-2',
project: 'average-time-to-prod',
environment: 'default',
},
{
id: 67,
type: 'feature-environment-enabled',
createdBy: 'Fredrik',
createdAt: new Date('2023-01-26T09:37:32.508Z'),
data: null,
preData: null,
tags: [],
featureName: 'average-prod-time-3',
project: 'average-time-to-prod',
environment: 'default',
},
{
id: 68,
type: 'feature-environment-enabled',
createdBy: 'Fredrik',
createdAt: new Date('2023-02-02T09:37:32.509Z'),
data: null,
preData: null,
tags: [],
featureName: 'average-prod-time-4',
project: 'average-time-to-prod',
environment: 'default',
},
];
const environments = [
{
name: 'default',
type: 'production',
sortOrder: 1,
enabled: true,
protected: true,
projectApiTokenCount: 0,
projectEnabledToggleCount: 0,
},
];
const features = [
{
name: 'average-prod-time',
type: 'release',
project: 'average-time-to-prod',
stale: false,
createdAt: new Date('2022-12-05T09:37:32.483Z'),
impressionData: false,
archived: false,
},
{
name: 'average-prod-time-4',
type: 'release',
project: 'average-time-to-prod',
stale: false,
createdAt: new Date('2023-01-19T09:37:32.484Z'),
impressionData: false,
archived: false,
},
{
name: 'average-prod-time-2',
type: 'release',
project: 'average-time-to-prod',
stale: false,
createdAt: new Date('2023-01-19T09:37:32.484Z'),
impressionData: false,
archived: false,
},
{
name: 'average-prod-time-3',
type: 'release',
project: 'average-time-to-prod',
stale: false,
createdAt: new Date('2023-01-19T09:37:32.486Z'),
impressionData: false,
archived: false,
},
];
describe('calculate average time to production', () => {
test('should build a map of feature events', () => {
const projectStatus = new TimeToProduction(
features,
environments,
events,
);
const featureEvents = projectStatus.getFeatureEvents();
expect(Object.keys(featureEvents).length).toBe(4);
expect(featureEvents['average-prod-time'].createdAt).toBeTruthy();
expect(featureEvents['average-prod-time'].events).toBeInstanceOf(Array);
});
test('[legacy] should calculate average correctly', () => {
const projectStatus = new TimeToProduction(
features,
environments,
events,
);
const timeToProduction = projectStatus.calculateAverageTimeToProd();
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),
...events,
]);
const featureEvents = projectStatus.getFeatureEvents();
const sortedFeatureEvents =
projectStatus.sortFeatureEventsByCreatedAt(featureEvents);
const [firstEvent, secondEvent] =
sortedFeatureEvents['average-prod-time'].events;
const firstEventCreatedAt = new Date(firstEvent.createdAt);
const secondEventCreatedAt = new Date(secondEvent.createdAt);
expect(firstEventCreatedAt.getTime()).toBeLessThan(
secondEventCreatedAt.getTime(),
);
const [firstEvent2, secondEvent2] =
sortedFeatureEvents['average-prod-time-2'].events;
const firstEventCreatedAt2 = new Date(firstEvent2.createdAt);
const secondEventCreatedAt2 = new Date(secondEvent2.createdAt);
expect(firstEventCreatedAt2.getTime()).toBeLessThan(
secondEventCreatedAt2.getTime(),
);
});
test('should not count events that are development environments', () => {
const projectStatus = new TimeToProduction(features, environments, [
createEvent('development', {
createdAt: subDays(new Date('2023-01-25T09:37:32.504Z'), 10),
}),
createEvent('development', {
createdAt: subDays(new Date('2023-01-25T09:37:32.504Z'), 10),
}),
...events,
]);
const timeToProduction = projectStatus.calculateAverageTimeToProd();
expect(timeToProduction).toBe(21);
});
});

View File

@ -1,150 +0,0 @@
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;
}
interface IFeatureTimeToProdData {
createdAt: string;
events: IEvent[];
}
export class TimeToProduction {
private features: FeatureToggle[];
private productionEnvironments: IProjectEnvironment[];
private events: IEvent[];
// todo: remove
constructor(
features: FeatureToggle[],
productionEnvironments: IProjectEnvironment[],
events: IEvent[],
) {
this.features = features;
this.productionEnvironments = productionEnvironments;
this.events = events;
}
// todo: remove
calculateAverageTimeToProd(): number {
const featureEvents = this.getFeatureEvents();
const sortedFeatureEvents =
this.sortFeatureEventsByCreatedAt(featureEvents);
const timeToProdPerFeature =
this.calculateTimeToProdForFeatures(sortedFeatureEvents);
if (timeToProdPerFeature.length) {
const sum = timeToProdPerFeature.reduce(
(acc, curr) => acc + curr,
0,
);
return Number(
(sum / Object.keys(sortedFeatureEvents).length).toFixed(1),
);
}
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]) {
acc[event.featureName].events.push(event);
} else {
const foundFeature = this.features.find(
(feature) => feature.name === event.featureName,
);
acc[event.featureName] = { events: [event] };
acc[event.featureName].createdAt = foundFeature?.createdAt;
}
return acc;
}, {});
}
// 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,
);
if (found) {
return found.type === 'production';
}
return false;
});
}
private calculateTimeToProdForFeatures(
featureEvents: IFeatureTimeToProdCalculationMap,
): number[] {
return Object.keys(featureEvents).map((featureName) => {
const feature = featureEvents[featureName];
const earliestEvent = feature.events[0];
const createdAtDate = new Date(feature.createdAt);
const eventDate = new Date(earliestEvent.createdAt);
const diff = differenceInDays(eventDate, createdAtDate);
return diff;
});
}
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 {
return Object.keys(featureEvents).reduce((acc, featureName) => {
const feature = featureEvents[featureName];
acc[featureName] = {
...feature,
events: feature.events.sort((a, b) => {
const aDate = new Date(a.createdAt);
const bDate = new Date(b.createdAt);
if (aDate > bDate) {
return 1;
}
if (aDate < bDate) {
return -1;
}
return 0;
}),
};
return acc;
}, {});
}
}

View File

@ -8,7 +8,6 @@ import { projectSchema } from './project-schema';
import NotFoundError from '../error/notfound-error';
import {
DEFAULT_PROJECT,
FEATURE_ENVIRONMENT_ENABLED,
FeatureToggle,
IAccountStore,
IEnvironmentStore,
@ -54,7 +53,7 @@ import { arraysHaveSameItems } from '../util';
import { GroupService } from './group-service';
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
import { FavoritesService } from './favorites-service';
import { TimeToProduction } from '../read-models/time-to-production/time-to-production';
import { calculateAverageTimeToProd } from '../features/feature-toggle/time-to-production/time-to-production';
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
import { uniqueByKey } from '../util/unique';
@ -688,20 +687,6 @@ 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',
project: projectId,
});
const dateMinusThirtyDays = subDays(new Date(), 30).toISOString();
const dateMinusSixtyDays = subDays(new Date(), 60).toISOString();
@ -759,57 +744,10 @@ 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',
parameters: {
features: allFeatures.map((feature) => feature.name),
environments: productionEnvironments.map((env) => env.name),
type: FEATURE_ENVIRONMENT_ENABLED,
projectId,
},
},
]);
// todo: remove after release of the improved query
const timeToProduction = new TimeToProduction(
allFeatures,
productionEnvironments,
eventsData,
const avgTimeToProdCurrentWindow = calculateAverageTimeToProd(
await this.projectStatsStore.getTimeToProdDates(projectId),
);
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,

View File

@ -13,7 +13,7 @@ import { SegmentService } from '../../../lib/services/segment-service';
import { GroupService } from '../../../lib/services/group-service';
import { FavoritesService } from '../../../lib/services';
import { FeatureEnvironmentEvent } from '../../../lib/types/events';
import { subDays } from 'date-fns';
import { addDays, subDays } from 'date-fns';
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
let stores;
@ -1269,6 +1269,94 @@ test('should calculate average time to production', async () => {
expect(result.updates.avgTimeToProdCurrentWindow).toBe(11.4);
});
test('should calculate average time to production ignoring some items', async () => {
const project = {
id: 'average-time-to-prod-corner-cases',
name: 'average-time-to-prod',
mode: 'open' as const,
};
const makeEvent = (featureName: string) => ({
enabled: true,
project: project.id,
featureName,
environment: 'default',
createdBy: 'Fredrik',
tags: [],
});
await projectService.createProject(project, user.id);
await stores.environmentStore.create({
name: 'customEnv',
type: 'development',
});
await environmentService.addEnvironmentToProject('customEnv', project.id);
// actual toggle we take for calculations
const toggle = { name: 'main-toggle' };
await featureToggleService.createFeatureToggle(project.id, toggle, user);
await updateFeature(toggle.name, {
created_at: subDays(new Date(), 20),
});
await stores.eventStore.store(
new FeatureEnvironmentEvent(makeEvent(toggle.name)),
);
// ignore events added after first enabled
await updateEventCreatedAt(addDays(new Date(), 1), toggle.name);
await stores.eventStore.store(
new FeatureEnvironmentEvent(makeEvent(toggle.name)),
);
// ignore toggles enabled in non-prod envs
const devToggle = { name: 'dev-toggle' };
await featureToggleService.createFeatureToggle(project.id, devToggle, user);
await stores.eventStore.store(
new FeatureEnvironmentEvent({
...makeEvent(devToggle.name),
environment: 'customEnv',
}),
);
// ignore toggles from other projects
const otherProjectToggle = { name: 'other-project' };
await featureToggleService.createFeatureToggle(
'default',
otherProjectToggle,
user,
);
await stores.eventStore.store(
new FeatureEnvironmentEvent(makeEvent(otherProjectToggle.name)),
);
// ignore non-release toggles
const nonReleaseToggle = { name: 'permission-toggle', type: 'permission' };
await featureToggleService.createFeatureToggle(
project.id,
nonReleaseToggle,
user,
);
await stores.eventStore.store(
new FeatureEnvironmentEvent(makeEvent(nonReleaseToggle.name)),
);
// ignore toggles with events before toggle creation time
const previouslyDeleteToggle = { name: 'previously-deleted' };
await featureToggleService.createFeatureToggle(
project.id,
previouslyDeleteToggle,
user,
);
await stores.eventStore.store(
new FeatureEnvironmentEvent(makeEvent(previouslyDeleteToggle.name)),
);
await updateEventCreatedAt(
subDays(new Date(), 30),
previouslyDeleteToggle.name,
);
const result = await projectService.getStatusUpdates(project.id);
expect(result.updates.avgTimeToProdCurrentWindow).toBe(20);
});
test('should get correct amount of features created in current and past window', async () => {
const project = {
id: 'features-created',