feat(server): split the Sentry DSN into front-end and backend variables (#12678)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip reports server and browser errors through optional Sentry
monitoring
> - One environment variable sends both error types to one Sentry
project
> - Operators need separate control for browser and server error data
> - This pull request adds specific variables and keeps the existing
variable as a fallback
> - The benefit is separate monitoring without breaking current
deployments

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The Sentry configuration for server and browser monitoring uses one
environment variable.

**Subsystem affected**

Cross-cutting (multiple of the above)

**Current behavior**

`SENTRY_DSN` supplies the server and browser clients. Both clients
therefore report to the same Sentry project.

**Proposed behavior**

`SENTRY_DSN_FRONTEND` supplies the browser client. `SENTRY_DSN_BACKEND`
supplies the server process. `SENTRY_DSN` remains a fallback for either
component.

**Reason and benefit**

Operators can send browser and server errors to separate Sentry
projects. Operators can also activate only one component.

**Breaking changes**

None. Existing deployments can continue to use `SENTRY_DSN`.

## What Changed

- Add `resolveSentryDsns(env)` and use it in the server and browser
configuration paths.
- Add precedence, empty-string, fallback, and route tests.
- Update the README, observability guide, and stale code comments.
- Log one warning when the server uses the legacy fallback without
exposing a DSN value.

## Verification

- `pnpm vitest run --project server sentry-dsn` — 8 tests pass.
- `pnpm vitest run --project server auth-routes` — 21 tests pass.
- The earlier run of the three targeted suites passed 40 tests.
- `tsc --noEmit` passes for the files in this diff.
- All required GitHub Actions checks pass, including the full
continuous-integration suite.

## Risks

The main risk is an incorrect environment variable precedence rule. Unit
tests cover specific values, empty strings, and legacy fallback
behavior. The existing `SENTRY_DSN` path remains compatible.

## Model Used

OpenAI Codex — GPT-5, current runtime, tool use and code execution.

## 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
- [x] I have run tests locally and they pass
- [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
- [x] 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>
This commit is contained in:
Nicky Leach 2026-09-01 11:02:04 -07:00 committed by GitHub
parent 314ff24b7a
commit ed3559dd21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 320 additions and 54 deletions

View File

@ -469,7 +469,7 @@ Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-p
Paperclip ships with opt-in OpenTelemetry auto-instrumentation for the server (traces only). It activates when `OTEL_EXPORTER_OTLP_ENDPOINT` is set and supports `grpc`, `http/protobuf`, and `http/json` via the standard `OTEL_EXPORTER_OTLP_PROTOCOL` env var. `@opentelemetry/api` is a normal server dependency; the SDK, auto-instrumentation, and exporter packages are optional peer dependencies — install them only if you want tracing. See [doc/observability.md](doc/observability.md) for install commands and the full env-var reference.
Paperclip also ships with opt-in Sentry error monitoring for the server and the browser. Set `SENTRY_DSN` to activate it — the server and the browser then report to the same Sentry project. The supported server SDK version is `@sentry/node@10.71.0`; it is an optional peer dependency for the server, so install it only if you want error monitoring. The browser SDK, `@sentry/browser`, is pinned to the same exact version. See [doc/observability.md](doc/observability.md#sentry-error-monitoring) for the install command, the privacy settings, and the full default capture set.
Paperclip also ships with opt-in Sentry error monitoring for the server and the browser. Set `SENTRY_DSN_FRONTEND` to activate it for the browser and `SENTRY_DSN_BACKEND` to activate it for the server — each variable is optional, and the legacy `SENTRY_DSN` variable still works as a fallback for either component. The supported server SDK version is `@sentry/node@10.71.0`; it is an optional peer dependency for the server, so install it only if you want error monitoring. The browser SDK, `@sentry/browser`, is pinned to the same exact version. See [doc/observability.md](doc/observability.md#sentry-error-monitoring) for the install command, the privacy settings, and the full default capture set.
## Telemetry

View File

@ -211,14 +211,20 @@ run-log copy is unaffected.
## Sentry Error Monitoring
Paperclip ships with **opt-in** Sentry error monitoring for the server
process and the browser app. The operator activates it with one
environment variable, `SENTRY_DSN`. The server and the browser both read
this same value, so both report to **one** Sentry project. The feature
uses built-in Sentry options only. It adds no `beforeSend` hook and no
custom filter code.
process and the browser app. The operator activates it with two
environment variables: `SENTRY_DSN_FRONTEND` for the browser and
`SENTRY_DSN_BACKEND` for the server. Each variable is optional. A
specific variable always wins for its own component; a legacy variable,
`SENTRY_DSN`, supplies a component that has no specific value set. An
empty string counts as absent for all three variables. The feature uses
built-in Sentry options only. It adds no `beforeSend` hook and no custom
filter code.
When `SENTRY_DSN` is unset, the feature is fully inactive. The server
imports no Sentry package. The browser fetches no Sentry chunk.
The server is inactive when the backend DSN resolves to `null`; then it
imports no Sentry package. The browser is inactive when the front-end DSN
resolves to `null`; then it fetches no Sentry chunk. The two components
resolve their DSN independently, so the operator can activate one
component and leave the other inactive.
### Enabling Sentry
@ -229,11 +235,12 @@ version this feature is audited against (see "Server request data"
below). Install it in the server, the same way you install the
OpenTelemetry packages above. `@sentry/node` is an *optional peer
dependency*: it is not in the default lockfile, and the server loads it
dynamically only when `SENTRY_DSN` is set. `server/package.json` declares
this exact version; installing a different version defeats the audit, so
the server checks the installed version against the declared one at
startup and logs one diagnostic instead of enabling error monitoring on a
mismatch (see "Server request data" below).
dynamically only when the backend DSN resolves to a value.
`server/package.json` declares this exact version; installing a different
version defeats the audit, so the server checks the installed version
against the declared one at startup and logs one diagnostic instead of
enabling error monitoring on a mismatch (see "Server request data"
below).
```bash
pnpm add @sentry/node@10.71.0
@ -242,7 +249,8 @@ pnpm add @sentry/node@10.71.0
**The hosted image variant ships this package pre-installed.** A managed
tenant runs the image built from the Dockerfile's `cloud` target, and that
target installs the declared version of `@sentry/node` at build time. A
managed tenant needs only `SENTRY_DSN` set; no install step is needed.
managed tenant needs only `SENTRY_DSN_BACKEND` set (or `SENTRY_DSN_FRONTEND`
for the browser); no install step is needed.
A self-hosted operator runs the image built from the `production` target.
That image holds no Sentry package, the same as before this feature
@ -259,30 +267,45 @@ below.
#### 2. Set the environment
```bash
export SENTRY_DSN="https://<public-key>@<host>/<project-id>"
export SENTRY_DSN_FRONTEND="https://<public-key>@<host>/<project-id>"
export SENTRY_DSN_BACKEND="https://<public-key>@<host>/<project-id>"
```
No other variable is needed.
The operator can set either variable alone. The component with no value
set stays inactive.
### One Sentry project
### Two Sentry projects
The server and the browser report to **one** Sentry project, because both
read the same `SENTRY_DSN` value. The server reads it from the process
environment. The browser reads it from the authenticated
The server and the browser report to two separate Sentry projects by
default, one per component. The server reads its DSN,
`SENTRY_DSN_BACKEND`, from the process environment. The browser reads its
DSN, `SENTRY_DSN_FRONTEND`, from the authenticated
`GET /api/auth/get-session` response.
The legacy `SENTRY_DSN` variable still works. When the operator sets only
`SENTRY_DSN`, both components use it, so both report to **one** Sentry
project. In that mode the server prints one warning at start. The warning
names the three variables (`SENTRY_DSN`, `SENTRY_DSN_FRONTEND`,
`SENTRY_DSN_BACKEND`) and prints no DSN value.
To add a DSN for a new component later, add a field to the `SentryDsns`
type, add a variable with the `SENTRY_DSN_` prefix, and resolve it with
the same precedence rule: the specific variable wins, and `SENTRY_DSN`
supplies a component that has no specific value set.
### DSN delivery to the browser
The browser never reads the DSN from a `<meta>` tag or from any other part
of `index.html`. The served `index.html` holds no DSN — it is a static
file, built once and served unchanged to every request.
Instead, the browser receives the DSN inside the authenticated
`GET /api/auth/get-session` response body, next to the signed-in session
and the user profile. A signed-out browser calls this route with no board
actor, so the route answers 401 and sends no DSN. A signed-out browser
therefore loads no Sentry chunk and sends no event. These pages run
signed out:
Instead, the browser receives the front-end DSN inside the authenticated
`GET /api/auth/get-session` response body, in the `sentryDsn` field, next
to the signed-in session and the user profile. The backend DSN stays in
the server process and never reaches the browser. A signed-out browser
calls this route with no board actor, so the route answers 401 and sends
no DSN. A signed-out browser therefore loads no Sentry chunk and sends no
event. These pages run signed out:
- `/auth`
- `/cli-auth/:id`
@ -419,12 +442,13 @@ Two controls belong to the operator. This feature ships neither one.
1. **Set a rate limit and a quota alert.** Set a per-client-key ingestion
rate limit and a quota alert in the Sentry project. The feature sends
no built-in rate limit of its own.
2. **Give a self-hosted sink a reachable host name.** If `SENTRY_DSN`
points at a self-hosted Sentry instance, give it an externally
reachable ingest host name, not an internal-only host name. The
browser sends its events from the operator's network, not from the
server's network, so an internal-only host name fails silently for
the browser even when it works for the server.
2. **Give a self-hosted sink a reachable host name.** If
`SENTRY_DSN_FRONTEND` (or the legacy `SENTRY_DSN`) points at a
self-hosted Sentry instance, give it an externally reachable ingest
host name, not an internal-only host name. The browser sends its
events from the operator's network, not from the server's network, so
an internal-only host name fails silently for the browser even when it
works for the server.
## Sandbox Startup Trace Spans

View File

@ -198,9 +198,10 @@ export const authSessionSchema = z.object({
userId: z.string().min(1),
}),
user: currentUserProfileSchema,
// The Sentry DSN for the current instance, or `null` when the operator has
// not set `SENTRY_DSN`. Required, not optional: a missing value must fail
// the response schema instead of silently disabling browser error
// The front-end Sentry DSN for the current instance, or `null` when the
// operator has set neither `SENTRY_DSN_FRONTEND` nor the legacy
// `SENTRY_DSN`. Required, not optional: a missing value must fail the
// response schema instead of silently disabling browser error
// monitoring. The browser reads this value to open its own Sentry gate —
// see `ui/src/lib/sentry.ts`.
sentryDsn: z.string().min(1).nullable(),

View File

@ -59,10 +59,16 @@ describe.sequential("auth routes", () => {
image: "https://example.com/jane.png",
};
const originalSentryDsn = process.env.SENTRY_DSN;
const originalSentryDsnFrontend = process.env.SENTRY_DSN_FRONTEND;
const originalSentryDsnBackend = process.env.SENTRY_DSN_BACKEND;
afterEach(() => {
if (originalSentryDsn === undefined) delete process.env.SENTRY_DSN;
else process.env.SENTRY_DSN = originalSentryDsn;
if (originalSentryDsnFrontend === undefined) delete process.env.SENTRY_DSN_FRONTEND;
else process.env.SENTRY_DSN_FRONTEND = originalSentryDsnFrontend;
if (originalSentryDsnBackend === undefined) delete process.env.SENTRY_DSN_BACKEND;
else process.env.SENTRY_DSN_BACKEND = originalSentryDsnBackend;
});
it("returns the persisted user profile in the session payload", async () => {
@ -123,6 +129,64 @@ describe.sequential("auth routes", () => {
expect(res.body.sentryDsn).toBe(null);
});
it("sends sentryDsn for a board actor when only SENTRY_DSN_FRONTEND is set", async () => {
delete process.env.SENTRY_DSN;
delete process.env.SENTRY_DSN_BACKEND;
process.env.SENTRY_DSN_FRONTEND = "https://public-frontend@o0.ingest.sentry.io/1";
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "session",
},
baseUser,
);
const res = await request(app).get("/api/auth/get-session");
expect(res.status).toBe(200);
expect(res.body.sentryDsn).toBe("https://public-frontend@o0.ingest.sentry.io/1");
});
it("sends a null sentryDsn when only SENTRY_DSN_BACKEND is set", async () => {
delete process.env.SENTRY_DSN;
delete process.env.SENTRY_DSN_FRONTEND;
process.env.SENTRY_DSN_BACKEND = "https://public-backend@o0.ingest.sentry.io/2";
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "session",
},
baseUser,
);
const res = await request(app).get("/api/auth/get-session");
expect(res.status).toBe(200);
expect(res.body.sentryDsn).toBe(null);
});
it("sends only the front-end DSN when SENTRY_DSN_FRONTEND and SENTRY_DSN_BACKEND differ", async () => {
delete process.env.SENTRY_DSN;
process.env.SENTRY_DSN_FRONTEND = "https://public-frontend@o0.ingest.sentry.io/1";
process.env.SENTRY_DSN_BACKEND = "https://public-backend@o0.ingest.sentry.io/2";
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "session",
},
baseUser,
);
const res = await request(app).get("/api/auth/get-session");
expect(res.status).toBe(200);
expect(res.body.sentryDsn).toBe("https://public-frontend@o0.ingest.sentry.io/1");
expect(res.body.sentryDsn).not.toBe("https://public-backend@o0.ingest.sentry.io/2");
});
it("answers 401 and sends no DSN when the actor type is none", async () => {
process.env.SENTRY_DSN = "https://public@o0.ingest.sentry.io/1";
const app = await createApp(

View File

@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { resolveSentryDsns } from "../sentry-dsn.js";
const FRONTEND_DSN = "https://public-frontend@o0.ingest.sentry.io/1";
const BACKEND_DSN = "https://public-backend@o0.ingest.sentry.io/2";
const LEGACY_DSN = "https://public-legacy@o0.ingest.sentry.io/3";
describe("resolveSentryDsns", () => {
it("resolves both fields to null when no variable is set", () => {
const result = resolveSentryDsns({});
expect(result).toEqual({ frontend: null, backend: null, legacyFallbackUsed: false });
});
it("uses the legacy value for both fields when only SENTRY_DSN is set", () => {
const result = resolveSentryDsns({ SENTRY_DSN: LEGACY_DSN });
expect(result).toEqual({ frontend: LEGACY_DSN, backend: LEGACY_DSN, legacyFallbackUsed: true });
});
it("resolves only the frontend field when only SENTRY_DSN_FRONTEND is set", () => {
const result = resolveSentryDsns({ SENTRY_DSN_FRONTEND: FRONTEND_DSN });
expect(result).toEqual({ frontend: FRONTEND_DSN, backend: null, legacyFallbackUsed: false });
});
it("resolves only the backend field when only SENTRY_DSN_BACKEND is set", () => {
const result = resolveSentryDsns({ SENTRY_DSN_BACKEND: BACKEND_DSN });
expect(result).toEqual({ frontend: null, backend: BACKEND_DSN, legacyFallbackUsed: false });
});
it("resolves each field to its own value when both specific variables are set", () => {
const result = resolveSentryDsns({
SENTRY_DSN_FRONTEND: FRONTEND_DSN,
SENTRY_DSN_BACKEND: BACKEND_DSN,
});
expect(result).toEqual({ frontend: FRONTEND_DSN, backend: BACKEND_DSN, legacyFallbackUsed: false });
});
it("prefers the specific variables over SENTRY_DSN when all three are set", () => {
const result = resolveSentryDsns({
SENTRY_DSN_FRONTEND: FRONTEND_DSN,
SENTRY_DSN_BACKEND: BACKEND_DSN,
SENTRY_DSN: LEGACY_DSN,
});
expect(result).toEqual({ frontend: FRONTEND_DSN, backend: BACKEND_DSN, legacyFallbackUsed: false });
});
it("falls back to the legacy value for a component whose specific variable is an empty string", () => {
const result = resolveSentryDsns({ SENTRY_DSN_FRONTEND: "", SENTRY_DSN: LEGACY_DSN });
expect(result).toEqual({ frontend: LEGACY_DSN, backend: LEGACY_DSN, legacyFallbackUsed: true });
});
it("treats an empty SENTRY_DSN as absent when no specific variable is set", () => {
const result = resolveSentryDsns({ SENTRY_DSN: "" });
expect(result).toEqual({ frontend: null, backend: null, legacyFallbackUsed: false });
});
});

View File

@ -21,7 +21,11 @@ import * as sentryModule from "../sentry.js";
*/
const DSN_ENV = "SENTRY_DSN";
const FRONTEND_DSN_ENV = "SENTRY_DSN_FRONTEND";
const BACKEND_DSN_ENV = "SENTRY_DSN_BACKEND";
const originalDsn = process.env[DSN_ENV];
const originalFrontendDsn = process.env[FRONTEND_DSN_ENV];
const originalBackendDsn = process.env[BACKEND_DSN_ENV];
async function importFreshSentry() {
vi.resetModules();
@ -90,11 +94,17 @@ const DEFAULT_INTEGRATION_NAMES = [
beforeEach(() => {
delete process.env[DSN_ENV];
delete process.env[FRONTEND_DSN_ENV];
delete process.env[BACKEND_DSN_ENV];
});
afterEach(() => {
if (originalDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = originalDsn;
if (originalFrontendDsn === undefined) delete process.env[FRONTEND_DSN_ENV];
else process.env[FRONTEND_DSN_ENV] = originalFrontendDsn;
if (originalBackendDsn === undefined) delete process.env[BACKEND_DSN_ENV];
else process.env[BACKEND_DSN_ENV] = originalBackendDsn;
vi.restoreAllMocks();
vi.doUnmock("@sentry/node");
vi.doUnmock("../peer-version-check.js");
@ -255,7 +265,7 @@ describe("finalizeServerShutdown Sentry teardown", () => {
describe("missing @sentry/node package", () => {
it("logs one warning and resolves", async () => {
process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1";
process.env[BACKEND_DSN_ENV] = "https://public@o0.ingest.sentry.io/1";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { sentryReady } = await importFreshSentry();
@ -274,7 +284,7 @@ describe("missing @sentry/node package", () => {
describe("@sentry/node installed at an unsupported version", () => {
it("logs one diagnostic and resolves without importing the package", async () => {
process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1";
process.env[BACKEND_DSN_ENV] = "https://public@o0.ingest.sentry.io/1";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.doMock("../peer-version-check.js", () => ({
checkExactPeerVersions: () => ({
@ -303,6 +313,62 @@ describe("@sentry/node installed at an unsupported version", () => {
});
});
describe("split DSN gating", () => {
it("opens the gate and passes the backend DSN to Sentry.init when SENTRY_DSN_BACKEND alone is set", async () => {
process.env[BACKEND_DSN_ENV] = "https://public-backend@o0.ingest.sentry.io/2";
const mocks = mockSentryPackage();
const { sentryReady } = await importFreshSentry();
await sentryReady;
expect(mocks.init).toHaveBeenCalledTimes(1);
const initOptions = mocks.init.mock.calls[0][0] as { dsn: string };
expect(initOptions.dsn).toBe("https://public-backend@o0.ingest.sentry.io/2");
});
it("leaves the gate closed and loads no SDK when SENTRY_DSN_FRONTEND alone is set", async () => {
process.env[FRONTEND_DSN_ENV] = "https://public-frontend@o0.ingest.sentry.io/1";
const mocks = mockSentryPackage();
const { sentryReady } = await importFreshSentry();
await sentryReady;
expect(mocks.init).not.toHaveBeenCalled();
});
it("logs one warning that names the three variables and holds no DSN value when only SENTRY_DSN is set", async () => {
process.env[DSN_ENV] = "https://public-legacy@o0.ingest.sentry.io/3";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
mockSentryPackage();
const { sentryReady } = await importFreshSentry();
await sentryReady;
expect(warn).toHaveBeenCalledTimes(1);
const [message] = warn.mock.calls[0]!;
expect(message).toEqual(expect.stringContaining("SENTRY_DSN_FRONTEND"));
expect(message).toEqual(expect.stringContaining("SENTRY_DSN_BACKEND"));
expect(message).toEqual(expect.stringContaining("SENTRY_DSN"));
for (const call of warn.mock.calls) {
for (const arg of call) {
expect(String(arg)).not.toContain("https://public-legacy@o0.ingest.sentry.io/3");
}
}
});
it("logs no warning when both specific variables are set", async () => {
process.env[FRONTEND_DSN_ENV] = "https://public-frontend@o0.ingest.sentry.io/1";
process.env[BACKEND_DSN_ENV] = "https://public-backend@o0.ingest.sentry.io/2";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
mockSentryPackage();
const { sentryReady } = await importFreshSentry();
await sentryReady;
expect(warn).not.toHaveBeenCalled();
});
});
describe("buildSentryInitOptions", () => {
it("sets sendDefaultPii false, tracesSampleRate 0, and skipOpenTelemetrySetup true", async () => {
const { buildSentryInitOptions } = await importFreshSentry();

View File

@ -9,6 +9,7 @@ import {
} from "@paperclipai/shared";
import { unauthorized } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { resolveSentryDsns } from "../sentry-dsn.js";
async function loadCurrentUserProfile(db: Db, userId: string) {
const user = await db
@ -51,8 +52,9 @@ export function authRoutes(db: Db) {
user,
// The browser reads this value to open its own Sentry gate — see
// `ui/src/lib/sentry.ts`. `req.actor.type` already gates this whole
// handler, so no second authorization check runs here.
sentryDsn: process.env.SENTRY_DSN || null,
// handler, so no second authorization check runs here. This field
// carries the front-end DSN only; it never carries the backend DSN.
sentryDsn: resolveSentryDsns().frontend,
}));
});

32
server/src/sentry-dsn.ts Normal file
View File

@ -0,0 +1,32 @@
// Resolves the Sentry DSN for each component from the process environment.
//
// Precedence: a specific variable always wins for its own component.
// `SENTRY_DSN` supplies a component that has no specific value set.
// An empty string counts as absent for every variable, so a specific
// variable set to `""` still falls back to `SENTRY_DSN`.
//
// To add a DSN for a new component, add a field to `SentryDsns`, add a
// variable named with the shared `SENTRY_DSN_` prefix, and resolve it with
// `normalize` the same way `frontend` and `backend` resolve here.
export interface SentryDsns {
frontend: string | null;
backend: string | null;
legacyFallbackUsed: boolean;
}
function normalize(value: string | undefined): string | null {
return value ? value : null;
}
export function resolveSentryDsns(env: NodeJS.ProcessEnv = process.env): SentryDsns {
const legacy = normalize(env.SENTRY_DSN);
const specificFrontend = normalize(env.SENTRY_DSN_FRONTEND);
const specificBackend = normalize(env.SENTRY_DSN_BACKEND);
const frontend = specificFrontend ?? legacy;
const backend = specificBackend ?? legacy;
const legacyFallbackUsed = (specificFrontend === null || specificBackend === null) && legacy !== null;
return { frontend, backend, legacyFallbackUsed };
}

View File

@ -1,7 +1,9 @@
// Optional Sentry error monitoring for the server process.
//
// Activated only when `SENTRY_DSN` is set. When unset, no Sentry package is
// loaded at all.
// Activated only when the backend DSN resolves to a value — see
// `resolveSentryDsns` in `sentry-dsn.ts` for the precedence between
// `SENTRY_DSN_BACKEND` and the legacy `SENTRY_DSN` fallback. When it
// resolves to `null`, no Sentry package is loaded at all.
//
// The import is dynamic and the package is an optional runtime dependency —
// operators who want server-side error monitoring install `@sentry/node`
@ -43,8 +45,19 @@
// `instrumentation.ts`.
import { checkExactPeerVersions } from "./peer-version-check.js";
import { resolveSentryDsns } from "./sentry-dsn.js";
const dsn = process.env.SENTRY_DSN;
const { backend: dsn, legacyFallbackUsed } = resolveSentryDsns();
if (legacyFallbackUsed) {
// eslint-disable-next-line no-console
console.warn(
"[paperclip] SENTRY_DSN_FRONTEND or SENTRY_DSN_BACKEND is not set. " +
"The server uses the legacy SENTRY_DSN value for the affected " +
"component. Set SENTRY_DSN_FRONTEND and SENTRY_DSN_BACKEND to send " +
"each component to its own Sentry project.",
);
}
/** The subset of the `@sentry/node` client surface this gate calls. */
interface SentryHandle {
@ -57,16 +70,17 @@ let shutdownPromise: Promise<void> | null = null;
/**
* Resolves once the Sentry SDK has started, or once bootstrap has failed and
* logged, or at once when `SENTRY_DSN` is unset. No caller needs to await
* this before calling `captureException` it is a no-op until ready but
* `index.ts` awaits it at startup so the first real error has a live client.
* logged, or at once when the backend DSN resolves to `null`. No caller
* needs to await this before calling `captureException` it is a no-op
* until ready but `index.ts` awaits it at startup so the first real error
* has a live client.
*/
export const sentryReady: Promise<void> = dsn ? bootstrapSentry(dsn) : Promise.resolve();
/**
* Report an error to Sentry. A no-op before the gate opens, when the gate
* never opens (`SENTRY_DSN` unset), or when bootstrap failed. Never throws
* observability must not change control flow.
* never opens (the backend DSN resolves to `null`), or when bootstrap
* failed. Never throws observability must not change control flow.
*/
export function captureException(error: unknown): void {
if (!sentryHandle) return;
@ -165,10 +179,10 @@ async function bootstrapSentry(dsn: string): Promise<void> {
if (!versionCheck.ok) {
// eslint-disable-next-line no-console
console.warn(
"[paperclip] SENTRY_DSN is set but the @sentry/node package is not " +
"installed, or is installed at an unsupported version. Install the " +
"declared version of @sentry/node to enable server error " +
"monitoring. Continuing without it.",
"[paperclip] The backend Sentry DSN is set, but the @sentry/node " +
"package is not installed, or is installed at an unsupported " +
"version. Install the declared version of @sentry/node to enable " +
"server error monitoring. Continuing without it.",
versionCheck.detail,
);
return;
@ -192,9 +206,9 @@ async function bootstrapSentry(dsn: string): Promise<void> {
// after that point reaches this block.
// eslint-disable-next-line no-console
console.warn(
"[paperclip] SENTRY_DSN is set and @sentry/node passed the version " +
"check, but it failed to load or initialize. Continuing without " +
"error monitoring.",
"[paperclip] The backend Sentry DSN is set, and @sentry/node passed " +
"the version check, but it failed to load or initialize. " +
"Continuing without error monitoring.",
err,
);
}