Skip to content

bridge: attribute server errors to the user who hit them - #1902

Open
njrini99-code wants to merge 1 commit into
mainfrom
agent/bridge-user-attribution
Open

bridge: attribute server errors to the user who hit them#1902
njrini99-code wants to merge 1 commit into
mainfrom
agent/bridge-user-attribution

Conversation

@njrini99-code

Copy link
Copy Markdown
Owner

The problem

61.7% of error rows in the last 30 days carry no user_id, so the Bridge shows an error with nobody attached to it.

The read side already works — src/app/admin/errors/[fingerprint]/page.tsx links user_id to the user page whenever there is one. This is purely a capture gap: enrichTraceContext defaults traceId ambiently but never userId, so a row is attributed only when the call site happened to pass one. There are ~583 auth.getUser() call sites; threading userId through each is not a fix anyone finishes.

The fix

Attribute once at the source.

  • createClient() (server) wraps auth.getUser and writes the resolved id into the existing AsyncLocalStorage request scope (request-context.ts, the same store requestId already uses).
  • enrichTraceContext reads it as a default. Call sites change nothing.
  • Failure path: when getUser() returns no user — the common "session expired mid-round" shape, which is exactly when we most want to know who — the wrapper decodes the sub claim out of the sb-*-auth-token cookie without verifying it and tags the row user_id_unverified.

Two deliberate constraints

  1. It never calls getSession(). With autoRefreshToken: false on the SSR client, getSession() still refreshes on demand and rotates the refresh token. Logging must never mutate auth state. There is a test asserting getSession is not called and no cookie is written.
  2. The ambient userId is ATTRIBUTION ONLY. Authorization keeps calling supabase.auth.getUser() directly. A verified id is never downgraded by a later unverified read.

user_id_unverified means "getUser() did not return this id", not "this session is bad" — it also fires on transient auth-check failures where the session is fine. Documented at the tag site.

Measured reach

Against requestId (same scope, already shipped), 30d server_action errors:

rows
total 4,924
in a request scope (requestId present) 4,246
already attributed 1,927
gain a user_id from this change 2,319
outside any scope — unreachable by this change 678

Sources with no withAdminObserved scope — integrity (278), cron (32), server_component (35), auth (41) — stay at 0%; several of those legitimately have no user. Forward-only: the 3,402 existing unattributed rows cannot be back-filled.

Tests

  • src/lib/__tests__/server-error-logger-user-attribution.test.ts (7) — ambient attribution with no call-site change; both tables agree; explicit call-site userId wins; unverified subject flagged; no-op outside a scope; unverified never downgrades verified; verified upgrades unverified.
  • src/lib/supabase/__tests__/server-user-attribution.test.ts (6) — getSession never called, zero cookie writes, chunked-cookie reassembly, non-base64 cookie, silence on malformed cookies, original getUser result returned identically.

Verified the logger tests fail (3 of 7) with the change stashed. Full local gates green: typecheck 0, lint 0, 7,565 tests pass.

🤖 Generated with Claude Code

61.7% of error rows in the last 30 days carry no user_id, so the Bridge
shows an error with no one attached to it. The read side already renders
the user when there is one (admin/errors/[fingerprint] links user_id to
the user page) — this was purely a capture gap.

The cause: enrichTraceContext defaults traceId ambiently but never
userId, so a row is attributed only when the call site happens to pass
one. There are ~583 auth.getUser() call sites; threading userId through
each of them is not a fix anyone finishes.

Instead, attribute once at the source. createClient() (server) wraps
auth.getUser and writes the resolved id into the existing
AsyncLocalStorage request scope; enrichTraceContext reads it as a
default. Call sites change nothing.

Failure path: when getUser() returns no user — the common
"session expired mid-round" shape, which is exactly when we most want to
know who — the wrapper decodes the sub claim out of the sb-*-auth-token
cookie WITHOUT verifying it and tags the row user_id_unverified. It
deliberately does not call getSession(): with autoRefreshToken:false
getSession still refreshes on demand and rotates the refresh token, and
logging must never mutate auth state.

The ambient userId is ATTRIBUTION ONLY. Authorization keeps calling
supabase.auth.getUser() directly; a verified id is never downgraded by a
later unverified read.

Reach, measured against requestId (same scope, already shipped), 30d
server_action errors: 4,924 total, 4,246 in a request scope, 1,927
attributed. 2,319 rows gain a user_id; 678 sit outside any scope and are
unreachable by this change. Sources without a withAdminObserved scope
(integrity, cron, server_component, auth) stay at 0% — several of those
legitimately have no user. Forward-only; the 3,402 existing unattributed
rows cannot be back-filled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant