Skip to content

feat(webapp): user-based Sentry attribution with tenant tags#3678

Open
d-cs wants to merge 13 commits into
mainfrom
sentry-user-attribution
Open

feat(webapp): user-based Sentry attribution with tenant tags#3678
d-cs wants to merge 13 commits into
mainfrom
sentry-user-attribution

Conversation

@d-cs
Copy link
Copy Markdown
Collaborator

@d-cs d-cs commented May 20, 2026

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 (from requireUser). "Users Impacted" counts humans, not tenants.
  • Tenant context (org / project / env slugs, IDs, env type) moves entirely onto tags: org_slug, project_slug, env_slug, org_id, project_id, project_ref, environment_id, env_type, plus impersonating when set.
  • Backed by an AsyncLocalStorage scope 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

  • Express middleware (tenantContextResolver.server.ts) — parses the URL with a regex and always opens an ALS scope. Populates whatever subset of slugs is present: /orgs/:o → just orgSlug; /orgs/:o/projects/:p adds projectSlug; the full triple adds envSlug. Non-tenant paths get an empty scope so loaders can still enrich.
  • _app/route.tsx — already calls requireUser. Adds tenantContext.enrich({ userId: user.id }) for every authenticated dashboard request. No new query.
  • Env layout loader (_app.orgs.$o.projects.$p.env.$e/route.tsx) — its existing prisma.project.findFirst gains two columns in select (externalRef, organization.id). After it picks an env, calls tenantContext.enrich({ orgId, projectId, projectRef, envId, envType }). Same query, +2 columns.
  • API path (apiBuilder.server.ts) — wraps every handler in tenantContext.run(tenantContextFromAuthEnvironment(authenticationResult.environment), …). The mapper pulls userId from env.orgMember?.userId (already selected by authIncludeBase — no schema change). Covers createLoaderApiRoute, createActionApiRoute, and createMultiMethodApiRoute.
  • Event processor (sentryTenantContext.server.ts) — registered in entry.server.tsx so it lives in the Remix bundle and shares the same tenantContext ALS instance as the middleware and loaders. Stamps whatever's present; nothing forced.

Example events from local verification

URL user.id Tags
/orgs/:o/projects/:p/env/:e/... real user cuid org_slug, project_slug, env_slug, org_id, project_id, project_ref, environment_id, env_type
/orgs/:o/settings (non-env-scoped) real user cuid org_slug only
API request with orgMember orgMember.userId full tenant set
API request without orgMember (unset) full tenant set

Trade-offs

  1. On env-scoped pages, errors that fire before the env layout loader's enrich callback runs get slugs + user.id but 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.id are populated up-front by the middleware and _app enrich.)
  2. API requests where the environment has no orgMember get tenant tags but no user.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 set tenantContext.run yet — 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 outside run(), concurrent-scope isolation, empty-scope + enrich pattern (for non-tenant pages).
  • tenantContextFromAuthEnvironment: with and without orgMember — verifies the API path's user.id mapping.
  • addTenantContextToEvent: empty scope, userId-only, slugs-only, full enrichment, conditional tag emission, preservation of prior event.user fields.

Test plan

  • pnpm run typecheck --filter webapp
  • pnpm run test --filter webapp -- test/tenantContext.test.ts test/sentryTenantContext.test.ts test/tenantContextResolver.test.ts test/tenantContextFromAuthEnvironment.test.ts
  • Local manual: with SENTRY_DSN set, hit a dashboard URL and an API route, confirm the captured events carry user.id + the expected tag set in Sentry.
  • After ship: confirm "Users Impacted" on a real Sentry issue reflects distinct users (not tenants).

🤖 Generated with Claude Code

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>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 20, 2026

⚠️ No Changeset found

Latest commit: 307d39d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds 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)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description is comprehensive and well-structured, covering summary, design decisions, implementation details, trade-offs, tests, and test plan. It follows the repository's template with all key sections properly filled, though the checklist items are not explicitly marked.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main feature: enabling Sentry event attribution using user identity and tenant context (org/project/env) tags.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sentry-user-attribution

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.

