diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 0d576a191ef4..87b905d3e265 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -53,6 +53,7 @@ import { IntegrationEventsStore } from '../features/integration-events/integrati import { FeatureCollaboratorsReadModel } from '../features/feature-toggle/feature-collaborators-read-model'; import { createProjectReadModel } from '../features/project/createProjectReadModel'; import { OnboardingReadModel } from '../features/onboarding/onboarding-read-model'; +import { OnboardingStore } from '../features/onboarding/onboarding-store'; export const createStores = ( config: IUnleashConfig, @@ -173,6 +174,7 @@ export const createStores = ( featureLifecycleStore: new FeatureLifecycleStore(db), featureStrategiesReadModel: new FeatureStrategiesReadModel(db), onboardingReadModel: new OnboardingReadModel(db), + onboardingStore: new OnboardingStore(db), featureLifecycleReadModel: new FeatureLifecycleReadModel( db, config.flagResolver, diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index f5a2f8acdbc4..2be081ca3b39 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -298,6 +298,15 @@ class UserStore implements IUserStore { const row = await this.activeUsers().where({ id }).first(); return rowToUser(row); } + + async getFirstUserDate(): Promise { + const firstInstanceUser = await this.db('users') + .select('created_at') + .orderBy('created_at', 'asc') + .first(); + + return firstInstanceUser ? firstInstanceUser.created_at : null; + } } module.exports = UserStore; diff --git a/src/lib/features/onboarding/fake-onboarding-store.ts b/src/lib/features/onboarding/fake-onboarding-store.ts new file mode 100644 index 000000000000..979d15de0a49 --- /dev/null +++ b/src/lib/features/onboarding/fake-onboarding-store.ts @@ -0,0 +1,14 @@ +import type { + InstanceEvent, + IOnboardingStore, + ProjectEvent, +} from './onboarding-store-type'; + +export class FakeOnboardingStore implements IOnboardingStore { + insertProjectEvent(event: ProjectEvent): Promise { + throw new Error('Method not implemented.'); + } + insertInstanceEvent(event: InstanceEvent): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/lib/features/onboarding/onboarding-service.e2e.test.ts b/src/lib/features/onboarding/onboarding-service.e2e.test.ts new file mode 100644 index 000000000000..3e751c57ac12 --- /dev/null +++ b/src/lib/features/onboarding/onboarding-service.e2e.test.ts @@ -0,0 +1,87 @@ +import type { IUnleashStores } from '../../../lib/types'; +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import getLogger from '../../../test/fixtures/no-logger'; +import { minutesToMilliseconds } from 'date-fns'; +import { OnboardingService } from './onboarding-service'; +import { createTestConfig } from '../../../test/config/test-config'; + +let db: ITestDb; +let stores: IUnleashStores; +let onboardingService: OnboardingService; + +beforeAll(async () => { + db = await dbInit('onboarding_store', getLogger); + const config = createTestConfig({ + experimental: { flags: { onboardingMetrics: true } }, + }); + stores = db.stores; + const { userStore, onboardingStore, projectReadModel } = stores; + onboardingService = new OnboardingService( + { onboardingStore, userStore, projectReadModel }, + config, + ); +}); + +afterAll(async () => { + await db.destroy(); +}); + +beforeEach(async () => { + jest.useRealTimers(); +}); + +test('Storing onboarding events', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + const { userStore, featureToggleStore, projectStore, projectReadModel } = + stores; + const user = await userStore.insert({}); + await projectStore.create({ id: 'test_project', name: 'irrelevant' }); + await featureToggleStore.create('test_project', { + name: 'test', + createdByUserId: user.id, + }); + + jest.advanceTimersByTime(minutesToMilliseconds(1)); + await onboardingService.insert({ type: 'first-user-login' }); + jest.advanceTimersByTime(minutesToMilliseconds(1)); + await onboardingService.insert({ type: 'second-user-login' }); + jest.advanceTimersByTime(minutesToMilliseconds(1)); + await onboardingService.insert({ type: 'flag-created', flag: 'test' }); + await onboardingService.insert({ type: 'flag-created', flag: 'test' }); + await onboardingService.insert({ type: 'flag-created', flag: 'invalid' }); + jest.advanceTimersByTime(minutesToMilliseconds(1)); + await onboardingService.insert({ type: 'pre-live', flag: 'test' }); + await onboardingService.insert({ type: 'pre-live', flag: 'test' }); + await onboardingService.insert({ type: 'pre-live', flag: 'invalid' }); + jest.advanceTimersByTime(minutesToMilliseconds(1)); + await onboardingService.insert({ type: 'live', flag: 'test' }); + jest.advanceTimersByTime(minutesToMilliseconds(1)); + await onboardingService.insert({ type: 'live', flag: 'test' }); + jest.advanceTimersByTime(minutesToMilliseconds(1)); + await onboardingService.insert({ type: 'live', flag: 'invalid' }); + + const { rows: instanceEvents } = await db.rawDatabase.raw( + 'SELECT * FROM onboarding_events_instance', + ); + expect(instanceEvents).toMatchObject([ + { event: 'first-user-login', time_to_event: 60 }, + { event: 'second-user-login', time_to_event: 120 }, + { event: 'first-flag', time_to_event: 180 }, + { event: 'first-pre-live', time_to_event: 240 }, + { event: 'first-live', time_to_event: 300 }, + ]); + + const { rows: projectEvents } = await db.rawDatabase.raw( + 'SELECT * FROM onboarding_events_project', + ); + expect(projectEvents).toMatchObject([ + { event: 'first-flag', time_to_event: 180, project: 'test_project' }, + { + event: 'first-pre-live', + time_to_event: 240, + project: 'test_project', + }, + { event: 'first-live', time_to_event: 300, project: 'test_project' }, + ]); +}); diff --git a/src/lib/features/onboarding/onboarding-service.ts b/src/lib/features/onboarding/onboarding-service.ts new file mode 100644 index 000000000000..bb88262909dc --- /dev/null +++ b/src/lib/features/onboarding/onboarding-service.ts @@ -0,0 +1,135 @@ +import type { + IFlagResolver, + IProjectReadModel, + IUnleashConfig, + IUserStore, +} from '../../types'; +import type EventEmitter from 'events'; +import type { Logger } from '../../logger'; +import { STAGE_ENTERED, USER_LOGIN } from '../../metric-events'; +import type { NewStage } from '../feature-lifecycle/feature-lifecycle-store-type'; +import type { + InstanceEvent, + IOnboardingStore, + ProjectEvent, +} from './onboarding-store-type'; +import { millisecondsToSeconds } from 'date-fns'; + +export class OnboardingService { + private flagResolver: IFlagResolver; + + private eventBus: EventEmitter; + + private logger: Logger; + + private onboardingStore: IOnboardingStore; + + private projectReadModel: IProjectReadModel; + + private userStore: IUserStore; + + constructor( + { + onboardingStore, + projectReadModel, + userStore, + }: { + onboardingStore: IOnboardingStore; + projectReadModel: IProjectReadModel; + userStore: IUserStore; + }, + { + flagResolver, + eventBus, + getLogger, + }: Pick, + ) { + this.onboardingStore = onboardingStore; + this.projectReadModel = projectReadModel; + this.userStore = userStore; + this.flagResolver = flagResolver; + this.eventBus = eventBus; + this.logger = getLogger('onboarding/onboarding-service.ts'); + } + + listen() { + this.eventBus.on(USER_LOGIN, async (event: { loginOrder: number }) => { + if (!this.flagResolver.isEnabled('onboardingMetrics')) return; + + if (event.loginOrder === 0) { + await this.insert({ type: 'first-user-login' }); + } + if (event.loginOrder === 1) { + await this.insert({ + type: 'second-user-login', + }); + } + }); + this.eventBus.on(STAGE_ENTERED, async (stage: NewStage) => { + if (!this.flagResolver.isEnabled('onboardingMetrics')) return; + + if (stage.stage === 'initial') { + await this.insert({ + type: 'flag-created', + flag: stage.feature, + }); + } else if (stage.stage === 'pre-live') { + await this.insert({ + type: 'pre-live', + flag: stage.feature, + }); + } else if (stage.stage === 'live') { + await this.insert({ + type: 'live', + flag: stage.feature, + }); + } + }); + } + + async insert( + event: + | { flag: string; type: ProjectEvent['type'] } + | { type: 'first-user-login' | 'second-user-login' }, + ): Promise { + await this.insertInstanceEvent(event); + if ('flag' in event) { + await this.insertProjectEvent(event); + } + } + + private async insertInstanceEvent(event: { + flag?: string; + type: InstanceEvent['type']; + }): Promise { + const firstInstanceUserDate = await this.userStore.getFirstUserDate(); + if (!firstInstanceUserDate) return; + + const timeToEvent = millisecondsToSeconds( + new Date().getTime() - firstInstanceUserDate.getTime(), + ); + await this.onboardingStore.insertInstanceEvent({ + type: event.type, + timeToEvent, + }); + } + + private async insertProjectEvent(event: { + flag: string; + type: ProjectEvent['type']; + }): Promise { + const project = await this.projectReadModel.getFeatureProject( + event.flag, + ); + if (!project) return; + + const timeToEvent = millisecondsToSeconds( + new Date().getTime() - project.createdAt.getTime(), + ); + await this.onboardingStore.insertProjectEvent({ + type: event.type, + timeToEvent, + project: project.project, + }); + } +} diff --git a/src/lib/features/onboarding/onboarding-store-type.ts b/src/lib/features/onboarding/onboarding-store-type.ts new file mode 100644 index 000000000000..bd48b74e3f7f --- /dev/null +++ b/src/lib/features/onboarding/onboarding-store-type.ts @@ -0,0 +1,16 @@ +export type ProjectEvent = + | { type: 'flag-created'; project: string; timeToEvent: number } + | { type: 'pre-live'; project: string; timeToEvent: number } + | { type: 'live'; project: string; timeToEvent: number }; +export type InstanceEvent = + | { type: 'flag-created'; timeToEvent: number } + | { type: 'pre-live'; timeToEvent: number } + | { type: 'live'; timeToEvent: number } + | { type: 'first-user-login'; timeToEvent: number } + | { type: 'second-user-login'; timeToEvent: number }; + +export interface IOnboardingStore { + insertProjectEvent(event: ProjectEvent): Promise; + + insertInstanceEvent(event: InstanceEvent): Promise; +} diff --git a/src/lib/features/onboarding/onboarding-store.ts b/src/lib/features/onboarding/onboarding-store.ts new file mode 100644 index 000000000000..af132bdb87ca --- /dev/null +++ b/src/lib/features/onboarding/onboarding-store.ts @@ -0,0 +1,72 @@ +import type { Db } from '../../db/db'; +import type { + InstanceEvent, + IOnboardingStore, + ProjectEvent, +} from './onboarding-store-type'; + +export type DBProjectEvent = { + event: 'first-flag' | 'first-pre-live' | 'first-live'; + time_to_event: number; + project: string; +}; + +export type DBInstanceEvent = + | { + event: 'first-flag' | 'first-pre-live' | 'first-live'; + time_to_event: number; + project?: string; + } + | { + event: 'first-user-login' | 'second-user-login'; + time_to_event: number; + }; + +const projectEventLookup: Record< + ProjectEvent['type'], + DBProjectEvent['event'] +> = { + 'flag-created': 'first-flag', + 'pre-live': 'first-pre-live', + live: 'first-live', +}; + +const instanceEventLookup: Record< + InstanceEvent['type'], + DBInstanceEvent['event'] +> = { + 'flag-created': 'first-flag', + 'pre-live': 'first-pre-live', + live: 'first-live', + 'first-user-login': 'first-user-login', + 'second-user-login': 'second-user-login', +}; + +export class OnboardingStore implements IOnboardingStore { + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async insertInstanceEvent(event: InstanceEvent): Promise { + await this.db('onboarding_events_instance') + .insert({ + event: instanceEventLookup[event.type], + time_to_event: event.timeToEvent, + }) + .onConflict() + .ignore(); + } + + async insertProjectEvent(event: ProjectEvent): Promise { + await this.db('onboarding_events_project') + .insert({ + event: projectEventLookup[event.type], + time_to_event: event.timeToEvent, + project: event.project, + }) + .onConflict() + .ignore(); + } +} diff --git a/src/lib/features/project/fake-project-read-model.ts b/src/lib/features/project/fake-project-read-model.ts index 9b7b81287a8d..1d9e40b8f7cd 100644 --- a/src/lib/features/project/fake-project-read-model.ts +++ b/src/lib/features/project/fake-project-read-model.ts @@ -5,6 +5,9 @@ import type { } from './project-read-model-type'; export class FakeProjectReadModel implements IProjectReadModel { + getFeatureProject(): Promise<{ project: string; createdAt: Date } | null> { + return Promise.resolve(null); + } getProjectsForAdminUi(): Promise { return Promise.resolve([]); } diff --git a/src/lib/features/project/project-read-model-type.ts b/src/lib/features/project/project-read-model-type.ts index ff5e31284c72..4487d31f0839 100644 --- a/src/lib/features/project/project-read-model-type.ts +++ b/src/lib/features/project/project-read-model-type.ts @@ -37,4 +37,7 @@ export interface IProjectReadModel { getProjectsForInsights( query?: IProjectQuery, ): Promise; + getFeatureProject( + featureName: string, + ): Promise<{ project: string; createdAt: Date } | null>; } diff --git a/src/lib/features/project/project-read-model.ts b/src/lib/features/project/project-read-model.ts index 4bdd703e067a..c793543d81ba 100644 --- a/src/lib/features/project/project-read-model.ts +++ b/src/lib/features/project/project-read-model.ts @@ -62,6 +62,22 @@ export class ProjectReadModel implements IProjectReadModel { this.flagResolver = flagResolver; } + async getFeatureProject( + featureName: string, + ): Promise<{ project: string; createdAt: Date } | null> { + const result = await this.db<{ project: string; created_at: Date }>( + 'features', + ) + .join('projects', 'features.project', '=', 'projects.id') + .select('features.project', 'projects.created_at') + .where('features.name', featureName) + .first(); + + if (!result) return null; + + return { project: result.project, createdAt: result.created_at }; + } + async getProjectsForAdminUi( query?: IProjectQuery, userId?: number, diff --git a/src/lib/features/project/project-store.ts b/src/lib/features/project/project-store.ts index f926d5cb045c..4dfb65e778ea 100644 --- a/src/lib/features/project/project-store.ts +++ b/src/lib/features/project/project-store.ts @@ -302,7 +302,7 @@ class ProjectStore implements IProjectStore { project: IProjectInsert & IProjectSettings, ): Promise { const row = await this.db(TABLE) - .insert(this.fieldToRow(project)) + .insert({ ...this.fieldToRow(project), created_at: new Date() }) .returning('*'); const settingsRow = await this.db(SETTINGS_TABLE) .insert({ diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 38228a5e46ac..634bfedca46d 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -50,6 +50,7 @@ import type { IntegrationEventsStore } from '../features/integration-events/inte import { IFeatureCollaboratorsReadModel } from '../features/feature-toggle/types/feature-collaborators-read-model-type'; import type { IProjectReadModel } from '../features/project/project-read-model-type'; import { IOnboardingReadModel } from '../features/onboarding/onboarding-read-model-type'; +import { IOnboardingStore } from '../features/onboarding/onboarding-store-type'; export interface IUnleashStores { accessStore: IAccessStore; @@ -104,6 +105,7 @@ export interface IUnleashStores { featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel; projectReadModel: IProjectReadModel; onboardingReadModel: IOnboardingReadModel; + onboardingStore: IOnboardingStore; } export { @@ -157,4 +159,5 @@ export { IOnboardingReadModel, type IntegrationEventsStore, type IProjectReadModel, + IOnboardingStore, }; diff --git a/src/lib/types/stores/user-store.ts b/src/lib/types/stores/user-store.ts index 73f5484d6c38..2ca1f39a1e32 100644 --- a/src/lib/types/stores/user-store.ts +++ b/src/lib/types/stores/user-store.ts @@ -34,6 +34,7 @@ export interface IUserStore extends Store { disallowNPreviousPasswords: number, ): Promise; getPasswordsPreviouslyUsed(userId: number): Promise; + getFirstUserDate(): Promise; incLoginAttempts(user: IUser): Promise; successfullyLogin(user: IUser): Promise; count(): Promise; diff --git a/src/test/fixtures/fake-user-store.ts b/src/test/fixtures/fake-user-store.ts index 0dda7cbe8142..a201d70c4029 100644 --- a/src/test/fixtures/fake-user-store.ts +++ b/src/test/fixtures/fake-user-store.ts @@ -17,6 +17,22 @@ class UserStoreMock implements IUserStore { this.previousPasswords = new Map(); } + async getFirstUserDate(): Promise { + if (this.data.length === 0) { + return null; + } + const oldestUser = this.data.reduce((oldest, user) => { + if (!user.createdAt) { + return oldest; + } + return !oldest.createdAt || user.createdAt < oldest.createdAt + ? user + : oldest; + }, this.data[0]); + + return oldestUser.createdAt || null; + } + getPasswordsPreviouslyUsed(userId: number): Promise { return Promise.resolve(this.previousPasswords.get(userId) || []); } diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 9cf6167dcd49..fa3d60eb052e 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -53,6 +53,7 @@ import { FakeLargestResourcesReadModel } from '../../lib/features/metrics/sizes/ import { FakeFeatureCollaboratorsReadModel } from '../../lib/features/feature-toggle/fake-feature-collaborators-read-model'; import { createFakeProjectReadModel } from '../../lib/features/project/createProjectReadModel'; import { FakeOnboardingReadModel } from '../../lib/features/onboarding/fake-onboarding-read-model'; +import { FakeOnboardingStore } from '../../lib/features/onboarding/fake-onboarding-store'; const db = { select: () => ({ @@ -115,6 +116,7 @@ const createStores: () => IUnleashStores = () => { integrationEventsStore: {} as IntegrationEventsStore, featureCollaboratorsReadModel: new FakeFeatureCollaboratorsReadModel(), projectReadModel: createFakeProjectReadModel(), + onboardingStore: new FakeOnboardingStore(), }; };