From c5d6bdecac39b97466c9b4c5e6de07e7941bf88f Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Thu, 29 Aug 2024 14:57:27 +0200 Subject: [PATCH] feat: projects onboarding metrics (#8014) --- src/lib/db/user-store.ts | 2 +- .../onboarding/fake-onboarding-read-model.ts | 8 ++- .../onboarding/onboarding-read-model-type.ts | 11 ++++ .../onboarding/onboarding-read-model.test.ts | 15 +++++- .../onboarding/onboarding-read-model.ts | 52 ++++++++++++++++--- src/lib/metrics.ts | 28 ++++++++-- 6 files changed, 102 insertions(+), 14 deletions(-) diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index a3e58d581578..01c3c13903a9 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -109,7 +109,7 @@ class UserStore implements IUserStore { async insert(user: ICreateUser): Promise { const rows = await this.db(TABLE) - .insert(mapUserToColumns(user)) + .insert({ ...mapUserToColumns(user), created_at: new Date() }) .returning(USER_COLUMNS); return rowToUser(rows[0]); } diff --git a/src/lib/features/onboarding/fake-onboarding-read-model.ts b/src/lib/features/onboarding/fake-onboarding-read-model.ts index 2ab870c07f4d..edc823bbcb72 100644 --- a/src/lib/features/onboarding/fake-onboarding-read-model.ts +++ b/src/lib/features/onboarding/fake-onboarding-read-model.ts @@ -1,5 +1,8 @@ import type { IOnboardingReadModel } from '../../types'; -import type { InstanceOnboarding } from './onboarding-read-model-type'; +import type { + InstanceOnboarding, + ProjectOnboarding, +} from './onboarding-read-model-type'; export class FakeOnboardingReadModel implements IOnboardingReadModel { getInstanceOnboardingMetrics(): Promise { @@ -11,4 +14,7 @@ export class FakeOnboardingReadModel implements IOnboardingReadModel { firstLive: null, }); } + getProjectsOnboardingMetrics(): Promise { + return Promise.resolve([]); + } } diff --git a/src/lib/features/onboarding/onboarding-read-model-type.ts b/src/lib/features/onboarding/onboarding-read-model-type.ts index 3ff13d5e705c..8a2a4adbc89a 100644 --- a/src/lib/features/onboarding/onboarding-read-model-type.ts +++ b/src/lib/features/onboarding/onboarding-read-model-type.ts @@ -9,6 +9,17 @@ export type InstanceOnboarding = { firstLive: number | null; }; +/** + * All the values are in minutes + */ +export type ProjectOnboarding = { + project: string; + firstFeatureFlag: number | null; + firstPreLive: number | null; + firstLive: number | null; +}; + export interface IOnboardingReadModel { getInstanceOnboardingMetrics(): Promise; + getProjectsOnboardingMetrics(): Promise>; } diff --git a/src/lib/features/onboarding/onboarding-read-model.test.ts b/src/lib/features/onboarding/onboarding-read-model.test.ts index 09cde8b5142d..848c3752b58d 100644 --- a/src/lib/features/onboarding/onboarding-read-model.test.ts +++ b/src/lib/features/onboarding/onboarding-read-model.test.ts @@ -37,6 +37,7 @@ beforeEach(async () => { }); test('can get onboarding durations', async () => { + jest.useFakeTimers(); const initialResult = await onboardingReadModel.getInstanceOnboardingMetrics(); expect(initialResult).toMatchObject({ @@ -56,7 +57,7 @@ test('can get onboarding durations', async () => { firstLogin: 0, secondLogin: null, }); - jest.useFakeTimers(); + jest.advanceTimersByTime(minutesToMilliseconds(10)); const secondUser = await userStore.insert({}); @@ -103,4 +104,16 @@ test('can get onboarding durations', async () => { firstPreLive: 30, firstLive: 40, }); + + const projectOnboardingResult = + await onboardingReadModel.getProjectsOnboardingMetrics(); + + expect(projectOnboardingResult).toMatchObject([ + { + project: 'default', + firstFeatureFlag: 20, + firstPreLive: 30, + firstLive: 40, + }, + ]); }); diff --git a/src/lib/features/onboarding/onboarding-read-model.ts b/src/lib/features/onboarding/onboarding-read-model.ts index e8d20508649a..772cb4d10493 100644 --- a/src/lib/features/onboarding/onboarding-read-model.ts +++ b/src/lib/features/onboarding/onboarding-read-model.ts @@ -2,16 +2,10 @@ import type { Db } from '../../db/db'; import type { IOnboardingReadModel, InstanceOnboarding, + ProjectOnboarding, } from './onboarding-read-model-type'; import { millisecondsToMinutes } from 'date-fns'; -interface IOnboardingUser { - first_login: string; -} -const parseStringToNumber = (value: string): number | null => { - return Number.isNaN(Number(value)) ? null : Number(value); -}; - const calculateTimeDifferenceInMinutes = (date1?: Date, date2?: Date) => { if (date1 && date2) { const diffInMilliseconds = date2.getTime() - date1.getTime(); @@ -89,4 +83,48 @@ export class OnboardingReadModel implements IOnboardingReadModel { firstLive: firstLiveDiff, }; } + + async getProjectsOnboardingMetrics(): Promise> { + const lifecycleResults = await this.db('projects') + .join('features', 'projects.id', 'features.project') + .join( + 'feature_lifecycles', + 'features.name', + 'feature_lifecycles.feature', + ) + .select('projects.id as project_id') + .select('projects.created_at as project_created_at') + .select( + this.db.raw( + ` MIN(CASE WHEN feature_lifecycles.stage = 'initial' THEN feature_lifecycles.created_at ELSE NULL END) AS first_initial`, + ), + ) + .select( + this.db.raw( + `MIN(CASE WHEN feature_lifecycles.stage = 'pre-live' THEN feature_lifecycles.created_at ELSE NULL END) AS first_pre_live`, + ), + ) + .select( + this.db.raw( + `MIN(CASE WHEN feature_lifecycles.stage = 'live' THEN feature_lifecycles.created_at ELSE NULL END) AS first_live`, + ), + ) + .groupBy('projects.id'); + + return lifecycleResults.map((result) => ({ + project: result.project_id, + firstFeatureFlag: calculateTimeDifferenceInMinutes( + result.project_created_at, + result.first_initial, + ), + firstPreLive: calculateTimeDifferenceInMinutes( + result.project_created_at, + result.first_pre_live, + ), + firstLive: calculateTimeDifferenceInMinutes( + result.project_created_at, + result.first_live, + ), + })); + } } diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index edab60c3ebce..94ce05bd0ecd 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -312,6 +312,11 @@ export default class MetricsMonitor { labelNames: ['event'], help: 'firstLogin, secondLogin, firstFeatureFlag, firstPreLive, firstLive from first user creation', }); + const projectOnboardingDuration = createGauge({ + name: 'project_onboarding_duration', + labelNames: ['event', 'project'], + help: 'firstFeatureFlag, firstPreLive, firstLive from project creation', + }); const featureLifecycleStageCountByProject = createGauge({ name: 'feature_lifecycle_stage_count_by_project', @@ -394,7 +399,8 @@ export default class MetricsMonitor { largestProjectEnvironments, largestFeatureEnvironments, deprecatedTokens, - onboardingMetrics, + instanceOnboardingMetrics, + projectsOnboardingMetrics, ] = await Promise.all([ stores.featureStrategiesReadModel.getMaxFeatureStrategies(), stores.featureStrategiesReadModel.getMaxFeatureEnvironmentStrategies(), @@ -412,6 +418,9 @@ export default class MetricsMonitor { flagResolver.isEnabled('onboardingMetrics') ? stores.onboardingReadModel.getInstanceOnboardingMetrics() : Promise.resolve({}), + flagResolver.isEnabled('onboardingMetrics') + ? stores.onboardingReadModel.getProjectsOnboardingMetrics() + : Promise.resolve([]), ]); featureFlagsTotal.reset(); @@ -539,15 +548,26 @@ export default class MetricsMonitor { .set(featureEnvironment.size); } - Object.keys(onboardingMetrics).forEach((key) => { - if (Number.isInteger(onboardingMetrics[key])) { + Object.keys(instanceOnboardingMetrics).forEach((key) => { + if (Number.isInteger(instanceOnboardingMetrics[key])) { onboardingDuration .labels({ event: key, }) - .set(onboardingMetrics[key]); + .set(instanceOnboardingMetrics[key]); } }); + projectsOnboardingMetrics.forEach( + ({ project, ...projectMetrics }) => { + Object.keys(projectMetrics).forEach((key) => { + if (Number.isInteger(projectMetrics[key])) { + projectOnboardingDuration + .labels({ event: key, project }) + .set(projectMetrics[key]); + } + }); + }, + ); for (const [resource, limit] of Object.entries( config.resourceLimits,