diff --git a/README.md b/README.md index 601e0252a1..969882e929 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/doc/observability.md b/doc/observability.md index 3331ec00bc..e730ad2120 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -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://@/" +export SENTRY_DSN_FRONTEND="https://@/" +export SENTRY_DSN_BACKEND="https://@/" ``` -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 `` 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 diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index d9f7f39d48..a5eddd48e5 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -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(), diff --git a/server/src/__tests__/auth-routes.test.ts b/server/src/__tests__/auth-routes.test.ts index 549f67618e..3cb3cf6fb0 100644 --- a/server/src/__tests__/auth-routes.test.ts +++ b/server/src/__tests__/auth-routes.test.ts @@ -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( diff --git a/server/src/__tests__/sentry-dsn.test.ts b/server/src/__tests__/sentry-dsn.test.ts new file mode 100644 index 0000000000..64a3361247 --- /dev/null +++ b/server/src/__tests__/sentry-dsn.test.ts @@ -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 }); + }); +}); diff --git a/server/src/__tests__/sentry.test.ts b/server/src/__tests__/sentry.test.ts index 627804e4de..60edd8fc41 100644 --- a/server/src/__tests__/sentry.test.ts +++ b/server/src/__tests__/sentry.test.ts @@ -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(); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 4cb37e0508..6455636f82 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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, })); }); diff --git a/server/src/sentry-dsn.ts b/server/src/sentry-dsn.ts new file mode 100644 index 0000000000..09b6d51ff9 --- /dev/null +++ b/server/src/sentry-dsn.ts @@ -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 }; +} diff --git a/server/src/sentry.ts b/server/src/sentry.ts index 6dee0b5cbc..8cac6728c2 100644 --- a/server/src/sentry.ts +++ b/server/src/sentry.ts @@ -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 | 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 = 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 { 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 { // 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, ); }