mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: projects onboarding metrics (#8014)
This commit is contained in:
		
							parent
							
								
									d3b65a3105
								
							
						
					
					
						commit
						c5d6bdecac
					
				@ -109,7 +109,7 @@ class UserStore implements IUserStore {
 | 
			
		||||
 | 
			
		||||
    async insert(user: ICreateUser): Promise<User> {
 | 
			
		||||
        const rows = await this.db(TABLE)
 | 
			
		||||
            .insert(mapUserToColumns(user))
 | 
			
		||||
            .insert({ ...mapUserToColumns(user), created_at: new Date() })
 | 
			
		||||
            .returning(USER_COLUMNS);
 | 
			
		||||
        return rowToUser(rows[0]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -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<InstanceOnboarding> {
 | 
			
		||||
@ -11,4 +14,7 @@ export class FakeOnboardingReadModel implements IOnboardingReadModel {
 | 
			
		||||
            firstLive: null,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    getProjectsOnboardingMetrics(): Promise<ProjectOnboarding[]> {
 | 
			
		||||
        return Promise.resolve([]);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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<InstanceOnboarding>;
 | 
			
		||||
    getProjectsOnboardingMetrics(): Promise<Array<ProjectOnboarding>>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
        },
 | 
			
		||||
    ]);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -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<Array<ProjectOnboarding>> {
 | 
			
		||||
        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,
 | 
			
		||||
            ),
 | 
			
		||||
        }));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user