From e07aab68cc2e985b8985f8ca468e661183bd8029 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Fri, 8 Nov 2024 10:45:47 +0100 Subject: [PATCH] feat: Add lifecycle summary info read model + average time spent in lifecycle query (#8691) This PR adds a project lifecycle read model file along with the most important (and most complicated) query that runs with it: calculating the average time spent in each stage. The calculation relies on the following: - when calculating the average of a stage, only flags who have gone into a following stage are taken into account. - we'll count "next stage" as the next row for the same feature where the `created_at` timestamp is higher than the current row - if you skip a stage (go straight to live or archived, for instance), that doesn't matter, because we don't look at that. The UI only shows the time spent in days, so I decided to go with rounding to days directly in the query. ## Discussion point: This one uses a subquery, but I'm not sure it's possible to do without it. However, if it's too expensive, we can probably also cache the value somehow, so it's not calculated more than every so often. --- ...oject-lifecycle-summary-read-model.test.ts | 105 ++++++++++++++ .../project-lifecycle-summary-read-model.ts | 136 ++++++++++++++++++ src/lib/openapi/spec/project-status-schema.ts | 8 +- 3 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts create mode 100644 src/lib/features/project-status/project-lifecycle-summary-read-model.ts diff --git a/src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts b/src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts new file mode 100644 index 000000000000..a77b894e206c --- /dev/null +++ b/src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts @@ -0,0 +1,105 @@ +import { addDays } from 'date-fns'; +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import getLogger from '../../../test/fixtures/no-logger'; +import { ProjectLifecycleSummaryReadModel } from './project-lifecycle-summary-read-model'; +import type { StageName } from '../../types'; +import { randomId } from '../../util'; + +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('project_lifecycle_summary_read_model_serial', getLogger); +}); + +afterAll(async () => { + if (db) { + await db.destroy(); + } +}); + +const updateFeatureStageDate = async ( + flagName: string, + stage: string, + newDate: Date, +) => { + await db + .rawDatabase('feature_lifecycles') + .where({ feature: flagName, stage: stage }) + .update({ created_at: newDate }); +}; + +describe('Average time calculation', () => { + test('it calculates the average time for each stage', async () => { + const project1 = await db.stores.projectStore.create({ + name: 'project1', + id: 'project1', + }); + const now = new Date(); + + const flags = [ + { name: randomId(), offsets: [2, 5, 6, 10] }, + { name: randomId(), offsets: [1, null, 4, 7] }, + { name: randomId(), offsets: [12, 25, 8, 9] }, + { name: randomId(), offsets: [1, 2, 3, null] }, + ]; + + for (const { name, offsets } of flags) { + const created = await db.stores.featureToggleStore.create( + project1.id, + { + name, + createdByUserId: 1, + }, + ); + await db.stores.featureLifecycleStore.insert([ + { + feature: name, + stage: 'initial', + }, + ]); + + const stages = ['pre-live', 'live', 'completed', 'archived']; + for (const [index, stage] of stages.entries()) { + const offset = offsets[index]; + if (offset === null) { + continue; + } + + const offsetFromInitial = offsets + .slice(0, index + 1) + .reduce((a, b) => (a ?? 0) + (b ?? 0), 0) as number; + + await db.stores.featureLifecycleStore.insert([ + { + feature: created.name, + stage: stage as StageName, + }, + ]); + + await updateFeatureStageDate( + created.name, + stage, + addDays(now, offsetFromInitial), + ); + } + } + + const readModel = new ProjectLifecycleSummaryReadModel(db.rawDatabase); + + const result = await readModel.getAverageTimeInEachStage(project1.id); + + expect(result).toMatchObject({ + initial: 4, // (2 + 1 + 12 + 1) / 4 = 4 + 'pre-live': 9, // (5 + 25 + 2 + 4) / 4 = 9 + live: 6, // (6 + 8 + 3) / 3 ~= 5.67 ~= 6 + completed: 9, // (10 + 7 + 9) / 3 ~= 8.67 ~= 9 + }); + }); + + test('it returns `null` if it has no data for something', async () => {}); + test('it rounds to the nearest whole number', async () => {}); + test('it ignores flags in other projects', async () => {}); + test('it ignores flags in other projects', async () => {}); + + test("it ignores rows that don't have a next stage", async () => {}); +}); diff --git a/src/lib/features/project-status/project-lifecycle-summary-read-model.ts b/src/lib/features/project-status/project-lifecycle-summary-read-model.ts new file mode 100644 index 000000000000..b4bf1050ba19 --- /dev/null +++ b/src/lib/features/project-status/project-lifecycle-summary-read-model.ts @@ -0,0 +1,136 @@ +import * as permissions from '../../types/permissions'; +import type { Db } from '../../db/db'; + +const { ADMIN } = permissions; + +export type IProjectLifecycleSummaryReadModel = {}; + +type ProjectLifecycleSummary = { + initial: { + averageDays: number; + currentFlags: number; + }; + preLive: { + averageDays: number; + currentFlags: number; + }; + live: { + averageDays: number; + currentFlags: number; + }; + completed: { + averageDays: number; + currentFlags: number; + }; + archived: { + currentFlags: number; + archivedFlagsOverLastMonth: number; + }; +}; + +export class ProjectLifecycleSummaryReadModel + implements IProjectLifecycleSummaryReadModel +{ + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async getAverageTimeInEachStage(projectId: string): Promise<{ + initial: number; + 'pre-live': number; + live: number; + completed: number; + }> { + const q = this.db + .with( + 'stage_durations', + this.db('feature_lifecycles as fl1') + .select( + 'fl1.feature', + 'fl1.stage', + this.db.raw( + 'EXTRACT(EPOCH FROM (MIN(fl2.created_at) - fl1.created_at)) / 86400 AS days_in_stage', + ), + ) + .join('feature_lifecycles as fl2', function () { + this.on('fl1.feature', '=', 'fl2.feature').andOn( + 'fl2.created_at', + '>', + 'fl1.created_at', + ); + }) + .innerJoin('features as f', 'fl1.feature', 'f.name') + .where('f.project', projectId) + .whereNot('fl1.stage', 'archived') + .groupBy('fl1.feature', 'fl1.stage'), + ) + .select('stage_durations.stage') + .select( + this.db.raw('ROUND(AVG(days_in_stage)) AS avg_days_in_stage'), + ) + .from('stage_durations') + .groupBy('stage_durations.stage'); + + const result = await q; + return result.reduce( + (acc, row) => { + acc[row.stage] = Number(row.avg_days_in_stage); + return acc; + }, + { + initial: 0, + 'pre-live': 0, + live: 0, + completed: 0, + }, + ); + } + + async getCurrentFlagsInEachStage(projectId: string) { + return 0; + } + + async getArchivedFlagsOverLastMonth(projectId: string) { + return 0; + } + + async getProjectLifecycleSummary( + projectId: string, + ): Promise { + const [ + averageTimeInEachStage, + currentFlagsInEachStage, + archivedFlagsOverLastMonth, + ] = await Promise.all([ + this.getAverageTimeInEachStage(projectId), + this.getCurrentFlagsInEachStage(projectId), + this.getArchivedFlagsOverLastMonth(projectId), + ]); + + // collate the data + return { + initial: { + averageDays: 0, + currentFlags: 0, + }, + preLive: { + averageDays: 0, + currentFlags: 0, + }, + live: { + averageDays: 0, + currentFlags: 0, + }, + completed: { + averageDays: 0, + currentFlags: 0, + }, + archived: { + currentFlags: 0, + archivedFlagsOverLastMonth: 0, + }, + }; + } +} diff --git a/src/lib/openapi/spec/project-status-schema.ts b/src/lib/openapi/spec/project-status-schema.ts index 912a97065323..1efdd3625984 100644 --- a/src/lib/openapi/spec/project-status-schema.ts +++ b/src/lib/openapi/spec/project-status-schema.ts @@ -32,25 +32,25 @@ export const projectStatusSchema = { description: 'Key resources within the project', properties: { connectedEnvironments: { - type: 'number', + type: 'integer', minimum: 0, description: 'The number of environments that have received SDK traffic in this project.', }, apiTokens: { - type: 'number', + type: 'integer', minimum: 0, description: 'The number of API tokens created specifically for this project.', }, members: { - type: 'number', + type: 'integer', minimum: 0, description: 'The number of users who have been granted roles in this project. Does not include users who have access via groups.', }, segments: { - type: 'number', + type: 'integer', minimum: 0, description: 'The number of segments that are scoped to this project.',