1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +02:00

feat: onboarding store (#8027)

This commit is contained in:
Mateusz Kwasniewski 2024-09-02 08:53:23 +02:00 committed by GitHub
parent bb5aa64756
commit f27e07ab88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 380 additions and 1 deletions

View File

@ -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,

View File

@ -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;

View File

@ -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.');
}
}

View File

@ -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' },
]);
});

View File

@ -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,
});
}
}

View File

@ -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>;
}

View File

@ -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();
}
}

View File

@ -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([]);
}

View File

@ -37,4 +37,7 @@ export interface IProjectReadModel {
getProjectsForInsights(
query?: IProjectQuery,
): Promise<ProjectForInsights[]>;
getFeatureProject(
featureName: string,
): Promise<{ project: string; createdAt: Date } | null>;
}

View File

@ -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,

View File

@ -302,7 +302,7 @@ class ProjectStore implements IProjectStore {
project: IProjectInsert & IProjectSettings,
): Promise<IProject> {
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({

View File

@ -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,
};

View File

@ -34,6 +34,7 @@ export interface IUserStore extends Store<IUser, number> {
disallowNPreviousPasswords: number,
): Promise<void>;
getPasswordsPreviouslyUsed(userId: number): Promise<string[]>;
getFirstUserDate(): Promise<Date | null>;
incLoginAttempts(user: IUser): Promise<void>;
successfullyLogin(user: IUser): Promise<number>;
count(): Promise<number>;

View File

@ -17,6 +17,22 @@ class UserStoreMock implements IUserStore {
this.previousPasswords = new Map();
}
async getFirstUserDate(): Promise<Date | null> {
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<string[]> {
return Promise.resolve(this.previousPasswords.get(userId) || []);
}

View File

@ -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(),
};
};