Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions backend/__tests__/requestTracing.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
8 changes: 6 additions & 2 deletions backend/src/middleware/requestId.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 =
Expand All @@ -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);
}
Expand Down
8 changes: 3 additions & 5 deletions backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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,
}),
);
Expand Down
7 changes: 5 additions & 2 deletions backend/src/utils/correlationId.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
38 changes: 22 additions & 16 deletions frontend/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down
36 changes: 36 additions & 0 deletions frontend/__tests__/ErrorBoundary.correlation.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ErrorBoundary name="TracingWidget">
<BrokenComponent />
</ErrorBoundary>,
);

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();
});
});
50 changes: 50 additions & 0 deletions frontend/__tests__/correlation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 10 additions & 10 deletions frontend/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,13 +31,16 @@ export class ErrorBoundary extends Component<Props, State> {
}

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 = () => {
Expand All @@ -61,11 +66,6 @@ export class ErrorBoundary extends Component<Props, State> {
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
An unexpected error occurred while rendering this section.
</p>
{this.state.error && (
<div className="mt-3 p-3 rounded-lg bg-black/40 border border-white/5 text-xs font-mono text-slate-500 max-h-32 overflow-auto">
{this.state.error.toString()}
</div>
)}
<button
onClick={this.handleReset}
className="mt-4 px-4 py-2 bg-red-500/20 hover:bg-red-500/30 active:bg-red-500/40 border border-red-500/30 text-red-700 dark:text-red-300 text-sm font-medium rounded-xl transition-all duration-200"
Expand All @@ -84,7 +84,7 @@ export class ErrorBoundary extends Component<Props, State> {

export function withErrorBoundary<P extends object>(
WrappedComponent: React.ComponentType<P>,
name: string
name: string,
) {
const ComponentWithErrorBoundary = (props: P) => (
<ErrorBoundary name={name}>
Expand Down
Loading
Loading