mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 15:39:47 +02:00
## 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>
603 lines
22 KiB
TypeScript
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();
|
|
});
|
|
});
|