diff --git a/packages/core-shared/src/instrumentation/sentry/init-server.test.ts b/packages/core-shared/src/instrumentation/sentry/init-server.test.ts new file mode 100644 index 0000000..2f34b7e --- /dev/null +++ b/packages/core-shared/src/instrumentation/sentry/init-server.test.ts @@ -0,0 +1,102 @@ +// packages/core-shared/src/instrumentation/sentry/init-server.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@sentry/nextjs", () => ({ + init: vi.fn(), + replayIntegration: vi.fn(() => ({ name: "Replay" })), +})); + +import * as Sentry from "@sentry/nextjs"; +import { initSentryServer } from "@/instrumentation/sentry/init-server"; + +describe("initSentryServer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls Sentry.init with sendDefaultPii: false (R31)", () => { + initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); + expect(Sentry.init).toHaveBeenCalledTimes(1); + const call = (Sentry.init as ReturnType).mock.calls[0][0] as Record< + string, + unknown + >; + expect(call["sendDefaultPii"]).toBe(false); + }); + + it("passes the configured DSN", () => { + initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); + const call = (Sentry.init as ReturnType).mock.calls[0][0] as Record< + string, + unknown + >; + expect(call["dsn"]).toBe("https://x@y/1"); + }); + + it("attaches beforeSend + beforeSendTransaction scrubbers", () => { + initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); + const call = (Sentry.init as ReturnType).mock.calls[0][0] as Record< + string, + unknown + >; + expect(typeof call["beforeSend"]).toBe("function"); + expect(typeof call["beforeSendTransaction"]).toBe("function"); + }); + + it("uses SENTRY_TRACES_SAMPLE_RATE env when set", () => { + const prev = process.env["SENTRY_TRACES_SAMPLE_RATE"]; + process.env["SENTRY_TRACES_SAMPLE_RATE"] = "0.25"; + initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); + const call = (Sentry.init as ReturnType).mock.calls[0][0] as Record< + string, + unknown + >; + expect(call["tracesSampleRate"]).toBe(0.25); + if (prev === undefined) delete process.env["SENTRY_TRACES_SAMPLE_RATE"]; + else process.env["SENTRY_TRACES_SAMPLE_RATE"] = prev; + }); + + it("defaults tracesSampleRate to 1.0 in dev, 0.1 in production", () => { + const prevEnv = process.env["NODE_ENV"]; + const prevRate = process.env["SENTRY_TRACES_SAMPLE_RATE"]; + delete process.env["SENTRY_TRACES_SAMPLE_RATE"]; + + process.env["NODE_ENV"] = "development"; + initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); + expect( + ((Sentry.init as ReturnType).mock.calls[0][0] as Record)[ + "tracesSampleRate" + ], + ).toBe(1.0); + + (Sentry.init as ReturnType).mockClear(); + process.env["NODE_ENV"] = "production"; + initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); + expect( + ((Sentry.init as ReturnType).mock.calls[0][0] as Record)[ + "tracesSampleRate" + ], + ).toBe(0.1); + + if (prevEnv === undefined) delete process.env["NODE_ENV"]; + else process.env["NODE_ENV"] = prevEnv; + if (prevRate !== undefined) process.env["SENTRY_TRACES_SAMPLE_RATE"] = prevRate; + }); + + it("tags events with the app name", () => { + initSentryServer({ dsn: "https://x@y/1", app: "web-next" }); + const call = (Sentry.init as ReturnType).mock.calls[0][0] as Record< + string, + unknown + >; + const initialScope = call["initialScope"] as { tags?: Record }; + expect(initialScope?.tags?.["app"]).toBe("web-next"); + }); + + it("is a no-op when dsn is empty/undefined", () => { + initSentryServer({ dsn: "", app: "web-next" }); + expect(Sentry.init).not.toHaveBeenCalled(); + initSentryServer({ dsn: undefined as unknown as string, app: "web-next" }); + expect(Sentry.init).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core-shared/src/instrumentation/sentry/init-server.ts b/packages/core-shared/src/instrumentation/sentry/init-server.ts new file mode 100644 index 0000000..c662a8f --- /dev/null +++ b/packages/core-shared/src/instrumentation/sentry/init-server.ts @@ -0,0 +1,39 @@ +// packages/core-shared/src/instrumentation/sentry/init-server.ts +import * as Sentry from "@sentry/nextjs"; +import { beforeSend, beforeSendTransaction } from "./scrub"; + +export type InitServerOpts = { + dsn: string | undefined; + app: "web-next" | "cms" | "web-tanstack"; + release?: string; +}; + +export function initSentryServer(opts: InitServerOpts): void { + if (!opts.dsn) return; + + const isProd = process.env["NODE_ENV"] === "production"; + const tracesSampleRate = + process.env["SENTRY_TRACES_SAMPLE_RATE"] !== undefined + ? Number(process.env["SENTRY_TRACES_SAMPLE_RATE"]) + : isProd + ? 0.1 + : 1.0; + + const environment = + process.env["SENTRY_ENVIRONMENT"] ?? + process.env["VERCEL_ENV"] ?? + process.env["NODE_ENV"] ?? + "development"; + const release = opts.release ?? process.env["VERCEL_GIT_COMMIT_SHA"] ?? "unknown"; + + Sentry.init({ + dsn: opts.dsn, + environment, + release, + tracesSampleRate, + sendDefaultPii: false, // R31 — non-negotiable + beforeSend, // R32 + beforeSendTransaction, // R33 + initialScope: { tags: { app: opts.app } }, + }); +}