Skip to content

feat(hutch): emit signup_attempted to make the signup form measurable#918

Open
FagnerMartinsBrack wants to merge 2 commits into
mainfrom
conversion/signup-attempted-instrumentation
Open

feat(hutch): emit signup_attempted to make the signup form measurable#918
FagnerMartinsBrack wants to merge 2 commits into
mainfrom
conversion/signup-attempted-instrumentation

Conversation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member

Hypothesis

The signup form is a black box. POST /signup emits nothing on any outcome except success (user_created), so a visitor who submitted and was rejected — disposable email, duplicate account, failed validation — is indistinguishable in analytics from one who bounced without ever submitting. In particular, disposable-email blocking is a deliberate product friction whose signup cost has never been measured. This PR emits a signup_attempted analytics event at every terminal branch of the handler so the form's own conversion — and the share lost to each gate — becomes countable.

Evidence (production, last 30 days)

  • 214 distinct anonymous visitors hit /signup; 35 accounts were created. The ~180 who didn't convert are currently unattributable: bounce vs. rejected-submission is invisible.
  • The repo rejects disposable-email domains at signup (commit e87d530f) — a real gate with an unmeasured cost. If a non-trivial share of those ~180 are being turned away at submit time, that's recoverable signup volume hiding in plain sight.
  • Instrumentation audit flagged this as the Add reading list view with URL filtering to popup #2 dark funnel stage (after the blog): "a visitor who submitted and was rejected is indistinguishable from one who bounced."

What this changes

A new signup_attempted event (analytics stream) emitted at the four terminal outcomes of POST /signup:

outcome When
created account created (free/founding or trial path)
disposable_email rejected because the email domain is on the disposable blocklist
invalid_input other schema failure (weak password, mismatch, invalid email)
duplicate_email address already has an account
  • Carries visitor_id (joins to user_created and the reader funnel) and visitor_hash (dashboard owner-exclusion).
  • Bot-defense trips are intentionally not counted here — they already have their own bot-defense stream. The per-IP rate-limit 429 short-circuits in middleware before the handler, so it's out of scope (noted in the event's doc comment).
  • Two dashboard widgets added: "Signup form outcomes" (bar) and "Signup form outcomes per day" (time series).

Files: observability/events.ts (event + SIGNUP_OUTCOMES), web/middleware/analytics.ts (event type + buildSignupAttemptedEvent), web/auth/auth.page.ts (emit at 4 branches), server.ts (thread the existing analytics logger + salt into the auth routes), observability/analytics-dashboard.ts (2 widgets).

Estimated result

No direct conversion change — this is a measurement enabler for a suspected friction. Its payoff is a decision: once you can see disposable_email as a share of signup_attempted, you can quantify whether relaxing or refining the blocklist would recover signups, and compute the true signup-page → account conversion rate (impossible today). This is a prerequisite for optimizing the top of the funnel with data instead of guesses.

How to measure

  • Signup-form conversion = count(outcome='created') / count(*) over signup_attempted.
  • Cost of the disposable-email gate = count(outcome='disposable_email') / count(*).
  • Both render on the new dashboard widgets; the raw query:
# Logs Insights (hutch-handler)
fields @timestamp, outcome
| filter stream="analytics" and event="signup_attempted"
| stats count(*) as attempts by outcome
| sort attempts desc

Verification

pnpm check passes (all 36 projects; lint, types, unit + integration + Playwright E2E, 100% coverage gate). New tests assert one event with the correct outcome for each of the five paths (created ×2, disposable, invalid, duplicate) and that bot trips emit none.

https://claude.ai/code/session_01En2uUiiwQoRQKWenCnkPi9

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 9m 10s —— View job


Review: signup_attempted instrumentation

Well-scoped, well-tested measurement PR. The event/const/type follow the existing ANALYTICS_EVENTS + buildSaveIntentEvent patterns, the disposable-vs-invalid split keys off the shared DISPOSABLE_EMAIL_MESSAGE constant (connascence of name — robust), the dashboard widgets sit cleanly at y:114 (no overlap; last prior widget ends at y:106+8=114), the drift-count test is updated 24 → 26, and the bot-negative test is a nice touch. Two completeness/coverage issues below.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

1. Two terminal "duplicate" branches don't emit signup_attempted — undercounts duplicate_email

The PR states the event fires "at every terminal branch of the handler," but two terminal branches emit nothing. When validation passes the duplicate check but createUserWithPasswordHash returns !created.ok (a duplicate caught at insert time — the race guard), both the founding path and the trial path render "An account with this email already exists" without calling logSignupAttempt:

// auth.page.ts:312-316  (founding path)
const created = await deps.createUserWithPasswordHash({ email, passwordHash, attribution });
if (!created.ok) {
    await renderFailure(email, [{ message: "An account with this email already exists" }]);
    return;   // ← no logSignupAttempt(SIGNUP_OUTCOMES.duplicateEmail)
}
// auth.page.ts:338-342  (trial path) — same gap

These are real, reachable outcomes — auth.route.test.ts:550 already exercises the free-path race and asserts the "already exists" render. Consequences: duplicate_email is undercounted, and a submitted‑and‑rejected visitor on this path is once again indistinguishable from a bounce — the exact blind spot this PR exists to close. Emit logSignupAttempt(SIGNUP_OUTCOMES.duplicateEmail) before each of the two renderFailure calls (and extend a test to assert it on the race path). Fix this →

2. visitor_id: params.req.visitorId ?? null — the null branch is unreachable and untested

In buildSignupAttemptedEvent (analytics.ts:384):

visitor_id: params.req.visitorId ?? null,

The visitor-id middleware is mounted globally at server.ts:405 before the auth router at server.ts:839, and it always sets req.visitorId (reads the cookie or mints a fresh UUID — visitor-id.middleware.ts:32-43). So on POST /signup, req.visitorId is guaranteed truthy and the ?? null fallback can never be taken. All six new tests go through the real app (so they only ever hit the truthy branch), and there is no unit test of this builder with a visitorId-less request — unlike the pageview/click ?? null branches, which are explicitly covered by analytics.test.ts:88 (visitor_id: null) and :96. This looks like an uncovered branch (worth confirming against the CI coverage run) and diverges from the sibling buildSaveIntentEvent 12 lines above, which handles the identical invariant with assert(params.req.visitorId, …) and a non-null visitor_id.

Per CLAUDE.md "Coverage Over Legibility" (which calls out exactly this — "?? creates a V8 branch for a null case that never happens") and "Use assert for Runtime Invariants," prefer:

assert(params.req.visitorId, "visitor-id middleware must run before POST /signup emits signup_attempted");
// …
visitor_id: params.req.visitorId,   // and type SignupAttemptedEvent.visitor_id: string

Alternatively, if the nullable shape is intentional (to mirror pageview/click), add a builder-level unit test that passes a request without visitorId to cover the null branch. Fix this →

Low Priority Suggestions

1. Google-OAuth account creation is not counted as a signup

signup_attempted covers only the email POST /signup handler; Google OAuth signups go through a separate router (googleAuthRouter, server.ts:863). That's a defensible boundary — the widget measures the form's conversion, and OAuth is a button, not the form — but the doc comments on SIGNUP_OUTCOMES / SignupAttemptedEvent and the "Signup form outcomes" widget title read as if they cover all signups. A one-line note ("email form only; OAuth signups are separate") would stop the dashboard being misread as total account-creation conversion.

2. Disposable + other error co-occurs as disposable_email (intentional precedence — worth a note)

result.errors.some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) means a submission that is both disposable and otherwise invalid (e.g. weak password) is counted as disposable_email, not invalid_input. That's the right precedence for measuring the disposable gate's cost, but since it's a silent priority decision it's worth a short comment so a future reader doesn't assume the buckets are mutually exclusive by input.

This PR has issues that should be addressed before merging.


  • Read review instructions & gather context
  • Review events.ts, analytics.ts, auth.page.ts, server.ts, analytics-dashboard.ts
  • Review tests + trace terminal branches of POST /signup
  • Check CLAUDE.md guideline adherence (assert vs ??, coverage, naming)
  • Post review
    · branch conversion/signup-attempted-instrumentation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

Review: signup_attempted instrumentation

Well-scoped, well-tested measurement PR. The event/const/type follow the existing ANALYTICS_EVENTS + buildSaveIntentEvent patterns, the disposable-vs-invalid split keys off the shared DISPOSABLE_EMAIL_MESSAGE constant (connascence of name — robust), the dashboard widgets sit cleanly at y:114 (no overlap; last prior widget ends at y:106+8=114), the drift-count test is updated 24 → 26, and the bot-negative test is a nice touch. Two completeness/coverage issues below.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

1. Two terminal "duplicate" branches don't emit signup_attempted — undercounts duplicate_email

The PR states the event fires "at every terminal branch of the handler," but two terminal branches emit nothing. When validation passes the duplicate check but createUserWithPasswordHash returns !created.ok (a duplicate caught at insert time — the race guard), both the founding path and the trial path render "An account with this email already exists" without calling logSignupAttempt:

// auth.page.ts:312-316  (founding path)
const created = await deps.createUserWithPasswordHash({ email, passwordHash, attribution });
if (!created.ok) {
    await renderFailure(email, [{ message: "An account with this email already exists" }]);
    return;   // ← no logSignupAttempt(SIGNUP_OUTCOMES.duplicateEmail)
}
// auth.page.ts:338-342  (trial path) — same gap

These are real, reachable outcomes — auth.route.test.ts:550 already exercises the free-path race and asserts the "already exists" render. Consequences: duplicate_email is undercounted, and a submitted‑and‑rejected visitor on this path is once again indistinguishable from a bounce — the exact blind spot this PR exists to close. Emit logSignupAttempt(SIGNUP_OUTCOMES.duplicateEmail) before each of the two renderFailure calls (and extend a test to assert it on the race path).

2. visitor_id: params.req.visitorId ?? null — the null branch is unreachable and untested

In buildSignupAttemptedEvent (analytics.ts:384), visitor_id: params.req.visitorId ?? null. The visitor-id middleware is mounted globally at server.ts:405 before the auth router at server.ts:839, and it always sets req.visitorId (reads the cookie or mints a fresh UUID). So on POST /signup, req.visitorId is guaranteed truthy and the ?? null fallback can never be taken. All six new tests go through the real app (truthy branch only), and there is no unit test of this builder with a visitorId-less request — unlike the pageview/click ?? null branches, which are explicitly covered by analytics.test.ts:88 (visitor_id: null) and :96. This looks like an uncovered branch (worth confirming against CI coverage) and diverges from the sibling buildSaveIntentEvent 12 lines above, which handles the identical invariant with assert(params.req.visitorId, …) and a non-null visitor_id. Per CLAUDE.md "Coverage Over Legibility" and "Use assert for Runtime Invariants," prefer assert + visitor_id: params.req.visitorId (type string), or add a builder-level unit test covering the null branch.

