diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 0726fdbc1b..1290517c4c 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -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, }, diff --git a/src/lib/db/project-stats-store.ts b/src/lib/db/project-stats-store.ts index 3fd2491e3f..0ff7664ffe 100644 --- a/src/lib/db/project-stats-store.ts +++ b/src/lib/db/project-stats-store.ts @@ -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 { + 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; 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 index a0f50419c4..3f5fb58ba2 100644 --- 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 @@ -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), 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 index b402bd7fee..3e1edb9faf 100644 --- a/src/lib/read-models/time-to-production/time-to-production.ts +++ b/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; @@ -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 { diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index a68f3aab92..f09f8c4717 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -689,11 +689,13 @@ 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', @@ -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, }, }; } diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index f2d221666e..4252ed06b9 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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, diff --git a/src/lib/types/stores/project-stats-store-type.ts b/src/lib/types/stores/project-stats-store-type.ts index 56c59c21b2..b8b0558cbe 100644 --- a/src/lib/types/stores/project-stats-store-type.ts +++ b/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; getProjectStats(projectId: string): Promise; + getTimeToProdDates(projectId: string): Promise; } diff --git a/src/test/fixtures/fake-project-stats-store.ts b/src/test/fixtures/fake-project-stats-store.ts index e518a49eb0..d42f25869e 100644 --- a/src/test/fixtures/fake-project-stats-store.ts +++ b/src/test/fixtures/fake-project-stats-store.ts @@ -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 { throw new Error('not implemented'); } + + getTimeToProdDates(): Promise { + throw new Error('not implemented'); + } }