Files
PaperClipAI/server/src/__tests__/sentry.test.ts
T
Devin FoleyandPaperclip 90182b4f8b Report bounded Cloud portfolio failure diagnostics (#15340)
## Thinking Path

> - Paperclip manages work for AI agents and their companies.
> - Cloud instances proxy a trusted user's portfolio through the control
plane.
> - A failed request returns a generic error to the client.
> - That replacement error loses the failure phase and network code in
Sentry.
> - Operators need bounded evidence without upstream messages or
credentials.
> - This PR adds safe diagnostics while preserving the existing request
behavior.

## Linked Issues or Issue Description

Refs #10850, which added the portfolio proxy. No open PR for this
diagnostic gap was found.

**What happened?**

A rejected portfolio fetch becomes a generic 502 in Sentry. The event
cannot distinguish a connection reset, deadline, HTTP response failure,
or body failure. The route's previous warning also included the original
error and a stack identifier.

**Steps to reproduce**

Make the portfolio proxy's fetch reject with a TypeError whose cause has
`code: ECONNRESET`. The client correctly receives the generic 502, but
the captured replacement error loses that code.

**Expected behavior**

Keep the existing client response. Attach only bounded server-side
diagnostic fields to the failure event. Do not retry the request or
expose the original error.

## What Changed

- Add a typed portfolio error with a private frozen diagnostic record:
phase, upstream HTTP status, elapsed milliseconds, and an allowlisted
network code.
- Read at most four error/cause objects through own data properties.
Unknown codes, messages, getters, and out-of-range values do not enter
the record.
- Replace the route's raw-error warnings with safe fields. Send a plain
error plus event-local context through the existing optional Sentry
gate. Keep the route callsite and default fingerprint policy.
- Preserve authentication, trusted headers, cookies, exact HTTP error
bodies, cache behavior, the ten-second deadline, and one fetch per
request. Public responses receive no diagnostic fields.
- Document the fields and test HTTP behavior, privacy, and event
isolation with the real Sentry SDK.

## Verification

- `PAPERCLIP_REQUIRE_SENTRY_TEST_SDK=1 pnpm exec vitest run
server/src/__tests__/cloud-portfolio-error.test.ts
server/src/__tests__/cloud-routes.test.ts
server/src/__tests__/sentry.test.ts
server/src/__tests__/run-failure-sentry-real-sdk.test.ts`: 65 passed,
with the audited optional SDK installed.
- Full local `pnpm -r typecheck` and `pnpm build` passed on Node 24.21.0
and pnpm 9.15.4.
- Independent review found no blockers and independently passed all 65
tests, including the real SDK checks, on this exact commit.
- Full Linux CI passed on this exact commit and provides aggregate suite
coverage (54 successful checks, 2 intentional skips). A duplicate full
local aggregate was not run.
- The first SDK contract job failed before tests when npm could not
resolve an OpenTelemetry transitive package. A subsequent empty-cache
install first encountered a missing tarball, then succeeded after the
registry artifact became available. All 6 real-SDK tests passed against
that fresh install; the single unchanged-head CI retry passed. The SDK
pin, workflow, and dependency files are unchanged.
- The first browser shard 8 run timed out waiting for the inbox retry
reply after 45 seconds. The unchanged isolated case passed (1/1), and
the test, UI handler, fixture, and recovery files match the base commit.
The failed log contains no wakeup POST before the test's immediate
navigation; a navigation/request timing race is suspected but unproven
without a trace. The single unchanged-head shard retry passed (20
passed, 1 skipped); no timeout or source change was made.
- Greptile reviewed this exact commit at 5/5 with no unresolved review
threads.
- No live portfolio request was replayed. Route tests use controlled
local upstream responses.
- The added diff passed the secret and PII scan and `git diff --check`.

## Risks

This is a diagnostic change. It does not identify or repair the origin
of a connection reset. Unknown transport failures remain `unknown`.
Elapsed values outside 0–60,000 ms become null. Default Sentry
fingerprinting remains enabled; exact historical group membership is not
guaranteed. No retry, migration, deployment, or configuration change is
included.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository editing, code
execution, and independent agent review. The exact deployment model ID
and context window are not exposed in this session.

## 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>
2026-10-06 06:58:36 -07:00

878 lines
34 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRequire } from "node:module";
import os from "node:os";
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 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();
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];
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");
});
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("sends only private normalized portfolio diagnostics on the matching event", async () => {
process.env[BACKEND_DSN_ENV] = "https://fixture@example.com/1";
const sdk = mockSentryPackage();
const { captureException, sentryReady } = await importFreshSentry();
const { CloudPortfolioError } = await import("../services/cloud-portfolio-error.js");
await sentryReady;
const error = new CloudPortfolioError("upstream", { phase: "fetch", elapsedMs: 270, upstreamStatus: null }, { code: "ECONNRESET" });
Object.assign(error, { cause: new Error("private cause"), headers: { authorization: "private token" } });
Object.defineProperty(error, "diagnostics", { value: { token: "private replacement" } });
captureException(error);
const unrelated = new Error("unrelated");
captureException(unrelated);
expect(sdk.captureException.mock.calls[0]).toEqual([
expect.objectContaining({ message: error.message, stack: error.stack }),
{ tags: { error_code: "cloud_portfolio_failure" }, fingerprint: ["{{ default }}"], contexts: {
cloud_portfolio: { phase: "fetch", elapsedMs: 270, upstreamStatus: null, networkCode: "ECONNRESET" },
} },
]);
expect(JSON.stringify(sdk.captureException.mock.calls[0])).not.toContain("private");
expect(sdk.captureException.mock.calls[0]![0]).not.toHaveProperty("cause");
expect(sdk.captureException.mock.calls[1]).toEqual([unrelated]);
});
it("adds bounded Stop timeout context only to that event", async () => {
process.env[BACKEND_DSN_ENV] = "https://fixture@example.com/1";
const sdk = mockSentryPackage();
const { captureException, sentryReady } = await importFreshSentry();
const { AdapterStopTimeoutError } = await import("../services/adapter-stop-timeout.js");
await sentryReady;
const error = new AdapterStopTimeoutError(60_000, {
runId: "11111111-1111-4111-8111-111111111111",
adapterType: "cursor", runtimeMode: "legacy", abortRequested: true,
phase: "instruction_collection", phaseElapsedMs: 60_321,
});
Object.assign(error, { providerResponse: "private fixture payload" });
captureException(error);
const unrelated = new Error("unrelated");
captureException(unrelated);
expect(sdk.captureException.mock.calls[0]).toEqual([
expect.objectContaining({ message: error.message, stack: error.stack }),
{ tags: { error_code: "adapter_stop_unconfirmed" }, fingerprint: ["{{ default }}"], contexts: {
adapter_stop: { runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor", runtimeMode: "legacy", abortRequested: true, timeoutMs: 60_000,
phase: "instruction_collection", phaseElapsedMs: 60_321 },
} },
]);
expect(JSON.stringify(sdk.captureException.mock.calls[0])).not.toContain("private fixture payload");
expect(sdk.captureException.mock.calls[1]).toEqual([unrelated]);
});
it("does not send arbitrary Stop diagnostic values", async () => {
process.env[BACKEND_DSN_ENV] = "https://fixture@example.com/1";
const sdk = mockSentryPackage();
const { captureException, sentryReady } = await importFreshSentry();
const { AdapterStopTimeoutError } = await import("../services/adapter-stop-timeout.js");
await sentryReady;
captureException(new AdapterStopTimeoutError(NaN, {
runId: "private fixture payload", adapterType: "private fixture payload", runtimeMode: "private fixture payload",
phase: "private fixture payload", phaseElapsedMs: Infinity,
}));
expect(JSON.stringify(sdk.captureException.mock.calls)).not.toContain("private fixture payload");
expect(sdk.captureException.mock.calls[0]).toEqual([expect.any(Error), expect.objectContaining({ contexts: {
adapter_stop: { runId: null, adapterType: "unknown", runtimeMode: "unknown", abortRequested: null, timeoutMs: null,
phase: "unknown", phaseElapsedMs: null },
} })]);
});
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[BACKEND_DSN_ENV] = "https://public@o0.ingest.sentry.io/1";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
// Keep this failure-mode test valid when the optional real-SDK tests run.
vi.doMock("../peer-version-check.js", () => ({
checkExactPeerVersions: () => ({
ok: false,
detail: { missing: ["@sentry/node"], mismatched: [] },
}),
}));
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[BACKEND_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("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();
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("buildSentryInitOptions serverName", () => {
it("sets serverName to the host name", async () => {
const { buildSentryInitOptions } = await importFreshSentry();
const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", {
httpIntegration: () => ({ name: "Http" }),
onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }),
});
expect(options.serverName).toBe(os.hostname());
});
it("reads the host name from node:os at call time, not at module load time", async () => {
vi.doMock("node:os", () => ({
default: { hostname: () => "fixed-test-host" },
hostname: () => "fixed-test-host",
}));
const { buildSentryInitOptions } = await importFreshSentry();
const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", {
httpIntegration: () => ({ name: "Http" }),
onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }),
});
expect(options.serverName).toBe("fixed-test-host");
vi.doUnmock("node:os");
});
const SENTRY_NAME_ENV = "SENTRY_NAME";
let originalSentryName: string | undefined;
beforeEach(() => {
originalSentryName = process.env[SENTRY_NAME_ENV];
});
afterEach(() => {
if (originalSentryName === undefined) delete process.env[SENTRY_NAME_ENV];
else process.env[SENTRY_NAME_ENV] = originalSentryName;
});
it("uses SENTRY_NAME as serverName when the variable holds a non-empty string", async () => {
process.env[SENTRY_NAME_ENV] = "opaque-operator-id";
const { buildSentryInitOptions } = await importFreshSentry();
const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", {
httpIntegration: () => ({ name: "Http" }),
onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }),
});
expect(options.serverName).toBe("opaque-operator-id");
});
it("uses the host name as serverName when SENTRY_NAME is absent", async () => {
delete process.env[SENTRY_NAME_ENV];
const { buildSentryInitOptions } = await importFreshSentry();
const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", {
httpIntegration: () => ({ name: "Http" }),
onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }),
});
expect(options.serverName).toBe(os.hostname());
});
it("uses the host name as serverName when SENTRY_NAME is an empty string", async () => {
process.env[SENTRY_NAME_ENV] = "";
const { buildSentryInitOptions } = await importFreshSentry();
const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", {
httpIntegration: () => ({ name: "Http" }),
onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }),
});
expect(options.serverName).toBe(os.hostname());
});
});
describe("buildSentryInitOptions release", () => {
const commit = "0123456789abcdef0123456789abcdef01234567";
const readBuildCommit = vi.fn<() => string | null>();
const integrations = {
httpIntegration: () => ({ name: "Http" }),
onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }),
};
beforeEach(() => {
vi.stubEnv("SENTRY_RELEASE", "");
readBuildCommit.mockReturnValue(commit);
vi.doMock("../build-commit.js", () => ({ readBuildCommit }));
});
afterEach(() => {
vi.unstubAllEnvs();
vi.doUnmock("../build-commit.js");
readBuildCommit.mockReset();
});
it("uses the server build commit", async () => {
const { buildSentryInitOptions } = await importFreshSentry();
expect(buildSentryInitOptions("test-dsn", integrations).release).toBe(commit);
});
it("preserves an operator's explicit release", async () => {
vi.stubEnv("SENTRY_RELEASE", " custom-release ");
const { buildSentryInitOptions } = await importFreshSentry();
expect(buildSentryInitOptions("test-dsn", integrations).release).toBe("custom-release");
});
it("leaves an unknown build unattributed", async () => {
// Keep one module factory and change its return value explicitly for this
// case, rather than depending on a second factory replacing the first.
readBuildCommit.mockReturnValue(null);
const { buildSentryInitOptions } = await importFreshSentry();
expect(buildSentryInitOptions("test-dsn", integrations).release).toBeUndefined();
expect(readBuildCommit).toHaveBeenCalled();
});
});
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("attaches the actual build commit to an emitted event", async () => {
const commit = "0123456789abcdef0123456789abcdef01234567";
vi.stubEnv("PAPERCLIP_BUILD_COMMIT", commit);
vi.stubEnv("SENTRY_RELEASE", "");
try {
let captured: Record<string, unknown> | null = null;
await initRealSentryForTest((event) => { captured = event; });
sentryPackage!.captureException(new Error("build attribution check"));
await sentryPackage!.flush(2000);
expect(captured).toMatchObject({ release: commit });
expect(captured).not.toHaveProperty("request");
} finally {
vi.unstubAllEnvs();
}
});
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();
});
});