feat(hutch): emit signup_attempted to make the signup form measurable#918
feat(hutch): emit signup_attempted to make the signup form measurable#918FagnerMartinsBrack wants to merge 2 commits into
Conversation
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 9m 10s —— View job Review:
|
Review:
|
|
@claude Fix the review issues. Use your Read tool to read the local file Context
Review to AddressSee the review comment above this one for the issues to fix. |
|
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
What changed & whyMedium #1 — undercounted The
Medium #2 — unreachable, untested
Verification: |
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 5m 35s —— View job Re-review:
|
High Priority Issues (Must Fix)None found Medium Priority IssuesNone found Low Priority Suggestions1.
|
|
@claude Resolve conflicts. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 11m 33s —— View job Conflicts resolved ✅Rebased
What conflicted & how it was resolvedBoth 1. Textual conflict — widget-count drift assertion (
2. Latent semantic conflict — widget grid overlap ( Preserving the PR's intent ("append these widgets after the last existing one"), I moved the signup-form widgets from The follow-up review-fix commit applied on top with no conflicts — both fixes survived ( Verification: |
d450da0 to
fb94830
Compare
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 4m 51s —— View job Re-review:
|
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(aftermain's audience-device row aty:106pushed the errors widget toy:114); no overlap, drift test updated to 28,deps.analytics/deps.saltthreaded atserver.ts:827-828.SignupAttemptedEventis in theAnalyticsEventunion sodeps.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 /signupfor completeness - Check CLAUDE.md guideline adherence (assert vs
??, coverage, naming, comments) - Review dashboard widgets, events, tests
- Post review
· branchconversion/signup-attempted-instrumentation
High Priority Issues (Must Fix)None found Medium Priority IssuesNone found Low Priority Suggestions1.
|
|
@claude Resolve conflicts. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 11m 40s —— View job Conflicts resolved ✅Rebased
What conflicted & how it was resolved
1. Widget grid overlap ( 2. Widget-count drift assertion (
The follow-up review-fix commit ( Verification: |
fb94830 to
bfcad99
Compare
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 5m 10s —— View job Re-review:
|
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 /signupfor completeness - Verify prior-review fixes are intact (duplicate-race emit,
assertonvisitor_id) - Check dashboard placement survived the rebase onto
main - Check CLAUDE.md guideline adherence
- Post review
· branchconversion/signup-attempted-instrumentation
Re-review:
|
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.
|
@claude Resolve conflicts. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 26m 35s —— View job Conflicts resolved ✅Rebased
What conflicted & how it was resolved
1. 2.
3. 4. Dashboard ( Both prior review fixes survived the rebase: the Verification: full |
|
@claude Resolve conflicts. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 28m 14s —— View job Conflicts resolved ✅Rebased
What conflicted & how it was resolved
Resolved by following main's new architecture — the PR's additions moved into the package: 1. Analytics primitives → the package (
2. hutch 3. 4. Dashboard ( 5. Package coverage — because Verification:
|
f1612b1 to
20ed31e
Compare
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 5m 52s —— View job Re-review:
|
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 captureAnalytics → harness.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 /signupfor completeness - Verify prior-review fixes survived the rebase (duplicate-race emit,
assertonvisitor_id) - Check the
web-analyticspackage relocation (exports, coverage, tests) - Check dashboard placement / drift count after the rebase
- Check CLAUDE.md guideline adherence
- Post review
· branchconversion/signup-attempted-instrumentation
Re-review:
|
20ed31e to
30c0077
Compare
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 4m 59s —— View job Re-review:
|
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 /signupfor completeness - Verify prior-review fixes survived the rebase (duplicate-race emit,
assertonvisitor_id) - Check the
web-analyticspackage relocation (exports, coverage, tests) - Check dashboard placement / drift count after the rebase
- Check CLAUDE.md guideline adherence
- Post review
· branchconversion/signup-attempted-instrumentation
Re-review:
|
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.
|
@claude Resolve conflicts. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 11m 9s —— View job Conflicts resolved ✅Rebased
What conflicted & how it was resolved
The other three files from the fix commit auto-merged cleanly, and I verified the PR's two prior-review fixes survived intact:
Latent dashboard check (no Git conflict, but the usual post-rebase risk): Verification: full |
30c0077 to
f02f88f
Compare
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 4m 16s —— View job Re-review:
|
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 ontocff18fab) - Trace all terminal branches of
POST /signupfor completeness - Verify prior-review fixes survived the rebase (duplicate-race emit,
assertonvisitor_id) - Check dashboard placement / drift count after the rebase
- Check CLAUDE.md guideline adherence
· branchconversion/signup-attempted-instrumentation
Re-review:
|
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.
|
@claude Resolve conflicts. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 21m 33s —— View job Conflicts resolved ✅Rebased
What conflicted & how it was resolvedSince the last rebase, 1. Textual conflict — import block ( 2. Latent semantic merge — the
Both log to the
Both prior-review fixes survived the rebase: the Dashboard — the only main dashboard change since base was Verification: full
|
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>
f02f88f to
019f3bc
Compare
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 4m 12s —— View job Re-review:
|
Branch (auth.page.ts) |
Emits |
|---|---|
bot-rejected → submit_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 onto78dfa44b) - Trace all terminal branches of
POST /signupfor completeness - Verify prior-review fixes survived the rebase (duplicate-race emit,
assertonvisitor_id) - Check dashboard placement / drift count after the rebase
- Check CLAUDE.md guideline adherence
· branchconversion/signup-attempted-instrumentation
Hypothesis
The signup form is a black box.
POST /signupemits 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 asignup_attemptedanalytics 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)
/signup; 35 accounts were created. The ~180 who didn't convert are currently unattributable: bounce vs. rejected-submission is invisible.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.What this changes
A new
signup_attemptedevent (analytics stream) emitted at the four terminal outcomes ofPOST /signup:outcomecreateddisposable_emailinvalid_inputduplicate_emailvisitor_id(joins touser_createdand the reader funnel) andvisitor_hash(dashboard owner-exclusion).bot-defensestream. 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).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_emailas a share ofsignup_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
count(outcome='created') / count(*)oversignup_attempted.count(outcome='disposable_email') / count(*).Verification
pnpm checkpasses (all 36 projects; lint, types, unit + integration + Playwright E2E, 100% coverage gate). New tests assert one event with the correctoutcomefor each of the five paths (created ×2, disposable, invalid, duplicate) and that bot trips emit none.https://claude.ai/code/session_01En2uUiiwQoRQKWenCnkPi9