Low Priority Suggestions

1. Google-OAuth account creation is not counted as a signup

signup_attempted covers only the email POST /signup handler; Google OAuth signups go through a separate router (googleAuthRouter, server.ts:863). Defensible boundary, but the doc comments and the "Signup form outcomes" widget title read as if they cover all signups — a one-line "email form only" note would prevent misreading the dashboard as total account-creation conversion.

2. Disposable + other error co-occurs as disposable_email (intentional precedence — worth a note)

result.errors.some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) means a submission that is both disposable and otherwise invalid (e.g. weak password) is counted as disposable_email, not invalid_input. Right precedence for measuring the gate's cost, but a short comment would stop a future reader assuming the buckets are mutually exclusive by input.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Fix the review issues. Use your Read tool to read the local file .github/workflows/claude-PR-code-review-auto-apply.md for instructions. The file is in the checked-out repository.

Context

Review to Address

See the review comment above this one for the issues to fix.

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 16m 21s —— View job


Review fixes applied ✅

Both Medium Priority issues from the review are fixed, verified, committed, and pushed as d450da0d. Low-priority suggestions were intentionally left alone (the auto-apply flow addresses High/Medium only).


What changed & why

Medium #1 — undercounted duplicate_email on the insert-time race (auth.page.ts)

The POST /signup handler has two terminal branches where a duplicate is caught at insert time — createUserWithPasswordHash returns !created.ok after validation's duplicate check already passed (the race guard), on both the founding path (auth.page.ts:313) and the trial path (auth.page.ts:339). Both rendered "An account with this email already exists" but emitted nothing, so duplicate_email was undercounted and a submitted‑and‑rejected visitor on this path was again indistinguishable from a bounce — the exact blind spot this event exists to close.

  • Added logSignupAttempt(SIGNUP_OUTCOMES.duplicateEmail) before each renderFailure.
  • Added two tests (auth.route.test.ts) using the existing findUserByEmail race-override pattern (miss the duplicate on the first lookup, pre-create the user so the insert fails), asserting 422 + outcome=duplicate_email — one free-path, one trial-path. The trial-path test also covers a branch that had no prior test, which is why branch coverage rose to 97.18%.

Medium #2 — unreachable, untested ?? null branch (middleware/analytics.ts)

buildSignupAttemptedEvent used visitor_id: params.req.visitorId ?? null, but the global visitor-id middleware (server.ts:405) always sets req.visitorId before the auth router, so the ?? null fallback is unreachable — a V8 branch for a null case that never happens.

  • Replaced it with assert(params.req.visitorId, …) + visitor_id: params.req.visitorId, and tightened SignupAttemptedEvent.visitor_id to string.
  • This mirrors the sibling buildSaveIntentEvent 12 lines above and follows CLAUDE.md's "Coverage Over Legibility" and "Use assert for Runtime Invariants" (the assert's branch lives inside assert, not the caller's block coverage). Confirmed no unit test passed a visitorId-less request, so nothing broke.

Verification: pnpm nx run hutch:check passed (2712 unit + 11 integration + 2 E2E; coverage 99.53% / 97.18% / 100% / 99.53%). The pre-commit hook additionally ran the full monorepo check across all 36 projects — green.
· branch conversion/signup-attempted-instrumentation · commit d450da0d

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 5m 35s —— View job


Re-review: signup_attempted instrumentation (commit d450da0d)

This re-review confirms the two Medium issues from the prior review are correctly fixed and covered by tests. I traced all terminal branches of POST /signup, checked the dashboard placement and dependency threading, and verified guideline adherence. No high or medium issues remain — only minor low-priority polish (two are carry-overs the auto-apply flow intentionally skipped).

Verification of the prior fixes

  • Medium Add AWS Lambda deployment infrastructure with Pulumi IaC #1 (undercounted duplicate_email) — both insert-race branches now emit before rendering: founding path auth.page.ts:314 and trial path auth.page.ts:341. Two new race-path tests assert 422 + outcome=duplicate_email (free + trial). The emit precedes renderFailure, so it fires even if the render throws. All 8 terminal outcomes are now instrumented; bot-rejected correctly emits nothing.
  • Medium Add reading list view with URL filtering to popup #2 (unreachable ?? null)analytics.ts:377 now asserts req.visitorId and returns a non-null visitor_id: string, matching the sibling buildSaveIntentEvent and eliminating the V8 null branch.
  • ✅ Dashboard widgets sit at y:114 with no overlap (the errors widget ends at y:106+8=114), drift test updated 24 → 26, and deps.analytics/deps.salt are threaded correctly (server.ts:304-305, 827-828).

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. is_authenticated: 0 is hardcoded, but its doc comment overstates the invariant

analytics.ts:209 documents the field as "Always is_authenticated: 0 (a signed-in visitor is redirected off /signup before the handler runs)." That redirect only exists on GET /signup (auth.page.ts:231res.redirect(303, "/queue")). POST /signup (auth.page.ts:247) carries only signupRateLimit and the router is mounted bare (app.use(authRouter), server.ts:839) — so an already-authenticated request to POST /signup runs the handler and would emit is_authenticated: 0 regardless of req.userId. This isn't a practical bug (the form is never rendered to signed-in users, so real submissions are anonymous) and the underlying "POST has no guest guard" is pre-existing/out of scope — but the comment's justification is inaccurate for the very handler that emits the event, and it diverges from the sibling buildSaveIntentEvent, which computes is_authenticated: params.req.userId ? 1 : 0. Either compute it the same way, or soften the comment to "the form is only shown to anonymous visitors." Fix this →

2. Disposable + other-error co-occurrence is silently bucketed as disposable_email (carry-over)

auth.page.ts:289-293: result.errors.some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) means a submission that is both disposable and otherwise invalid (e.g. weak password) counts as disposable_email, not invalid_input. That's the right precedence for measuring the gate's cost, but it's a silent priority decision — a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input. Still unaddressed (the auto-apply flow only fixes High/Medium). Fix this →

3. Widget/doc wording reads as total signup conversion, but only the email form is counted (carry-over)

signup_attempted covers only the email POST /signup handler; Google OAuth signups go through googleAuthRouter (server.ts:863) and are not counted. Defensible boundary, but the SIGNUP_OUTCOMES/SignupAttemptedEvent doc comments and the "Signup form outcomes" widget title read as if they cover all account creation. A one-line "email form only; OAuth is separate" note would prevent misreading the dashboard as total signup conversion. Still unaddressed. Fix this →

cc @FagnerMartinsBrack - No high or medium priority issues found. This PR is ready for human review.


  • Read review instructions & gather context
  • Review the diff at commit d450da0d (post-fix state)
  • Verify the two prior-review fixes landed correctly (duplicate-race emit, assert on visitor_id)
  • Trace all terminal branches of POST /signup for completeness
  • Check CLAUDE.md guideline adherence (assert vs ??, coverage, naming, comments)
  • Review dashboard widgets, events, tests
  • Post review
    · branch conversion/signup-attempted-instrumentation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. is_authenticated: 0 is hardcoded, but its doc comment overstates the invariant

analytics.ts:209 documents the field as "Always is_authenticated: 0 (a signed-in visitor is redirected off /signup before the handler runs)." That redirect only exists on GET /signup (auth.page.ts:231). POST /signup (auth.page.ts:247) carries only signupRateLimit and the router is mounted bare (app.use(authRouter), server.ts:839), so an already-authenticated request runs the handler and emits is_authenticated: 0 regardless of req.userId. Not a practical bug (the form is never rendered to signed-in users) and the missing guard is pre-existing/out of scope, but the comment is inaccurate for the emitting handler and diverges from the sibling buildSaveIntentEvent, which computes params.req.userId ? 1 : 0. Compute it the same way, or soften the comment.

2. Disposable + other-error co-occurrence is silently bucketed as disposable_email (carry-over)

auth.page.ts:289-293: result.errors.some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) counts a submission that is both disposable and otherwise invalid as disposable_email. Correct precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input. Still unaddressed (auto-apply only fixes High/Medium).

3. Widget/doc wording reads as total signup conversion, but only the email form is counted (carry-over)

signup_attempted covers only POST /signup; Google OAuth signups (googleAuthRouter, server.ts:863) are not counted. A one-line "email form only" note on the doc comments and the "Signup form outcomes" widget title would prevent misreading the dashboard as total account-creation conversion.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Resolve conflicts. Use your Read tool to read the local file .github/workflows/claude-PR-conflict-fixer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 11m 33s —— View job


Conflicts resolved ✅

Rebased conversion/signup-attempted-instrumentation onto origin/main and pushed (--force-with-lease). The branch now sits cleanly on top of main with both PR commits intact.

  • Read conflict-fixer instructions & PR commit history
  • Rebase onto origin/main and identify conflicts
  • Resolve conflicts (cross-referencing PR intent)
  • Run pnpm nx run hutch:check — green
  • Push with --force-with-lease

What conflicted & how it was resolved

Both main and this PR grew the analytics dashboard from the same 24-widget base, so they collided in two related ways:

1. Textual conflict — widget-count drift assertion (analytics-dashboard.test.ts)
main added 2 audience-device widgets (→26); this PR added 2 signup-form widgets (→26). Git saw two different "26" edits to the same drift-guard line. Combined, the dashboard now has 28 widgets, so I merged both breakdowns:

28 widgets (… 1 summary-engagement, 2 audience-device, 1 errors, 2 signup-form) and toHaveLength(28)

2. Latent semantic conflict — widget grid overlap (analytics-dashboard.ts)
This one didn't surface as a Git conflict but would have failed the "widget positions do not overlap" test. In the PR's base the errors widget sat at y:106, so the PR placed its signup-form widgets at y:114. But main inserted the audience-device widgets at y:106, pushing the errors widget down to y:114 — exactly where the PR's new widgets now sat (full-width errors row colliding with both signup widgets).

Preserving the PR's intent ("append these widgets after the last existing one"), I moved the signup-form widgets from y:114y:122, so they follow main's relocated errors row instead of overlapping it.

