mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-09 01:17:06 +02:00
feat: start collecting prometheus metrics for onboarding events (#8012)
We start collecting prometheus metrics for onboarding events. Co-authored-by: @kwasniew
This commit is contained in:
parent
5fe811ca3e
commit
e61f016c8c
@ -52,6 +52,7 @@ import { LargestResourcesReadModel } from '../features/metrics/sizes/largest-res
|
|||||||
import { IntegrationEventsStore } from '../features/integration-events/integration-events-store';
|
import { IntegrationEventsStore } from '../features/integration-events/integration-events-store';
|
||||||
import { FeatureCollaboratorsReadModel } from '../features/feature-toggle/feature-collaborators-read-model';
|
import { FeatureCollaboratorsReadModel } from '../features/feature-toggle/feature-collaborators-read-model';
|
||||||
import { createProjectReadModel } from '../features/project/createProjectReadModel';
|
import { createProjectReadModel } from '../features/project/createProjectReadModel';
|
||||||
|
import { OnboardingReadModel } from '../features/onboarding/onboarding-read-model';
|
||||||
|
|
||||||
export const createStores = (
|
export const createStores = (
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
@ -171,6 +172,7 @@ export const createStores = (
|
|||||||
projectFlagCreatorsReadModel: new ProjectFlagCreatorsReadModel(db),
|
projectFlagCreatorsReadModel: new ProjectFlagCreatorsReadModel(db),
|
||||||
featureLifecycleStore: new FeatureLifecycleStore(db),
|
featureLifecycleStore: new FeatureLifecycleStore(db),
|
||||||
featureStrategiesReadModel: new FeatureStrategiesReadModel(db),
|
featureStrategiesReadModel: new FeatureStrategiesReadModel(db),
|
||||||
|
onboardingReadModel: new OnboardingReadModel(db),
|
||||||
featureLifecycleReadModel: new FeatureLifecycleReadModel(
|
featureLifecycleReadModel: new FeatureLifecycleReadModel(
|
||||||
db,
|
db,
|
||||||
config.flagResolver,
|
config.flagResolver,
|
||||||
|
@ -63,6 +63,7 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore {
|
|||||||
stage: stage.stage,
|
stage: stage.stage,
|
||||||
status: stage.status,
|
status: stage.status,
|
||||||
status_value: stage.statusValue,
|
status_value: stage.statusValue,
|
||||||
|
created_at: new Date(),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.returning('*')
|
.returning('*')
|
||||||
|
14
src/lib/features/onboarding/fake-onboarding-read-model.ts
Normal file
14
src/lib/features/onboarding/fake-onboarding-read-model.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { IOnboardingReadModel } from '../../types';
|
||||||
|
import type { InstanceOnboarding } from './onboarding-read-model-type';
|
||||||
|
|
||||||
|
export class FakeOnboardingReadModel implements IOnboardingReadModel {
|
||||||
|
getInstanceOnboardingMetrics(): Promise<InstanceOnboarding> {
|
||||||
|
return Promise.resolve({
|
||||||
|
firstLogin: null,
|
||||||
|
secondLogin: null,
|
||||||
|
firstFeatureFlag: null,
|
||||||
|
firstPreLive: null,
|
||||||
|
firstLive: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
14
src/lib/features/onboarding/onboarding-read-model-type.ts
Normal file
14
src/lib/features/onboarding/onboarding-read-model-type.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* All the values are in minutes
|
||||||
|
*/
|
||||||
|
export type InstanceOnboarding = {
|
||||||
|
firstLogin: number | null;
|
||||||
|
secondLogin: number | null;
|
||||||
|
firstFeatureFlag: number | null;
|
||||||
|
firstPreLive: number | null;
|
||||||
|
firstLive: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IOnboardingReadModel {
|
||||||
|
getInstanceOnboardingMetrics(): Promise<InstanceOnboarding>;
|
||||||
|
}
|
106
src/lib/features/onboarding/onboarding-read-model.test.ts
Normal file
106
src/lib/features/onboarding/onboarding-read-model.test.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
|
||||||
|
import getLogger from '../../../test/fixtures/no-logger';
|
||||||
|
import type {
|
||||||
|
IFeatureLifecycleStore,
|
||||||
|
IFeatureToggleStore,
|
||||||
|
IUserStore,
|
||||||
|
} from '../../types';
|
||||||
|
import { OnboardingReadModel } from './onboarding-read-model';
|
||||||
|
import type { IOnboardingReadModel } from './onboarding-read-model-type';
|
||||||
|
import { minutesToMilliseconds } from 'date-fns';
|
||||||
|
|
||||||
|
let db: ITestDb;
|
||||||
|
let onboardingReadModel: IOnboardingReadModel;
|
||||||
|
let userStore: IUserStore;
|
||||||
|
let lifecycleStore: IFeatureLifecycleStore;
|
||||||
|
let featureToggleStore: IFeatureToggleStore;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = await dbInit('onboarding_read_model', getLogger, {
|
||||||
|
experimental: { flags: { onboardingMetrics: true } },
|
||||||
|
});
|
||||||
|
onboardingReadModel = new OnboardingReadModel(db.rawDatabase);
|
||||||
|
userStore = db.stores.userStore;
|
||||||
|
lifecycleStore = db.stores.featureLifecycleStore;
|
||||||
|
featureToggleStore = db.stores.featureToggleStore;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (db) {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await userStore.deleteAll();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can get onboarding durations', async () => {
|
||||||
|
const initialResult =
|
||||||
|
await onboardingReadModel.getInstanceOnboardingMetrics();
|
||||||
|
expect(initialResult).toMatchObject({
|
||||||
|
firstLogin: null,
|
||||||
|
secondLogin: null,
|
||||||
|
firstFeatureFlag: null,
|
||||||
|
firstPreLive: null,
|
||||||
|
firstLive: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstUser = await userStore.insert({});
|
||||||
|
await userStore.successfullyLogin(firstUser);
|
||||||
|
|
||||||
|
const firstLoginResult =
|
||||||
|
await onboardingReadModel.getInstanceOnboardingMetrics();
|
||||||
|
expect(firstLoginResult).toMatchObject({
|
||||||
|
firstLogin: 0,
|
||||||
|
secondLogin: null,
|
||||||
|
});
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.advanceTimersByTime(minutesToMilliseconds(10));
|
||||||
|
|
||||||
|
const secondUser = await userStore.insert({});
|
||||||
|
await userStore.successfullyLogin(secondUser);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(minutesToMilliseconds(10));
|
||||||
|
|
||||||
|
await featureToggleStore.create('default', {
|
||||||
|
name: 'test',
|
||||||
|
createdByUserId: secondUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await lifecycleStore.insert([
|
||||||
|
{
|
||||||
|
feature: 'test',
|
||||||
|
stage: 'initial',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(minutesToMilliseconds(10));
|
||||||
|
|
||||||
|
await lifecycleStore.insert([
|
||||||
|
{
|
||||||
|
feature: 'test',
|
||||||
|
stage: 'pre-live',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(minutesToMilliseconds(10));
|
||||||
|
|
||||||
|
await lifecycleStore.insert([
|
||||||
|
{
|
||||||
|
feature: 'test',
|
||||||
|
stage: 'live',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const secondLoginResult =
|
||||||
|
await onboardingReadModel.getInstanceOnboardingMetrics();
|
||||||
|
expect(secondLoginResult).toMatchObject({
|
||||||
|
firstLogin: 0,
|
||||||
|
secondLogin: 10,
|
||||||
|
firstFeatureFlag: 20,
|
||||||
|
firstPreLive: 30,
|
||||||
|
firstLive: 40,
|
||||||
|
});
|
||||||
|
});
|
92
src/lib/features/onboarding/onboarding-read-model.ts
Normal file
92
src/lib/features/onboarding/onboarding-read-model.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import type { Db } from '../../db/db';
|
||||||
|
import type {
|
||||||
|
IOnboardingReadModel,
|
||||||
|
InstanceOnboarding,
|
||||||
|
} 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();
|
||||||
|
return millisecondsToMinutes(diffInMilliseconds);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OnboardingReadModel implements IOnboardingReadModel {
|
||||||
|
private db: Db;
|
||||||
|
|
||||||
|
constructor(db: Db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInstanceOnboardingMetrics(): Promise<InstanceOnboarding> {
|
||||||
|
const firstUserCreatedResult = await this.db('users')
|
||||||
|
.select('created_at')
|
||||||
|
.orderBy('created_at')
|
||||||
|
.first();
|
||||||
|
const firstLoginResult = await this.db('users')
|
||||||
|
.select('first_seen_at')
|
||||||
|
.orderBy('first_seen_at')
|
||||||
|
.limit(2);
|
||||||
|
|
||||||
|
const firstInitialResult = await this.db('feature_lifecycles')
|
||||||
|
.select('created_at')
|
||||||
|
.where('stage', 'initial')
|
||||||
|
.orderBy('created_at')
|
||||||
|
.first();
|
||||||
|
const firstPreLiveResult = await this.db('feature_lifecycles')
|
||||||
|
.select('created_at')
|
||||||
|
.where('stage', 'pre-live')
|
||||||
|
.orderBy('created_at')
|
||||||
|
.first();
|
||||||
|
const firstLiveResult = await this.db('feature_lifecycles')
|
||||||
|
.select('created_at')
|
||||||
|
.where('stage', 'live')
|
||||||
|
.orderBy('created_at')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const createdAt = firstUserCreatedResult?.created_at;
|
||||||
|
const firstLogin = firstLoginResult[0]?.first_seen_at;
|
||||||
|
const secondLogin = firstLoginResult[1]?.first_seen_at;
|
||||||
|
const firstInitial = firstInitialResult?.created_at;
|
||||||
|
const firstPreLive = firstPreLiveResult?.created_at;
|
||||||
|
const firstLive = firstLiveResult?.created_at;
|
||||||
|
|
||||||
|
const firstLoginDiff = calculateTimeDifferenceInMinutes(
|
||||||
|
createdAt,
|
||||||
|
firstLogin,
|
||||||
|
);
|
||||||
|
const secondLoginDiff = calculateTimeDifferenceInMinutes(
|
||||||
|
createdAt,
|
||||||
|
secondLogin,
|
||||||
|
);
|
||||||
|
const firstFlagDiff = calculateTimeDifferenceInMinutes(
|
||||||
|
createdAt,
|
||||||
|
firstInitial,
|
||||||
|
);
|
||||||
|
const firstPreLiveDiff = calculateTimeDifferenceInMinutes(
|
||||||
|
createdAt,
|
||||||
|
firstPreLive,
|
||||||
|
);
|
||||||
|
const firstLiveDiff = calculateTimeDifferenceInMinutes(
|
||||||
|
createdAt,
|
||||||
|
firstLive,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstLogin: firstLoginDiff,
|
||||||
|
secondLogin: secondLoginDiff,
|
||||||
|
firstFeatureFlag: firstFlagDiff,
|
||||||
|
firstPreLive: firstPreLiveDiff,
|
||||||
|
firstLive: firstLiveDiff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -307,6 +307,12 @@ export default class MetricsMonitor {
|
|||||||
help: 'Duration of feature lifecycle stages',
|
help: 'Duration of feature lifecycle stages',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onboardingDuration = createGauge({
|
||||||
|
name: 'onboarding_duration',
|
||||||
|
labelNames: ['event'],
|
||||||
|
help: 'firstLogin, secondLogin, firstFeatureFlag, firstPreLive, firstLive from first user creation',
|
||||||
|
});
|
||||||
|
|
||||||
const featureLifecycleStageCountByProject = createGauge({
|
const featureLifecycleStageCountByProject = createGauge({
|
||||||
name: 'feature_lifecycle_stage_count_by_project',
|
name: 'feature_lifecycle_stage_count_by_project',
|
||||||
help: 'Count features in a given stage by project id',
|
help: 'Count features in a given stage by project id',
|
||||||
@ -388,6 +394,7 @@ export default class MetricsMonitor {
|
|||||||
largestProjectEnvironments,
|
largestProjectEnvironments,
|
||||||
largestFeatureEnvironments,
|
largestFeatureEnvironments,
|
||||||
deprecatedTokens,
|
deprecatedTokens,
|
||||||
|
onboardingMetrics,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
stores.featureStrategiesReadModel.getMaxFeatureStrategies(),
|
stores.featureStrategiesReadModel.getMaxFeatureStrategies(),
|
||||||
stores.featureStrategiesReadModel.getMaxFeatureEnvironmentStrategies(),
|
stores.featureStrategiesReadModel.getMaxFeatureEnvironmentStrategies(),
|
||||||
@ -402,6 +409,9 @@ export default class MetricsMonitor {
|
|||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
stores.apiTokenStore.countDeprecatedTokens(),
|
stores.apiTokenStore.countDeprecatedTokens(),
|
||||||
|
flagResolver.isEnabled('onboardingMetrics')
|
||||||
|
? stores.onboardingReadModel.getInstanceOnboardingMetrics()
|
||||||
|
: Promise.resolve({}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
featureFlagsTotal.reset();
|
featureFlagsTotal.reset();
|
||||||
@ -529,6 +539,16 @@ export default class MetricsMonitor {
|
|||||||
.set(featureEnvironment.size);
|
.set(featureEnvironment.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object.keys(onboardingMetrics).forEach((key) => {
|
||||||
|
if (Number.isInteger(onboardingMetrics[key])) {
|
||||||
|
onboardingDuration
|
||||||
|
.labels({
|
||||||
|
event: key,
|
||||||
|
})
|
||||||
|
.set(onboardingMetrics[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for (const [resource, limit] of Object.entries(
|
for (const [resource, limit] of Object.entries(
|
||||||
config.resourceLimits,
|
config.resourceLimits,
|
||||||
)) {
|
)) {
|
||||||
|
@ -49,6 +49,7 @@ import { ILargestResourcesReadModel } from '../features/metrics/sizes/largest-re
|
|||||||
import type { IntegrationEventsStore } from '../features/integration-events/integration-events-store';
|
import type { IntegrationEventsStore } from '../features/integration-events/integration-events-store';
|
||||||
import { IFeatureCollaboratorsReadModel } from '../features/feature-toggle/types/feature-collaborators-read-model-type';
|
import { IFeatureCollaboratorsReadModel } from '../features/feature-toggle/types/feature-collaborators-read-model-type';
|
||||||
import type { IProjectReadModel } from '../features/project/project-read-model-type';
|
import type { IProjectReadModel } from '../features/project/project-read-model-type';
|
||||||
|
import { IOnboardingReadModel } from '../features/onboarding/onboarding-read-model-type';
|
||||||
|
|
||||||
export interface IUnleashStores {
|
export interface IUnleashStores {
|
||||||
accessStore: IAccessStore;
|
accessStore: IAccessStore;
|
||||||
@ -102,6 +103,7 @@ export interface IUnleashStores {
|
|||||||
integrationEventsStore: IntegrationEventsStore;
|
integrationEventsStore: IntegrationEventsStore;
|
||||||
featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel;
|
featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel;
|
||||||
projectReadModel: IProjectReadModel;
|
projectReadModel: IProjectReadModel;
|
||||||
|
onboardingReadModel: IOnboardingReadModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -152,6 +154,7 @@ export {
|
|||||||
IFeatureLifecycleReadModel,
|
IFeatureLifecycleReadModel,
|
||||||
ILargestResourcesReadModel,
|
ILargestResourcesReadModel,
|
||||||
IFeatureCollaboratorsReadModel,
|
IFeatureCollaboratorsReadModel,
|
||||||
|
IOnboardingReadModel,
|
||||||
type IntegrationEventsStore,
|
type IntegrationEventsStore,
|
||||||
type IProjectReadModel,
|
type IProjectReadModel,
|
||||||
};
|
};
|
||||||
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -52,6 +52,7 @@ import { FakeFeatureLifecycleReadModel } from '../../lib/features/feature-lifecy
|
|||||||
import { FakeLargestResourcesReadModel } from '../../lib/features/metrics/sizes/fake-largest-resources-read-model';
|
import { FakeLargestResourcesReadModel } from '../../lib/features/metrics/sizes/fake-largest-resources-read-model';
|
||||||
import { FakeFeatureCollaboratorsReadModel } from '../../lib/features/feature-toggle/fake-feature-collaborators-read-model';
|
import { FakeFeatureCollaboratorsReadModel } from '../../lib/features/feature-toggle/fake-feature-collaborators-read-model';
|
||||||
import { createFakeProjectReadModel } from '../../lib/features/project/createProjectReadModel';
|
import { createFakeProjectReadModel } from '../../lib/features/project/createProjectReadModel';
|
||||||
|
import { FakeOnboardingReadModel } from '../../lib/features/onboarding/fake-onboarding-read-model';
|
||||||
|
|
||||||
const db = {
|
const db = {
|
||||||
select: () => ({
|
select: () => ({
|
||||||
@ -109,6 +110,7 @@ const createStores: () => IUnleashStores = () => {
|
|||||||
featureLifecycleStore: new FakeFeatureLifecycleStore(),
|
featureLifecycleStore: new FakeFeatureLifecycleStore(),
|
||||||
featureStrategiesReadModel: new FakeFeatureStrategiesReadModel(),
|
featureStrategiesReadModel: new FakeFeatureStrategiesReadModel(),
|
||||||
featureLifecycleReadModel: new FakeFeatureLifecycleReadModel(),
|
featureLifecycleReadModel: new FakeFeatureLifecycleReadModel(),
|
||||||
|
onboardingReadModel: new FakeOnboardingReadModel(),
|
||||||
largestResourcesReadModel: new FakeLargestResourcesReadModel(),
|
largestResourcesReadModel: new FakeLargestResourcesReadModel(),
|
||||||
integrationEventsStore: {} as IntegrationEventsStore,
|
integrationEventsStore: {} as IntegrationEventsStore,
|
||||||
featureCollaboratorsReadModel: new FakeFeatureCollaboratorsReadModel(),
|
featureCollaboratorsReadModel: new FakeFeatureCollaboratorsReadModel(),
|
||||||
|
Loading…
Reference in New Issue
Block a user