mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 03:08:10 +02:00
## 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>
878 lines
34 KiB
TypeScript
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();
|
|
});
|
|
});
|