The follow-up review-fix commit applied on top with no conflicts — both fixes survived (assert(params.req.visitorId, …) in analytics.ts; duplicate_email emits on the two insert-race branches in auth.page.ts).

Verification: pnpm nx run hutch:check passed — unit + integration (11) + E2E (2), coverage thresholds met (statements 99.54%, branches 97.21%, functions 100%, lines 99.54%). The overlap and 28-widget drift tests both pass.
· branch conversion/signup-attempted-instrumentation

@FagnerMartinsBrack FagnerMartinsBrack force-pushed the conversion/signup-attempted-instrumentation branch from d450da0 to fb94830 Compare July 4, 2026 18:13
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 4m 51s —— View job


Re-review: signup_attempted instrumentation (commit fb948305)

I traced every terminal branch of POST /signup, re-verified the two prior-review fixes, checked the race-path tests exercise the intended branches, and confirmed the dashboard placement survived the rebase onto main. No high or medium issues remain. Three low-priority items are unaddressed carry-overs (the auto-apply flow only fixes High/Medium).

Verification of the prior fixes

  • Medium Add AWS Lambda deployment infrastructure with Pulumi IaC #1duplicate_email undercount on the insert-time race. Both !created.ok branches now emit before rendering: founding path auth.page.ts:314 and trial path auth.page.ts:341. Two new race tests (auth.route.test.ts:884, :913) return null on the first findUserByEmail (so validation's duplicate check passes) then let createUserWithPasswordHash fail on the pre-created user — correctly forcing the insert-time branch. TEST_FOUNDING_MEMBER_LIMIT=3 means the free test (1 user) hits the founding path and the trial test (4 users) hits the trial path, so the two tests cover distinct branches. Emit precedes renderFailure, so it fires even if the render throws.
  • Medium Add reading list view with URL filtering to popup #2 — unreachable ?? null. analytics.ts:383 now asserts req.visitorId and returns non-null visitor_id: string, matching the sibling buildSaveIntentEvent and eliminating the V8 null branch. assert is the non-strict node:assert import — correct for production code.

Completeness — all 7 terminal outcomes of POST /signup are accounted for

Branch (auth.page.ts) Emits
bot-rejected (:278) nothing — intentional, own bot-defense stream ✓
field-errors (:288) disposable_email / invalid_input
duplicate-email validation (:296) duplicate_email
founding !created.ok (:313) duplicate_email(fixed)
founding success (:322) created
trial !created.ok (:340) duplicate_email(fixed)
trial success (:368) created
  • ✅ Dashboard: the rebase correctly moved the two signup widgets to y:122 (after main's audience-device row at y:106 pushed the errors widget to y:114); no overlap, drift test updated to 28, deps.analytics/deps.salt threaded at server.ts:827-828. SignupAttemptedEvent is in the AnalyticsEvent union so deps.analytics.info(...) typechecks.

Low Priority Suggestions (all carry-overs, still unaddressed)

1. is_authenticated: 0 doc comment overstates the invariant. events.ts:216-217 says a signed-in visitor "is redirected off /signup before the handler runs," but only GET /signup redirects (auth.page.ts:231) — POST /signup has no guest guard. Not a practical bug (the form is only rendered to anonymous visitors, so real submissions are anonymous), and the missing POST guard is pre-existing/out of scope, but the justification is inaccurate for the emitting handler and diverges from buildSaveIntentEvent (params.req.userId ? 1 : 0). Soften the comment, or compute it like the sibling.

2. Disposable + other-error co-occurrence is silently bucketed as disposable_email. auth.page.ts:289-293: the .some() gives the disposable gate precedence over a co-occurring weak-password/mismatch error. Right precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input.

3. Widget/doc wording reads as total signup conversion, but only the email form is counted. Google OAuth signups go through googleAuthRouter and emit no signup_attempted. Defensible boundary, but a one-line "email form only; OAuth is separate" note on SIGNUP_OUTCOMES / SignupAttemptedEvent / the "Signup form outcomes" widget title would prevent misreading the dashboard as total account-creation conversion.

cc @FagnerMartinsBrack — No high or medium priority issues found. This PR is ready for human review.


  • Read review instructions & gather context
  • Review the diff at commit fb948305 (latest state)
  • Trace all terminal branches of POST /signup for completeness
  • Check CLAUDE.md guideline adherence (assert vs ??, coverage, naming, comments)
  • Review dashboard widgets, events, tests
  • Post review
    · branch conversion/signup-attempted-instrumentation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. is_authenticated: 0 doc comment overstates the invariant

events.ts:216-217 documents the field as "Always is_authenticated: 0 (a signed-in visitor is redirected off /signup before the handler runs)." That redirect only exists on GET /signup (auth.page.ts:231). POST /signup (auth.page.ts:247) carries only signupRateLimit and the router is mounted bare, so an already-authenticated request runs the handler and emits is_authenticated: 0 regardless of req.userId. Not a practical bug (the form is only rendered to anonymous visitors) and the missing POST guard is pre-existing/out of scope, but the comment's justification is inaccurate for the emitting handler and diverges from the sibling buildSaveIntentEvent, which computes params.req.userId ? 1 : 0. Soften the comment to "the form is only shown to anonymous visitors," or compute it the same way.

2. Disposable + other-error co-occurrence is silently bucketed as disposable_email (carry-over)

auth.page.ts:289-293: result.errors.some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) counts a submission that is both disposable and otherwise invalid (e.g. weak password) as disposable_email, not invalid_input. Correct precedence for measuring the gate's cost, but it's a silent priority decision — a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input.

3. Widget/doc wording reads as total signup conversion, but only the email form is counted (carry-over)

signup_attempted covers only POST /signup; Google OAuth signups (googleAuthRouter, server.ts:863) emit nothing. Defensible boundary, but the SIGNUP_OUTCOMES / SignupAttemptedEvent doc comments and the "Signup form outcomes" widget title read as if they cover all account creation. A one-line "email form only; OAuth is separate" note would prevent misreading the dashboard as total signup conversion.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Resolve conflicts. Use your Read tool to read the local file .github/workflows/claude-PR-conflict-fixer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 11m 40s —— View job


Conflicts resolved ✅

Rebased conversion/signup-attempted-instrumentation onto origin/main (now at 8bde9e69) and pushed with --force-with-lease. The branch sits cleanly on top of main (2 ahead, 0 behind) with both PR commits intact.

  • Read conflict-fixer instructions & PR commit history
  • Rebase onto origin/main and identify conflicts
  • Resolve conflicts (cross-referencing PR intent)
  • Run pnpm nx run hutch:check — green
  • Push with --force-with-lease

What conflicted & how it was resolved

