Skip to content

Commit c78e88f

Browse files
d-csclaude
andcommitted
refactor(webapp): user-based sentry attribution, no middleware DB lookup
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>
1 parent 978b4e5 commit c78e88f

9 files changed

Lines changed: 288 additions & 187 deletions

File tree

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) {
Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import { AsyncLocalStorage } from "node:async_hooks";
22
import type { AuthenticatedEnvironment } from "./apiAuth.server";
33

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`.
410
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-
};
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";
1220
impersonating?: boolean;
1321
};
1422

@@ -21,12 +29,22 @@ export const tenantContext = {
2129
get(): TenantContext | undefined {
2230
return storage.getStore();
2331
},
32+
enrich(patch: Partial<TenantContext>): void {
33+
const current = storage.getStore();
34+
if (current) Object.assign(current, patch);
35+
},
2436
};
2537

2638
export function tenantContextFromAuthEnvironment(env: AuthenticatedEnvironment): TenantContext {
2739
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 },
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,
3149
};
3250
}

apps/webapp/app/services/tenantContextResolver.server.ts

Lines changed: 20 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,41 @@
11
import type { NextFunction, Request, Response } from "express";
2-
import { prisma } from "~/db.server";
32
import { tenantContext, type TenantContext } from "./tenantContext.server";
4-
import { logger } from "./logger.server";
53

64
const URL_PATTERN = /^\/orgs\/([^/]+)(?:\/projects\/([^/]+)(?:\/env\/([^/]+))?)?/;
75

86
export type ParsedTenantPath = {
97
orgSlug: string;
10-
projectParam: string;
11-
envParam: string;
8+
projectSlug?: string;
9+
envSlug?: string;
1210
};
1311

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.
1415
export function parseTenantPath(pathname: string): ParsedTenantPath | undefined {
1516
const match = pathname.match(URL_PATTERN);
1617
if (!match) return undefined;
17-
const [, orgSlug, projectParam, envParam] = match;
18-
if (!orgSlug || !projectParam || !envParam) return undefined;
19-
return { orgSlug, projectParam, envParam };
18+
const [, orgSlug, projectSlug, envSlug] = match;
19+
if (!orgSlug) return undefined;
20+
return {
21+
orgSlug,
22+
...(projectSlug ? { projectSlug } : {}),
23+
...(envSlug ? { envSlug } : {}),
24+
};
2025
}
2126

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-
}
27+
export function resolveTenantContextFromPath(pathname: string): TenantContext {
28+
return parseTenantPath(pathname) ?? {};
5929
}
6030

61-
export type PathResolver = (pathname: string) => Promise<TenantContext | undefined>;
31+
export type PathResolver = (pathname: string) => TenantContext;
6232

6333
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-
}
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());
7539
};
7640
}
7741

apps/webapp/app/utils/sentryTenantContext.server.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,20 @@ export function addTenantContextToEvent(event: Event, _hint: EventHint): Event {
66
if (!ctx) return event;
77
return {
88
...event,
9-
user: {
10-
...event.user,
11-
id: ctx.org.id,
12-
username: ctx.org.slug,
13-
},
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 } } : {}),
1413
tags: {
1514
...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,
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 } : {}),
2323
...(ctx.impersonating ? { impersonating: "true" } : {}),
2424
},
2525
};

apps/webapp/test/sentryTenantContext.test.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,20 @@ import type { Event } from "@sentry/remix";
33
import { tenantContext } from "../app/services/tenantContext.server";
44
import { addTenantContextToEvent } from "../app/utils/sentryTenantContext.server";
55

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 },
6+
const slugOnly = {
7+
orgSlug: "acme",
8+
projectSlug: "web",
9+
envSlug: "prod",
10+
};
11+
12+
const enrichedWithUser = {
13+
...slugOnly,
14+
userId: "usr_42",
15+
orgId: "org_1",
16+
projectId: "proj_1",
17+
projectRef: "proj_abc",
18+
envId: "env_1",
19+
envType: "PRODUCTION" as const,
1020
};
1121

1222
describe("addTenantContextToEvent", () => {
@@ -16,37 +26,68 @@ describe("addTenantContextToEvent", () => {
1626
expect(out).toEqual(event);
1727
});
1828

19-
it("stamps user + tags when ALS context is set", () => {
20-
tenantContext.run(sample, () => {
29+
it("stamps only userId when the scope holds just a user (non-tenant page)", () => {
30+
tenantContext.run({ userId: "usr_42" }, () => {
2131
const event: Event = { message: "boom", tags: { existing: "1" } };
2232
const out = addTenantContextToEvent(event, {});
23-
expect(out.user).toEqual({ id: "org_1", username: "acme" });
33+
expect(out.user).toEqual({ id: "usr_42" });
34+
expect(out.tags).toEqual({ existing: "1" });
35+
});
36+
});
37+
38+
it("stamps slug tags and no user.id when only slugs are set", () => {
39+
tenantContext.run(slugOnly, () => {
40+
const event: Event = { message: "boom", tags: { existing: "1" } };
41+
const out = addTenantContextToEvent(event, {});
42+
expect(out.user).toBeUndefined();
2443
expect(out.tags).toMatchObject({
2544
existing: "1",
26-
org_id: "org_1",
2745
org_slug: "acme",
46+
project_slug: "web",
47+
env_slug: "prod",
48+
});
49+
expect(out.tags?.org_id).toBeUndefined();
50+
expect(out.tags?.env_type).toBeUndefined();
51+
});
52+
});
53+
54+
it("stamps user.id + full tag set when fully enriched", () => {
55+
tenantContext.run(enrichedWithUser, () => {
56+
const out = addTenantContextToEvent({}, {});
57+
expect(out.user).toEqual({ id: "usr_42" });
58+
expect(out.tags).toMatchObject({
59+
org_slug: "acme",
60+
project_slug: "web",
61+
env_slug: "prod",
62+
org_id: "org_1",
2863
project_id: "proj_1",
2964
project_ref: "proj_abc",
3065
environment_id: "env_1",
31-
env_slug: "prod",
3266
env_type: "PRODUCTION",
3367
});
34-
expect(out.tags?.impersonating).toBeUndefined();
68+
});
69+
});
70+
71+
it("emits no slug/ID tags when scope is empty", () => {
72+
tenantContext.run({}, () => {
73+
const out = addTenantContextToEvent({ tags: { existing: "1" } }, {});
74+
expect(out.tags).toEqual({ existing: "1" });
75+
expect(out.user).toBeUndefined();
3576
});
3677
});
3778

3879
it("adds impersonating tag when flag set", () => {
39-
tenantContext.run({ ...sample, impersonating: true }, () => {
80+
tenantContext.run({ ...slugOnly, impersonating: true }, () => {
4081
const out = addTenantContextToEvent({}, {});
4182
expect(out.tags?.impersonating).toBe("true");
4283
});
4384
});
4485

4586
it("preserves prior event.user fields it does not own", () => {
46-
tenantContext.run(sample, () => {
87+
tenantContext.run(enrichedWithUser, () => {
4788
const event: Event = { user: { ip_address: "1.2.3.4" } };
4889
const out = addTenantContextToEvent(event, {});
49-
expect(out.user).toEqual({ ip_address: "1.2.3.4", id: "org_1", username: "acme" });
90+
expect(out.user).toEqual({ ip_address: "1.2.3.4", id: "usr_42" });
5091
});
5192
});
5293
});

0 commit comments

Comments
 (0)