Files
PaperClipAI/server/src/__tests__/sentry.test.ts
T
Nicky LeachandPaperclip 1de105c475 fix(observability): pin the Sentry browser SDK and gate the optional Sentry server peer on the exact version (#12270)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip uses separate server and browser packages for runtime
services and the board.
> - Sentry integrations need an exact SDK version and safe optional
loading.
> - A version range can select an SDK that the privacy tests did not
audit.
> - Missing peer metadata does not describe the optional server SDK
contract.
> - This pull request pins the browser SDK and gates the optional server
SDK on its exact version.
> - The benefit is a clear SDK contract with fail-open startup behavior.

## Linked Issues or Issue Description

**What happened?**

The browser package used the range ^10.71.0, so a lockfile refresh could
select a newer SDK. The server loaded @sentry/node dynamically but did
not declare its optional peer contract.

**Expected behavior**

The browser package must use the audited 10.71.0 version. The server
must load @sentry/node only when the installed peer matches 10.71.0. The
server must start when the optional peer is absent.

**Steps to reproduce**

1. Install the project dependencies.
2. Inspect the browser Sentry version and the server package metadata.
3. Start the server without installing @sentry/node.
4. Confirm that the server starts and that the dynamic Sentry bootstrap
does not load an unsupported peer version.

**Paperclip version or commit**

9c57c0f119

**Deployment mode**

Built from source with pnpm dev or pnpm build.

**Installation method**

Built from source.

**Agent adapter(s) involved**

Not adapter-specific (core change).

**Database mode**

Not database-related.

## What Changed

- Pin @sentry/browser to exactly 10.71.0 as a UI development dependency.
- Declare @sentry/node as an optional server peer dependency at 10.71.0.
- Gate the dynamic server bootstrap on the exact peer version.
- Add tests for the browser pin, peer metadata, version gate, and
fail-open loading.
- Document the supported server SDK version.
- Keep the lockfile unchanged because the pull request workflow
regenerates it for manifest changes.

## Verification

- Server tests pass with six expected skips when @sentry/node is absent.
- UI tests pass.
- The UI build emits the lazy Sentry browser chunk.
- git diff --check passes.
- GitHub pull request checks must pass after this pull request opens.
- Greptile must return a 5/5 score with no open findings.

## Risks

The exact version gate prevents Sentry startup when an unsupported SDK
version exists. The integration remains optional and fail-open. The
lockfile workflow must regenerate the lockfile before frozen downstream
jobs run. The label-gated Storybook visual job must not run until it can
restore the generated lockfile artifact.

## Model Used

OpenAI Codex, GPT-5, tool use and code review support, exact context
window details are managed by the execution platform.

## 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
- [x] My branch name describes the change 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>
2026-08-27 07:20:03 -07:00

603 lines
22 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRequire } from "node:module";
import http from "node:http";
import express from "express";
import request from "supertest";
import type { NextFunction, Request, Response } from "express";
import { HttpError } from "../errors.js";
import { errorHandler } from "../middleware/error-handler.js";
import { finalizeServerShutdown } from "../shutdown.js";
import { authRoutes } from "../routes/auth.js";
import * as sentryModule from "../sentry.js";
/**
* Tests for the opt-in Sentry error-monitoring gate. `@sentry/node` is an
* optional runtime dependency and is NOT installed in CI, which is itself
* part of the contract under test: with `SENTRY_DSN` set and the package
* absent, the module must warn and settle instead of crashing the server.
*
* The module reads `SENTRY_DSN` at import time, so each test resets the
* module registry and imports a fresh copy.
*/
const DSN_ENV = "SENTRY_DSN";
const originalDsn = process.env[DSN_ENV];
async function importFreshSentry() {
vi.resetModules();
return await import("../sentry.js");
}
/**
* Register a fake `@sentry/node` module for the next dynamic import. Each
* mock function is returned so a test can assert on the call it received.
* The mock stays in place until `vi.doUnmock` runs, so `afterEach` clears it.
*
* `@sentry/node` is not installed on disk in this test environment, so the
* exact-version gate would report it missing before the dynamic import ever
* runs. This helper also mocks the gate module itself to report success, so
* a test can exercise the "package present and at the right version" path
* without installing the real package.
*/
function mockSentryPackage() {
const init = vi.fn();
const captureException = vi.fn(() => "event-id");
const close = vi.fn(async () => true);
const httpIntegration = vi.fn((options: unknown) => ({ name: "Http", ...(options as object) }));
const onUnhandledRejectionIntegration = vi.fn((options: unknown) => ({
name: "OnUnhandledRejection",
...(options as object),
}));
vi.doMock("@sentry/node", () => ({
init,
captureException,
close,
httpIntegration,
onUnhandledRejectionIntegration,
}));
vi.doMock("../peer-version-check.js", () => ({
checkExactPeerVersions: () => ({ ok: true }),
}));
return { init, captureException, close, httpIntegration, onUnhandledRejectionIntegration };
}
// A representative default-integration list, shaped like the array
// `@sentry/node@10.71.0`'s `getDefaultIntegrations()` returns for a Node
// server. Recorded against the published package with:
// node -e "const S=require('@sentry/node'); \
// console.log(S.getDefaultIntegrations({}).map(i=>i.name))"
const DEFAULT_INTEGRATION_NAMES = [
"InboundFilters",
"FunctionToString",
"LinkedErrors",
"RequestData",
"NodeSystemError",
"ConversationId",
"Console",
"OnUncaughtException",
"OnUnhandledRejection",
"ContextLines",
"LocalVariablesAsync",
"Context",
"ChildProcess",
"ProcessSession",
"Modules",
"Http",
"NodeFetch",
];
beforeEach(() => {
delete process.env[DSN_ENV];
});
afterEach(() => {
if (originalDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = originalDsn;
vi.restoreAllMocks();
vi.doUnmock("@sentry/node");
vi.doUnmock("../peer-version-check.js");
});
describe("sentryReady", () => {
it("resolves and imports no SDK when SENTRY_DSN is unset", async () => {
// A spy that fails the test if the module attempts a dynamic import of
// an SDK it should never touch on the closed-gate path. `@sentry/node`
// is not installed, so an attempted import would settle this promise
// with a warning instead of resolving clean.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { sentryReady } = await importFreshSentry();
await expect(sentryReady).resolves.toBeUndefined();
expect(warn).not.toHaveBeenCalled();
});
});
describe("captureException", () => {
it("is a no-op and does not throw when the gate is closed", async () => {
const { captureException, sentryReady } = await importFreshSentry();
await sentryReady;
expect(() => captureException(new Error("boom"))).not.toThrow();
});
});
describe("shutdownSentry", () => {
it("resolves once for concurrent callers", async () => {
const { shutdownSentry } = await importFreshSentry();
const first = shutdownSentry();
const second = shutdownSentry();
// Memoized: concurrent callers share one shutdown promise.
expect(first).toBe(second);
await expect(first).resolves.toBeUndefined();
});
});
/**
* Seam tests for the two `errorHandler` call sites that report to Sentry.
* These tests spy on the exported `captureException` binding rather than
* import a real client, so they assert the call-site shape (one call, the
* Error object only) without a live Sentry SDK.
*/
describe("errorHandler Sentry capture", () => {
function makeReq(): Request {
return {
method: "GET",
originalUrl: "/api/test",
body: { a: 1 },
params: { id: "123" },
query: { q: "x" },
} as unknown as Request;
}
function makeRes(): Response {
const res = {
status: vi.fn(),
json: vi.fn(),
} as unknown as Response;
(res.status as unknown as ReturnType<typeof vi.fn>).mockReturnValue(res);
return res;
}
it("captures one event for a 500-level HttpError", () => {
const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {});
const req = makeReq();
const res = makeRes();
const next = vi.fn() as unknown as NextFunction;
const err = new HttpError(500, "db exploded");
errorHandler(err, req, res, next);
expect(capture).toHaveBeenCalledTimes(1);
});
it("captures one event for an unknown error", () => {
const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {});
const req = makeReq();
const res = makeRes();
const next = vi.fn() as unknown as NextFunction;
const err = new Error("boom");
errorHandler(err, req, res, next);
expect(capture).toHaveBeenCalledTimes(1);
});
it("captures no event for a Zod validation 400 response", () => {
const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {});
const req = makeReq();
const res = makeRes();
const next = vi.fn() as unknown as NextFunction;
const issue = {
code: "invalid_type",
expected: "string",
received: "undefined",
path: ["provider"],
message: "Required",
};
const err = Object.assign(new Error("Validation failed"), {
name: "ZodError",
issues: [issue],
});
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(capture).not.toHaveBeenCalled();
});
it("passes the Error object only to captureException, never the request-bearing ErrorContext", () => {
const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {});
const req = makeReq();
const res = makeRes();
const next = vi.fn() as unknown as NextFunction;
const err = new Error("boom");
errorHandler(err, req, res, next);
expect(capture).toHaveBeenCalledWith(err);
const [received] = capture.mock.calls[0]!;
expect(received).toBeInstanceOf(Error);
// The `ErrorContext` shape carries the request body, params, and query.
// The received value must not carry any of them.
expect(received).not.toHaveProperty("reqBody");
expect(received).not.toHaveProperty("reqParams");
expect(received).not.toHaveProperty("reqQuery");
});
});
describe("finalizeServerShutdown Sentry teardown", () => {
it("calls shutdownSentry after shutdownInstrumentation", async () => {
const order: string[] = [];
const shutdownInstrumentation = vi.fn(async () => {
order.push("instrumentation");
});
const shutdownSentry = vi.fn(async () => {
order.push("sentry");
});
await finalizeServerShutdown({
signal: "SIGTERM",
shutdownAppServices: undefined,
stopEmbeddedPostgres: null,
shutdownInstrumentation,
shutdownSentry,
log: { info: vi.fn(), error: vi.fn() },
});
expect(order).toEqual(["instrumentation", "sentry"]);
});
});
describe("missing @sentry/node package", () => {
it("logs one warning and resolves", async () => {
process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { sentryReady } = await importFreshSentry();
// Bootstrap must absorb the failed dynamic import — the server keeps
// booting without error monitoring rather than crashing on an opt-in
// feature.
await expect(sentryReady).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("@sentry/node package is not installed"),
expect.anything(),
);
});
});
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";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.doMock("../peer-version-check.js", () => ({
checkExactPeerVersions: () => ({
ok: false,
diagnostic: "unused by the Sentry gate; see server/src/sentry.ts",
detail: {
missing: [],
mismatched: [{ name: "@sentry/node", installed: "9.0.0", expected: "10.71.0" }],
},
}),
}));
const { sentryReady } = await importFreshSentry();
// Bootstrap must absorb the reported mismatch — the server keeps
// booting without error monitoring rather than crashing on an opt-in
// feature.
await expect(sentryReady).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("@sentry/node package is not installed"),
expect.anything(),
);
vi.doUnmock("../peer-version-check.js");
});
});
describe("buildSentryInitOptions", () => {
it("sets sendDefaultPii false, tracesSampleRate 0, and skipOpenTelemetrySetup true", async () => {
const { buildSentryInitOptions } = await importFreshSentry();
const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", {
httpIntegration: () => ({ name: "Http" }),
onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }),
});
expect(options.sendDefaultPii).toBe(false);
expect(options.tracesSampleRate).toBe(0);
expect(options.skipOpenTelemetrySetup).toBe(true);
});
it("passes onUnhandledRejection with mode strict", async () => {
const { buildSentryInitOptions } = await importFreshSentry();
const onUnhandledRejectionIntegration = vi.fn((options: unknown) => ({
name: "OnUnhandledRejection",
...(options as object),
}));
const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", {
httpIntegration: () => ({ name: "Http" }),
onUnhandledRejectionIntegration,
});
const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name })));
expect(onUnhandledRejectionIntegration).toHaveBeenCalledWith({ mode: "strict" });
const rejectionIntegration = resolved.find((i) => i.name === "OnUnhandledRejection");
expect(rejectionIntegration).toMatchObject({ mode: "strict" });
// The default OnUnhandledRejection entry must not survive alongside it.
expect(resolved.filter((i) => i.name === "OnUnhandledRejection")).toHaveLength(1);
});
it("the resolved server integration list holds no Console integration and no ContextLines integration", async () => {
const { buildSentryInitOptions } = await importFreshSentry();
const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", {
httpIntegration: () => ({ name: "Http" }),
onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }),
});
const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name })));
const names = resolved.map((i) => i.name);
expect(names).not.toContain("Console");
expect(names).not.toContain("ContextLines");
});
it("the resolved server integration list keeps OnUncaughtException, OnUnhandledRejection, LinkedErrors, and RequestData", async () => {
const { buildSentryInitOptions } = await importFreshSentry();
const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", {
httpIntegration: () => ({ name: "Http" }),
onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }),
});
const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name })));
const names = resolved.map((i) => i.name);
// The full error-capture set the plan requires, not just the four named
// in this test's title.
expect(names).toEqual(
expect.arrayContaining([
"OnUncaughtException",
"OnUnhandledRejection",
"ChildProcess",
"LinkedErrors",
"RequestData",
"Modules",
"Context",
"ProcessSession",
]),
);
});
it("turns the outbound HTTP breadcrumb off and keeps the rest of the Http integration", async () => {
const { buildSentryInitOptions } = await importFreshSentry();
const httpIntegration = vi.fn((options: unknown) => ({ name: "Http", ...(options as object) }));
const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", {
httpIntegration,
onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }),
});
const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name })));
expect(httpIntegration).toHaveBeenCalledWith({ breadcrumbs: false });
expect(resolved.filter((i) => i.name === "Http")).toHaveLength(1);
});
});
describe("with @sentry/node mocked", () => {
it("initializes the client and shares captureException / shutdownSentry with it", async () => {
process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1";
const mocks = mockSentryPackage();
const { sentryReady, captureException, shutdownSentry } = 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@o0.ingest.sentry.io/1");
captureException(new Error("boom"));
expect(mocks.captureException).toHaveBeenCalledWith(expect.any(Error));
await shutdownSentry();
expect(mocks.close).toHaveBeenCalledWith(5_000);
});
});
/**
* Proves the one-project result: the server and the browser both resolve
* their Sentry client from the same `SENTRY_DSN` value, so both send events
* to the same Sentry project. The browser reads its DSN from the
* `GET /api/auth/get-session` response body (see `ui/src/lib/sentry.ts` and
* `ui/src/components/SentryGate.tsx`), never from a `<meta>` tag.
*/
describe("one-project resolution", () => {
function makeSessionApp() {
const app = express();
app.use((req, _res, next) => {
req.actor = { type: "board", userId: "user-1", source: "session" };
next();
});
const db = {
select: () => ({
from: () => ({
where: () =>
Promise.resolve([
{ id: "user-1", name: "Jane Example", email: "jane@example.com", image: null },
]),
}),
}),
};
app.use("/api/auth", authRoutes(db as unknown as Parameters<typeof authRoutes>[0]));
app.use(errorHandler);
return app;
}
it("the server initializer and GET /api/auth/get-session resolve the same Sentry project", async () => {
process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1";
const mocks = mockSentryPackage();
const { sentryReady } = await importFreshSentry();
await sentryReady;
const initOptions = mocks.init.mock.calls[0]![0] as { dsn: string };
const app = makeSessionApp();
const res = await request(app).get("/api/auth/get-session");
expect(res.status).toBe(200);
expect(res.body.sentryDsn).toBe(initOptions.dsn);
});
});
// `@sentry/node` is an optional runtime dependency (see the module comment
// in sentry.ts). When it is absent, the three tests below cannot run against
// the true SDK, so they are skipped — the same pattern instrumentation.test.ts
// uses for its OpenTelemetry-SDK-dependent test.
const sentryPackage = (() => {
try {
const require = createRequire(import.meta.url);
return require("@sentry/node") as {
init(options: Record<string, unknown>): unknown;
captureException(error: unknown): string;
httpIntegration(options: { breadcrumbs: boolean }): { name: string };
onUnhandledRejectionIntegration(options: { mode: string }): { name: string };
flush(timeout?: number): Promise<boolean>;
close(timeout?: number): Promise<boolean>;
};
} catch {
return null;
}
})();
describe.skipIf(!sentryPackage)("captured event shape against the real @sentry/node SDK", () => {
/**
* Initialize the real SDK with this module's exact options, plus a
* transport stub so no event leaves the test process, plus `beforeSend`
* so the test can inspect the resolved event before it would have been
* sent. `beforeSend` is test-only introspection — the module under test
* adds no `beforeSend` of its own (constraint: built-in options only).
*/
async function initRealSentryForTest(onEvent: (event: Record<string, unknown>) => void) {
const Sentry = sentryPackage!;
const { buildSentryInitOptions } = await importFreshSentry();
const options = {
...buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", Sentry),
transport: () => ({ send: async () => ({}), flush: async () => true }),
beforeSend: (event: Record<string, unknown>) => {
onEvent(event);
return event;
},
};
Sentry.init(options);
}
it("a server event captured after a console.error call carries no console breadcrumb", async () => {
const Sentry = sentryPackage!;
let captured: Record<string, unknown> | null = null;
await initRealSentryForTest((event) => {
captured = event;
});
// eslint-disable-next-line no-console
console.error("child process stderr: simulated failure");
Sentry.captureException(new Error("after console.error"));
await Sentry.flush(2000);
expect(captured).not.toBeNull();
expect((captured as Record<string, unknown>).breadcrumbs).toBeUndefined();
});
it("a server event carries no stack-frame source context", async () => {
const Sentry = sentryPackage!;
let captured: Record<string, unknown> | null = null;
await initRealSentryForTest((event) => {
captured = event;
});
Sentry.captureException(new Error("stack frame check"));
await Sentry.flush(2000);
const event = captured as unknown as {
exception: { values: Array<{ stacktrace: { frames: Array<Record<string, unknown>> } }> };
};
const frames = event.exception.values[0].stacktrace.frames;
expect(frames.length).toBeGreaterThan(0);
for (const frame of frames) {
expect(frame.context_line).toBeUndefined();
expect(frame.pre_context).toBeUndefined();
expect(frame.post_context).toBeUndefined();
}
});
it("a server event carries no outbound HTTP breadcrumb", async () => {
const Sentry = sentryPackage!;
let captured: Record<string, unknown> | null = null;
await initRealSentryForTest((event) => {
captured = event;
});
const server = http.createServer((_req, res) => res.end("ok"));
await new Promise<void>((resolve) => server.listen(0, resolve));
const port = (server.address() as { port: number }).port;
await new Promise<void>((resolve) => {
http.get(`http://127.0.0.1:${port}/probe?token=secret`, (res) => {
res.resume();
res.on("end", resolve);
});
});
server.close();
Sentry.captureException(new Error("after outbound http call"));
await Sentry.flush(2000);
expect((captured as unknown as Record<string, unknown>).breadcrumbs).toBeUndefined();
});
/**
* `skipOpenTelemetrySetup: true` (set above) keeps this module out of
* Paperclip's separate, independently opt-in OpenTelemetry feature. It
* also turns off Sentry's own per-request async-context tracking. The
* `RequestData` integration reads the inbound URL, method, headers,
* cookies, and query string from that per-request context. With the
* context off, a captured event carries no request field — not the
* SDK's documented default. This test proves the gap, so the operator
* documentation states the true capture set.
*/
it("a server event captured inside a real HTTP request handler carries no request field", async () => {
const Sentry = sentryPackage!;
let captured: Record<string, unknown> | null = null;
await initRealSentryForTest((event) => {
captured = event;
});
const server = http.createServer((req, res) => {
req.on("data", () => {});
req.on("end", () => {
Sentry.captureException(new Error("boom from a real request handler"));
res.end("ok");
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const port = (server.address() as { port: number }).port;
await new Promise<void>((resolve) => {
http.get(`http://127.0.0.1:${port}/api/test?foo=bar`, (res) => {
res.resume();
res.on("end", resolve);
});
});
server.close();
await Sentry.flush(2000);
expect(captured).not.toBeNull();
expect((captured as unknown as Record<string, unknown>).request).toBeUndefined();
});
});