Skip to content
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
116 changes: 109 additions & 7 deletions src/frontend/src/utils/analytics/Funnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ describe("Funnel", () => {

it("init() - triggers start-login event", () => {
funnel.init();
expect(analytics.event).toHaveBeenCalledWith("start-login");
expect(analytics.event).toHaveBeenCalledWith("start-login", undefined);
});

it("init() - triggers start-login event with properties", () => {
const properties = { userId: "123", source: "test" };
funnel.init(properties);
expect(analytics.event).toHaveBeenCalledWith("start-login", properties);
});

it("init() - tracks window session enter when window becomes visible", () => {
Expand All @@ -51,6 +57,18 @@ describe("Funnel", () => {
document.dispatchEvent(new Event("visibilitychange"));
expect(analytics.event).toHaveBeenCalledWith(
"start-login-window-session-enter",
undefined,
);
});

it("init() - tracks window session enter with properties when window becomes visible", () => {
const properties = { userId: "123", source: "test" };
funnel.init(properties);
mockVisibilityState("visible");
document.dispatchEvent(new Event("visibilitychange"));
expect(analytics.event).toHaveBeenCalledWith(
"start-login-window-session-enter",
properties,
);
});

Expand All @@ -60,45 +78,108 @@ describe("Funnel", () => {
document.dispatchEvent(new Event("visibilitychange"));
expect(analytics.event).toHaveBeenCalledWith(
"start-login-window-session-leave",
undefined,
);
});

it("init() - tracks window session leave with properties when window is hidden", () => {
const properties = { userId: "123", source: "test" };
funnel.init(properties);
mockVisibilityState("hidden");
document.dispatchEvent(new Event("visibilitychange"));
expect(analytics.event).toHaveBeenCalledWith(
"start-login-window-session-leave",
properties,
);
});

it("trigger() - tracks new registration start event", () => {
funnel.trigger(LoginEvents.NewRegistrationStart);
expect(analytics.event).toHaveBeenCalledWith(
"login-new-registration-start",
undefined,
);
});

it("trigger() - tracks new registration start event with init properties", () => {
const properties = { userId: "123", source: "test" };
funnel.init(properties);
funnel.trigger(LoginEvents.NewRegistrationStart);
expect(analytics.event).toHaveBeenCalledWith(
"login-new-registration-start",
properties,
);
});

it("trigger() - tracks new registration start event with additional properties", () => {
const additionalProps = { step: 1, method: "email" };
funnel.trigger(LoginEvents.NewRegistrationStart, additionalProps);
expect(analytics.event).toHaveBeenCalledWith(
"login-new-registration-start",
additionalProps,
);
});

it("trigger() - merges init properties with additional properties", () => {
const initProps = { userId: "123", source: "test" };
const additionalProps = { step: 1, method: "email" };
const expectedProps = { ...initProps, ...additionalProps };

funnel.init(initProps);
funnel.trigger(LoginEvents.NewRegistrationStart, additionalProps);

expect(analytics.event).toHaveBeenCalledWith(
"login-new-registration-start",
expectedProps,
);
});

it("trigger() - tracks complete passkey login flow", () => {
funnel.trigger(LoginEvents.ExistingUserStart);
expect(analytics.event).toHaveBeenCalledWith("login-existing-user-start");
expect(analytics.event).toHaveBeenCalledWith(
"login-existing-user-start",
undefined,
);

funnel.trigger(LoginEvents.ExistingUserPasskey);
expect(analytics.event).toHaveBeenCalledWith("login-existing-user-passkey");
expect(analytics.event).toHaveBeenCalledWith(
"login-existing-user-passkey",
undefined,
);

funnel.trigger(LoginEvents.ExistingUserPasskeySuccess);
expect(analytics.event).toHaveBeenCalledWith(
"login-existing-user-passkey-success",
undefined,
);
});

it("trigger() - tracks complete OpenID login flow", () => {
funnel.trigger(LoginEvents.ExistingUserStart);
expect(analytics.event).toHaveBeenCalledWith("login-existing-user-start");
expect(analytics.event).toHaveBeenCalledWith(
"login-existing-user-start",
undefined,
);

funnel.trigger(LoginEvents.ExistingUserOpenId);
expect(analytics.event).toHaveBeenCalledWith("login-existing-user-openid");
expect(analytics.event).toHaveBeenCalledWith(
"login-existing-user-openid",
undefined,
);

funnel.trigger(LoginEvents.ExistingUserOpenIdSuccess);
expect(analytics.event).toHaveBeenCalledWith(
"login-existing-user-openid-success",
undefined,
);
});

it("trigger() - tracks recovery start event", () => {
funnel.trigger(LoginEvents.RecoveryStart);
expect(analytics.event).toHaveBeenCalledWith("login-recovery-start");
expect(analytics.event).toHaveBeenCalledWith(
"login-recovery-start",
undefined,
);
});

it("close() - stops tracking window session events", () => {
Expand All @@ -120,7 +201,27 @@ describe("Funnel", () => {
vi.useFakeTimers();

funnel.init();
expect(analytics.event).toHaveBeenCalledWith("start-login");
expect(analytics.event).toHaveBeenCalledWith("start-login", undefined);
expect(analytics.event).toHaveBeenCalledTimes(1);

// Advance time by 5 seconds
vi.advanceTimersByTime(5000);

funnel.close();
expect(analytics.event).toHaveBeenCalledTimes(2);
expect(analytics.event).toHaveBeenCalledWith("end-login", {
"duration-login": 5,
});

vi.useRealTimers();
});

it("close() - tracks duration since init and includes init properties", () => {
vi.useFakeTimers();

const properties = { userId: "123", source: "test" };
funnel.init(properties);
expect(analytics.event).toHaveBeenCalledWith("start-login", properties);
expect(analytics.event).toHaveBeenCalledTimes(1);

// Advance time by 5 seconds
Expand All @@ -129,6 +230,7 @@ describe("Funnel", () => {
funnel.close();
expect(analytics.event).toHaveBeenCalledTimes(2);
expect(analytics.event).toHaveBeenCalledWith("end-login", {
...properties,
"duration-login": 5,
});

Expand Down
37 changes: 29 additions & 8 deletions src/frontend/src/utils/analytics/Funnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,30 @@ export class Funnel<T extends Record<string, string>> {
#name: string;
#cleanupSession?: () => void;
#startTimestamp?: number;
#properties?: Record<string, string | number>;

constructor(name: string) {
this.#name = name;
}

init(): void {
init(properties?: Record<string, string | number>): void {
this.#startTimestamp = Date.now();
analytics.event("start-" + this.#name);
this.#properties = properties;
analytics.event("start-" + this.#name, this.#properties);

// Start window session tracking
this.#cleanupSession = trackWindowSession({
onEnterSession: () => {
analytics.event(`start-${this.#name}-window-session-enter`);
analytics.event(
`start-${this.#name}-window-session-enter`,
this.#properties,
);
},
onLeaveSession: () => {
analytics.event(`start-${this.#name}-window-session-leave`);
analytics.event(
`start-${this.#name}-window-session-leave`,
this.#properties,
);
},
});
}
Expand All @@ -39,14 +47,27 @@ export class Funnel<T extends Record<string, string>> {
) {
const durationMs = Date.now() - this.#startTimestamp;
const durationSec = durationMs / 1000;
analytics.event(`end-${this.#name}`, {
const eventProperties = {
...(this.#properties || {}),
[`duration-${this.#name}`]: durationSec,
});
};
analytics.event(`end-${this.#name}`, eventProperties);
this.#startTimestamp = undefined;
}
}

trigger(event: T[keyof T]): void {
analytics.event(event);
trigger(
event: T[keyof T],
additionalProperties?: Record<string, string | number>,
): void {
const eventProperties = {
...(this.#properties || {}),
...(additionalProperties || {}),
};

analytics.event(
event,
Object.keys(eventProperties).length > 0 ? eventProperties : undefined,
);
}
}