Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: onboarding store #8027

Merged
merged 8 commits into from
Sep 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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') {
Copy link
Contributor Author

@kwasniew kwasniew Aug 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do this translation since there's not 100% domain vocab match between lifecycle and onboarding. lifecycle cares about initial flag, while onboarding cares about the first flag created

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
Loading