Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/sentry-tenant-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

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).
17 changes: 17 additions & 0 deletions apps/webapp/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node"; // or cloudflare/deno
import { RemixServer } from "@remix-run/react";
import * as Sentry from "@sentry/remix";
import { wrapHandleErrorWithSentry } from "@sentry/remix";
import { addTenantContextToEvent } from "~/utils/sentryTenantContext.server";
import { parseAcceptLanguage } from "intl-parse-accept-language";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
Expand Down Expand Up @@ -289,9 +291,24 @@ process.on("uncaughtException", (error, origin) => {
singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
Comment thread
d-cs marked this conversation as resolved.

// Wrapped in singleton() so Remix's dev-mode CJS reloads don't append
// duplicate copies of the processor — Sentry's processor list lives in
// node_modules and persists across module reloads. Idempotent at runtime
// (the processor is a pure read+stamp), but the pattern matches the rest
// of this file.
singleton("SentryTenantContextProcessor", () => {
if (env.SENTRY_DSN) {
Sentry.addEventProcessor(addTenantContextToEvent);
}
// Return a truthy value — `singleton()` uses `??=` so a `void`
// callback would re-execute (and re-register) on every dev reload.
return true;
});
Comment thread
d-cs marked this conversation as resolved.

export { apiRateLimiter } from "./services/apiRateLimit.server";
export { engineRateLimiter } from "./services/engineRateLimit.server";
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
export { tenantContextMiddleware } from "./services/tenantContextResolver.server";
export { socketIo } from "./v3/handleSocketIo.server";
export { wss } from "./v3/handleWebsockets.server";

Expand Down
1 change: 1 addition & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ const EnvironmentSchema = z
ELECTRIC_ORIGIN_SHARDS: z.string().optional(),
APP_ENV: z.string().default(process.env.NODE_ENV),
SERVICE_NAME: z.string().default("trigger.dev webapp"),
SENTRY_DSN: z.string().optional(),
POSTHOG_PROJECT_KEY: z.string().default("phc_LFH7kJiGhdIlnO22hTAKgHpaKhpM8gkzWAFvHmf5vfS"),
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { redirectWithErrorMessage } from "~/models/message.server";
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { tenantContext } from "~/services/tenantContext.server";
import { EnvironmentParamSchema, v3ProjectPath } from "~/utils/pathBuilder";

export const loader = async ({ request, params }: LoaderFunctionArgs) => {
Expand All @@ -26,6 +27,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
},
select: {
id: true,
externalRef: true,
organization: { select: { id: true } },
environments: {
select: {
id: true,
Expand All @@ -52,6 +55,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}

let environmentId: string | undefined = undefined;
let environmentType: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION" | undefined;

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

environmentId = bestEnvironment.id;
environmentType = bestEnvironment.type;
} else {
environmentId = environments[0].id;
environmentType = environments[0].type;
}

// userId is enriched higher up in `_app/route.tsx`; only stamp tenant fields here.
tenantContext.enrich({
orgId: project.organization.id,
projectId: project.id,
projectRef: project.externalRef,
envId: environmentId,
envType: environmentType,
});

await updateCurrentProjectEnvironmentId({ user: user, projectId: project.id, environmentId });

return project;
Expand Down
2 changes: 2 additions & 0 deletions apps/webapp/app/routes/_app/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
import { requireUser } from "~/services/session.server";
import { tenantContext } from "~/services/tenantContext.server";
import { confirmBasicDetailsPath } from "~/utils/pathBuilder";

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

//you have to confirm basic details before you can do anything
if (!user.confirmedBasicDetails) {
Expand Down
73 changes: 47 additions & 26 deletions apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
Comment thread
d-cs marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import { API_VERSIONS, getApiVersion } from "~/api/versions";
import { WORKER_HEADERS } from "@trigger.dev/core/v3/runEngineWorker";
import { ServiceValidationError } from "~/v3/services/common.server";
import { EngineServiceValidationError } from "@internal/run-engine";
import {
tenantContext,
tenantContextFromAuthEnvironment,
} from "~/services/tenantContext.server";

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

const apiVersion = getApiVersion(request);

const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
request,
resource,
apiVersion,
});
const result = await tenantContext.run(
tenantContextFromAuthEnvironment(authenticationResult.environment),
() =>
handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
request,
resource,
apiVersion,
})
);
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
Expand Down Expand Up @@ -586,6 +594,11 @@ export function createLoaderPATApiRoute<
}
}

// PAT auth carries `userId` but no environment — enrich the scope
// the Express middleware established with the authenticated user so
// Sentry events from this handler get user-level attribution.
tenantContext.enrich({ userId: authenticationResult.userId });

const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
Expand Down Expand Up @@ -903,15 +916,19 @@ export function createActionApiRoute<
);
}

const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
body: parsedBody,
authentication: authenticationResult,
request,
resource,
});
const result = await tenantContext.run(
tenantContextFromAuthEnvironment(authenticationResult.environment),
() =>
handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
body: parsedBody,
authentication: authenticationResult,
request,
resource,
})
);
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
Expand Down Expand Up @@ -1156,14 +1173,18 @@ export function createMultiMethodApiRoute<
}

