diff --git a/src/lib/db/project-stats-store.ts b/src/lib/db/project-stats-store.ts index 0ff7664ffe..0f3211067b 100644 --- a/src/lib/db/project-stats-store.ts +++ b/src/lib/db/project-stats-store.ts @@ -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')) diff --git a/src/lib/features/feature-toggle/time-to-production/time-to-production.test.ts b/src/lib/features/feature-toggle/time-to-production/time-to-production.test.ts new file mode 100644 index 0000000000..e2b90cc628 --- /dev/null +++ b/src/lib/features/feature-toggle/time-to-production/time-to-production.test.ts @@ -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); + }); +}); diff --git a/src/lib/features/feature-toggle/time-to-production/time-to-production.ts b/src/lib/features/feature-toggle/time-to-production/time-to-production.ts new file mode 100644 index 0000000000..1bc75544ff --- /dev/null +++ b/src/lib/features/feature-toggle/time-to-production/time-to-production.ts @@ -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; +}; diff --git a/src/lib/read-models/time-to-production/time-to-production.test.ts b/src/lib/read-models/time-to-production/time-to-production.test.ts deleted file mode 100644 index 3f5fb58ba2..0000000000 --- a/src/lib/read-models/time-to-production/time-to-production.test.ts +++ /dev/null @@ -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) => { - 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); - }); -}); diff --git a/src/lib/read-models/time-to-production/time-to-production.ts b/src/lib/read-models/time-to-production/time-to-production.ts deleted file mode 100644 index 3e1edb9faf..0000000000 --- a/src/lib/read-models/time-to-production/time-to-production.ts +++ /dev/null @@ -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; - }, {}); - } -} diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index f09f8c4717..279eca65d8 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -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 { - // 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, diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index 4a315988ef..e582d9a79a 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -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',