main moved forward with the homepage A/B split (#908), which — like this PR — appended widgets to the analytics dashboard from the same 26-widget base. Both collisions were in the dashboard:

1. Widget grid overlap (analytics-dashboard.ts)
main added a Homepage A/B widget at x:0, y:122 (right after the errors row, which ends at y:122). This PR had also placed its two signup-form widgets at y:122 — so they now collided on the same row. Preserving the PR's intent (the two signup widgets as a side-by-side pair on their own row), I kept main's A/B widget at y:122 and moved the signup-form widgets down to y:130 (x:0 bar + x:12 timeSeries). No overlap — the "widget positions do not overlap" test passes.

2. Widget-count drift assertion (analytics-dashboard.test.ts)
main bumped the count 26 → 27 (+1 homepage-ab); this PR bumped it 26 → 28 (+2 signup-form). Git saw two different edits to the same drift-guard line. Combined, the dashboard now has 29 widgets, so I merged both breakdowns (… 1 errors, 1 homepage-ab, 2 signup-form), set toHaveLength(29), and kept main's new Homepage A/B widget test.

server.ts auto-merged (no conflict): main's isbot/landing-route additions (top + / handler + route registration) and this PR's analytics/salt threading into the auth router (server.ts:835-836) touch different regions and both survived.

The follow-up review-fix commit (bfcad999) replayed on top with no further conflicts — both prior fixes are intact (assert(params.req.visitorId, …) in analytics.ts; duplicate_email emits on the two insert-race branches in auth.page.ts).

Verification: pnpm nx run hutch:check passed — 2787 unit + 11 integration + 2 E2E, coverage thresholds met (statements 99.54%, branches 97.24%, functions 100%, lines 99.54%). The overlap and 29-widget drift tests both pass.
· branch conversion/signup-attempted-instrumentation · commit bfcad999

@FagnerMartinsBrack FagnerMartinsBrack force-pushed the conversion/signup-attempted-instrumentation branch from fb94830 to bfcad99 Compare July 5, 2026 07:07
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 5m 10s —— View job


Re-review: signup_attempted instrumentation (commit bfcad999)

I re-traced every terminal branch of POST /signup, re-verified the two prior-review fixes, checked the race-path tests isolate the intended branches, and confirmed the dashboard placement survived the rebase onto main (now carrying the homepage A/B widget). No high or medium issues remain. The three low-priority items are unchanged carry-overs the auto-apply flow intentionally skips (High/Medium only).

Verification of the prior fixes

  • duplicate_email undercount on the insert-time race — both !created.ok branches emit before rendering: founding path auth.page.ts:314 and trial path auth.page.ts:341. The two race tests override findUserByEmail to return null on the first lookup (so validation's duplicate check passes) then let the pre-created user fail createUserWithPasswordHash — correctly forcing the insert-time branch and asserting 422 + outcome=duplicate_email. Emit precedes renderFailure, so it fires even if the render throws.
  • Unreachable ?? nullanalytics.ts:383 asserts req.visitorId and returns non-null visitor_id: string (analytics.ts:226), matching the sibling buildSaveIntentEvent and eliminating the V8 null branch. assert is the non-strict node:assert import — correct for production code.

Completeness — all 7 terminal outcomes of POST /signup accounted for

Branch (auth.page.ts) Emits
bot-rejected (:278) nothing — intentional, own bot-defense stream ✓
field-errors (:288) disposable_email / invalid_input
duplicate-email validation (:296) duplicate_email
founding !created.ok (:313) duplicate_email
founding success (:322) created
trial !created.ok (:340) duplicate_email
trial success (:368) created

Rebase integrity (the post-rebase risk)main's homepage A/B widget landed at x:0, y:122 (right after the full-width errors row, which ends at y:114+8=122). This PR's two signup widgets correctly moved to y:130 (x:0 bar + x:12 timeSeries), so the occupied rows are errors y:114–122 → homepage-ab y:122–130 → signup y:130–138 with no overlap. Drift test updated 27 → 29 with the merged breakdown, and I hand-counted the builder: exactly 29 widgets (…1 errors, 1 homepage-ab, 2 signup-form). deps.analytics/deps.salt are threaded from the same AppDependencies instances four other routers already use (server.ts:835-836); SignupAttemptedEvent is in the AnalyticsEvent union so deps.analytics.info(...) typechecks, and the dashboard's "every ANALYTICS_EVENTS value has a widget" test guarantees signup_attempted is wired.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions (all carry-overs, still unaddressed)

1. is_authenticated: 0 doc comment overstates the invariant

events.ts / analytics.ts:216-217 says a signed-in visitor "is redirected off /signup before the handler runs," but only GET /signup redirects (auth.page.ts:231) — POST /signup (:247) carries only signupRateLimit and the router is mounted bare, so an already-authenticated POST runs the handler and emits is_authenticated: 0 regardless of req.userId. Not a practical bug (the form is only rendered to anonymous visitors, and no widget slices signup_attempted by is_authenticated) and the missing POST guard is pre-existing/out of scope — but the justification is inaccurate for the emitting handler and diverges from the sibling buildSaveIntentEvent (params.req.userId ? 1 : 0). Soften the comment to "the form is only shown to anonymous visitors," or compute it like the sibling. Fix this →

2. Disposable + other-error co-occurrence is silently bucketed as disposable_email

auth.page.ts:289-293: the .some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) check gives the disposable gate precedence over a co-occurring weak-password/mismatch error. Right precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input. Fix this →

3. Widget/doc wording reads as total signup conversion, but only the email form is counted

signup_attempted covers only POST /signup; Google OAuth signups (googleAuthRouter, server.ts:849) emit nothing. Defensible boundary, but the SIGNUP_OUTCOMES / SignupAttemptedEvent doc comments and the "Signup form outcomes" widget title read as if they cover all account creation. A one-line "email form only; OAuth is separate" note would prevent misreading the dashboard as total signup conversion. Fix this →

cc @FagnerMartinsBrack - No high or medium priority issues found. This PR is ready for human review.


  • Read review instructions & gather context
  • Review the diff at commit bfcad999 (latest state, post-rebase)
  • Trace all terminal branches of POST /signup for completeness
  • Verify prior-review fixes are intact (duplicate-race emit, assert on visitor_id)
  • Check dashboard placement survived the rebase onto main
  • Check CLAUDE.md guideline adherence
  • Post review
    · branch conversion/signup-attempted-instrumentation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

Re-review: signup_attempted instrumentation (commit bfcad999)

I re-traced every terminal branch of POST /signup, re-verified the two prior-review fixes, checked the race-path tests isolate the intended branches, and confirmed the dashboard placement survived the rebase onto main (now carrying the homepage A/B widget). No high or medium issues remain. The three low-priority items are unchanged carry-overs the auto-apply flow intentionally skips (High/Medium only).

Verification of the prior fixes

  • duplicate_email undercount on the insert-time race — both !created.ok branches emit before rendering: founding path auth.page.ts:314 and trial path auth.page.ts:341. The two race tests override findUserByEmail to return null on the first lookup (so validation's duplicate check passes) then let the pre-created user fail createUserWithPasswordHash — correctly forcing the insert-time branch and asserting 422 + outcome=duplicate_email. Emit precedes renderFailure, so it fires even if the render throws.
  • Unreachable ?? nullanalytics.ts:383 asserts req.visitorId and returns non-null visitor_id: string (analytics.ts:226), matching the sibling buildSaveIntentEvent and eliminating the V8 null branch. assert is the non-strict node:assert import — correct for production code.

Completeness — all 7 terminal outcomes of POST /signup accounted for

Branch (auth.page.ts) Emits
bot-rejected (:278) nothing — intentional, own bot-defense stream ✓
field-errors (:288) disposable_email / invalid_input
duplicate-email validation (:296) duplicate_email
founding !created.ok (:313) duplicate_email
founding success (:322) created
trial !created.ok (:340) duplicate_email
trial success (:368) created

Rebase integrity (the post-rebase risk)main's homepage A/B widget landed at x:0, y:122 (right after the full-width errors row, which ends at y:114+8=122). This PR's two signup widgets correctly moved to y:130 (x:0 bar + x:12 timeSeries), so the occupied rows are errors y:114–122 → homepage-ab y:122–130 → signup y:130–138 with no overlap. Drift test updated 27 → 29 with the merged breakdown, and I hand-counted the builder: exactly 29 widgets (…1 errors, 1 homepage-ab, 2 signup-form). deps.analytics/deps.salt are threaded from the same AppDependencies instances four other routers already use (server.ts:835-836); SignupAttemptedEvent is in the AnalyticsEvent union so deps.analytics.info(...) typechecks, and the dashboard's "every ANALYTICS_EVENTS value has a widget" test guarantees signup_attempted is wired.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions (all carry-overs, still unaddressed)

1. is_authenticated: 0 doc comment overstates the invariant

analytics.ts:216-217 says a signed-in visitor "is redirected off /signup before the handler runs," but only GET /signup redirects (auth.page.ts:231) — POST /signup (:247) carries only signupRateLimit and the router is mounted bare, so an already-authenticated POST runs the handler and emits is_authenticated: 0 regardless of req.userId. Not a practical bug (the form is only rendered to anonymous visitors, and no widget slices signup_attempted by is_authenticated) and the missing POST guard is pre-existing/out of scope — but the justification is inaccurate for the emitting handler and diverges from the sibling buildSaveIntentEvent (params.req.userId ? 1 : 0). Soften the comment to "the form is only shown to anonymous visitors," or compute it like the sibling.

2. Disposable + other-error co-occurrence is silently bucketed as disposable_email

auth.page.ts:289-293: the .some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) check gives the disposable gate precedence over a co-occurring weak-password/mismatch error. Right precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input.

3. Widget/doc wording reads as total signup conversion, but only the email form is counted

signup_attempted covers only POST /signup; Google OAuth signups (googleAuthRouter, server.ts:849) emit nothing. Defensible boundary, but the SIGNUP_OUTCOMES / SignupAttemptedEvent doc comments and the "Signup form outcomes" widget title read as if they cover all account creation. A one-line "email form only; OAuth is separate" note would prevent misreading the dashboard as total signup conversion.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Resolve conflicts. Use your Read tool to read the local file .github/workflows/claude-PR-conflict-fixer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 26m 35s —— View job


Conflicts resolved ✅

Rebased conversion/signup-attempted-instrumentation onto origin/main and force-pushed with --force-with-lease (f1612b16 → 20ed31e8). Both PR commits are intact and the branch sits cleanly on top of main.

  • Read conflict-fixer instructions & PR commit history
  • Rebase onto origin/main and identify conflicts
  • Resolve conflicts (cross-referencing PR intent)
  • Run full pnpm check (40 projects) — green
  • Push with --force-with-lease

What conflicted & how it was resolved

main landed #941 (blog-analytics), which extracted the analytics primitives — STREAMS, ANALYTICS_EVENTS, the event interfaces, buildSaveIntentEvent, and the whole analytics middleware — out of hutch and into a new @packages/web-analytics package, deleting web/middleware/analytics.ts and slimming observability/events.ts down to a re-export. This PR added the signup_attempted event to those same primitives, so they collided. Resolution preserves the PR's intent by relocating its additions into main's new package architecture.

1. observability/events.ts (textual conflict) — main re-exports the analytics constants from @packages/web-analytics; the PR still defined them inline plus SIGNUP_OUTCOMES/signupAttempted. Resolved to main's re-export form, adding SIGNUP_OUTCOMES + type SignupOutcome to the re-export so hutch consumers (auth.page.ts, the dashboard) are unaffected.

2. @packages/web-analytics (the relocation) — moved the PR's signup primitives into the package so they live where the analytics code now does:

  • events.ts: added signupAttempted to ANALYTICS_EVENTS, plus SIGNUP_OUTCOMES + SignupOutcome.
  • index.ts: exported SIGNUP_OUTCOMES, SignupOutcome, buildSignupAttemptedEvent, SignupAttemptedEvent.
  • analytics.ts: git's rename detection correctly carried the PR's buildSignupAttemptedEvent/SignupAttemptedEvent and the fix commit's assert(req.visitorId) + non-null visitor_id: string into the package file.
  • Added a package-level buildSignupAttemptedEvent test (mirroring the sibling buildSaveIntentEvent test) — the function now lives in the package, whose own 100% coverage gate required it since the PR only exercised it through hutch's route tests.

3. auth.page.ts (auto-merged but semantically broken) — the PR's import … from "../middleware/analytics" survived textually but that path was deleted by main. Repointed buildSignupAttemptedEvent + AnalyticsEvent to @packages/web-analytics, matching the sibling save.page.ts/queue.page.ts.

4. Dashboard (analytics-dashboard.ts + .test.ts) — main added a Blog pageviews widget at x:12, y:122 (sharing the row with Homepage A/B). The PR's two signup widgets moved to the next free row y:130 (x:0 bar + x:12 timeSeries) — no overlap. Merged the drift-count breakdown to 30 widgets (… 1 homepage-ab, 1 blog-traffic, 2 signup-form); the "positions do not overlap" and "every event has a widget" tests both pass.

Both prior review fixes survived the rebase: the assert on visitor_id and the duplicate_email emits on the two insert-time race branches (auth.page.ts).

Verification: full pnpm checkSuccessfully ran target check for 40 projects (lint, types, knip, unit + integration + Playwright E2E, coverage gates). @packages/web-analytics 100% across the board; hutch statements 99.47% / branches 97.06% / functions 100%.
· branch conversion/signup-attempted-instrumentation · commit 20ed31e8

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Resolve conflicts. Use your Read tool to read the local file .github/workflows/claude-PR-conflict-fixer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 28m 14s —— View job


Conflicts resolved ✅

Rebased conversion/signup-attempted-instrumentation onto origin/main (3c527309) and force-pushed (--force-with-lease). The branch sits cleanly on top of main — 2 ahead, 0 behind — with both PR commits intact.

  • Read conflict-fixer instructions & PR commit history
  • Rebase onto origin/main and identify conflicts
  • Resolve conflicts (cross-referencing PR intent)
  • Run pnpm nx run hutch:check + @packages/web-analytics:check + full pre-commit monorepo check — all green
  • Push with --force-with-lease

What conflicted & how it was resolved

main (#941) extracted the analytics primitives — STREAMS, ANALYTICS_EVENTS, SAVE_*, the event interfaces, buildSaveIntentEvent, and the analytics middleware — out of hutch into a new @packages/web-analytics package (hutch's observability/events.ts became a thin re-export facade, and web/middleware/analytics.ts was deleted). This PR added its signup_attempted event to those same primitives, so they collided. main also added a blog-traffic dashboard widget on the same row this PR targeted.

Resolved by following main's new architecture — the PR's additions moved into the package:

1. Analytics primitives → the package (@packages/web-analytics/src/events.ts, index.ts)

  • Added signupAttempted: "signup_attempted" to ANALYTICS_EVENTS, plus SIGNUP_OUTCOMES + SignupOutcome (housed beside SAVE_OUTCOMES), and exported them + buildSignupAttemptedEvent/SignupAttemptedEvent from the barrel. Git's rename-following auto-applied the PR's SignupAttemptedEvent type, AnalyticsEvent-union entry, and buildSignupAttemptedEvent builder into web-analytics/src/analytics.ts (the moved middleware); the second commit's assert(req.visitorId, …) + non-null visitor_id: string fix replayed onto it cleanly.

2. hutch observability/events.ts — kept main's re-export facade, augmented with SIGNUP_OUTCOMES + type SignupOutcome.

3. auth.page.ts — repointed buildSignupAttemptedEvent / AnalyticsEvent imports from the deleted ../middleware/analytics to @packages/web-analytics (a latent semantic conflict the text auto-merge missed).

4. Dashboard (analytics-dashboard.ts / .test.ts) — kept both main's blog-traffic widget (x:12, y:122, beside homepage-A/B) and the PR's two signup widgets, moved to their own free row (y:130); no overlap. Merged the drift-guard count to 30 (… 1 errors, 1 homepage-ab, 1 blog-traffic, 2 signup-form).

5. Package coverage — because buildSignupAttemptedEvent moved into the package (100% coverage gate), added a package-level unit test for it (happy path, salted-visitor_hash, and the assert branch), mirroring the sibling buildSaveIntentEvent test. server.ts (threading analytics/salt) and auth.route.test.ts auto-merged.

Verification: @packages/web-analytics:check — 85 tests, 100% coverage. hutch:check — unit + integration + E2E (9) pass, coverage thresholds met (functions 100%). The amend's pre-commit hook ran the full monorepo check (40 projects) — green.

ℹ️ A concurrent automation run force-pushed its own resolution mid-task (based on e7b1dc42, one main commit behind). I compared both — functionally equivalent — and pushed this one because it's rebased onto the current main tip and carries the extra coverage test.
· branch conversion/signup-attempted-instrumentation · commit 30c00773

@FagnerMartinsBrack FagnerMartinsBrack force-pushed the conversion/signup-attempted-instrumentation branch from f1612b1 to 20ed31e Compare July 9, 2026 16:09
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 5m 52s —— View job


Re-review: signup_attempted instrumentation (commit 20ed31e8)

I re-reviewed after the rebase that landed #941 (blog-analytics), which relocated the analytics primitives into @packages/web-analytics. I verified the relocation preserved the PR's intent, re-traced every terminal branch of POST /signup, re-confirmed the two prior-review fixes survived, and checked the package's coverage gate and the dashboard placement. No high or medium issues remain. Three low-priority items are unchanged carry-overs; one new low-priority nit came in with the rebase.

Relocation into @packages/web-analytics is correct

  • SIGNUP_OUTCOMES + SignupOutcome + SignupAttemptedEvent + buildSignupAttemptedEvent moved into the package (events.ts/analytics.ts) and are exported from index.ts; signupAttempted is in ANALYTICS_EVENTS and SignupAttemptedEvent is in the AnalyticsEvent union, so deps.analytics.info(...) typechecks. hutch's observability/events.ts re-exports SIGNUP_OUTCOMES/SignupOutcome, so consumers are unaffected.
  • Import split in auth.page.ts matches the sibling convention: builder + type AnalyticsEvent from the package (like queue.page.ts:16 / save.page.ts:11), constants via the local observability/events re-export.
  • The builder now lives in the package, whose 100% coverage gate is satisfied by a new package-level test covering both branches — happy path (visitor_id present) and the assert throw (req.visitorId unset).

Prior fixes survived the rebase

  • duplicate_email on the insert-time race — both !created.ok branches emit before rendering: founding auth.page.ts:320, trial auth.page.ts:347. Two race tests (findUserByEmail returns null on first lookup, pre-created user fails the insert) assert 422 + outcome=duplicate_email.
  • ✅ Unreachable ?? nullanalytics.ts asserts req.visitorId (non-strict node:assert, correct for production) and returns non-null visitor_id: string, matching buildSaveIntentEvent.

Completeness — all 7 terminal outcomes of POST /signup accounted for

Branch (auth.page.ts) Emits
bot-rejected (:284) nothing — intentional, own bot-defense stream ✓
field-errors (:294) disposable_email / invalid_input
duplicate-email validation (:302) duplicate_email
founding !created.ok (:319) duplicate_email
founding success (:328) created
trial !created.ok (:346) duplicate_email
trial success (:385) created

Every emit precedes its renderFailure/redirect, so it fires even if the render throws. The test harness genuinely captures events (test-app.ts captureAnalyticsharness.analytics.events).

Dashboard — signup widgets at y:130 (x:0 bar + x:12 timeSeries) sit after errors (y:114), homepage-ab (x:0, y:122) and blog-traffic (x:12, y:122); the machine-checked "positions do not overlap" test passes, drift count updated to 30, and the "every ANALYTICS_EVENTS value has a widget" test guarantees signup_attempted is wired. deps.analytics/deps.salt threaded at server.ts:897-898, consistent with four other routers.


High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. New: two separate imports from @packages/web-analytics in auth.page.ts. Line 67 (readClickAttribution) and line 71 (buildSignupAttemptedEvent, type AnalyticsEvent) both import from the same module; the siblings consolidate into one line (queue.page.ts:16, save.page.ts:11). Biome accepts it (pnpm check is green), so this is pure polish — merge the two lines to match the sibling pattern.

2. Carry-over: is_authenticated: 0 doc comment overstates the invariant. analytics.ts says a signed-in visitor "is redirected off /signup before the handler runs," but only GET /signup redirects (auth.page.ts:231) — POST /signup has no guest guard. Not a practical bug (the form is only shown to anonymous visitors, and no widget slices by is_authenticated), but the justification is inaccurate for the emitting handler. Soften to "the form is only shown to anonymous visitors," or compute it like buildSaveIntentEvent (req.userId ? 1 : 0).

3. Carry-over: disposable + other-error co-occurrence is silently bucketed as disposable_email. auth.page.ts:295-299 — the .some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) gives the disposable gate precedence over a co-occurring weak-password/mismatch error. Right precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input.

4. Carry-over: widget/doc wording reads as total signup conversion. Google OAuth signups (googleAuthRouter) emit nothing; only the email POST /signup form is counted. A one-line "email form only; OAuth is separate" note on SIGNUP_OUTCOMES / SignupAttemptedEvent / the "Signup form outcomes" widget title would prevent misreading the dashboard as total account-creation conversion.


cc @FagnerMartinsBrack — No high or medium priority issues found. This PR is ready for human review.

  • Read review instructions & gather context
  • Review the diff at commit 20ed31e8 (post rebase into @packages/web-analytics)
  • Trace all terminal branches of POST /signup for completeness
  • Verify prior-review fixes survived the rebase (duplicate-race emit, assert on visitor_id)
  • Check the web-analytics package relocation (exports, coverage, tests)
  • Check dashboard placement / drift count after the rebase
  • Check CLAUDE.md guideline adherence
  • Post review
    · branch conversion/signup-attempted-instrumentation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

Re-review: signup_attempted instrumentation (commit 20ed31e8)

I re-reviewed after the rebase that landed #941 (blog-analytics), which relocated the analytics primitives into @packages/web-analytics. I verified the relocation preserved the PR's intent, re-traced every terminal branch of POST /signup, re-confirmed the two prior-review fixes survived, and checked the package's coverage gate and the dashboard placement. No high or medium issues remain.

Relocation into @packages/web-analytics is correctSIGNUP_OUTCOMES/SignupOutcome/SignupAttemptedEvent/buildSignupAttemptedEvent moved into the package and are exported from index.ts; signupAttempted is in ANALYTICS_EVENTS and SignupAttemptedEvent is in the AnalyticsEvent union so deps.analytics.info(...) typechecks; hutch's observability/events.ts re-exports the constants so consumers are unaffected. The auth.page.ts import split (builder + type AnalyticsEvent from the package, constants via the local re-export) matches queue.page.ts:16 / save.page.ts:11. The builder now lives in the package, whose 100% coverage gate is satisfied by a new package test covering both the happy path and the assert throw.

Prior fixes survivedduplicate_email emits on both insert-time race branches (founding auth.page.ts:320, trial :347) before rendering, covered by two race tests asserting 422 + outcome=duplicate_email; the unreachable ?? null is replaced by assert(req.visitorId) + non-null visitor_id: string.

Completeness — all 7 terminal outcomes of POST /signup accounted for: bot-rejected (nothing, intentional), field-errors (disposable_email/invalid_input), duplicate-email validation, founding !created.ok, founding success, trial !created.ok, trial success. Every emit precedes its render/redirect, so it fires even if the render throws.

Dashboard — signup widgets at y:130 sit below errors/homepage-ab/blog-traffic with no overlap (machine-checked), drift count updated to 30, and the "every event has a widget" test guarantees signup_attempted is wired. deps.analytics/deps.salt threaded at server.ts:897-898.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. New: two separate imports from @packages/web-analytics in auth.page.ts

Line 67 (readClickAttribution) and line 71 (buildSignupAttemptedEvent, type AnalyticsEvent) both import from the same module; the siblings consolidate into a single line (queue.page.ts:16, save.page.ts:11). Biome accepts it (pnpm check is green), so this is pure polish — merge the two lines to match the sibling pattern.

2. Carry-over: is_authenticated: 0 doc comment overstates the invariant

analytics.ts says a signed-in visitor "is redirected off /signup before the handler runs," but only GET /signup redirects (auth.page.ts:231) — POST /signup has no guest guard, so an already-authenticated POST would still emit is_authenticated: 0. Not a practical bug (the form is only shown to anonymous visitors, and no widget slices by is_authenticated), but the justification is inaccurate for the emitting handler and diverges from the sibling buildSaveIntentEvent (req.userId ? 1 : 0). Soften the comment to "the form is only shown to anonymous visitors," or compute it like the sibling.

3. Carry-over: disposable + other-error co-occurrence is silently bucketed as disposable_email

auth.page.ts:295-299: the .some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) check gives the disposable gate precedence over a co-occurring weak-password/mismatch error. Right precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input.

