Skip to content

Commit 6de50ba

Browse files
fix(sentry): carry the deployment environment to the browser (#13784)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators can enable Sentry for the server and the signed-in browser. > - The server SDK reads `SENTRY_ENVIRONMENT` from the process environment. > - The browser receives its DSN through the session response, but receives no environment. > - A browser in staging therefore reports errors under the SDK's production default. > - This pull request passes the configured environment through the existing session and monitoring gate. > - Browser errors then identify the deployment environment while preserving the existing privacy settings. ## Linked Issues or Issue Description **What happened?** With `SENTRY_ENVIRONMENT=staging`, browser exceptions are tagged `production`. This can send errors to the wrong environment's alerts and makes deployment follow-up unreliable. **Expected behavior** The browser uses the server's configured Sentry environment. A reused image works in either staging or production. A signed-out browser still sends no events. **Steps to reproduce** 1. Configure a frontend Sentry DSN and `SENTRY_ENVIRONMENT=staging`. 2. Sign in and capture a browser exception. 3. Inspect the event environment. Before this change, it is `production`. **Paperclip version or commit** Reproduced on `a3749aac4680a901fa0fe1cc898907887abc9908` with the real browser SDK and a local test transport. **Deployment mode** Authenticated server and browser with optional Sentry monitoring enabled. No duplicate environment-attribution issue or pull request was found in the targeted GitHub search. ## What Changed - Add `sentryEnvironment` to the authenticated session response and shared schema. The optional field supports a newer browser reading an older server response. - Pass the environment to the browser SDK. An environment change restarts the client through its existing serialized lifecycle. - Cover environment attribution with a real SDK event, session authorization, unchanged-session refetches, environment changes, and legacy responses. - Document configuration and compatibility. Keep the loaded bundle's release identity and existing privacy filters. ## Verification - The regression test emits `production` for a requested staging environment before the fix. - Focused route, schema, browser lifecycle and real-SDK tests: 69 pass. - UI and shared-package typechecks, direct server `tsc --noEmit`, and token gates pass. - Full `pnpm build` and `pnpm -r typecheck` were attempted. Both stop at the Runner Rust step because `cargo` is absent on this machine. - Complete UI suite: 6,540 tests pass in 626 files. - Full `pnpm test:run`: 8,210 passed, 14 failed, 4,753 skipped; 36 files fail due to embedded PostgreSQL startup/cleanup and macOS runtime-cache `EACCES`. These match the existing local baseline; none touch the changed behavior. - Greptile: 5/5, no unresolved review threads. Linux CI has passed Build, Typecheck + Release Registry, and the completed test jobs so far. Remaining jobs are running or queued: the AWS runner provisioner is retrying EC2 CreateFleet `InternalError` responses. Full results will be recorded before merge. ## Risks Low risk. This adds one optional session field and changes Sentry attribution only. No migration or new monitoring opt-in is introduced. Missing settings keep the browser SDK default. Agent and unauthenticated requests still receive 401 without monitoring settings. Existing loaded browser bundles keep their old behavior until refreshed. ## Model Used OpenAI GPT-6 via Codex, with reasoning, repository inspection, code editing, and test execution. The session does not expose an exact model snapshot or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass (focused and full UI suites pass; full-root environment failures documented above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 5842185 commit 6de50ba

9 files changed

Lines changed: 148 additions & 15 deletions

File tree

doc/observability.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,22 @@ event. These pages run signed out:
317317
session response arrives is not captured. The gate opens only after the
318318
session query resolves.
319319

320+
### Environment attribution
321+
322+
Set `SENTRY_ENVIRONMENT` to the deployment environment, such as `staging`
323+
or `production`. The server SDK reads this value from its process environment.
324+
The authenticated session sends the same value in `sentryEnvironment`, and
325+
`SentryGate` passes it to the browser SDK. This is runtime configuration, so the
326+
same built image can report correctly in different environments. It does not
327+
infer an environment from the page URL or include a tenant identifier.
328+
329+
When the variable is absent or empty, the session sends `null` and the browser
330+
keeps the SDK's default environment. The field is optional in the session
331+
schema so a newer browser can still read a response from an older server.
332+
A session refetch that changes the environment closes and restarts monitoring;
333+
signing out still closes it. The browser release continues to identify the
334+
loaded bundle, even if the server has since deployed another version.
335+
320336
### Privacy settings
321337

322338
The feature uses built-in Sentry options only.

packages/shared/src/validators/access.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,19 @@ describe("authSessionSchema", () => {
160160
expect(result.success && result.data.sentryDsn).toBe(null);
161161
});
162162

163+
it.each([undefined, null, "staging", "production"])(
164+
"preserves the optional Sentry environment (%s)",
165+
(environment) => {
166+
const result = authSessionSchema.parse({
167+
session: { id: "s1", userId: "u1" },
168+
user: { id: "u1", email: "a@b.com", name: "Jane", image: null },
169+
sentryDsn: null,
170+
...(environment === undefined ? {} : { sentryEnvironment: environment }),
171+
});
172+
expect(result.sentryEnvironment).toBe(environment);
173+
},
174+
);
175+
163176
it("accepts a real sentryDsn value", () => {
164177
const result = authSessionSchema.safeParse({
165178
session: { id: "s1", userId: "u1" },

packages/shared/src/validators/access.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ export const authSessionSchema = z.object({
205205
// monitoring. The browser reads this value to open its own Sentry gate —
206206
// see `ui/src/lib/sentry.ts`.
207207
sentryDsn: z.string().min(1).nullable(),
208+
// Optional for browser/server version skew; null leaves the SDK default.
209+
sentryEnvironment: z.string().nullable().optional(),
208210
});
209211

210212
export type AuthSession = z.infer<typeof authSessionSchema>;

server/src/__tests__/auth-routes.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import express from "express";
22
import request from "supertest";
3-
import { afterEach, describe, expect, it } from "vitest";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
44
import { errorHandler } from "../middleware/index.js";
55
import { authRoutes } from "../routes/auth.js";
66

@@ -63,6 +63,7 @@ describe.sequential("auth routes", () => {
6363
const originalSentryDsnBackend = process.env.SENTRY_DSN_BACKEND;
6464

6565
afterEach(() => {
66+
vi.unstubAllEnvs();
6667
if (originalSentryDsn === undefined) delete process.env.SENTRY_DSN;
6768
else process.env.SENTRY_DSN = originalSentryDsn;
6869
if (originalSentryDsnFrontend === undefined) delete process.env.SENTRY_DSN_FRONTEND;
@@ -72,6 +73,7 @@ describe.sequential("auth routes", () => {
7273
});
7374

7475
it("returns the persisted user profile in the session payload", async () => {
76+
vi.stubEnv("SENTRY_ENVIRONMENT", undefined);
7577
delete process.env.SENTRY_DSN;
7678
const app = await createApp(
7779
{
@@ -92,6 +94,7 @@ describe.sequential("auth routes", () => {
9294
},
9395
user: baseUser,
9496
sentryDsn: null,
97+
sentryEnvironment: null,
9598
});
9699
});
97100

@@ -203,6 +206,44 @@ describe.sequential("auth routes", () => {
203206
expect(res.body.sentryDsn).toBeUndefined();
204207
});
205208

209+
it.each(["staging", "production", "preview"])(
210+
"sends the configured Sentry environment %s to board actors",
211+
async (environment) => {
212+
vi.stubEnv("SENTRY_ENVIRONMENT", environment);
213+
const app = createApp({ type: "board", userId: "user-1", source: "session" }, baseUser);
214+
215+
const res = await request(app).get("/api/auth/get-session");
216+
217+
expect(res.status).toBe(200);
218+
expect(res.body.sentryEnvironment).toBe(environment);
219+
},
220+
);
221+
222+
it.each([undefined, ""])("sends null for an unset Sentry environment (%s)", async (environment) => {
223+
vi.stubEnv("SENTRY_ENVIRONMENT", environment);
224+
const app = createApp({ type: "board", userId: "user-1", source: "session" }, baseUser);
225+
226+
const res = await request(app).get("/api/auth/get-session");
227+
228+
expect(res.status).toBe(200);
229+
expect(res.body.sentryEnvironment).toBeNull();
230+
});
231+
232+
it.each([
233+
{ type: "none", source: "none" },
234+
{ type: "agent", agentId: "agent-1", companyId: "company-1", source: "agent_key" },
235+
] satisfies Express.Request["actor"][])("withholds Sentry settings from a $type actor", async (actor) => {
236+
vi.stubEnv("SENTRY_ENVIRONMENT", "staging");
237+
vi.stubEnv("SENTRY_DSN_FRONTEND", "https://public@o0.ingest.sentry.io/1");
238+
const app = createApp(actor, baseUser);
239+
240+
const res = await request(app).get("/api/auth/get-session");
241+
242+
expect(res.status).toBe(401);
243+
expect(res.body.sentryDsn).toBeUndefined();
244+
expect(res.body.sentryEnvironment).toBeUndefined();
245+
});
246+
206247
it("updates the signed-in profile", async () => {
207248
const app = await createApp(
208249
{

server/src/routes/auth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export function authRoutes(db: Db) {
5555
// handler, so no second authorization check runs here. This field
5656
// carries the front-end DSN only; it never carries the backend DSN.
5757
sentryDsn: resolveSentryDsns().frontend,
58+
// Match the server SDK's runtime environment, including in reused images.
59+
sentryEnvironment: process.env.SENTRY_ENVIRONMENT || null,
5860
}));
5961
});
6062

ui/src/components/SentryGate.test.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ import { queryKeys } from "@/lib/queryKeys";
88
import { SentryGate } from "./SentryGate";
99

1010
const getSessionMock = vi.hoisted(() => vi.fn());
11-
const initBrowserErrorMonitoringMock = vi.hoisted(() => vi.fn(async (_dsn: string) => {}));
11+
const initBrowserErrorMonitoringMock = vi.hoisted(() => vi.fn(async (_dsn: string, _environment?: string) => {}));
1212
const teardownBrowserErrorMonitoringMock = vi.hoisted(() => vi.fn(async () => {}));
1313

1414
vi.mock("@/api/auth", () => ({
1515
authApi: { getSession: () => getSessionMock() },
1616
}));
1717

1818
vi.mock("@/lib/sentry", () => ({
19-
initBrowserErrorMonitoring: (dsn: string) => initBrowserErrorMonitoringMock(dsn),
19+
initBrowserErrorMonitoring: (dsn: string, environment?: string) => initBrowserErrorMonitoringMock(dsn, environment),
2020
teardownBrowserErrorMonitoring: () => teardownBrowserErrorMonitoringMock(),
2121
}));
2222

@@ -93,7 +93,7 @@ describe("SentryGate", () => {
9393
const root = await renderGate();
9494

9595
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
96-
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledWith("https://public@o0.ingest.sentry.io/1");
96+
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledWith("https://public@o0.ingest.sentry.io/1", undefined);
9797
root.unmount();
9898
});
9999

@@ -113,10 +113,43 @@ describe("SentryGate", () => {
113113
await flushReact();
114114

115115
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
116-
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledWith("https://public@o0.ingest.sentry.io/1");
116+
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledWith("https://public@o0.ingest.sentry.io/1", undefined);
117117
root.unmount();
118118
});
119119

120+
it("restarts monitoring when the session environment changes with the same DSN", async () => {
121+
const session = {
122+
session: { id: "s1", userId: "u1" },
123+
user: { id: "u1", email: "a@b.com", name: "Jane", image: null },
124+
sentryDsn: "https://public@o0.ingest.sentry.io/1",
125+
sentryEnvironment: "staging",
126+
};
127+
getSessionMock.mockResolvedValue(session);
128+
const root = await renderGate();
129+
try {
130+
expect(initBrowserErrorMonitoringMock).toHaveBeenLastCalledWith(session.sentryDsn, "staging");
131+
await act(async () => {
132+
await queryClient.refetchQueries({ queryKey: queryKeys.auth.session });
133+
});
134+
await flushReact();
135+
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
136+
expect(teardownBrowserErrorMonitoringMock).not.toHaveBeenCalled();
137+
138+
getSessionMock.mockResolvedValue({ ...session, sentryEnvironment: "production" });
139+
await act(async () => {
140+
await queryClient.refetchQueries({ queryKey: queryKeys.auth.session });
141+
});
142+
await flushReact();
143+
expect(teardownBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
144+
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(2);
145+
expect(initBrowserErrorMonitoringMock).toHaveBeenLastCalledWith(session.sentryDsn, "production");
146+
expect(teardownBrowserErrorMonitoringMock.mock.invocationCallOrder[0])
147+
.toBeLessThan(initBrowserErrorMonitoringMock.mock.invocationCallOrder[1]);
148+
} finally {
149+
root.unmount();
150+
}
151+
});
152+
120153
it("closes browser monitoring when sign-out clears the session's DSN", async () => {
121154
getSessionMock.mockResolvedValue({
122155
session: { id: "s1", userId: "u1" },

ui/src/components/SentryGate.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ export function SentryGate() {
2626
});
2727

2828
const dsn = session?.sentryDsn;
29+
const environment = session?.sentryEnvironment ?? undefined;
2930

3031
useEffect(() => {
3132
if (!dsn) return;
32-
void initBrowserErrorMonitoring(dsn);
33+
void initBrowserErrorMonitoring(dsn, environment);
3334
return () => {
3435
void teardownBrowserErrorMonitoring();
3536
};
36-
}, [dsn]);
37+
}, [dsn, environment]);
3738

3839
return null;
3940
}

ui/src/lib/sentry.test.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,10 @@ describe("initBrowserErrorMonitoring", () => {
111111
const mocks = mockSentryPackage();
112112
const { initBrowserErrorMonitoring } = await importFreshSentry();
113113

114-
await initBrowserErrorMonitoring(DSN);
114+
await initBrowserErrorMonitoring(DSN, "staging");
115115

116116
expect(mocks.init).toHaveBeenCalledTimes(1);
117-
const initOptions = mocks.init.mock.calls[0][0] as { dsn: string };
118-
expect(initOptions.dsn).toBe(DSN);
117+
expect(mocks.init.mock.calls[0][0]).toMatchObject({ dsn: DSN, environment: "staging" });
119118
});
120119

121120
it("a second call starts no second client", async () => {
@@ -376,11 +375,14 @@ describe("captured event shape against the real @sentry/browser SDK", () => {
376375
* adds no `beforeSend` of its own (see the "holds no beforeSend hook"
377376
* test above).
378377
*/
379-
async function initRealSentryForTest(onEvent: (event: Record<string, unknown>) => void) {
378+
async function initRealSentryForTest(
379+
onEvent: (event: Record<string, unknown>) => void,
380+
environment?: string | null,
381+
) {
380382
const { buildBrowserSentryInitOptions } = await importFreshSentry();
381383
const Sentry = await import("@sentry/browser");
382384
Sentry.init({
383-
...buildBrowserSentryInitOptions(DSN),
385+
...buildBrowserSentryInitOptions(DSN, environment),
384386
transport: () => ({ send: async () => ({}), flush: async () => true }),
385387
beforeSend: (event) => {
386388
onEvent(event as unknown as Record<string, unknown>);
@@ -390,6 +392,25 @@ describe("captured event shape against the real @sentry/browser SDK", () => {
390392
return Sentry;
391393
}
392394

395+
it.each([
396+
["staging", "staging"],
397+
["production", "production"],
398+
[null, "production"],
399+
[undefined, "production"],
400+
])("emits environment %s as %s without page context", async (environment, expected) => {
401+
let captured: Record<string, unknown> | null = null;
402+
const Sentry = await initRealSentryForTest((event) => { captured = event; }, environment);
403+
try {
404+
Sentry.captureException(new Error("environment attribution check"));
405+
await Sentry.flush(2000);
406+
expect(captured).toMatchObject({ environment: expected });
407+
expect(captured).not.toHaveProperty("request");
408+
expect((captured as unknown as Record<string, unknown>).breadcrumbs).toBeUndefined();
409+
} finally {
410+
await Sentry.close();
411+
}
412+
});
413+
393414
it("attaches the bundle release to an emitted event without page context", async () => {
394415
const commit = "0123456789abcdef0123456789abcdef01234567";
395416
vi.stubGlobal("__PAPERCLIP_BUILD_COMMIT__", commit);

ui/src/lib/sentry.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,12 @@ let sentry: SentryBrowserModule | null = null;
6565
* — the session query can refetch and call this again, and a second call is
6666
* a no-op because a client is already started.
6767
*/
68-
export function initBrowserErrorMonitoring(dsn: string): Promise<void> {
68+
export function initBrowserErrorMonitoring(dsn: string, environment?: string | null): Promise<void> {
6969
return enqueue(async () => {
7070
if (sentry) return;
7171
try {
7272
const Sentry = await import("@sentry/browser");
73-
Sentry.init(buildBrowserSentryInitOptions(dsn));
73+
Sentry.init(buildBrowserSentryInitOptions(dsn, environment));
7474
sentry = Sentry;
7575
} catch (err) {
7676
// The dynamic import or the init call failed. Fall through with a
@@ -152,9 +152,13 @@ export function captureBrowserException(error: unknown): void {
152152
* `@sentry/browser` module and assert the resolved integration list and the
153153
* captured-event shape against the true SDK, not a stand-in.
154154
*/
155-
export function buildBrowserSentryInitOptions(dsn: string): BrowserSentryInitOptions {
155+
export function buildBrowserSentryInitOptions(
156+
dsn: string,
157+
environment?: string | null,
158+
): BrowserSentryInitOptions {
156159
return {
157160
dsn,
161+
environment: environment ?? undefined,
158162
// Use the loaded bundle's build, even when the server has since deployed.
159163
release:
160164
typeof __PAPERCLIP_BUILD_COMMIT__ === "string"

0 commit comments

Comments
 (0)