feat(web-next): Sentry instrumentation hooks + withSentryConfig + R38 PII test

Adds apps/web-next/instrumentation.ts (server) and instrumentation-client.ts
(browser) hooks, wraps next.config.mjs with withSentryConfig (R52), and adds
the R38 per-app PII scrubber smoke test.

Spec deviation: extend PII_KEY_SUBSTRINGS with "ipaddress" so keys like
ipAddress trigger key-level redaction (tighter posture than the spec's
substring list; existing scrub.test.ts still passes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:13:39 +02:00
parent d4bc045a28
commit f911892d0b
8 changed files with 133 additions and 2 deletions

View File

@@ -0,0 +1,10 @@
// apps/web-next/instrumentation-client.ts
// Next.js 15+ browser hook: runs in the client bundle on app start.
import { initSentryClient } from "@repo/core-shared/instrumentation/sentry/init-client";
initSentryClient({
dsn: process.env["NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN"],
app: "web-next",
release: process.env["NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA"],
});

View File

@@ -0,0 +1,19 @@
// apps/web-next/instrumentation.ts
// Next.js convention: this module runs once on server boot.
// Delegates to the centralized init helper in core-shared.
export async function register() {
if (
process.env["NEXT_RUNTIME"] === "nodejs" ||
process.env["NEXT_RUNTIME"] === "edge"
) {
const { initSentryServer } = await import(
"@repo/core-shared/instrumentation/sentry/init-server"
);
initSentryServer({
dsn: process.env["WEB_NEXT_SENTRY_DSN"],
app: "web-next",
release: process.env["VERCEL_GIT_COMMIT_SHA"],
});
}
}

View File

@@ -1,3 +1,5 @@
import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: [
@@ -14,4 +16,12 @@ const nextConfig = {
],
};
export default nextConfig;
export default withSentryConfig(nextConfig, {
// R52 — token is build-time only; CI sets SENTRY_AUTH_TOKEN
silent: process.env.CI !== "true",
authToken: process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT_WEB_NEXT,
hideSourceMaps: true,
disableLogger: true,
});

View File

@@ -23,6 +23,7 @@
"@repo/marketing-pages": "workspace:*",
"@repo/media": "workspace:*",
"@repo/navigation": "workspace:*",
"@sentry/nextjs": "^10.51.0",
"@tanstack/react-query": "^5.66.0",
"@trpc/server": "^11.0.0",
"inversify": "^6.2.0",

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import {
beforeSend,
beforeSendTransaction,
} from "@repo/core-shared/instrumentation/sentry/scrub";
describe("R38 — apps/web-next PII scrubber", () => {
it("strips email/password/cookie/auth/IP from event payload", () => {
const event = {
extra: {
userEmail: "alice@example.com",
password: "p4$$w0rd",
ipAddress: "192.168.1.10",
note: "request from 10.0.0.1",
},
request: {
headers: {
Authorization: "Bearer secret",
"Set-Cookie": "session=abc",
"User-Agent": "Mozilla",
},
},
} as Parameters<typeof beforeSend>[0];
const result = beforeSend(event, {}) as {
extra: Record<string, string>;
request: { headers: Record<string, string> };
};
expect(result.extra["userEmail"]).toBe("[redacted]");
expect(result.extra["password"]).toBe("[redacted]");
expect(result.extra["ipAddress"]).toBe("[redacted]");
expect(result.extra["note"]).toContain("[redacted-ip]");
expect(result.request.headers["Authorization"]).toBe("[redacted]");
expect(result.request.headers["Set-Cookie"]).toBe("[redacted]");
expect(result.request.headers["User-Agent"]).toBe("Mozilla");
});
it("strips ?token / ?email / ?password / ?secret / ?signature from URLs", () => {
const event = {
request: {
url: "https://app/api/x?token=abc&email=a@b.c&password=p&secret=z&signature=s&safe=1",
},
transaction: "/foo?accessToken=t",
} as Parameters<typeof beforeSendTransaction>[0];
const result = beforeSendTransaction(event, {}) as {
request: { url: string };
transaction: string;
};
const url = decodeURIComponent(result.request.url);
const txn = decodeURIComponent(result.transaction);
for (const key of ["token", "email", "password", "secret", "signature"]) {
expect(url).toContain(`${key}=[redacted]`);
}
expect(url).toContain("safe=1");
expect(txn).toContain("accessToken=[redacted]");
});
});