4. Carry-over: widget/doc wording reads as total signup conversion

signup_attempted covers only the email POST /signup form; Google OAuth signups (googleAuthRouter) emit nothing. A one-line "email form only; OAuth is separate" note on SIGNUP_OUTCOMES / SignupAttemptedEvent / the "Signup form outcomes" widget title would prevent misreading the dashboard as total account-creation conversion.

@FagnerMartinsBrack FagnerMartinsBrack force-pushed the conversion/signup-attempted-instrumentation branch from 20ed31e to 30c0077 Compare July 9, 2026 16:29
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 4m 59s —— View job


Re-review: signup_attempted instrumentation (commit 30c00773)

This commit is the re-resolved conflict fix after the latest rebase onto main (3c527309). I re-traced every terminal branch of POST /signup, re-verified the two prior-review fixes survived, confirmed the @packages/web-analytics relocation and its 100% coverage gate, and checked the dashboard placement. No high or medium issues remain. CI is green for this commit. Findings are unchanged from the 20ed31e8 review: four low-priority items (one import nit + three design/doc carry-overs).

Prior fixes intact

  • duplicate_email on the insert-time race — both !created.ok branches emit before rendering: founding auth.page.ts:321, trial auth.page.ts:348. The two race tests (auth.route.test.ts) return null on the first findUserByEmail (so validation's duplicate check passes) then let the pre-created user fail createUserWithPasswordHash, asserting 422 + outcome=duplicate_email on each path.
  • Unreachable ?? nullbuildSignupAttemptedEvent asserts req.visitorId (import assert from "node:assert" — non-strict, correct for production) and returns non-null visitor_id: string, matching the sibling buildSaveIntentEvent.

Relocation into @packages/web-analytics is correctSIGNUP_OUTCOMES / SignupOutcome / SignupAttemptedEvent / buildSignupAttemptedEvent live in the package (events.ts / analytics.ts) and are exported from index.ts; signupAttempted is in ANALYTICS_EVENTS and SignupAttemptedEvent is in the AnalyticsEvent union, so deps.analytics.info(...) typechecks. hutch's observability/events.ts re-exports the constants, so auth.page.ts / the dashboard are unaffected. The package's 100% coverage gate is met by a new package test covering the happy path, the salted visitor_hash, and the assert throw.

Completeness — all 7 terminal outcomes of POST /signup accounted for

Branch (auth.page.ts) Emits
bot-rejected (:285) nothing — intentional, own bot-defense stream ✓
field-errors (:295) disposable_email / invalid_input
duplicate-email validation (:303) duplicate_email
founding !created.ok (:320) duplicate_email
founding success (:329) created
trial !created.ok (:347) duplicate_email
trial success (:386) created

Every emit precedes its renderFailure/redirect, so it fires even if the render throws. created emits once per success path and after createUserWithPasswordHash succeeds, so its count reconciles with user_created (both method:"email") — a useful cross-check.

Dashboard — signup widgets at y:130 (x:0 bar + x:12 timeSeries) sit cleanly below errors (x:0, y:114), homepage-ab (x:0, y:122) and blog-traffic (x:12, y:122) — verified coordinates, no overlap. Drift count updated 28 → 30, and the "every ANALYTICS_EVENTS value has a widget" test guarantees signup_attempted is wired. deps.analytics / deps.salt threaded at server.ts:895-896.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. Three separate imports from @packages/web-analytics in auth.page.ts

readClickAttribution (line 68), type { AnalyticsEvent }, and { buildSignupAttemptedEvent } are three separate statements from the same module; the siblings consolidate into one (queue.page.ts, save.page.ts). Biome accepts it (pnpm check green), so this is pure polish. Fix this →

2. Carry-over: is_authenticated: 0 doc comment overstates the invariant

analytics.ts documents the field as "a signed-in visitor is redirected off /signup before the handler runs," but only GET /signup redirects (auth.page.ts:238) — POST /signup (:254) carries only signupRateLimit and the router is mounted bare, so an already-authenticated POST would run the handler and still emit is_authenticated: 0. Not a practical bug (the form is only rendered to anonymous visitors, and no widget slices by is_authenticated), but the justification is inaccurate for the emitting handler and diverges from the sibling buildSaveIntentEvent (req.userId ? 1 : 0). Soften the comment, or compute it like the sibling. Fix this →

3. Carry-over: disposable + other-error co-occurrence is silently bucketed as disposable_email

auth.page.ts:296-300: the .some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) gives the disposable gate precedence over a co-occurring weak-password/mismatch error. Right precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input. Fix this →

