Skip to content

Say why AI requests fail, offer the next step, and measure activation - #264

Merged
Programcode merged 5 commits into
mainfrom
fix/ai-failure-reasons-and-funnel
Oct 29, 2026
Merged

Programcode merged 5 commits into
mainfrom
fix/ai-failure-reasons-and-funnel

Conversation

@Programcode

@Programcode Programcode commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

In the last 14 days, 16 of 56 AI requests failed (29%). That hit 12 of 29 users, and ai_request_failed didn't say why. This PR changes only the web client. It touches no Edge Functions, migrations or config.

  • Failure reasons. ai_request_failed now says why a request failed, using a fixed set of codes mapped from the client's existing error classes (src/lib/ai/failure-reason.ts). It sends no motion text, prompts, answers, emails or names. The telemetry schema in src/lib/observability.ts drops any other value, and a test checks that.
  • Next step for each failure. A limit opens the plan dialog every tool already uses. A Pro-only model opens the upgrade dialog. A refused session (401, or a 403 that isn't about the model) now shows Sign in again instead of Retry, because sending again would be refused the same way. The button signs the tab out and opens /login with "Your session expired. Sign in again.", and signing in with an email code or a demo code returns to the same page. Google sign-in lands on /app, because the login page only sends the return path through Google when no account was signed in before. The recipe checks the login page, not the return trip. Timeouts, network failures, provider errors, malformed answers and server errors keep Retry. A request that is too large (413) has no Retry. This covers Generate, Attack, Brainstorm, Factoids, Ask, Sparring and Crossfire. Sparring and Crossfire retries already reuse the round's quotaSession/sessionId, so a retry is not charged twice. The PR adds one string, "Sign in again", in EN, HU and ES.
  • /login crash. All 27 React.lazy components and every route loader now load through one helper, loadChunk in src/lib/lazy-chunk.ts. After a deploy, Vite's vite:preloadError listener reloads the page, and Vite resolves the import to undefined. Callers then destructured nothing, which caused "(destructured parameter) is undefined". loadChunk now waits for that reload. A failed chunk import reloads once per path. The sessionStorage guard is shared with the listener, so the page can't reload in a loop. A second failure, or one while offline, reaches the route error boundary. Fix rate-limit overrides, AI error reports, and the login crash #190 already guarded the analytics import named in the Sentry stack (import("./index-*.js").then(({Analytics}) => ...)), so that report most likely comes from a tab or service worker still running an older bundle. I haven't confirmed that against the Sentry release. This PR closes the same pattern where it remained (AppLayout, FirstRun, the onboarding tour, the page loaders).
  • Activation events. These are client-side and carry no account id, email or text: signup_completed, trial_started, paywall_shown, first_ai_success and welcome_completed.

Events and properties

ai_request_failed adds these properties:

  • tool: generate, attack, brainstorm, factoids, ask, sparring or crossfire
  • error_code: limit_reached, model_restricted, auth_expired, timeout, network, aborted, provider_error, invalid_response, server_error or unknown. A 429 rate limit counts as limit_reached, with http_status 429.
  • http_status: 100 to 599, when a server answered. A client-side timeout sends none.
  • stage: request, stream (a streamed answer broke) or parse (the answer failed its check)
  • streaming: boolean, now always sent
  • duration_ms

The existing feature, errorName, statusCode and durationMs are still sent, because current insights and the Sentry duration metric read them. The overlap is deliberate.

New events:

  • signup_completed with method (email, google or demo). It fires on the first sign-in to an account created in the last 24 hours, once per account per browser. A demo code's account counts the first time the code is redeemed.
  • trial_started. It fires when a new account (same 24-hour rule) first loads on the trial plan, once per account per browser. handle_new_user starts the trial at sign-up, so it follows signup_completed. Accounts that existed before this ships never send it.
  • paywall_shown with reason (limit_reached, weekly_budget_reached, trial_ended, payment_failed, subscription_ended or model_restricted) and tool. It fires every time the limit or Pro-model dialog opens.
  • first_ai_success with tool. It fires on an account's first successful AI answer, once per account per browser.
  • welcome_completed. It fires when the owl welcome closes, once per account per browser.

There is no consent layer in the app. These events use the same gates as every other product event: VITE_POSTHOG_KEY must be set, and the closed schema must accept the event. The once-per-account guards keep the account id in localStorage only. PostHog runs with persistence: "memory" and never calls identify, so it can't link one student's events across page loads. Funnels across sessions would need a decision on identity first. This PR doesn't change that.

Verification

  • NODE_OPTIONS=--max-old-space-size=3072 npm run verify passed locally at b8f18da1 (exit 0). Docs check, SEO jargon check, lint (with knip and the repo rules) and typecheck all passed. Unit tests: 748 in 99 files. Collaboration tests: 30. Edge tests: 498. The production build, the service-worker offline check and the desktop Chromium @smoke browser set (47 tests) also passed. This is a local pass, not CI. The first run caught three untyped stub functions in the new tests. After that full run, one follow-up commit (180f3da6) drops the 408 a client timeout used to report as http_status, and makes namedChunk throw a named error for a missing export. I checked that commit with typecheck, lint, and the two test files it touches.

  • New unit tests: src/lib/ai/failure-reason.test.ts (error mapping, the reason fields on ai_request_failed, and the telemetry schema with PostHog mocked), src/test/lib/lazy-chunk.test.ts and src/test/lib/activation-events.test.ts (the once-only guards). Writing the mapping test caught a real bug: jsdom's DOMException isn't an Error, so an abort would have been reported as unknown.

  • I added a new verify-debatelab recipe, recipes/ai-failures (feature file features/ai-failures.md), and ran it on the local stack in fake mode. All 6 tests passed on desktop-chromium and all 6 on mobile-chromium. It forces each failure in the browser and covers Sign in again in Generate, Ask, Sparring and Crossfire, Retry after a network failure in Ask (Retry answers the question once), and the plan dialog on a limit.

  • I also ran the recipes whose Retry flows these changes touch: case-generation, usage-limits and practice. All 44 tests passed across both viewports.

  • Not covered: a real timeout (it needs the 200 s client budget, so only the unit test covers it), staging, production, and whether the events arrive in PostHog. Nothing has been deployed.

Screenshots

Desktop (1280 px) and phone (mobile-chromium) shots of each error state are in ~/.t3/scratch/debateowl-next/ai-failures-shots/ on the VPS:

  • *-01-generate-sign-in-again.png and *-02-login-after-sign-in-again.png
  • *-01-ask-sign-in-again.png
  • *-01-ask-network-retry.png and *-02-ask-answered-after-retry.png
  • *-01-sparring-sign-in-again.png
  • *-01-crossfire-sign-in-again.png
  • *-01-brainstorm-limit-dialog.png

Before merging

This needs Adam's OK to merge. Merging to main deploys the frontend to production on Vercel, so it also needs his OK to deploy. No Supabase deploy is needed.

🤖 Generated with Claude Code

Programcode and others added 3 commits October 28, 2026 20:18
ai_request_failed now carries tool, error_code, http_status, stage,
streaming and duration_ms, mapped from the client's AI error classes.
Each tool's error offers the matching next step: the plan dialog for a
limit, Sign in again for a refused session, Retry where sending again
can work.

Every lazy component and route loader goes through loadChunk, which
waits for the stale-chunk reload instead of destructuring a module that
loaded as nothing (the /login crash), and reloads once per path when a
chunk is missing.

New client events: signup_completed, trial_started, paywall_shown,
first_ai_success and welcome_completed, through the existing telemetry
schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 28, 2026 20:38
@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Next included review available in 25 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between c06378c and e604dd7.

Files selected for processing: 34
  • .claude/skills/verify-debatelab/features/README.md
  • .claude/skills/verify-debatelab/features/ai-failures.md
  • .claude/skills/verify-debatelab/recipes/ai-failures.spec.ts
  • docs/architecture.md
  • src/App.tsx
  • src/components/WebTelemetry.tsx
  • src/components/auth/SignInAgainButton.tsx
  • src/components/features/ask/AskChat.tsx
  • src/components/features/sparring/SparringRound.tsx
  • src/components/features/tools/tool-run.tsx
  • src/hooks/use-subscription-error.ts
  • src/lib/activation-events.ts
  • src/lib/ai/failure-reason.test.ts
  • src/lib/ai/failure-reason.ts
  • src/lib/ai/request.ts
  • src/lib/ai/streaming.ts
  • src/lib/lazy-chunk.ts
  • src/lib/observability.ts
  • src/lib/reload-stale-chunk.ts
  • src/lib/route-modules.ts
  • src/lib/supabase/session-expiry.ts
  • src/locales/en.json
  • src/locales/es.json
  • src/locales/hu.json
  • src/pages/AskPage.tsx
  • src/pages/AttackPage.tsx
  • src/pages/BrainstormPage.tsx
  • src/pages/CrossfirePage.tsx
  • src/pages/FactoidsPage.tsx
  • src/pages/GeneratePage.tsx
  • src/pages/SparringPage.tsx
  • src/test/lib/activation-events.test.ts
  • src/test/lib/lazy-chunk.test.ts
  • src/test/lib/reload-stale-chunk.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

Programcode and others added 2 commits October 28, 2026 20:43
A TimeoutError's 408 is the browser's own deadline, so http_status left
it reading as a server answer. namedChunk now throws ChunkLoadError for
an export that is not there instead of handing React undefined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Programcode
Programcode merged commit 0760dd2 into main Oct 29, 2026
3 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants