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:
parent
bb5aa64756
commit
f27e07ab88
@ -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,
|
||||
|
@ -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;
|
||||
|
14
src/lib/features/onboarding/fake-onboarding-store.ts
Normal file
14
src/lib/features/onboarding/fake-onboarding-store.ts
Normal 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.');
|
||||
}
|
||||
}
|
87
src/lib/features/onboarding/onboarding-service.e2e.test.ts
Normal file
87
src/lib/features/onboarding/onboarding-service.e2e.test.ts
Normal 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' },
|
||||
]);
|
||||
});
|
135
src/lib/features/onboarding/onboarding-service.ts
Normal file
135
src/lib/features/onboarding/onboarding-service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
16
src/lib/features/onboarding/onboarding-store-type.ts
Normal file
16
src/lib/features/onboarding/onboarding-store-type.ts
Normal 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>;
|
||||
}
|
72
src/lib/features/onboarding/onboarding-store.ts
Normal file
72
src/lib/features/onboarding/onboarding-store.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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([]);
|
||||
}
|
||||
|
@ -37,4 +37,7 @@ export interface IProjectReadModel {
|
||||
getProjectsForInsights(
|
||||
query?: IProjectQuery,
|
||||
): Promise<ProjectForInsights[]>;
|
||||
getFeatureProject(
|
||||
featureName: string,
|
||||
): Promise<{ project: string; createdAt: Date } | null>;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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>;
|
||||
|
16
src/test/fixtures/fake-user-store.ts
vendored
16
src/test/fixtures/fake-user-store.ts
vendored
@ -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) || []);
|
||||
}
|
||||
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user