4. Carry-over: widget/doc wording reads as total signup conversion

signup_attempted covers only the email POST /signup form; Google OAuth signups (googleAuthRouter) emit nothing. A one-line "email form only; OAuth is separate" note on SIGNUP_OUTCOMES / SignupAttemptedEvent / the "Signup form outcomes" widget title would prevent misreading the dashboard as total account-creation conversion. Fix this →

cc @FagnerMartinsBrack - No high or medium priority issues found. This PR is ready for human review.


  • Read review instructions & gather context
  • Review the diff at commit 30c00773 (latest state)
  • Trace all terminal branches of POST /signup for completeness
  • Verify prior-review fixes survived the rebase (duplicate-race emit, assert on visitor_id)
  • Check the web-analytics package relocation (exports, coverage, tests)
  • Check dashboard placement / drift count after the rebase
  • Check CLAUDE.md guideline adherence
  • Post review
    · branch conversion/signup-attempted-instrumentation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

Re-review: signup_attempted instrumentation (commit 30c00773)

This commit is the re-resolved conflict fix after the latest rebase onto main (3c527309). I re-traced every terminal branch of POST /signup, re-verified the two prior-review fixes survived, confirmed the @packages/web-analytics relocation and its 100% coverage gate, and checked the dashboard placement. No high or medium issues remain. CI is green for this commit. Findings are unchanged from the 20ed31e8 review: four low-priority items (one import nit + three design/doc carry-overs).

Prior fixes intact

  • duplicate_email on the insert-time race — both !created.ok branches emit before rendering: founding auth.page.ts:321, trial auth.page.ts:348. The two race tests return null on the first findUserByEmail then let the pre-created user fail createUserWithPasswordHash, asserting 422 + outcome=duplicate_email on each path.
  • Unreachable ?? nullbuildSignupAttemptedEvent asserts req.visitorId (non-strict node:assert, correct for production) and returns non-null visitor_id: string, matching the sibling buildSaveIntentEvent.

Relocation into @packages/web-analytics is correctSIGNUP_OUTCOMES / SignupOutcome / SignupAttemptedEvent / buildSignupAttemptedEvent live in the package and are exported from index.ts; signupAttempted is in ANALYTICS_EVENTS and SignupAttemptedEvent is in the AnalyticsEvent union, so deps.analytics.info(...) typechecks. hutch's observability/events.ts re-exports the constants. The package's 100% coverage gate is met by a new package test covering the happy path, the salted visitor_hash, and the assert throw.

Completeness — all 7 terminal outcomes of POST /signup accounted for

Branch (auth.page.ts) Emits
bot-rejected (:285) nothing — intentional, own bot-defense stream ✓
field-errors (:295) disposable_email / invalid_input
duplicate-email validation (:303) duplicate_email
founding !created.ok (:320) duplicate_email
founding success (:329) created
trial !created.ok (:347) duplicate_email
trial success (:386) created

Every emit precedes its renderFailure/redirect, so it fires even if the render throws. created emits once per success path, so its count reconciles with user_created (both method:"email") — a useful cross-check.

Dashboard — signup widgets at y:130 (x:0 bar + x:12 timeSeries) sit cleanly below errors (x:0, y:114), homepage-ab (x:0, y:122) and blog-traffic (x:12, y:122) — verified coordinates, no overlap. Drift count updated 28 → 30; deps.analytics / deps.salt threaded at server.ts:895-896.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. Three separate imports from @packages/web-analytics in auth.page.ts

readClickAttribution (line 68), type { AnalyticsEvent }, and { buildSignupAttemptedEvent } are three separate statements from the same module; the siblings consolidate into one (queue.page.ts, save.page.ts). Biome accepts it (pnpm check green), so this is pure polish.

2. Carry-over: is_authenticated: 0 doc comment overstates the invariant

analytics.ts documents the field as "a signed-in visitor is redirected off /signup before the handler runs," but only GET /signup redirects (auth.page.ts:238) — POST /signup (:254) carries only signupRateLimit, so an already-authenticated POST would run the handler and still emit is_authenticated: 0. Not a practical bug (the form is only rendered to anonymous visitors, and no widget slices by is_authenticated), but the justification is inaccurate for the emitting handler and diverges from the sibling buildSaveIntentEvent (req.userId ? 1 : 0). Soften the comment, or compute it like the sibling.

3. Carry-over: disposable + other-error co-occurrence is silently bucketed as disposable_email

auth.page.ts:296-300: the .some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) gives the disposable gate precedence over a co-occurring weak-password/mismatch error. Right precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input.

4. Carry-over: widget/doc wording reads as total signup conversion

signup_attempted covers only the email POST /signup form; Google OAuth signups (googleAuthRouter) emit nothing. A one-line "email form only; OAuth is separate" note on SIGNUP_OUTCOMES / SignupAttemptedEvent / the "Signup form outcomes" widget title would prevent misreading the dashboard as total account-creation conversion.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Resolve conflicts. Use your Read tool to read the local file .github/workflows/claude-PR-conflict-fixer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 11m 9s —— View job


Conflicts resolved ✅

Rebased conversion/signup-attempted-instrumentation onto origin/main (326f11a4) and force-pushed with --force-with-lease (30c00773 → f02f88ff). The branch sits cleanly on top of main — 2 ahead, 0 behind — with both PR commits intact.

  • Read conflict-fixer instructions & PR commit history
  • Rebase onto origin/main and identify conflicts
  • Resolve conflicts (cross-referencing PR intent)
  • Run pnpm check — green (40 projects)
  • Push with --force-with-lease