// Dispatch to method handler
const result = await methodConfig.handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
body: parsedBody,
authentication: authenticationResult,
request,
});
const result = await tenantContext.run(
tenantContextFromAuthEnvironment(authenticationResult.environment),
() =>
methodConfig.handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
body: parsedBody,
authentication: authenticationResult,
request,
})
);
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
Expand Down
50 changes: 50 additions & 0 deletions apps/webapp/app/services/tenantContext.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { AuthenticatedEnvironment } from "./apiAuth.server";

// All fields are optional. The middleware establishes an empty scope per
// request; entry points fill what they know:
// - URL-matching paths get the slug trio from the Express middleware (zero IO).
// - The `_app` layout adds `userId` for any authenticated request.
// - The env layout adds tenant IDs / env type after its own existing DB query.
// - API routes get the full set up-front from `authenticationResult.environment`.
export type TenantContext = {
userId?: string;
orgSlug?: string;
projectSlug?: string;
envSlug?: string;
orgId?: string;
projectId?: string;
projectRef?: string;
envId?: string;
envType?: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION";
impersonating?: boolean;
};

const storage = new AsyncLocalStorage<TenantContext>();

export const tenantContext = {
run<T>(ctx: TenantContext, fn: () => T): T {
return storage.run(ctx, fn);
},
get(): TenantContext | undefined {
return storage.getStore();
},
enrich(patch: Partial<TenantContext>): void {
const current = storage.getStore();
if (current) Object.assign(current, patch);
},
};

export function tenantContextFromAuthEnvironment(env: AuthenticatedEnvironment): TenantContext {
return {
userId: env.orgMember?.userId,
orgSlug: env.organization.slug,
projectSlug: env.project.slug,
envSlug: env.slug,
orgId: env.organization.id,
projectId: env.project.id,
projectRef: env.project.externalRef,
envId: env.id,
envType: env.type,
};
}
42 changes: 42 additions & 0 deletions apps/webapp/app/services/tenantContextResolver.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { NextFunction, Request, Response } from "express";
import { tenantContext, type TenantContext } from "./tenantContext.server";

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

export type ParsedTenantPath = {
orgSlug: string;
projectSlug?: string;
envSlug?: string;
};

// Pulls whatever tenant slugs are present in the URL. `/orgs/:o` returns the
// org alone; `/orgs/:o/projects/:p` adds the project; `/orgs/:o/projects/:p/env/:e`
// returns all three. Non-tenant paths (`/`, `/login`, `/admin/*`) return undefined.
export function parseTenantPath(pathname: string): ParsedTenantPath | undefined {
const match = pathname.match(URL_PATTERN);
if (!match) return undefined;
const [, orgSlug, projectSlug, envSlug] = match;
if (!orgSlug) return undefined;
return {
orgSlug,
...(projectSlug ? { projectSlug } : {}),
...(envSlug ? { envSlug } : {}),
};
}

export function resolveTenantContextFromPath(pathname: string): TenantContext {
return parseTenantPath(pathname) ?? {};
}

export type PathResolver = (pathname: string) => TenantContext;

export function createTenantContextMiddleware(resolver: PathResolver) {
// Always establish an ALS scope, even when the path carries no tenant
// slugs. Authenticated loaders (e.g. the `_app` layout) then enrich the
// same scope with `userId`, so non-tenant pages still get user attribution.
return function tenantContextMiddleware(req: Request, res: Response, next: NextFunction) {
tenantContext.run(resolver(req.path), () => next());
};
}

export const tenantContextMiddleware = createTenantContextMiddleware(resolveTenantContextFromPath);
26 changes: 26 additions & 0 deletions apps/webapp/app/utils/sentryTenantContext.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Event, EventHint } from "@sentry/remix";
import { tenantContext } from "../services/tenantContext.server";

export function addTenantContextToEvent(event: Event, _hint: EventHint): Event {
const ctx = tenantContext.get();
if (!ctx) return event;
return {
...event,
// Only stamp user.id when we have a real user — keeps "Users Impacted"
// counting distinct humans rather than mixing in tenants. Events without
// a known user (e.g. unauthenticated paths) skip user attribution.
...(ctx.userId ? { user: { ...event.user, id: ctx.userId } } : {}),
tags: {
...event.tags,
...(ctx.orgSlug ? { org_slug: ctx.orgSlug } : {}),
...(ctx.projectSlug ? { project_slug: ctx.projectSlug } : {}),
...(ctx.envSlug ? { env_slug: ctx.envSlug } : {}),
...(ctx.orgId ? { org_id: ctx.orgId } : {}),
...(ctx.projectId ? { project_id: ctx.projectId } : {}),
...(ctx.projectRef ? { project_ref: ctx.projectRef } : {}),
...(ctx.envId ? { environment_id: ctx.envId } : {}),
...(ctx.envType ? { env_type: ctx.envType } : {}),
...(ctx.impersonating ? { impersonating: "true" } : {}),
},
};
}
4 changes: 4 additions & 0 deletions apps/webapp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;
const engineRateLimiter: RateLimitMiddleware = build.entry.module.engineRateLimiter;
const runWithHttpContext: RunWithHttpContextFunction = build.entry.module.runWithHttpContext;
const tenantContextMiddleware: import("express").RequestHandler =
build.entry.module.tenantContextMiddleware;

app.use((req, res, next) => {
// helpful headers:
Expand Down Expand Up @@ -171,6 +173,8 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
app.use(apiRateLimiter);
app.use(engineRateLimiter);

app.use(tenantContextMiddleware);

app.all(
"*",
// @ts-ignore
Expand Down
Loading