Skip to content

Commit

Permalink
feat: onboarding store (#8027)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew authored Sep 2, 2024
1 parent bb5aa64 commit f27e07a
Show file tree
Hide file tree
Showing 15 changed files with 380 additions and 1 deletion.
2 changes: 2 additions & 0 deletions src/lib/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/db/user-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,15 @@ class UserStore implements IUserStore {
const row = await this.activeUsers().where({ id }).first();
return rowToUser(row);
}

async getFirstUserDate(): Promise<Date | null> {
const firstInstanceUser = await this.db('users')
.select('created_at')
.orderBy('created_at', 'asc')
.first();

return firstInstanceUser ? firstInstanceUser.created_at : null;
}
}

module.exports = UserStore;
Expand Down
14 changes: 14 additions & 0 deletions src/lib/features/onboarding/fake-onboarding-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type {
InstanceEvent,
IOnboardingStore,
ProjectEvent,
} from './onboarding-store-type';

export class FakeOnboardingStore implements IOnboardingStore {
insertProjectEvent(event: ProjectEvent): Promise<void> {
throw new Error('Method not implemented.');
}
insertInstanceEvent(event: InstanceEvent): Promise<void> {
throw new Error('Method not implemented.');
}
}
87 changes: 87 additions & 0 deletions src/lib/features/onboarding/onboarding-service.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
]);
});
135 changes: 135 additions & 0 deletions src/lib/features/onboarding/onboarding-service.ts
Original file line number Diff line number Diff line change
@@ -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<IUnleashConfig, 'flagResolver' | 'eventBus' | 'getLogger'>,
) {
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<void> {
await this.insertInstanceEvent(event);
if ('flag' in event) {
await this.insertProjectEvent(event);
}
}

private async insertInstanceEvent(event: {
flag?: string;
type: InstanceEvent['type'];
}): Promise<void> {
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<void> {
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,
});
}
}
16 changes: 16 additions & 0 deletions src/lib/features/onboarding/onboarding-store-type.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

insertInstanceEvent(event: InstanceEvent): Promise<void>;
}
72 changes: 72 additions & 0 deletions src/lib/features/onboarding/onboarding-store.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await this.db('onboarding_events_instance')
.insert({
event: instanceEventLookup[event.type],
time_to_event: event.timeToEvent,
})
.onConflict()
.ignore();
}

async insertProjectEvent(event: ProjectEvent): Promise<void> {
await this.db<DBProjectEvent>('onboarding_events_project')
.insert({
event: projectEventLookup[event.type],
time_to_event: event.timeToEvent,
project: event.project,
})
.onConflict()
.ignore();
}
}
3 changes: 3 additions & 0 deletions src/lib/features/project/fake-project-read-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectForUi[]> {
return Promise.resolve([]);
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/features/project/project-read-model-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,7 @@ export interface IProjectReadModel {
getProjectsForInsights(
query?: IProjectQuery,
): Promise<ProjectForInsights[]>;
getFeatureProject(
featureName: string,
): Promise<{ project: string; createdAt: Date } | null>;
}
Loading

0 comments on commit f27e07a

Please sign in to comment.