From 51f09dd0a9ba7ae5e2e51e754cd782262198c3b8 Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Sat, 4 Jul 2026 22:10:36 +1000 Subject: [PATCH 1/2] feat(hutch): emit signup_attempted to make the signup form measurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /signup emits nothing on any outcome except success, so a visitor who submitted and was rejected — disposable email, duplicate account, failed validation — is indistinguishable from one who bounced without submitting. Disposable-email blocking in particular is a deliberate friction whose signup cost has never been measured. Production (last 30 days): 214 distinct anonymous visitors reached /signup and 35 accounts were created; the ~180 non-converters are currently unattributable. Emit a signup_attempted analytics event at each terminal branch of the handler with an outcome enum: created | disposable_email | invalid_input | duplicate_email. Carries visitor_id (joins to user_created and the reader funnel) and visitor_hash (dashboard owner-exclusion). Bot-defense trips are intentionally excluded (they have their own stream); the per-IP 429 short- circuits in middleware before the handler, so it is out of scope. Adds two dashboard widgets — "Signup form outcomes" and "…per day" — so signup-form conversion and the disposable-email share are visible without an ad-hoc query. Threads the existing analytics logger + salt (already built at the composition root and used by the save/queue routes) into the auth routes. Claude-Session: https://claude.ai/code/session_01En2uUiiwQoRQKWenCnkPi9 --- .../observability/analytics-dashboard.test.ts | 4 +- .../observability/analytics-dashboard.ts | 36 +++++++ .../hutch/src/runtime/observability/events.ts | 2 + projects/hutch/src/runtime/server.ts | 2 + .../hutch/src/runtime/web/auth/auth.page.ts | 19 ++++ .../src/runtime/web/auth/auth.route.test.ts | 99 +++++++++++++++++++ src/packages/web-analytics/src/analytics.ts | 45 ++++++++- src/packages/web-analytics/src/events.ts | 20 ++++ src/packages/web-analytics/src/index.ts | 4 + 9 files changed, 228 insertions(+), 3 deletions(-) diff --git a/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts b/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts index 8f84841f6..630f5ff75 100644 --- a/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts +++ b/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts @@ -70,9 +70,9 @@ function collectReferencedEvents(): Set { } 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)", () => { diff --git a/projects/hutch/src/runtime/observability/analytics-dashboard.ts b/projects/hutch/src/runtime/observability/analytics-dashboard.ts index 0fc2b1bfc..a263aaab2 100644 --- a/projects/hutch/src/runtime/observability/analytics-dashboard.ts +++ b/projects/hutch/src/runtime/observability/analytics-dashboard.ts @@ -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 }; } diff --git a/projects/hutch/src/runtime/observability/events.ts b/projects/hutch/src/runtime/observability/events.ts index bc0341073..5f3054d0a 100644 --- a/projects/hutch/src/runtime/observability/events.ts +++ b/projects/hutch/src/runtime/observability/events.ts @@ -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 = { diff --git a/projects/hutch/src/runtime/server.ts b/projects/hutch/src/runtime/server.ts index 2c256348f..5be9e6cd2 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -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, diff --git a/projects/hutch/src/runtime/web/auth/auth.page.ts b/projects/hutch/src/runtime/web/auth/auth.page.ts index 02b068244..009489f13 100644 --- a/projects/hutch/src/runtime/web/auth/auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/auth.page.ts @@ -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(); @@ -112,6 +116,8 @@ interface AuthDependencies { now: () => Date; botDefenseLogger: HutchLogger.Typed; conversionLogger: HutchLogger.Typed; + analytics: HutchLogger.Typed; + salt: string; foundingAllocation: FoundingAllocation; buildBannerState: BuildBannerState; consumeRateLimit: ConsumeRateLimit; @@ -254,6 +260,11 @@ export function initAuthRoutes(deps: AuthDependencies): Router { const pendingSaveHost = pendingSaveHostFrom(returnUrl); const body = (req.body ?? {}) as Record; + 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( @@ -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; } @@ -322,6 +339,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 }, { @@ -377,6 +395,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 }, { diff --git a/projects/hutch/src/runtime/web/auth/auth.route.test.ts b/projects/hutch/src/runtime/web/auth/auth.route.test.ts index eb9923b53..995680967 100644 --- a/projects/hutch/src/runtime/web/auth/auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/auth.route.test.ts @@ -810,6 +810,105 @@ 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("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)); diff --git a/src/packages/web-analytics/src/analytics.ts b/src/packages/web-analytics/src/analytics.ts index 0feb848e5..15cf138e3 100644 --- a/src/packages/web-analytics/src/analytics.ts +++ b/src/packages/web-analytics/src/analytics.ts @@ -9,6 +9,7 @@ import { INTERNAL_CLICK_MEDIUM, type SaveOutcome, type SaveSurface, + type SignupOutcome, STREAMS, } from "./events"; import { @@ -255,6 +256,25 @@ export interface ViewSaveIntentEvent { is_authenticated: 0 | 1; } +/** + * Emitted at each terminal branch of the POST /signup handler so the signup + * form's own conversion — submissions vs accounts created — is countable, and + * so the share lost to each rejection gate (disposable email, duplicate, + * generic validation) is separable. Carries `visitor_id` to join to + * `user_created` and to the anonymous reader funnel. Always `is_authenticated: + * 0` (a signed-in visitor is redirected off /signup before the handler runs). + */ +export interface SignupAttemptedEvent { + stream: typeof STREAMS.analytics; + event: typeof ANALYTICS_EVENTS.signupAttempted; + timestamp: string; + method: "email"; + outcome: SignupOutcome; + visitor_hash: string | null; + visitor_id: string | null; + is_authenticated: 0; +} + export type AnalyticsEvent = | AnalyticsPageview | AnalyticsClick @@ -264,7 +284,8 @@ export type AnalyticsEvent = | ArticleReadEvent | SummaryToggledEvent | ViewOpenedEvent - | ViewSaveIntentEvent; + | ViewSaveIntentEvent + | SignupAttemptedEvent; function shouldLog(params: { req: Request; path: string; statusCode: number }): boolean { if (params.req.method !== "GET") return false; @@ -399,6 +420,28 @@ export function buildSaveIntentEvent( }; } +/** + * Builds a `signup_attempted` event for a terminal outcome of the POST /signup + * handler. Centralizes the `visitor_hash`/`visitor_id` derivation so signup + * attempts carry the same join and dashboard-exclusion identifiers as the rest + * of the analytics stream. + */ +export function buildSignupAttemptedEvent( + deps: { now: () => Date; salt: string }, + params: { req: Request; outcome: SignupOutcome }, +): SignupAttemptedEvent { + return { + stream: STREAMS.analytics, + event: ANALYTICS_EVENTS.signupAttempted, + timestamp: deps.now().toISOString(), + method: "email", + outcome: params.outcome, + visitor_hash: hashIp({ ip: params.req.ip, salt: deps.salt }), + visitor_id: params.req.visitorId ?? null, + is_authenticated: 0, + }; +} + export function createAnalyticsMiddleware(deps: { logger: HutchLogger.Typed; salt: string; diff --git a/src/packages/web-analytics/src/events.ts b/src/packages/web-analytics/src/events.ts index 703663f0d..826bcba1b 100644 --- a/src/packages/web-analytics/src/events.ts +++ b/src/packages/web-analytics/src/events.ts @@ -25,8 +25,28 @@ export const ANALYTICS_EVENTS = { summaryToggled: "summary_toggled", viewOpened: "view_opened", viewSaveIntent: "view_save_intent", + signupAttempted: "signup_attempted", } as const; +/** + * Terminal outcomes of a POST /signup submission, emitted so the signup form's + * own conversion (submissions → accounts) is measurable and the cost of each + * rejection gate is separable. `disposable_email` is split out from generic + * `invalid_input` because rejecting disposable-email domains is a deliberate + * product friction whose signup cost was previously invisible. Bot-defense + * trips are NOT counted here — they have their own bot-defense stream. The + * per-IP rate-limit (429) short-circuits in middleware before the handler, so + * it is likewise out of scope for this event. + */ +export const SIGNUP_OUTCOMES = { + created: "created", + disposableEmail: "disposable_email", + invalidInput: "invalid_input", + duplicateEmail: "duplicate_email", +} as const; + +export type SignupOutcome = (typeof SIGNUP_OUTCOMES)[keyof typeof SIGNUP_OUTCOMES]; + /** * Dimensions of the `view_save_intent` event. Each is the single source of * truth shared by emitters (the save surfaces) and the dashboard widgets that diff --git a/src/packages/web-analytics/src/index.ts b/src/packages/web-analytics/src/index.ts index f90795ceb..a6f936bc1 100644 --- a/src/packages/web-analytics/src/index.ts +++ b/src/packages/web-analytics/src/index.ts @@ -3,10 +3,12 @@ export { ANALYTICS_EVENTS, SAVE_SURFACES, SAVE_OUTCOMES, + SIGNUP_OUTCOMES, CONTENT_CLASSES, INTERNAL_CLICK_MEDIUM, type SaveSurface, type SaveOutcome, + type SignupOutcome, } from "./events"; export { OWN_CONTENT_DOMAINS, @@ -35,7 +37,9 @@ export { classifyDeviceClass, classifyBrowser, buildSaveIntentEvent, + buildSignupAttemptedEvent, type AnalyticsEvent, + type SignupAttemptedEvent, type AnalyticsPageview, type AnalyticsClick, type ImportUploadedEvent, From 019f3bc0daca5a01506ef3c93153625d91cf2a7c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:44:51 +0000 Subject: [PATCH 2/2] fix: address high/medium priority review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit signup_attempted(duplicate_email) on the two insert-time race branches of POST /signup (founding + trial paths), where a duplicate is caught by createUserWithPasswordHash after validation passes. These terminal outcomes previously emitted nothing — undercounting duplicate_email and re-hiding a submitted-and-rejected visitor as a bounce, the exact blind spot this event exists to close. Add free-path and trial-path race tests asserting the outcome; the trial-path test also covers a previously-untested branch. Replace the unreachable `visitor_id: req.visitorId ?? null` in buildSignupAttemptedEvent with an assert on the visitor-id invariant (the global middleware always sets it before the auth router) plus a non-null `visitor_id`, tightening SignupAttemptedEvent.visitor_id to `string`. Matches the sibling buildSaveIntentEvent and removes the untestable V8 branch. Co-authored-by: Fayner Brack Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hutch/src/runtime/web/auth/auth.page.ts | 2 + .../src/runtime/web/auth/auth.route.test.ts | 61 +++++++++++++++++++ .../web-analytics/src/analytics.test.ts | 41 ++++++++++++- src/packages/web-analytics/src/analytics.ts | 5 +- 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/projects/hutch/src/runtime/web/auth/auth.page.ts b/projects/hutch/src/runtime/web/auth/auth.page.ts index 009489f13..a0c958096 100644 --- a/projects/hutch/src/runtime/web/auth/auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/auth.page.ts @@ -332,6 +332,7 @@ 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; } @@ -358,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; } diff --git a/projects/hutch/src/runtime/web/auth/auth.route.test.ts b/projects/hutch/src/runtime/web/auth/auth.route.test.ts index 995680967..a62e853d9 100644 --- a/projects/hutch/src/runtime/web/auth/auth.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/auth.route.test.ts @@ -894,6 +894,67 @@ describe("Auth routes", () => { 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)); diff --git a/src/packages/web-analytics/src/analytics.test.ts b/src/packages/web-analytics/src/analytics.test.ts index cabe24d87..2323ddef9 100644 --- a/src/packages/web-analytics/src/analytics.test.ts +++ b/src/packages/web-analytics/src/analytics.test.ts @@ -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; @@ -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", + ); + }); +}); diff --git a/src/packages/web-analytics/src/analytics.ts b/src/packages/web-analytics/src/analytics.ts index 15cf138e3..291638fab 100644 --- a/src/packages/web-analytics/src/analytics.ts +++ b/src/packages/web-analytics/src/analytics.ts @@ -271,7 +271,7 @@ export interface SignupAttemptedEvent { method: "email"; outcome: SignupOutcome; visitor_hash: string | null; - visitor_id: string | null; + visitor_id: string; is_authenticated: 0; } @@ -430,6 +430,7 @@ export function buildSignupAttemptedEvent( deps: { now: () => Date; salt: string }, params: { req: Request; outcome: SignupOutcome }, ): SignupAttemptedEvent { + assert(params.req.visitorId, "visitor-id middleware must run before POST /signup emits signup_attempted"); return { stream: STREAMS.analytics, event: ANALYTICS_EVENTS.signupAttempted, @@ -437,7 +438,7 @@ export function buildSignupAttemptedEvent( method: "email", outcome: params.outcome, visitor_hash: hashIp({ ip: params.req.ip, salt: deps.salt }), - visitor_id: params.req.visitorId ?? null, + visitor_id: params.req.visitorId, is_authenticated: 0, }; }