Skip to content

Commit

Permalink
feat: Add lifecycle summary info read model + average time spent in l…
Browse files Browse the repository at this point in the history
…ifecycle 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.
  • Loading branch information
thomasheartman authored Nov 8, 2024
1 parent 8a507b2 commit e07aab6
Show file tree
Hide file tree
Showing 3 changed files with 245 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -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 () => {});
});
Original file line number Diff line number Diff line change
@@ -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<ProjectLifecycleSummary> {
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,
},
};
}
}
8 changes: 4 additions & 4 deletions src/lib/openapi/spec/project-status-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down

0 comments on commit e07aab6

Please sign in to comment.