feat(webapp): user-based Sentry attribution with tenant tags#3678
feat(webapp): user-based Sentry attribution with tenant tags#3678d-cs wants to merge 13 commits into
Conversation
Stamp each Sentry event with the org/project/env it belongs to so "Users Impacted" reflects tenant count and events are filterable per org. Uses an AsyncLocalStorage scope set at HTTP entry points (API routes via apiBuilder, dashboard routes via an Express middleware that resolves org/project/env from the URL). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds an AsyncLocalStorage-backed TenantContext with run/get/enrich and a mapping from AuthenticatedEnvironment; implements a synchronous path parser and middleware that always establishes an ALS scope; provides addTenantContextToEvent to enrich Sentry events with tenant user and tenant tags; wires the middleware into Express/Remix entrypoints, registers the Sentry processor when SENTRY_DSN is present, scopes API handlers via tenantContext.run, updates env typing, and includes tests for all pieces. Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add SENTRY_DSN to env.server.ts schema and read it via env (not process.env) to match repo conventions. - Wrap multi-method API handlers in tenantContext.run too — they were missed in the initial pass and would have shipped without tenant tags. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@coderabbitai responses to the rest:
Test colocation nits (×4) — declining. Resolver error-handling note on |
|
Tip For best results, initiate chat on the files or code changes.
✏️ Learnings added
|
Pivot the sentry tenant attribution flow based on review feedback: - `user.id` is now the real signed-in user cuid, so "Users Impacted" counts distinct humans. Tenant context (org / project / env slugs, IDs, env type) moves entirely to tags. - The Express middleware no longer queries the database. It parses the URL with a regex and sets an ALS scope with whatever subset of slugs is present (`/orgs/:o`, `/orgs/:o/projects/:p`, or the full triple). - `_app/route.tsx` enriches the scope with `userId` for any authenticated dashboard request — reusing the existing `requireUser` call. No new query. - The env layout loader's existing `prisma.project.findFirst` gains two extra columns in its select (`externalRef`, `organization.id`) and enriches the scope with the IDs / env type after picking an env. Same single query, no extra round-trip. - API routes flow through `tenantContextFromAuthEnvironment`, which pulls `userId` from `env.orgMember.userId` (already selected by `authIncludeBase`) and stamps the full tenant set up-front. Trade-off: errors that fire before the env layout loader's enrich on env-scoped pages get slugs + `user.id` but no tenant IDs. Realistic errors after async work get the full set. API requests without an `orgMember` get tenant tags but no `user.id`. Out of scope (deferred): background workers, schedule-engine, socket handlers — those events still ship without tenant attribution. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remix's dev mode re-evaluates the entry module on code changes, but `@sentry/remix` lives in node_modules and isn't reloaded — so without a singleton guard each HMR reload was appending another copy of the processor to Sentry's global processor list. Idempotent at runtime (the processor is a pure read+stamp), but the list grew unbounded over a dev session. Matches the pattern used by the two adjacent `singleton()` calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code reviewFound 1 issue:
The PR body explicitly enumerates PAT/worker as out of scope, so this can also be deferred — flagging it because the inconsistency wasn't called out by the existing reviews. trigger.dev/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts Lines 595 to 607 in 6673298 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
createLoaderPATApiRoute was the only authenticated builder not stamping the scope with the request's user — PAT auth carries `userId` but not an AuthenticatedEnvironment, so the other builders' `tenantContext.run` pattern doesn't apply. Enrich the scope established by the Express middleware with the user id so Sentry events from PAT routes get user-level attribution. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Fixed in 90d64a9 — |
Behavioural test that imports the real builder, runs a handler under stubbed PAT auth, and asserts the handler observes `tenantContext.get().userId` matching the auth result. Catches the regression where the builder used to call `handler(...)` without stamping the scope (verified by reverting the fix locally — test fails). `rbac.authenticatePat` and `updateLastAccessedAtIfStale` are stubbed via `vi.mock` so the test stays unit-scoped (no testcontainer). The rest of the builder runs for real. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous version stubbed `rbac.authenticatePat` and `updateLastAccessedAtIfStale` to keep the test unit-scoped, but that violated the "never mock — use a real database" guidance in CLAUDE.md. The webapp's vitest setup already points `~/db.server` at the local postgres (`apps/webapp/test/setup.ts` loads `apps/webapp/.env`), so the test can seed a real User + PAT via Prisma and let `rbac.authenticatePat` validate end-to-end. No mocks. Cleanup runs in `afterAll` — deletes the PATs and the user. Verified to fail if the `enrich` call is removed from `createLoaderPATApiRoute`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The test relied on `~/db.server` reaching a postgres at localhost:5432. That works locally with the dev docker stack but not in CI, which only provides per-test containers via `@internal/testcontainers`. The webapp's singleton Prisma client can't easily be redirected to a per-test container without `vi.hoisted` plumbing, so dropping the test rather than carrying broken CI. The PAT enrich line is one line and exercised by manual verification — matches the rest of `apiBuilder.server.ts`, where the other builders also have no unit tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
User-based attribution (real user.id from requireUser / orgMember.userId) with org/project/env on tags when context is available — was the design pivot during review. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
singleton() uses `__trigger_singletons[name] ??= getValue()`. A `void`-returning callback stores `undefined` in the cache, which `??=` treats as "still unset" — so every Remix dev reload re-evaluates the callback and re-registers the Sentry processor (the exact problem this wrapper was meant to prevent). Return `true` so the cache holds a real value and the guard kicks in. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Stamp every Sentry event with the signed-in user and the tenant (org / project / env) the request belongs to, so "Users Impacted" counts distinct humans and events become filterable per tenant.
Design after review (current):
user.id = real user cuid(fromrequireUser). "Users Impacted" counts humans, not tenants.org_slug,project_slug,env_slug,org_id,project_id,project_ref,environment_id,env_type, plusimpersonatingwhen set.AsyncLocalStoragescope established at the HTTP entry. Each entry point fills what it knows; loaders enrich the same scope with what they already have.Zero new database queries. The middleware does a regex match only. Dashboard loaders that already query Prisma gain a couple of extra selected columns; nothing new round-trips.
How it's wired
tenantContextResolver.server.ts) — parses the URL with a regex and always opens an ALS scope. Populates whatever subset of slugs is present:/orgs/:o→ justorgSlug;/orgs/:o/projects/:paddsprojectSlug; the full triple addsenvSlug. Non-tenant paths get an empty scope so loaders can still enrich._app/route.tsx— already callsrequireUser. AddstenantContext.enrich({ userId: user.id })for every authenticated dashboard request. No new query._app.orgs.$o.projects.$p.env.$e/route.tsx) — its existingprisma.project.findFirstgains two columns inselect(externalRef,organization.id). After it picks an env, callstenantContext.enrich({ orgId, projectId, projectRef, envId, envType }). Same query, +2 columns.apiBuilder.server.ts) — wraps every handler intenantContext.run(tenantContextFromAuthEnvironment(authenticationResult.environment), …). The mapper pullsuserIdfromenv.orgMember?.userId(already selected byauthIncludeBase— no schema change). CoverscreateLoaderApiRoute,createActionApiRoute, andcreateMultiMethodApiRoute.sentryTenantContext.server.ts) — registered inentry.server.tsxso it lives in the Remix bundle and shares the sametenantContextALS instance as the middleware and loaders. Stamps whatever's present; nothing forced.Example events from local verification
user.id/orgs/:o/projects/:p/env/:e/...org_slug,project_slug,env_slug,org_id,project_id,project_ref,environment_id,env_type/orgs/:o/settings(non-env-scoped)org_slugonlyorgMemberorgMember.userIdorgMemberTrade-offs
user.idbut not the tenant IDs /env_type. Realistic errors deep in async work get the full set. (Same race as before, narrower window now that slugs/user.idare populated up-front by the middleware and_appenrich.)orgMemberget tenant tags but nouser.id. Those events still show in the issue but don't contribute to "Users Impacted".Out of scope (deferred)
Background workers (
redis-worker,schedule-engine) and socket handlers. Those entry points don't settenantContext.runyet — their events ship without tenant attribution until each is wired in a follow-up.Tests
31 unit tests across 4 files. New tests notably cover:
parseTenantPath: org-only, org+project, and full-triple URL variants.tenantContext.enrich: in-place patch, no-op outsiderun(), concurrent-scope isolation, empty-scope + enrich pattern (for non-tenant pages).tenantContextFromAuthEnvironment: with and withoutorgMember— verifies the API path'suser.idmapping.addTenantContextToEvent: empty scope, userId-only, slugs-only, full enrichment, conditional tag emission, preservation of priorevent.userfields.Test plan
pnpm run typecheck --filter webapppnpm run test --filter webapp -- test/tenantContext.test.ts test/sentryTenantContext.test.ts test/tenantContextResolver.test.ts test/tenantContextFromAuthEnvironment.test.tsSENTRY_DSNset, hit a dashboard URL and an API route, confirm the captured events carryuser.id+ the expected tag set in Sentry.🤖 Generated with Claude Code