What conflicted & how it was resolved

main landed cff18fab (slice the device-mix pie by device class / browser family), which added a classifyBrowser classifier + browser field to @packages/web-analytics. This PR added its signup_attempted primitives to the same package, so they collided in one file:

src/packages/web-analytics/src/analytics.test.ts (textual conflict, import block) — main imported classifyBrowser (+ its classifyBrowser describe block); this PR imported buildSignupAttemptedEvent / type SignupAttemptedEvent / SIGNUP_OUTCOMES. Both are additive and non-overlapping, so I kept both in the merged import (biome import order preserved). Nothing was dropped.

The other three files from the fix commit auto-merged cleanly, and I verified the PR's two prior-review fixes survived intact:

  • analytics.tsbuildSignupAttemptedEvent still asserts req.visitorId and returns non-null visitor_id: string; classifyBrowser (main) and SignupAttemptedEvent (PR) both present.
  • auth.page.ts / auth.route.test.ts — the duplicate_email emits on the two insert-time race branches and their tests replayed without conflict.

Latent dashboard check (no Git conflict, but the usual post-rebase risk): cff18fab only retitled and requeried the existing device-mix pie (Pageviews by device class… / browser, by device_classby device_browser) — it changed no widget x/y/height/width and added no widget. So the PR's signup widgets stay at y:130, the "positions do not overlap" test passes, and the drift count stays 30 (…2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic, 2 signup-form). Both main's browser-slice query and the PR's signup widgets coexist in analytics-dashboard.ts.

Verification: full pnpm checkSuccessfully ran target check for 40 projects (lint, types, knip, unit + integration + Playwright E2E). Coverage gates met: statements 99.48%, branches 97.08%, functions 100%, lines 99.48%.
· branch conversion/signup-attempted-instrumentation · commit f02f88ff

@FagnerMartinsBrack FagnerMartinsBrack force-pushed the conversion/signup-attempted-instrumentation branch from 30c0077 to f02f88f Compare July 10, 2026 07:46
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 4m 16s —— View job


Re-review: signup_attempted instrumentation (commit f02f88ff)

This is the re-resolved state after rebasing onto main (326f11a4, which landed cff18fab — the device-mix pie browser slice). I re-traced every terminal branch of POST /signup, re-verified the two prior-review fixes survived the rebase, confirmed the @packages/web-analytics relocation + its 100% coverage gate, and checked the dashboard placement. CI is green for this commit (run 29077707373). No high or medium issues remain; findings are unchanged from the 30c00773 review (one import nit + three design/doc carry-overs).

Rebase integritycff18fab only retitled/requeried the existing device-mix pie (no widget added, no x/y/w/h changed), so the only collision was the analytics.test.ts import block: the merged line keeps main's classifyBrowser and this PR's buildSignupAttemptedEvent / type SignupAttemptedEvent / SIGNUP_OUTCOMES — nothing dropped. The drift count is 30 and the machine-checked "widget positions do not overlap" (.test.ts:241) + "every ANALYTICS_EVENTS value is referenced by a widget" (:193) tests both pass — so the y:130 placement is verified non-overlapping and signup_attempted is guaranteed wired.

