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:
parent
314ff24b7a
commit
ed3559dd21
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue