Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ function collectReferencedEvents(): Set<string> {
}

describe("buildAnalyticsDashboardBody — drift prevention", () => {
it("emits 28 widgets (7 traffic+audience, 3 conversions, 3 imports+medium, 3 subscriptions, 2 view-funnel, 1 internal-clicks, 3 save-funnel, 1 summary-engagement, 2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic) — adding or dropping one without updating this count is a deliberate signal to review the dashboard's scope", () => {
it("emits 30 widgets (7 traffic+audience, 3 conversions, 3 imports+medium, 3 subscriptions, 2 view-funnel, 1 internal-clicks, 3 save-funnel, 1 summary-engagement, 2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic, 2 signup-form) — adding or dropping one without updating this count is a deliberate signal to review the dashboard's scope", () => {
const body = buildBody();
expect(body.widgets).toHaveLength(28);
expect(body.widgets).toHaveLength(30);
});

it("the homepage A/B widget compares arms by distinct visitors (assignment is sticky per browser, so raw counts pile a returning visitor's landings onto one arm) with raw landings alongside, grouped by variant (utm_content)", () => {
Expand Down
36 changes: 36 additions & 0 deletions projects/hutch/src/runtime/observability/analytics-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,42 @@ export function buildAnalyticsDashboardBody(deps: BuildAnalyticsDashboardDeps):
}),
);

// --- Signup form funnel ---
// Driven by the signup_attempted event the POST /signup handler emits at each
// terminal outcome. Makes the signup form's own conversion (submissions →
// accounts) measurable and separates the share lost to each rejection gate —
// notably disposable_email, a deliberate friction whose cost was invisible.

widgets.push(
logWidget({
region,
title: "Signup form outcomes",
logGroupNames: [hutchLogGroupName],
query: [
"fields @timestamp, outcome",
`| filter stream = "${STREAMS.analytics}" and event = "${ANALYTICS_EVENTS.signupAttempted}"`,
...exclude,
"| stats count(*) as attempts by outcome",
"| sort attempts desc",
].join(" "),
x: 0, y: 130, width: 12, height: 8,
view: "bar",
}),
logWidget({
region,
title: "Signup form outcomes per day",
logGroupNames: [hutchLogGroupName],
query: [
"fields @timestamp, outcome",
`| filter stream = "${STREAMS.analytics}" and event = "${ANALYTICS_EVENTS.signupAttempted}"`,
...exclude,
"| stats count(*) as attempts by bin(1d), outcome",
].join(" "),
x: 12, y: 130, width: 12, height: 8,
view: "timeSeries",
}),
);

return { widgets };
}

Expand Down
2 changes: 2 additions & 0 deletions projects/hutch/src/runtime/observability/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ export {
ANALYTICS_EVENTS,
SAVE_SURFACES,
SAVE_OUTCOMES,
SIGNUP_OUTCOMES,
type SaveSurface,
type SaveOutcome,
type SignupOutcome,
} from "@packages/web-analytics";

export const CONVERSION_EVENTS = {
Expand Down
2 changes: 2 additions & 0 deletions projects/hutch/src/runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,8 @@ export function createApp(dependencies: AppDependencies): Express {
now: deps.now,
botDefenseLogger: deps.botDefenseLogger,
conversionLogger: deps.conversionLogger,
analytics: deps.analytics,
salt: deps.salt,
foundingAllocation,
buildBannerState,
consumeRateLimit: deps.consumeRateLimit,
Expand Down
21 changes: 21 additions & 0 deletions projects/hutch/src/runtime/web/auth/auth.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ import { readClickAttribution } from "@packages/web-analytics";
import { consumePendingSaveId } from "../pending-save";
import type { ConversionEvent } from "../../conversions";
import { emitUserCreated } from "../../conversions";
import type { AnalyticsEvent } from "@packages/web-analytics";
import { buildSignupAttemptedEvent } from "@packages/web-analytics";
import { SIGNUP_OUTCOMES, type SignupOutcome } from "../../observability/events";
import { DISPOSABLE_EMAIL_MESSAGE } from "./disposable-email";

const TokenQuerySchema = z.object({ token: z.string().optional() }).passthrough();
const CheckoutSuccessQuerySchema = z.object({ session_id: z.string().min(1) }).passthrough();
Expand Down Expand Up @@ -112,6 +116,8 @@ interface AuthDependencies {
now: () => Date;
botDefenseLogger: HutchLogger.Typed<BotDefenseEvent>;
conversionLogger: HutchLogger.Typed<ConversionEvent>;
analytics: HutchLogger.Typed<AnalyticsEvent>;
salt: string;
foundingAllocation: FoundingAllocation;
buildBannerState: BuildBannerState;
consumeRateLimit: ConsumeRateLimit;
Expand Down Expand Up @@ -254,6 +260,11 @@ export function initAuthRoutes(deps: AuthDependencies): Router {
const pendingSaveHost = pendingSaveHostFrom(returnUrl);
const body = (req.body ?? {}) as Record<string, unknown>;

const logSignupAttempt = (outcome: SignupOutcome) =>
deps.analytics.info(
buildSignupAttemptedEvent({ now: deps.now, salt: deps.salt }, { req, outcome }),
);

const renderFailure = async (email: string | undefined, errors: ComponentError[]) => {
const userCount = await fetchUserCount();
sendComponent(
Expand Down Expand Up @@ -296,9 +307,15 @@ export function initAuthRoutes(deps: AuthDependencies): Router {
res.redirect(303, "/?signup=pending");
break;
case "field-errors":
logSignupAttempt(
result.errors.some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE)
? SIGNUP_OUTCOMES.disposableEmail
: SIGNUP_OUTCOMES.invalidInput,
);
await renderFailure(result.email, result.errors);
break;
case "duplicate-email":
logSignupAttempt(SIGNUP_OUTCOMES.duplicateEmail);
await renderFailure(result.email, [{ message: "An account with this email already exists" }]);
break;
}
Expand All @@ -315,13 +332,15 @@ export function initAuthRoutes(deps: AuthDependencies): Router {
if (!deps.foundingAllocation.isFoundingAllocationExhausted(userCount)) {
const created = await deps.createUserWithPasswordHash({ email, passwordHash, attribution });
if (!created.ok) {
logSignupAttempt(SIGNUP_OUTCOMES.duplicateEmail);
await renderFailure(email, [{ message: "An account with this email already exists" }]);
return;
}

const sessionId = await deps.createSession({ userId: created.userId, emailVerified: false });
res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions);
sendVerificationEmail(created.userId, email);
logSignupAttempt(SIGNUP_OUTCOMES.created);
emitUserCreated(
{ logger: deps.conversionLogger, now: deps.now },
{
Expand All @@ -340,6 +359,7 @@ export function initAuthRoutes(deps: AuthDependencies): Router {

const created = await deps.createUserWithPasswordHash({ email, passwordHash });
if (!created.ok) {
logSignupAttempt(SIGNUP_OUTCOMES.duplicateEmail);
await renderFailure(email, [{ message: "An account with this email already exists" }]);
return;
}
Expand Down Expand Up @@ -377,6 +397,7 @@ export function initAuthRoutes(deps: AuthDependencies): Router {
const sessionId = await deps.createSession({ userId: created.userId, emailVerified: false });
res.cookie(SESSION_COOKIE_NAME, sessionId, sessionCookieOptions);
sendVerificationEmail(created.userId, email);
logSignupAttempt(SIGNUP_OUTCOMES.created);
emitUserCreated(
{ logger: deps.conversionLogger, now: deps.now },
{
Expand Down
160 changes: 160 additions & 0 deletions projects/hutch/src/runtime/web/auth/auth.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,166 @@ describe("Auth routes", () => {

});

describe("POST /signup — signup_attempted analytics", () => {
function signupAttempts(harness: { analytics: { events: Array<{ event: string }> } }) {
return harness.analytics.events.filter((e) => e.event === "signup_attempted");
}

it("emits a single outcome=created event on a successful free signup", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));

await request(harness.server).post("/signup").type("form").send({
email: "attempt-free@gmail.com",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

const attempts = signupAttempts(harness);
assert.equal(attempts.length, 1, "exactly one signup_attempted");
expect(attempts[0]).toMatchObject({
stream: "analytics",
event: "signup_attempted",
method: "email",
outcome: "created",
is_authenticated: 0,
});
});

it("emits outcome=created on a trial signup once the founding allocation is exhausted", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
for (let i = 0; i < TEST_FOUNDING_MEMBER_LIMIT; i++) {
await harness.auth.createUser({ email: `seed${i}@test.com`, password: "password123" });
}

await request(harness.server).post("/signup").type("form").send({
email: "attempt-trial@gmail.com",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

const attempts = signupAttempts(harness);
assert.equal(attempts.length, 1, "exactly one signup_attempted");
expect(attempts[0]).toMatchObject({ outcome: "created" });
}, 30000);

it("emits outcome=disposable_email when a disposable domain is rejected — the deliberate friction whose signup cost was previously invisible", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));

await request(harness.server).post("/signup").type("form").send({
email: "user@slmail.me",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

expect(signupAttempts(harness)).toMatchObject([{ outcome: "disposable_email" }]);
});

it("emits outcome=invalid_input for a generic validation failure (password too short)", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));

await request(harness.server).post("/signup").type("form").send({
email: "shortpw@gmail.com",
password: "short",
confirmPassword: "short",
loadedAt: freshLoadedAt(),
});

expect(signupAttempts(harness)).toMatchObject([{ outcome: "invalid_input" }]);
});

it("emits outcome=duplicate_email when the address already has an account", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
await harness.auth.createUser({ email: "dupe@gmail.com", password: "password123" });

await request(harness.server).post("/signup").type("form").send({
email: "dupe@gmail.com",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

expect(signupAttempts(harness)).toMatchObject([{ outcome: "duplicate_email" }]);
});

it("emits outcome=duplicate_email on the free path when the duplicate is caught at insert time (createUserWithPasswordHash race), not by validation", async () => {
const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN);
let raceFindCount = 0;
const harness = useApp({
...fixture,
auth: {
...fixture.auth,
findUserByEmail: async (email) => {
if (email === "race-free@gmail.com") {
raceFindCount++;
if (raceFindCount === 1) return null;
}
return fixture.auth.findUserByEmail(email);
},
},
});
await fixture.auth.createUser({ email: "race-free@gmail.com", password: "existing" });

const response = await request(harness.server).post("/signup").type("form").send({
email: "race-free@gmail.com",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

expect(response.status).toBe(422);
expect(signupAttempts(harness)).toMatchObject([{ outcome: "duplicate_email" }]);
});

it("emits outcome=duplicate_email on the trial path insert-time race once the founding allocation is exhausted", async () => {
const fixture = createDefaultTestAppFixture(TEST_APP_ORIGIN);
let raceFindCount = 0;
const harness = useApp({
...fixture,
auth: {
...fixture.auth,
findUserByEmail: async (email) => {
if (email === "race-trial@gmail.com") {
raceFindCount++;
if (raceFindCount === 1) return null;
}
return fixture.auth.findUserByEmail(email);
},
},
});
for (let i = 0; i < TEST_FOUNDING_MEMBER_LIMIT; i++) {
await fixture.auth.createUser({ email: `seed${i}@test.com`, password: "password123" });
}
await fixture.auth.createUser({ email: "race-trial@gmail.com", password: "existing" });

const response = await request(harness.server).post("/signup").type("form").send({
email: "race-trial@gmail.com",
password: "password123",
confirmPassword: "password123",
loadedAt: freshLoadedAt(),
});

expect(response.status).toBe(422);
expect(signupAttempts(harness)).toMatchObject([{ outcome: "duplicate_email" }]);
}, 30000);

it("does not emit signup_attempted for a bot-rejected submission — those are counted on the bot-defense stream", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));

await request(harness.server).post("/signup").type("form").send({
email: "bot@gmail.com",
password: "password123",
confirmPassword: "password123",
website: "http://spam.example",
loadedAt: freshLoadedAt(),
});

assert.equal(signupAttempts(harness).length, 0, "bot trips are not signup_attempted events");
});
});

describe("POST /signup — bot defense", () => {
it("returns a fake-success 303 to /?signup=pending and logs a 'honeypot' rejection when the hidden website field is filled", async () => {
const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN));
Expand Down
41 changes: 39 additions & 2 deletions src/packages/web-analytics/src/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { EventEmitter } from "node:events";
import type { NextFunction, Request, Response } from "express";
import type { HutchLogger } from "@packages/hutch-logger";
import { type AnalyticsClick, type AnalyticsEvent, type AnalyticsPageview, buildSaveIntentEvent, classifyBrowser, classifyDeviceClass, createAnalyticsMiddleware, hashIp, suppressClickCount, type ViewSaveIntentEvent } from "./analytics";
import { SAVE_OUTCOMES, SAVE_SURFACES } from "./events";
import { type AnalyticsClick, type AnalyticsEvent, type AnalyticsPageview, buildSaveIntentEvent, buildSignupAttemptedEvent, classifyBrowser, classifyDeviceClass, createAnalyticsMiddleware, hashIp, type SignupAttemptedEvent, suppressClickCount, type ViewSaveIntentEvent } from "./analytics";
import { SAVE_OUTCOMES, SAVE_SURFACES, SIGNUP_OUTCOMES } from "./events";

function createCapturingLogger(): {
logger: HutchLogger.Typed<AnalyticsEvent>;
Expand Down Expand Up @@ -555,3 +555,40 @@ describe("buildSaveIntentEvent", () => {
);
});
});

function buildSignup(overrides: { req?: MockReqOverrides; outcome?: SignupAttemptedEvent["outcome"] } = {}): SignupAttemptedEvent {
return buildSignupAttemptedEvent(
{ now: () => new Date("2026-04-21T10:00:00.000Z"), salt: "test-salt" },
{
req: createReq(overrides.req ?? { visitorId: VALID_VISITOR_ID }) as Request,
outcome: overrides.outcome ?? SIGNUP_OUTCOMES.created,
},
);
}

describe("buildSignupAttemptedEvent", () => {
it("builds a signup_attempted event carrying the terminal outcome, the visitor identity (hash + id) that joins to user_created, and is_authenticated=0 because the signup form is only shown to anonymous visitors", () => {
const event = buildSignup({ outcome: SIGNUP_OUTCOMES.disposableEmail });
expect(event).toEqual({
stream: "analytics",
event: "signup_attempted",
timestamp: "2026-04-21T10:00:00.000Z",
method: "email",
outcome: "disposable_email",
visitor_hash: expect.any(String),
visitor_id: VALID_VISITOR_ID,
is_authenticated: 0,
});
});

it("stamps visitor_hash as the salted hash of the request ip so the dashboard owner-exclusion filter can drop the maintainer's own attempts", () => {
const event = buildSignup({ req: { visitorId: VALID_VISITOR_ID, ip: "9.9.9.9" } });
expect(event.visitor_hash).toBe(hashIp({ ip: "9.9.9.9", salt: "test-salt" }));
});

it("throws when the visitor-id middleware has not run (req.visitorId unset) — POST /signup must never emit signup_attempted without a visitor identity to join user_created on", () => {
expect(() => buildSignup({ req: {} })).toThrow(
"visitor-id middleware must run before POST /signup emits signup_attempted",
);
});
});
Loading
Loading