mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-04 00:18:01 +01:00
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.
This commit is contained in:
parent
8a507b2eec
commit
e07aab68cc
@ -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 () => {});
|
||||
});
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
@ -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.',
|
||||
|
Loading…
Reference in New Issue
Block a user