❤️ Share

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

@d-cs d-cs self-assigned this May 20, 2026
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
coderabbitai[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

- 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>
@d-cs
Copy link
Copy Markdown
Collaborator Author

d-cs commented May 20, 2026

@coderabbitai responses to the rest:

apiBuilder.server.ts 1170-1179 (multi-method wrap) — fixed in 978b4e5. Good catch — would have left a hole.

Test colocation nits (×4) — declining. apps/webapp/vitest.config.ts has include: ["test/**/*.test.ts"], so colocated tests would silently not run. The colocation guideline is a repo-wide default; webapp specifically opts out via its vitest config.

Resolver error-handling note on tenantContextResolver.server.ts 63-75 — agreed, no change needed. The test "calls next() without ALS when the resolver throws" documents that the middleware contract is that the resolver doesn't throw; resolveTenantContextFromPath upholds that with its own try/catch.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

Tip

For best results, initiate chat on the files or code changes.

@d-cs Thanks for the detailed follow-up on all three points!

  1. Multi-method wrap fix — Great, glad that's plugged. 👍

  2. Test colocation — Understood and noted. The webapp's explicit include: ["test/**/*.test.ts"] glob is a good reason to keep the flat test/ layout; colocated files would indeed be silently excluded. I'll remember this for future reviews.

  3. Resolver error-handling — Makes sense — the try/catch inside resolveTenantContextFromPath is the contract boundary, so the middleware not needing its own guard is correct by design.


✏️ Learnings added
Learnt from: d-cs
URL: https://github.com/triggerdotdev/trigger.dev/pull/3678

Timestamp: 2026-05-20T17:21:32.600Z
Learning: In the `apps/webapp` package of the triggerdotdev/trigger.dev repository, tests must live under `apps/webapp/test/` (flat, named `*.test.ts`) because `apps/webapp/vitest.config.ts` sets `include: ["test/**/*.test.ts"]`. Colocating test files next to source files (e.g., `*.test.ts` beside `*.ts` in `app/`) would cause them to be silently excluded from the test run. Do not suggest test colocation for files in `apps/webapp`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

d-cs and others added 2 commits May 21, 2026 09:49
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>
devin-ai-integration[bot]

This comment was marked as resolved.

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>
@d-cs
Copy link
Copy Markdown
Collaborator Author

d-cs commented May 21, 2026

Code review

Found 1 issue:

  1. createLoaderPATApiRoute does not wrap its handler(...) call in tenantContext.run(...), so PAT-authenticated requests miss user.id attribution in Sentry. The three sibling builders (createLoaderApiRoute, createActionApiRoute, createMultiMethodApiRoute) all wrap their handler invocations the same way. PAT auth carries userId but not an AuthenticatedEnvironment, so tenantContextFromAuthEnvironment doesn't apply — a tenantContext.enrich({ userId: authenticationResult.userId }) at the top of the handler block (or a wrapper tenantContext.run({ userId }, …)) would close the gap.

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.

}
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
ability,
request,
apiVersion,
});
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {

🤖 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>
@d-cs
Copy link
Copy Markdown
Collaborator Author

d-cs commented May 21, 2026

Fixed in 90d64a9createLoaderPATApiRoute now calls tenantContext.enrich({ userId: authenticationResult.userId }) after auth so PAT-authenticated handlers get user-level attribution. Used enrich rather than a nested tenantContext.run so the URL slugs the middleware already seeded survive.

d-cs and others added 5 commits May 21, 2026 11:28
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>
@d-cs d-cs changed the title feat(webapp): attach tenant context to Sentry events feat(webapp): user-based Sentry attribution with tenant tags May 21, 2026
@d-cs d-cs marked this pull request as ready for review May 21, 2026 12:26
devin-ai-integration[bot]

This comment was marked as resolved.

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>
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