diff --git a/backend/__tests__/requestTracing.test.js b/backend/__tests__/requestTracing.test.js new file mode 100644 index 00000000..fbaf4274 --- /dev/null +++ b/backend/__tests__/requestTracing.test.js @@ -0,0 +1,54 @@ +/* eslint-env jest */ +"use strict"; + +const mockChildLog = { info: jest.fn(), error: jest.fn() }; +const mockLogger = { child: jest.fn(() => mockChildLog) }; + +jest.mock("../src/utils/logger", () => mockLogger); +jest.mock("@sentry/node", () => ({ + getCurrentScope: () => ({ setTag: jest.fn(), setContext: jest.fn() }), +})); + +const express = require("express"); +const request = require("supertest"); +const { requestIdMiddleware } = require("../src/middleware/requestId"); +const { buildErrorResponse } = require("../src/utils/errorResponse"); + +describe("end-to-end request correlation", () => { + beforeEach(() => jest.clearAllMocks()); + + it("joins the inbound header, Pino log, response header, and error body", async () => { + const app = express(); + app.use(requestIdMiddleware); + app.get("/boom", (req, res) => { + req.log.error({ correlationId: req.id }, "Simulated backend failure"); + res.status(500).json(buildErrorResponse("SRV_INTERNAL")); + }); + + const response = await request(app) + .get("/boom") + .set("X-Request-ID", "frontend-backend-trace-643"); + + expect(mockLogger.child).toHaveBeenCalledWith({ requestId: "frontend-backend-trace-643" }); + expect(mockChildLog.error).toHaveBeenCalledWith( + { correlationId: "frontend-backend-trace-643" }, + "Simulated backend failure", + ); + expect(response.headers["x-request-id"]).toBe("frontend-backend-trace-643"); + expect(response.headers["x-correlation-id"]).toBe("frontend-backend-trace-643"); + expect(response.body.error.correlationId).toBe("frontend-backend-trace-643"); + }); + + it("accepts the X-Correlation-ID compatibility header", async () => { + const app = express(); + app.use(requestIdMiddleware); + app.get("/trace", (req, res) => res.json({ correlationId: req.id })); + + const response = await request(app) + .get("/trace") + .set("X-Correlation-ID", "compatibility-trace-643"); + + expect(response.body.correlationId).toBe("compatibility-trace-643"); + expect(response.headers["x-request-id"]).toBe("compatibility-trace-643"); + }); +}); diff --git a/backend/src/middleware/requestId.js b/backend/src/middleware/requestId.js index 8e641e08..1aaa3fc1 100644 --- a/backend/src/middleware/requestId.js +++ b/backend/src/middleware/requestId.js @@ -12,6 +12,7 @@ const crypto = require("crypto"); const Sentry = require("@sentry/node"); const logger = require("../utils/logger"); const { runWithRequestContext } = require("../utils/correlationId"); +const VALID_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/; /** * Express middleware: @@ -23,9 +24,11 @@ const { runWithRequestContext } = require("../utils/correlationId"); * - Tags the active Sentry scope with `correlationId` */ function requestIdMiddleware(req, res, next) { - const incoming = req.headers["x-request-id"]; + const incoming = req.headers["x-request-id"] || req.headers["x-correlation-id"]; const requestId = - typeof incoming === "string" && incoming.trim() ? incoming.trim() : crypto.randomUUID(); + typeof incoming === "string" && VALID_REQUEST_ID.test(incoming.trim()) + ? incoming.trim() + : crypto.randomUUID(); const sessionHeader = req.headers["x-session-id"]; const sessionId = @@ -37,6 +40,7 @@ function requestIdMiddleware(req, res, next) { } res.setHeader("X-Request-ID", requestId); + res.setHeader("X-Correlation-ID", requestId); if (sessionId) { res.setHeader("X-Session-ID", sessionId); } diff --git a/backend/src/server.js b/backend/src/server.js index cc4744bb..11d2646d 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -22,9 +22,7 @@ require("./config/fetchInterceptor"); const express = require("express"); const cors = require("cors"); -const helmet = require("helmet"); const pinoHttp = require("pino-http"); -const rateLimit = require("express-rate-limit"); const { strictLimiter, createInstrumentedLimiter } = require("./middleware/rateLimit"); const Sentry = require("@sentry/node"); const { formatErrorResponse, ERROR_CODES } = require("../../shared/errorCodes"); @@ -51,7 +49,6 @@ const featuresRoutes = require("./routes/features"); const adminFeatureFlagsRoutes = require("./routes/adminFeatureFlags"); const tokensRoutes = require("./routes/tokens"); const pushRoutes = require("./routes/push"); -const contactRoutes = require("./routes/contacts"); const emailRoutes = require("./routes/emails"); const swaggerUi = require("swagger-ui-express"); const swaggerSpec = require("./swagger"); @@ -66,7 +63,7 @@ const { validateEnv, parseAllowedOrigins } = require("./config/validateEnv"); const { requireJsonContentType } = require("./middleware/bodyParsing"); const { trackHttpMetrics } = require("./middleware/metrics"); const metricsRoutes = require("./routes/metrics"); -const { correlationMiddleware, getRequestId } = require("./utils/correlationId"); +const { getRequestId } = require("./utils/correlationId"); const { errorLogFields } = require("./utils/errorResponse"); const { initRedis, closeRedis } = require("./services/cacheService"); const shutdownState = require("./services/shutdownState"); @@ -230,11 +227,12 @@ app.use( "Content-Type", "Authorization", "X-Request-ID", + "X-Correlation-ID", "X-Session-ID", "traceparent", "tracestate", ], - exposedHeaders: ["X-Request-ID", "X-Session-ID"], + exposedHeaders: ["X-Request-ID", "X-Correlation-ID", "X-Session-ID"], credentials: true, }), ); diff --git a/backend/src/utils/correlationId.js b/backend/src/utils/correlationId.js index f5b8175a..ba090806 100644 --- a/backend/src/utils/correlationId.js +++ b/backend/src/utils/correlationId.js @@ -50,13 +50,16 @@ function getSessionId() { /** * Return an object suitable for spreading into outbound request headers. * - * @returns {{ "X-Request-ID"?: string, "X-Session-ID"?: string }} + * @returns {{ "X-Request-ID"?: string, "X-Correlation-ID"?: string, "X-Session-ID"?: string }} */ function getRequestIdHeader() { const requestId = getRequestId(); const sessionId = getSessionId(); const headers = {}; - if (requestId) headers["X-Request-ID"] = requestId; + if (requestId) { + headers["X-Request-ID"] = requestId; + headers["X-Correlation-ID"] = requestId; + } if (sessionId) headers["X-Session-ID"] = sessionId; return headers; } diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 3e0663e3..377c72b9 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,28 +1,34 @@ { - "extends": [ - "next/core-web-vitals", - "plugin:@typescript-eslint/recommended", - "prettier" - ], + "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"], "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint"], "rules": { + "@next/next/no-html-link-for-pages": "off", "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-unused-vars": ["warn", { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - }], + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], "@typescript-eslint/prefer-optional-chain": "off", "@typescript-eslint/no-non-null-assertion": "warn", - "no-console": ["warn", { - "allow": ["warn", "error"] - }], + "no-console": [ + "warn", + { + "allow": ["warn", "error"] + } + ], "react-hooks/exhaustive-deps": "warn", - "import/order": ["warn", { - "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], - "alphabetize": { "order": "asc" } - }] + "import/order": [ + "warn", + { + "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], + "alphabetize": { "order": "asc" } + } + ] }, "overrides": [ { diff --git a/frontend/__tests__/ErrorBoundary.correlation.test.tsx b/frontend/__tests__/ErrorBoundary.correlation.test.tsx new file mode 100644 index 00000000..eb8c9cab --- /dev/null +++ b/frontend/__tests__/ErrorBoundary.correlation.test.tsx @@ -0,0 +1,36 @@ +import * as Sentry from "@sentry/nextjs"; +import { render, screen } from "@testing-library/react"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { createActionId } from "@/lib/correlation"; + +jest.mock("@sentry/nextjs", () => ({ captureException: jest.fn() })); +jest.mock("@/lib/logger", () => ({ + logger: { error: jest.fn() }, +})); + +function BrokenComponent(): never { + throw new Error("simulated trace failure"); +} + +describe("ErrorBoundary correlation capture", () => { + it("sends the active correlation ID to Sentry without rendering it", () => { + const correlationId = createActionId(); + const consoleError = jest.spyOn(console, "error").mockImplementation(() => undefined); + + render( + + + , + ); + + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: "simulated trace failure" }), + expect.objectContaining({ + tags: expect.objectContaining({ correlationId, component: "TracingWidget" }), + }), + ); + expect(screen.queryByText(correlationId)).not.toBeInTheDocument(); + expect(screen.queryByText(/simulated trace failure/)).not.toBeInTheDocument(); + consoleError.mockRestore(); + }); +}); diff --git a/frontend/__tests__/correlation.test.ts b/frontend/__tests__/correlation.test.ts new file mode 100644 index 00000000..613557d3 --- /dev/null +++ b/frontend/__tests__/correlation.test.ts @@ -0,0 +1,50 @@ +import { apiFetch } from "@/lib/api"; +import { createActionId, getCorrelationId, getSessionId, withCorrelation } from "@/lib/correlation"; + +describe("correlation ID propagation", () => { + it("adds a unique request ID and stable session ID to each request", async () => { + const fetchMock = jest.fn().mockResolvedValue({ ok: true } as Response); + const correlatedFetch = withCorrelation(fetchMock as typeof fetch); + + await correlatedFetch("/api/one"); + await correlatedFetch("/api/two"); + + const first = new Headers(fetchMock.mock.calls[0][1].headers); + const second = new Headers(fetchMock.mock.calls[1][1].headers); + expect(first.get("X-Request-ID")).toBeTruthy(); + expect(second.get("X-Request-ID")).toBeTruthy(); + expect(first.get("X-Request-ID")).not.toBe(second.get("X-Request-ID")); + expect(first.get("X-Session-ID")).toBe(getSessionId()); + expect(second.get("X-Session-ID")).toBe(getSessionId()); + }); + + it("preserves a caller-provided correlation ID", async () => { + const fetchMock = jest.fn().mockResolvedValue({ ok: true } as Response); + await withCorrelation(fetchMock as typeof fetch)("/api/payments", { + headers: { "X-Request-ID": "trace-from-ui-643" }, + }); + + const headers = new Headers(fetchMock.mock.calls[0][1].headers); + expect(headers.get("X-Request-ID")).toBe("trace-from-ui-643"); + expect(getCorrelationId()).toBe("trace-from-ui-643"); + }); + + it("apiFetch sends correlation and W3C trace headers", async () => { + const fetchMock = jest.fn().mockResolvedValue({ ok: true } as Response); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + + await apiFetch("/api/health"); + + const headers = new Headers(fetchMock.mock.calls[0][1].headers); + expect(headers.get("X-Request-ID")).toBeTruthy(); + expect(headers.get("X-Session-ID")).toBe(getSessionId()); + expect(headers.get("traceparent")).toMatch(/^00-[a-f0-9]{32}-[a-f0-9]{16}-01$/); + globalThis.fetch = originalFetch; + }); + + it("tracks the most recent action for error capture", () => { + const id = createActionId(); + expect(getCorrelationId()).toBe(id); + }); +}); diff --git a/frontend/components/ErrorBoundary.tsx b/frontend/components/ErrorBoundary.tsx index 08414098..8cd73983 100644 --- a/frontend/components/ErrorBoundary.tsx +++ b/frontend/components/ErrorBoundary.tsx @@ -3,8 +3,10 @@ * Custom premium error boundary to isolate and catch rendering errors in critical widgets. */ +import * as Sentry from "@sentry/nextjs"; import React, { Component, ErrorInfo, ReactNode } from "react"; import { AlertCircleIcon } from "@/components/icons"; +import { getCorrelationId } from "@/lib/correlation"; import { logger } from "@/lib/logger"; interface Props { @@ -29,13 +31,16 @@ export class ErrorBoundary extends Component { } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + const correlationId = getCorrelationId(); logger.error( - { component: this.props.name || "unknown", errorInfo }, `ErrorBoundary caught an error in ${this.props.name || "component"}`, + { component: this.props.name || "unknown", errorInfo, correlationId }, + error, ); - // Also emit to console so Sentry's auto-capture picks it up alongside - // the structured logger entry above. - console.error(error); + Sentry.captureException(error, { + tags: { correlationId, component: this.props.name || "unknown" }, + extra: { correlationId, componentStack: errorInfo.componentStack }, + }); } private handleReset = () => { @@ -61,11 +66,6 @@ export class ErrorBoundary extends Component {

An unexpected error occurred while rendering this section.

- {this.state.error && ( -
- {this.state.error.toString()} -
- )}