Skip to content

Commit 1015876

Browse files
d-csclaude
andauthored
feat(webapp): user-based Sentry attribution with tenant tags (#3678)
## 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](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 71d98b4 commit 1015876

14 files changed

Lines changed: 600 additions & 26 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Stamp Sentry events with the signed-in user so "Users Impacted" counts individual humans, and enrich events with org / project / environment tags when that context is available (dashboard URLs, authenticated API requests).

apps/webapp/app/entry.server.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node"; // or cloudflare/deno
22
import { RemixServer } from "@remix-run/react";
3+
import * as Sentry from "@sentry/remix";
34
import { wrapHandleErrorWithSentry } from "@sentry/remix";
5+
import { addTenantContextToEvent } from "~/utils/sentryTenantContext.server";
46
import { parseAcceptLanguage } from "intl-parse-accept-language";
57
import isbot from "isbot";
68
import { renderToPipeableStream } from "react-dom/server";
@@ -257,9 +259,24 @@ process.on("uncaughtException", (error, origin) => {
257259
singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
258260
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
259261

262+
// Wrapped in singleton() so Remix's dev-mode CJS reloads don't append
263+
// duplicate copies of the processor — Sentry's processor list lives in
264+
// node_modules and persists across module reloads. Idempotent at runtime
265+
// (the processor is a pure read+stamp), but the pattern matches the rest
266+
// of this file.
267+
singleton("SentryTenantContextProcessor", () => {
268+
if (env.SENTRY_DSN) {
269+
Sentry.addEventProcessor(addTenantContextToEvent);
270+
}
271+
// Return a truthy value — `singleton()` uses `??=` so a `void`
272+
// callback would re-execute (and re-register) on every dev reload.
273+
return true;
274+
});
275+
260276
export { apiRateLimiter } from "./services/apiRateLimit.server";
261277
export { engineRateLimiter } from "./services/engineRateLimit.server";
262278
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
279+
export { tenantContextMiddleware } from "./services/tenantContextResolver.server";
263280
export { socketIo } from "./v3/handleSocketIo.server";
264281
export { wss } from "./v3/handleWebsockets.server";
265282

apps/webapp/app/env.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ const EnvironmentSchema = z
134134
ELECTRIC_ORIGIN_SHARDS: z.string().optional(),
135135
APP_ENV: z.string().default(process.env.NODE_ENV),
136136
SERVICE_NAME: z.string().default("trigger.dev webapp"),
137+
SENTRY_DSN: z.string().optional(),
137138
POSTHOG_PROJECT_KEY: z.string().default("phc_LFH7kJiGhdIlnO22hTAKgHpaKhpM8gkzWAFvHmf5vfS"),
138139
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
139140
AUTH_GITHUB_CLIENT_ID: z.string().optional(),

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { redirectWithErrorMessage } from "~/models/message.server";
55
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
66
import { logger } from "~/services/logger.server";
77
import { requireUser } from "~/services/session.server";
8+
import { tenantContext } from "~/services/tenantContext.server";
89
import { EnvironmentParamSchema, v3ProjectPath } from "~/utils/pathBuilder";
910

1011
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
@@ -26,6 +27,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
2627
},
2728
select: {
2829
id: true,
30+
externalRef: true,
31+
organization: { select: { id: true } },
2932
environments: {
3033
select: {
3134
id: true,
@@ -52,6 +55,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
5255
}
5356

5457
let environmentId: string | undefined = undefined;
58+
let environmentType: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION" | undefined;
5559

5660
if (environments.length > 1) {
5761
const bestEnvironment = environments.find((env) => env.orgMember?.userId === user.id);
@@ -63,10 +67,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
6367
}
6468

6569
environmentId = bestEnvironment.id;
70+
environmentType = bestEnvironment.type;
6671
} else {
6772
environmentId = environments[0].id;
73+
environmentType = environments[0].type;
6874
}
6975

76+
// userId is enriched higher up in `_app/route.tsx`; only stamp tenant fields here.
77+
tenantContext.enrich({
78+
orgId: project.organization.id,
79+
projectId: project.id,
80+
projectRef: project.externalRef,
81+
envId: environmentId,
82+
envType: environmentType,
83+
});
84+
7085
await updateCurrentProjectEnvironmentId({ user: user, projectId: project.id, environmentId });
7186

7287
return project;

apps/webapp/app/routes/_app/route.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { RouteErrorDisplay } from "~/components/ErrorDisplay";
55
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
66
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
77
import { requireUser } from "~/services/session.server";
8+
import { tenantContext } from "~/services/tenantContext.server";
89
import { confirmBasicDetailsPath } from "~/utils/pathBuilder";
910

1011
export const loader = async ({ request }: LoaderFunctionArgs) => {
1112
const user = await requireUser(request);
13+
tenantContext.enrich({ userId: user.id });
1214

1315
//you have to confirm basic details before you can do anything
1416
if (!user.confirmedBasicDetails) {

apps/webapp/app/services/routeBuilders/apiBuilder.server.ts

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import { API_VERSIONS, getApiVersion } from "~/api/versions";
1919
import { WORKER_HEADERS } from "@trigger.dev/core/v3/runEngineWorker";
2020
import { ServiceValidationError } from "~/v3/services/common.server";
2121
import { EngineServiceValidationError } from "@internal/run-engine";
22+
import {
23+
tenantContext,
24+
tenantContextFromAuthEnvironment,
25+
} from "~/services/tenantContext.server";
2226

2327
// Client aborts and service-level validation errors aren't bugs — they're
2428
// expected at API boundaries. Log them at `warn` so they stay in stdout
@@ -357,15 +361,19 @@ export function createLoaderApiRoute<
357361

358362
const apiVersion = getApiVersion(request);
359363

360-
const result = await handler({
361-
params: parsedParams,
362-
searchParams: parsedSearchParams,
363-
headers: parsedHeaders,
364-
authentication: authenticationResult,
365-
request,
366-
resource,
367-
apiVersion,
368-
});
364+
const result = await tenantContext.run(
365+
tenantContextFromAuthEnvironment(authenticationResult.environment),
366+
() =>
367+
handler({
368+
params: parsedParams,
369+
searchParams: parsedSearchParams,
370+
headers: parsedHeaders,
371+
authentication: authenticationResult,
372+
request,
373+
resource,
374+
apiVersion,
375+
})
376+
);
369377
return await wrapResponse(request, result, corsStrategy !== "none");
370378
} catch (error) {
371379
try {
@@ -586,6 +594,11 @@ export function createLoaderPATApiRoute<
586594
}
587595
}
588596

597+
// PAT auth carries `userId` but no environment — enrich the scope
598+
// the Express middleware established with the authenticated user so
599+
// Sentry events from this handler get user-level attribution.
600+
tenantContext.enrich({ userId: authenticationResult.userId });
601+
589602
const result = await handler({
590603
params: parsedParams,
591604
searchParams: parsedSearchParams,
@@ -903,15 +916,19 @@ export function createActionApiRoute<
903916
);
904917
}
905918

906-
const result = await handler({
907-
params: parsedParams,
908-
searchParams: parsedSearchParams,
909-
headers: parsedHeaders,
910-
body: parsedBody,
911-
authentication: authenticationResult,
912-
request,
913-
resource,
914-
});
919+
const result = await tenantContext.run(
920+
tenantContextFromAuthEnvironment(authenticationResult.environment),
921+
() =>
922+
handler({
923+
params: parsedParams,
924+
searchParams: parsedSearchParams,
925+
headers: parsedHeaders,
926+
body: parsedBody,
927+
authentication: authenticationResult,
928+
request,
929+
resource,
930+
})
931+
);
915932
return await wrapResponse(request, result, corsStrategy !== "none");
916933
} catch (error) {
917934
try {
@@ -1156,14 +1173,18 @@ export function createMultiMethodApiRoute<
11561173
}
11571174

11581175
// Dispatch to method handler
1159-
const result = await methodConfig.handler({
1160-
params: parsedParams,
1161-
searchParams: parsedSearchParams,
1162-
headers: parsedHeaders,
1163-
body: parsedBody,
1164-
authentication: authenticationResult,
1165-
request,
1166-
});
1176+
const result = await tenantContext.run(
1177+
tenantContextFromAuthEnvironment(authenticationResult.environment),
1178+
() =>
1179+
methodConfig.handler({
1180+
params: parsedParams,
1181+
searchParams: parsedSearchParams,
1182+
headers: parsedHeaders,
1183+
body: parsedBody,
1184+
authentication: authenticationResult,
1185+
request,
1186+
})
1187+
);
11671188
return await wrapResponse(request, result, corsStrategy !== "none");
11681189
} catch (error) {
11691190
try {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { AsyncLocalStorage } from "node:async_hooks";
2+
import type { AuthenticatedEnvironment } from "./apiAuth.server";
3+
4+
// All fields are optional. The middleware establishes an empty scope per
5+
// request; entry points fill what they know:
6+
// - URL-matching paths get the slug trio from the Express middleware (zero IO).
7+
// - The `_app` layout adds `userId` for any authenticated request.
8+
// - The env layout adds tenant IDs / env type after its own existing DB query.
9+
// - API routes get the full set up-front from `authenticationResult.environment`.
10+
export type TenantContext = {
11+
userId?: string;
12+
orgSlug?: string;
13+
projectSlug?: string;
14+
envSlug?: string;
15+
orgId?: string;
16+
projectId?: string;
17+
projectRef?: string;
18+
envId?: string;
19+
envType?: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION";
20+
impersonating?: boolean;
21+
};
22+
23+
const storage = new AsyncLocalStorage<TenantContext>();
24+
25+
export const tenantContext = {
26+
run<T>(ctx: TenantContext, fn: () => T): T {
27+
return storage.run(ctx, fn);
28+
},
29+
get(): TenantContext | undefined {
30+
return storage.getStore();
31+
},
32+
enrich(patch: Partial<TenantContext>): void {
33+
const current = storage.getStore();
34+
if (current) Object.assign(current, patch);
35+
},
36+
};
37+
38+
export function tenantContextFromAuthEnvironment(env: AuthenticatedEnvironment): TenantContext {
39+
return {
40+
userId: env.orgMember?.userId,
41+
orgSlug: env.organization.slug,
42+
projectSlug: env.project.slug,
43+
envSlug: env.slug,
44+
orgId: env.organization.id,
45+
projectId: env.project.id,
46+
projectRef: env.project.externalRef,
47+
envId: env.id,
48+
envType: env.type,
49+
};
50+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { NextFunction, Request, Response } from "express";
2+
import { tenantContext, type TenantContext } from "./tenantContext.server";
3+
4+
const URL_PATTERN = /^\/orgs\/([^/]+)(?:\/projects\/([^/]+)(?:\/env\/([^/]+))?)?/;
5+
6+
export type ParsedTenantPath = {
7+
orgSlug: string;
8+
projectSlug?: string;
9+
envSlug?: string;
10+
};
11+
12+
// Pulls whatever tenant slugs are present in the URL. `/orgs/:o` returns the
13+
// org alone; `/orgs/:o/projects/:p` adds the project; `/orgs/:o/projects/:p/env/:e`
14+
// returns all three. Non-tenant paths (`/`, `/login`, `/admin/*`) return undefined.
15+
export function parseTenantPath(pathname: string): ParsedTenantPath | undefined {
16+
const match = pathname.match(URL_PATTERN);
17+
if (!match) return undefined;
18+
const [, orgSlug, projectSlug, envSlug] = match;
19+
if (!orgSlug) return undefined;
20+
return {
21+
orgSlug,
22+
...(projectSlug ? { projectSlug } : {}),
23+
...(envSlug ? { envSlug } : {}),
24+
};
25+
}
26+
27+
export function resolveTenantContextFromPath(pathname: string): TenantContext {
28+
return parseTenantPath(pathname) ?? {};
29+
}
30+
31+
export type PathResolver = (pathname: string) => TenantContext;
32+
33+
export function createTenantContextMiddleware(resolver: PathResolver) {
34+
// Always establish an ALS scope, even when the path carries no tenant
35+
// slugs. Authenticated loaders (e.g. the `_app` layout) then enrich the
36+
// same scope with `userId`, so non-tenant pages still get user attribution.
37+
return function tenantContextMiddleware(req: Request, res: Response, next: NextFunction) {
38+
tenantContext.run(resolver(req.path), () => next());
39+
};
40+
}
41+
42+
export const tenantContextMiddleware = createTenantContextMiddleware(resolveTenantContextFromPath);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Event, EventHint } from "@sentry/remix";
2+
import { tenantContext } from "../services/tenantContext.server";
3+
4+
export function addTenantContextToEvent(event: Event, _hint: EventHint): Event {
5+
const ctx = tenantContext.get();
6+
if (!ctx) return event;
7+
return {
8+
...event,
9+
// Only stamp user.id when we have a real user — keeps "Users Impacted"
10+
// counting distinct humans rather than mixing in tenants. Events without
11+
// a known user (e.g. unauthenticated paths) skip user attribution.
12+
...(ctx.userId ? { user: { ...event.user, id: ctx.userId } } : {}),
13+
tags: {
14+
...event.tags,
15+
...(ctx.orgSlug ? { org_slug: ctx.orgSlug } : {}),
16+
...(ctx.projectSlug ? { project_slug: ctx.projectSlug } : {}),
17+
...(ctx.envSlug ? { env_slug: ctx.envSlug } : {}),
18+
...(ctx.orgId ? { org_id: ctx.orgId } : {}),
19+
...(ctx.projectId ? { project_id: ctx.projectId } : {}),
20+
...(ctx.projectRef ? { project_ref: ctx.projectRef } : {}),
21+
...(ctx.envId ? { environment_id: ctx.envId } : {}),
22+
...(ctx.envType ? { env_type: ctx.envType } : {}),
23+
...(ctx.impersonating ? { impersonating: "true" } : {}),
24+
},
25+
};
26+
}

apps/webapp/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
122122
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;
123123
const engineRateLimiter: RateLimitMiddleware = build.entry.module.engineRateLimiter;
124124
const runWithHttpContext: RunWithHttpContextFunction = build.entry.module.runWithHttpContext;
125+
const tenantContextMiddleware: import("express").RequestHandler =
126+
build.entry.module.tenantContextMiddleware;
125127

126128
app.use((req, res, next) => {
127129
// helpful headers:
@@ -171,6 +173,8 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
171173
app.use(apiRateLimiter);
172174
app.use(engineRateLimiter);
173175

176+
app.use(tenantContextMiddleware);
177+
174178
app.all(
175179
"*",
176180
// @ts-ignore

0 commit comments

Comments
 (0)