Prior fixes intact

  • duplicate_email on the insert-time race — both !created.ok branches emit before rendering: founding auth.page.ts:321, trial :348. The two race tests return null on the first findUserByEmail (validation's duplicate check passes) then let the pre-created user fail createUserWithPasswordHash, asserting 422 + outcome=duplicate_email on each path.
  • Unreachable ?? nullbuildSignupAttemptedEvent asserts req.visitorId (confirmed import assert from "node:assert" — non-strict, correct for production) and returns non-null visitor_id: string, matching the sibling buildSaveIntentEvent. A package-level test covers the happy path, the salted visitor_hash, and the assert throw (the package's 100% gate requires it).

Completeness — all 7 terminal outcomes of POST /signup accounted for

Branch (auth.page.ts) Emits
bot-rejected (:285) nothing — intentional, own bot-defense stream ✓
field-errors (:296) disposable_email / invalid_input ✓ (both ternary branches tested)
duplicate-email validation (:303) duplicate_email
founding !created.ok (:320) duplicate_email
founding success (:329) created
trial !created.ok (:347) duplicate_email
trial success (:386) created

Every emit precedes its renderFailure/redirect, so it fires even if the render throws. created emits exactly once per success path (founding/trial are mutually exclusive), so its count reconciles with user_created (both method:"email") — a useful cross-check. deps.analytics / deps.salt are required (non-optional) deps threaded at server.ts:897-898, and SignupAttemptedEvent is in the AnalyticsEvent union so deps.analytics.info(...) typechecks.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. Three separate imports from @packages/web-analytics in auth.page.ts

readClickAttribution (line 68), type { AnalyticsEvent }, and { buildSignupAttemptedEvent } are three separate statements from the same module; the siblings consolidate into one (queue.page.ts, save.page.ts). Biome accepts it (CI green), so this is pure polish. Fix this →

2. Carry-over: is_authenticated: 0 doc comment overstates the invariant

analytics.ts documents the field as "a signed-in visitor is redirected off /signup before the handler runs," but only GET /signup redirects (auth.page.ts:238) — POST /signup (:254) carries only signupRateLimit and the router is mounted bare, so an already-authenticated POST would run the handler and still emit is_authenticated: 0 regardless of req.userId. Not a practical bug (the form is only rendered to anonymous visitors, and no widget slices by is_authenticated), but the justification is inaccurate for the emitting handler and diverges from the sibling buildSaveIntentEvent (req.userId ? 1 : 0). Soften the comment to "the form is only shown to anonymous visitors," or compute it like the sibling. Fix this →

3. Carry-over: disposable + other-error co-occurrence is silently bucketed as disposable_email

auth.page.ts:296-300: the .some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) gives the disposable gate precedence over a co-occurring weak-password/mismatch error. Right precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input. Fix this →

4. Carry-over: widget/doc wording reads as total signup conversion

signup_attempted covers only the email POST /signup form; Google OAuth signups (googleAuthRouter) emit nothing. A one-line "email form only; OAuth is separate" note on SIGNUP_OUTCOMES / SignupAttemptedEvent / the "Signup form outcomes" widget title would prevent misreading the dashboard as total account-creation conversion. Fix this →

cc @FagnerMartinsBrack - No high or medium priority issues found. This PR is ready for human review.


  • Read review instructions & gather context
  • Review the diff at commit f02f88ff (latest state, post-rebase onto cff18fab)
  • Trace all terminal branches of POST /signup for completeness
  • Verify prior-review fixes survived the rebase (duplicate-race emit, assert on visitor_id)
  • Check dashboard placement / drift count after the rebase
  • Check CLAUDE.md guideline adherence
    · branch conversion/signup-attempted-instrumentation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

Re-review: signup_attempted instrumentation (commit f02f88ff)

This is the re-resolved state after rebasing onto main (326f11a4, which landed cff18fab — the device-mix pie browser slice). I re-traced every terminal branch of POST /signup, re-verified the two prior-review fixes survived the rebase, confirmed the @packages/web-analytics relocation + its 100% coverage gate, and checked the dashboard placement. CI is green for this commit (run 29077707373). No high or medium issues remain; findings are unchanged from the 30c00773 review (one import nit + three design/doc carry-overs).

Rebase integritycff18fab only retitled/requeried the existing device-mix pie (no widget added, no x/y/w/h changed), so the only collision was the analytics.test.ts import block: the merged line keeps main's classifyBrowser and this PR's buildSignupAttemptedEvent / type SignupAttemptedEvent / SIGNUP_OUTCOMES — nothing dropped. The drift count is 30 and the machine-checked "widget positions do not overlap" + "every ANALYTICS_EVENTS value is referenced by a widget" tests both pass — so the y:130 placement is verified non-overlapping and signup_attempted is guaranteed wired.

Prior fixes intact

  • duplicate_email on the insert-time race — both !created.ok branches emit before rendering: founding auth.page.ts:321, trial :348. The two race tests return null on the first findUserByEmail (validation's duplicate check passes) then let the pre-created user fail createUserWithPasswordHash, asserting 422 + outcome=duplicate_email on each path.
  • Unreachable ?? nullbuildSignupAttemptedEvent asserts req.visitorId (confirmed import assert from "node:assert" — non-strict, correct for production) and returns non-null visitor_id: string, matching the sibling buildSaveIntentEvent. A package-level test covers the happy path, the salted visitor_hash, and the assert throw (the package's 100% gate requires it).

Completeness — all 7 terminal outcomes of POST /signup accounted for

Branch (auth.page.ts) Emits
bot-rejected (:285) nothing — intentional, own bot-defense stream ✓
field-errors (:296) disposable_email / invalid_input ✓ (both ternary branches tested)
duplicate-email validation (:303) duplicate_email
founding !created.ok (:320) duplicate_email
founding success (:329) created
trial !created.ok (:347) duplicate_email
trial success (:386) created

Every emit precedes its renderFailure/redirect, so it fires even if the render throws. created emits exactly once per success path (founding/trial are mutually exclusive), so its count reconciles with user_created (both method:"email") — a useful cross-check. deps.analytics / deps.salt are required (non-optional) deps threaded at server.ts:897-898, and SignupAttemptedEvent is in the AnalyticsEvent union so deps.analytics.info(...) typechecks.

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. Three separate imports from @packages/web-analytics in auth.page.ts

readClickAttribution (line 68), type { AnalyticsEvent }, and { buildSignupAttemptedEvent } are three separate statements from the same module; the siblings consolidate into one (queue.page.ts, save.page.ts). Biome accepts it (CI green), so this is pure polish.

2. Carry-over: is_authenticated: 0 doc comment overstates the invariant

analytics.ts documents the field as "a signed-in visitor is redirected off /signup before the handler runs," but only GET /signup redirects (auth.page.ts:238) — POST /signup (:254) carries only signupRateLimit and the router is mounted bare, so an already-authenticated POST would run the handler and still emit is_authenticated: 0 regardless of req.userId. Not a practical bug (the form is only rendered to anonymous visitors, and no widget slices by is_authenticated), but the justification is inaccurate for the emitting handler and diverges from the sibling buildSaveIntentEvent (req.userId ? 1 : 0). Soften the comment to "the form is only shown to anonymous visitors," or compute it like the sibling.

3. Carry-over: disposable + other-error co-occurrence is silently bucketed as disposable_email

auth.page.ts:296-300: the .some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) gives the disposable gate precedence over a co-occurring weak-password/mismatch error. Right precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input.

4. Carry-over: widget/doc wording reads as total signup conversion

signup_attempted covers only the email POST /signup form; Google OAuth signups (googleAuthRouter) emit nothing. A one-line "email form only; OAuth is separate" note on SIGNUP_OUTCOMES / SignupAttemptedEvent / the "Signup form outcomes" widget title would prevent misreading the dashboard as total account-creation conversion.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Resolve conflicts. Use your Read tool to read the local file .github/workflows/claude-PR-conflict-fixer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 21m 33s —— View job


Conflicts resolved ✅

Rebased conversion/signup-attempted-instrumentation onto origin/main (78dfa44b) and force-pushed with --force-with-lease (f02f88ff → 019f3bc0). The branch sits cleanly on top of main — 2 ahead, 0 behind — with both PR commits intact.

  • Read conflict-fixer instructions & PR commit history
  • Rebase onto origin/main and identify conflicts
  • Resolve conflicts (cross-referencing PR intent)
  • Run pnpm check — green (42 projects)
  • Push with --force-with-lease

What conflicted & how it was resolved

Since the last rebase, main landed a cluster of signup-flow + analytics commits — most relevantly 78dfa44b (suppress click-counting on bot fake-success redirects), ab75c208 (re-render the signup form instead of faking success on too-fast submits), and 7e33d402 (drop the confirmPassword field) — all touching auth.page.ts / analytics.ts, exactly where this PR works. Only one surfaced as a Git conflict:

1. Textual conflict — import block (src/packages/web-analytics/src/analytics.test.ts)
main (78dfa44b) added suppressClickCount to the ./analytics import; this PR added buildSignupAttemptedEvent / type SignupAttemptedEvent (from ./analytics) and SIGNUP_OUTCOMES (from ./events). Both edits are additive and non-overlapping, so I kept both, in biome's case-insensitive order (… hashIp, type SignupAttemptedEvent, suppressClickCount, type ViewSaveIntentEvent; SAVE_OUTCOMES, SAVE_SURFACES, SIGNUP_OUTCOMES). Both test bodies (suppressClickCount(res) and the buildSignupAttemptedEvent describe block) auto-merged intact — nothing dropped.

2. Latent semantic merge — the bot-rejected branch was restructured (auth.page.ts, auto-merged but verified by hand)
main split the old single bot-rejected terminal branch of POST /signup into two sub-branches:

  • submit_too_fastre-render the form with "Please try again" (422) — ab75c208
  • other bot reasons → suppressClickCount(res) + fake-success redirect to /?signup=pending78dfa44b

Both log to the bot-defense stream (createBotDefenseEvent) and correctly emit no signup_attempted — consistent with this PR's explicit design ("bot-defense trips are intentionally not counted here; they have their own stream"). I traced all terminal outcomes to confirm the auto-merge preserved the PR's intent:

Branch (auth.page.ts) Emits
bot-rejected → too-fast re-render (:299) nothing — bot-defense stream ✓
bot-rejected → fake-success redirect (:306) nothing — bot-defense stream ✓
field-errors (:309) disposable_email / invalid_input
duplicate-email validation (:317) duplicate_email
founding !created.ok (:334) duplicate_email
founding success (:343) created
trial !created.ok (:361) duplicate_email
trial success (:400) created

Both prior-review fixes survived the rebase: the assert(params.req.visitorId, …) + non-null visitor_id: string in analytics.ts, and the duplicate_email emits on the two insert-time race branches. main's suppressClickCount and this PR's buildSignupAttemptedEvent coexist in analytics.ts.

Dashboard — the only main dashboard change since base was de203dcf (re-headline the Homepage A/B widget on distinct visitors — query/title, no position or count change), so the PR's signup widgets stay put; the machine-checked "positions do not overlap" and "every event has a widget" tests pass and the drift count is unchanged.

Verification: full pnpm checkSuccessfully ran target check for 42 projects (lint, types, knip, unit + integration + Playwright E2E). Coverage gates met — @packages/web-analytics 100% across the board; hutch statements 99.47% / branches 97.04% / functions 100% / lines 99.47%.

ℹ️ The initial pnpm check reported failures that were purely environmental — the .envrc vars (e.g. STATIC_BASE_URL) weren't loaded in the fresh shell, and running all 42 projects at full parallelism starved the runner (exit 130). Every test that actually ran passed. Re-running with .envrc sourced and NX_PARALLEL capped is fully green.
· branch conversion/signup-attempted-instrumentation · commit 019f3bc0

FagnerMartinsBrack and others added 2 commits July 10, 2026 15:16
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
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 <FagnerMartinsBrack@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FagnerMartinsBrack FagnerMartinsBrack force-pushed the conversion/signup-attempted-instrumentation branch from f02f88f to 019f3bc Compare July 10, 2026 15:36
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 4m 12s —— View job


Re-review: signup_attempted instrumentation (commit 019f3bc0)

First review after the rebase onto 78dfa44b, which split the single bot-rejected terminal branch of POST /signup into two sub-branches (submit_too_fast → visible re-render; other reasons → suppressClickCount + fake-success redirect). I independently re-traced every terminal branch, re-verified the two prior-review fixes survived, and confirmed the dashboard placement + @packages/web-analytics relocation are intact. CI is green for this commit (run 29104297639). No high or medium issues remain — three doc/design carry-overs plus one new low-priority observation from the rebase.

Prior fixes intact

  • duplicate_email on the insert-time race — both !created.ok branches emit before rendering: founding auth.page.ts:335, trial :362. The two race tests return null on the first findUserByEmail (so validation's duplicate check passes) then let the pre-created user fail createUserWithPasswordHash, asserting 422 + outcome=duplicate_email.
  • Unreachable ?? nullbuildSignupAttemptedEvent asserts req.visitorId (confirmed import assert from "node:assert" — non-strict, correct for production) and returns non-null visitor_id: string. A package-level test covers the happy path, the salted visitor_hash, and the assert throw (the package's 100% gate requires it).
  • ✅ Disposable/invalid split keys off the shared DISPOSABLE_EMAIL_MESSAGE — the same constant the zod refinement emits (auth.schema.ts:7), so it's connascence of name, not a duplicated string literal.

Completeness — all 8 terminal outcomes of POST /signup accounted for (bot branch now split by the rebase)

Branch (auth.page.ts) Emits
bot-rejectedsubmit_too_fast re-render (:299) nothing — bot-defense stream ✓
bot-rejected → fake-success redirect (:306) nothing — bot-defense stream ✓
field-errors (:309) disposable_email / invalid_input ✓ (both ternary branches tested)
duplicate-email validation (:317) duplicate_email
founding !created.ok (:334) duplicate_email
founding success (:343) created
trial !created.ok (:361) duplicate_email
trial success (:400) created

Every emit precedes its renderFailure/redirect, so it fires even if the render throws. created emits exactly once per success path (founding/trial are mutually exclusive), so its count reconciles with user_created (both method:"email") — a useful cross-check. deps.analytics / deps.salt are required (non-optional) deps threaded at server.ts:899-900, and SignupAttemptedEvent is in the AnalyticsEvent union so deps.analytics.info(...) typechecks.

Dashboard — signup widgets at y:130 (x:0 bar + x:12 timeSeries); the machine-checked "widget positions do not overlap" (.test.ts:243) and "every ANALYTICS_EVENTS value is referenced by a widget" (:195) tests both pass, so the placement is verified non-overlapping and signup_attempted is guaranteed wired. Drift count updated to 30 (hand-counted the breakdown: …1 errors, 1 homepage-ab, 1 blog-traffic, 2 signup-form).

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. New (from the rebase): a too-fast human submit is now a visible rejection but still emits nothing

main's ab75c208 changed the submit_too_fast branch from a silent fake-success redirect into a user-facing form re-render — 422 + "Please try again" (auth.page.ts:299-304). It correctly emits no signup_attempted (it's classed bot-rejected and logged to bot-defense), which is consistent with this PR's stated boundary. Worth being aware of, though: a hasty human who trips the sub-second threshold, is told to retry, and bounces is exactly the "submitted-and-rejected ≠ bounced" case this event exists to surface — and is now countable only on the bot-defense stream, not in the signup funnel. Defensible (the two streams reconcile to the full picture), but the semantics shifted with the rebase, so the "Signup form outcomes" dashboard slightly understates form-facing friction.

2. Carry-over: is_authenticated: 0 doc comment overstates the invariant

analytics.ts documents the field as "a signed-in visitor is redirected off /signup before the handler runs," but only GET /signup redirects (auth.page.ts:238) — POST /signup carries only signupRateLimit and the router is mounted bare, so an already-authenticated POST would run the handler and still emit is_authenticated: 0 regardless of req.userId. Not a practical bug (the form is only rendered to anonymous visitors, and no widget slices by is_authenticated), but the justification is inaccurate for the emitting handler and diverges from the sibling buildSaveIntentEvent (req.userId ? 1 : 0). Soften the comment to "the form is only shown to anonymous visitors," or compute it like the sibling. Fix this →

3. Carry-over: disposable + other-error co-occurrence is silently bucketed as disposable_email

auth.page.ts:310-313: the .some((e) => e.message === DISPOSABLE_EMAIL_MESSAGE) gives the disposable gate precedence over a co-occurring weak-password/mismatch error. Right precedence for measuring the gate's cost, but a one-line comment would stop a future reader assuming the buckets are mutually exclusive by input. Fix this →

4. Carry-over: widget/doc wording reads as total signup conversion

signup_attempted covers only the email POST /signup form; Google OAuth signups (googleAuthRouter) emit nothing. A one-line "email form only; OAuth is separate" note on SIGNUP_OUTCOMES / SignupAttemptedEvent / the "Signup form outcomes" widget title would prevent misreading the dashboard as total account-creation conversion. Fix this →

cc @FagnerMartinsBrack - No high or medium priority issues found. This PR is ready for human review.


  • Read review instructions & gather context
  • Review the diff at commit 019f3bc0 (latest state, post-rebase onto 78dfa44b)
  • Trace all terminal branches of POST /signup for completeness
  • Verify prior-review fixes survived the rebase (duplicate-race emit, assert on visitor_id)
  • Check dashboard placement / drift count after the rebase
  • Check CLAUDE.md guideline adherence
    · branch conversion/signup-attempted-instrumentation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant