Skip to content

Commit 0bcef22

Browse files
d-csclaude
andcommitted
feat(webapp): attach tenant context to Sentry events
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>
1 parent 6b46a34 commit 0bcef22

11 files changed

Lines changed: 480 additions & 18 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Attach organization / project / environment to every Sentry event so "Users Impacted" counts orgs and events are filterable by tenant.
7+
8+
Mechanism: `AsyncLocalStorage`-backed `tenantContext` + a Sentry `addEventProcessor` that stamps `user.id = orgId`, `user.username = orgSlug`, and tags (`org_id`, `org_slug`, `project_id`, `project_ref`, `environment_id`, `env_slug`, `env_type`, `impersonating`).
9+
10+
Wired at the HTTP entry points:
11+
12+
- API routes — `apiBuilder.server.ts` wraps each handler invocation in `tenantContext.run` using the authenticated `environment`.
13+
- Dashboard requests — an Express middleware (`tenantContextMiddleware`) resolves org/project/env from the URL pattern `/orgs/:org/projects/:project/env/:env/...` and wraps the Remix handler.
14+
15+
Background workers (redis-worker / schedule-engine) and socket handlers are not yet wired and remain a follow-up. Events from those entry points will continue to ship without tenant attribution until each handler is updated.

apps/webapp/app/entry.server.tsx

Lines changed: 7 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";
@@ -289,9 +291,14 @@ process.on("uncaughtException", (error, origin) => {
289291
singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
290292
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
291293

294+
if (process.env.SENTRY_DSN) {
295+
Sentry.addEventProcessor(addTenantContextToEvent);
296+
}
297+
292298
export { apiRateLimiter } from "./services/apiRateLimit.server";
293299
export { engineRateLimiter } from "./services/engineRateLimit.server";
294300
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
301+
export { tenantContextMiddleware } from "./services/tenantContextResolver.server";
295302
export { socketIo } from "./v3/handleSocketIo.server";
296303
export { wss } from "./v3/handleWebsockets.server";
297304

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

Lines changed: 30 additions & 18 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 {
@@ -903,15 +911,19 @@ export function createActionApiRoute<
903911
);
904912
}
905913

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-
});
914+
const result = await tenantContext.run(
915+
tenantContextFromAuthEnvironment(authenticationResult.environment),
916+
() =>
917+
handler({
918+
params: parsedParams,
919+
searchParams: parsedSearchParams,
920+
headers: parsedHeaders,
921+
body: parsedBody,
922+
authentication: authenticationResult,
923+
request,
924+
resource,
925+
})
926+
);
915927
return await wrapResponse(request, result, corsStrategy !== "none");
916928
} catch (error) {
917929
try {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { AsyncLocalStorage } from "node:async_hooks";
2+
import type { AuthenticatedEnvironment } from "./apiAuth.server";
3+
4+
export type TenantContext = {
5+
org: { id: string; slug: string };
6+
project: { id: string; ref: string };
7+
environment: {
8+
id: string;
9+
slug: string;
10+
type: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION";
11+
};
12+
impersonating?: boolean;
13+
};
14+
15+
const storage = new AsyncLocalStorage<TenantContext>();
16+
17+
export const tenantContext = {
18+
run<T>(ctx: TenantContext, fn: () => T): T {
19+
return storage.run(ctx, fn);
20+
},
21+
get(): TenantContext | undefined {
22+
return storage.getStore();
23+
},
24+
};
25+
26+
export function tenantContextFromAuthEnvironment(env: AuthenticatedEnvironment): TenantContext {
27+
return {
28+
org: { id: env.organization.id, slug: env.organization.slug },
29+
project: { id: env.project.id, ref: env.project.externalRef },
30+
environment: { id: env.id, slug: env.slug, type: env.type },
31+
};
32+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { NextFunction, Request, Response } from "express";
2+
import { prisma } from "~/db.server";
3+
import { tenantContext, type TenantContext } from "./tenantContext.server";
4+
import { logger } from "./logger.server";
5+
6+
const URL_PATTERN = /^\/orgs\/([^/]+)(?:\/projects\/([^/]+)(?:\/env\/([^/]+))?)?/;
7+
8+
export type ParsedTenantPath = {
9+
orgSlug: string;
10+
projectParam: string;
11+
envParam: string;
12+
};
13+
14+
export function parseTenantPath(pathname: string): ParsedTenantPath | undefined {
15+
const match = pathname.match(URL_PATTERN);
16+
if (!match) return undefined;
17+
const [, orgSlug, projectParam, envParam] = match;
18+
if (!orgSlug || !projectParam || !envParam) return undefined;
19+
return { orgSlug, projectParam, envParam };
20+
}
21+
22+
export async function resolveTenantContextFromPath(
23+
pathname: string
24+
): Promise<TenantContext | undefined> {
25+
const parsed = parseTenantPath(pathname);
26+
if (!parsed) return undefined;
27+
28+
try {
29+
const env = await prisma.runtimeEnvironment.findFirst({
30+
where: {
31+
slug: parsed.envParam,
32+
project: { slug: parsed.projectParam, organization: { slug: parsed.orgSlug } },
33+
},
34+
select: {
35+
id: true,
36+
slug: true,
37+
type: true,
38+
project: { select: { id: true, externalRef: true } },
39+
organization: { select: { id: true, slug: true } },
40+
},
41+
});
42+
if (!env) return undefined;
43+
return {
44+
org: { id: env.organization.id, slug: env.organization.slug },
45+
project: { id: env.project.id, ref: env.project.externalRef },
46+
environment: {
47+
id: env.id,
48+
slug: env.slug,
49+
type: env.type,
50+
},
51+
};
52+
} catch (error) {
53+
logger.warn("tenantContextResolver: lookup failed", {
54+
error: error instanceof Error ? error.message : String(error),
55+
pathname,
56+
});
57+
return undefined;
58+
}
59+
}
60+
61+
export type PathResolver = (pathname: string) => Promise<TenantContext | undefined>;
62+
63+
export function createTenantContextMiddleware(resolver: PathResolver) {
64+
return async function tenantContextMiddleware(
65+
req: Request,
66+
res: Response,
67+
next: NextFunction
68+
) {
69+
const ctx = await resolver(req.path);
70+
if (ctx) {
71+
tenantContext.run(ctx, () => next());
72+
} else {
73+
next();
74+
}
75+
};
76+
}
77+
78+
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+
user: {
10+
...event.user,
11+
id: ctx.org.id,
12+
username: ctx.org.slug,
13+
},
14+
tags: {
15+
...event.tags,
16+
org_id: ctx.org.id,
17+
org_slug: ctx.org.slug,
18+
project_id: ctx.project.id,
19+
project_ref: ctx.project.ref,
20+
environment_id: ctx.environment.id,
21+
env_slug: ctx.environment.slug,
22+
env_type: ctx.environment.type,
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
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, it, expect } from "vitest";
2+
import type { Event } from "@sentry/remix";
3+
import { tenantContext } from "../app/services/tenantContext.server";
4+
import { addTenantContextToEvent } from "../app/utils/sentryTenantContext.server";
5+
6+
const sample = {
7+
org: { id: "org_1", slug: "acme" },
8+
project: { id: "proj_1", ref: "proj_abc" },
9+
environment: { id: "env_1", slug: "prod", type: "PRODUCTION" as const },
10+
};
11+
12+
describe("addTenantContextToEvent", () => {
13+
it("returns the event unchanged when no ALS context", () => {
14+
const event: Event = { message: "hi", tags: { existing: "1" } };
15+
const out = addTenantContextToEvent(event, {});
16+
expect(out).toEqual(event);
17+
});
18+
19+
it("stamps user + tags when ALS context is set", () => {
20+
tenantContext.run(sample, () => {
21+
const event: Event = { message: "boom", tags: { existing: "1" } };
22+
const out = addTenantContextToEvent(event, {});
23+
expect(out.user).toEqual({ id: "org_1", username: "acme" });
24+
expect(out.tags).toMatchObject({
25+
existing: "1",
26+
org_id: "org_1",
27+
org_slug: "acme",
28+
project_id: "proj_1",
29+
project_ref: "proj_abc",
30+
environment_id: "env_1",
31+
env_slug: "prod",
32+
env_type: "PRODUCTION",
33+
});
34+
expect(out.tags?.impersonating).toBeUndefined();
35+
});
36+
});
37+
38+
it("adds impersonating tag when flag set", () => {
39+
tenantContext.run({ ...sample, impersonating: true }, () => {
40+
const out = addTenantContextToEvent({}, {});
41+
expect(out.tags?.impersonating).toBe("true");
42+
});
43+
});
44+
45+
it("preserves prior event.user fields it does not own", () => {
46+
tenantContext.run(sample, () => {
47+
const event: Event = { user: { ip_address: "1.2.3.4" } };
48+
const out = addTenantContextToEvent(event, {});
49+
expect(out.user).toEqual({ ip_address: "1.2.3.4", id: "org_1", username: "acme" });
50+
});
51+
});
52+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it, expect } from "vitest";
2+
import { tenantContext, type TenantContext } from "../app/services/tenantContext.server";
3+
4+
const sample: TenantContext = {
5+
org: { id: "org_1", slug: "acme" },
6+
project: { id: "proj_1", ref: "proj_abc" },
7+
environment: { id: "env_1", slug: "prod", type: "PRODUCTION" },
8+
};
9+
10+
describe("tenantContext", () => {
11+
it("returns undefined outside run()", () => {
12+
expect(tenantContext.get()).toBeUndefined();
13+
});
14+
15+
it("returns the active context inside run()", () => {
16+
tenantContext.run(sample, () => {
17+
expect(tenantContext.get()).toEqual(sample);
18+
});
19+
});
20+
21+
it("isolates concurrent async trees", async () => {
22+
const a: TenantContext = { ...sample, org: { id: "org_a", slug: "a" } };
23+
const b: TenantContext = { ...sample, org: { id: "org_b", slug: "b" } };
24+
25+
const [got1, got2] = await Promise.all([
26+
tenantContext.run(a, async () => {
27+
await new Promise((r) => setTimeout(r, 10));
28+
return tenantContext.get()?.org.id;
29+
}),
30+
tenantContext.run(b, async () => {
31+
await new Promise((r) => setTimeout(r, 5));
32+
return tenantContext.get()?.org.id;
33+
}),
34+
]);
35+
expect(got1).toBe("org_a");
36+
expect(got2).toBe("org_b");
37+
});
38+
39+
it("supports nested run() overriding", () => {
40+
const inner: TenantContext = { ...sample, org: { id: "org_inner", slug: "inner" } };
41+
tenantContext.run(sample, () => {
42+
tenantContext.run(inner, () => {
43+
expect(tenantContext.get()?.org.id).toBe("org_inner");
44+
});
45+
expect(tenantContext.get()?.org.id).toBe("org_1");
46+
});
47+
});
48+
});

0 commit comments

Comments
 (0)