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()} - - )} { export function withErrorBoundary( WrappedComponent: React.ComponentType, - name: string + name: string, ) { const ComponentWithErrorBoundary = (props: P) => ( diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index e7c45f69..448be300 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,8 +1,10 @@ /** * @file lib/api.ts - * @description API utilities and traceparent context propagation for frontend HTTP requests. + * @description API utilities for correlation-ID and trace context propagation. */ +import { withCorrelation } from "./correlation"; + /** * Generates a standard W3C traceparent header. * Format: 00-traceid-parentid-traceflags @@ -11,11 +13,15 @@ export function generateTraceParent(): string { const version = "00"; // Generate random 16 bytes (32 hex characters) trace ID const traceId = Array.from({ length: 16 }, () => - Math.floor(Math.random() * 256).toString(16).padStart(2, "0") + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, "0"), ).join(""); // Generate random 8 bytes (16 hex characters) parent ID (span ID) const parentId = Array.from({ length: 8 }, () => - Math.floor(Math.random() * 256).toString(16).padStart(2, "0") + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, "0"), ).join(""); const traceFlags = "01"; // Sampled return `${version}-${traceId}-${parentId}-${traceFlags}`; @@ -25,51 +31,46 @@ export function generateTraceParent(): string { * A wrapper around the native fetch API that automatically adds * traceparent headers for outgoing request tracing. */ -export async function apiFetch( - input: RequestInfo | URL, - init?: RequestInit -): Promise { +export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const headers = new Headers(init?.headers); if (!headers.has("traceparent")) { headers.set("traceparent", generateTraceParent()); } - return fetch(input, { + return withCorrelation(fetch)(input, { ...init, headers, }); } -// Automatically patch global fetch in the browser/client-side and Node environment. +// Automatically patch global fetch for backend API calls made outside apiFetch. // Uses a self-executing function to avoid top-level typeof checks that conflict // with certain tsconfig lib configurations. (function patchGlobalFetch() { - const globalObj = ( - typeof window !== 'undefined' ? window : globalThis - ) as typeof globalThis & { __fetchPatched?: boolean }; + const globalObj = (typeof window !== "undefined" ? window : globalThis) as typeof globalThis & { + __correlationFetchPatched?: boolean; + }; - if (globalObj && !(globalObj as any).__fetchPatched) { + if (globalObj && !globalObj.__correlationFetchPatched) { const originalFetch = globalObj.fetch?.bind(globalObj); if (originalFetch) { - (globalObj as any).fetch = async function ( - input: RequestInfo | URL, - init?: RequestInit - ) { + const correlatedFetch = withCorrelation(originalFetch); + globalObj.fetch = async function (input: RequestInfo | URL, init?: RequestInit) { const urlStr = - typeof input === 'string' + typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; // Only inject traceparent headers for relative paths or API endpoints - const isBackendApi = urlStr.includes('/api/') || !urlStr.startsWith('http'); + const isBackendApi = urlStr.includes("/api/") || !urlStr.startsWith("http"); if (isBackendApi) { const headers = new Headers(init?.headers); - if (!headers.has('traceparent')) { - headers.set('traceparent', generateTraceParent()); + if (!headers.has("traceparent")) { + headers.set("traceparent", generateTraceParent()); } - return originalFetch(input, { + return correlatedFetch(input, { ...init, headers, }); @@ -77,7 +78,7 @@ export async function apiFetch( return originalFetch(input, init); }; - (globalObj as any).__fetchPatched = true; + globalObj.__correlationFetchPatched = true; } } })(); diff --git a/frontend/lib/correlation.ts b/frontend/lib/correlation.ts index 1a636620..98b8d9ab 100644 --- a/frontend/lib/correlation.ts +++ b/frontend/lib/correlation.ts @@ -9,6 +9,8 @@ * carries both headers without touching each call site. */ +import { logger } from "./logger"; + const sessionId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() @@ -58,11 +60,12 @@ export function getCorrelationHeaders(): Record { * Existing caller headers win only if they already set these keys — we * always set fresh action IDs unless `X-Request-ID` is already present. */ -export function withCorrelation( - fetchImpl: typeof fetch, -): typeof fetch { +export function withCorrelation(fetchImpl: typeof fetch): typeof fetch { return (input: RequestInfo | URL, init?: RequestInit): Promise => { - const headers = new Headers(init?.headers); + const requestHeaders = + typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined; + const headers = new Headers(requestHeaders); + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); if (!headers.has("X-Request-ID")) { headers.set("X-Request-ID", createActionId()); @@ -108,6 +111,5 @@ export function logRpcCorrelation( operation, ...extra, }; - // Prefer structured JSON so browser/devtools log drains can parse it. - console.info(JSON.stringify(payload)); + logger.info(`${target} RPC call`, payload); } diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 3677b197..04f0e5c5 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -2,6 +2,16 @@ import { withSentryConfig } from "@sentry/nextjs"; import { validateEnv } from "./scripts/validateEnv.mjs"; import bundleAnalyzer from "@next/bundle-analyzer"; +const sentryRelease = + process.env.SENTRY_RELEASE || + process.env.VERCEL_GIT_COMMIT_SHA || + process.env.GITHUB_SHA; + +if (sentryRelease) { + process.env.SENTRY_RELEASE = sentryRelease; + process.env.NEXT_PUBLIC_SENTRY_RELEASE = sentryRelease; +} + const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true", }); @@ -45,8 +55,14 @@ export default withBundleAnalyzer( withSentryConfig(nextConfig, { // Suppress Sentry CLI output during builds silent: true, - // Disable source map upload unless SENTRY_AUTH_TOKEN is set - disableServerWebpackPlugin: !process.env.SENTRY_AUTH_TOKEN, - disableClientWebpackPlugin: !process.env.SENTRY_AUTH_TOKEN, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + release: sentryRelease ? { name: sentryRelease } : undefined, + sourcemaps: { + disable: !process.env.SENTRY_AUTH_TOKEN, + deleteSourcemapsAfterUpload: true, + }, + widenClientFileUpload: true, }) ); diff --git a/frontend/sentry.client.config.ts b/frontend/sentry.client.config.ts index 73894676..270a55e9 100644 --- a/frontend/sentry.client.config.ts +++ b/frontend/sentry.client.config.ts @@ -7,15 +7,12 @@ */ import * as Sentry from "@sentry/nextjs"; -import { - getCorrelationId, - getSessionId, - getLastActionId, -} from "@/lib/correlation"; +import { getCorrelationId, getSessionId, getLastActionId } from "@/lib/correlation"; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV, + release: process.env.NEXT_PUBLIC_SENTRY_RELEASE, // Only enable when a DSN is provided enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 0.2, diff --git a/frontend/sentry.server.config.ts b/frontend/sentry.server.config.ts index ec56e48b..929dcb12 100644 --- a/frontend/sentry.server.config.ts +++ b/frontend/sentry.server.config.ts @@ -7,14 +7,12 @@ */ import * as Sentry from "@sentry/nextjs"; -import { - getCorrelationId, - getSessionId, -} from "@/lib/correlation"; +import { getCorrelationId, getSessionId } from "@/lib/correlation"; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV, + release: process.env.SENTRY_RELEASE || process.env.NEXT_PUBLIC_SENTRY_RELEASE, enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 0.2, beforeSend(event) { diff --git a/sdk/.eslintrc.json b/sdk/.eslintrc.json new file mode 100644 index 00000000..704e9c3d --- /dev/null +++ b/sdk/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": ["plugin:@typescript-eslint/recommended", "prettier"], + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } +} diff --git a/sdk/src/client.ts b/sdk/src/client.ts index c10777cb..000dbbb4 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -59,8 +59,12 @@ import type { /** Default base URL for the Finchippay API. */ const DEFAULT_BASE_URL = "http://localhost:4000"; -/** Storage key for the cached JWT token. */ -const TOKEN_KEY = "finchippay_sdk_token"; +function createCorrelationId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `req-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} /* ─── Client options ─── */ @@ -76,6 +80,10 @@ export interface FinchippayClientOptions { cacheToken?: boolean; /** API version to use (e.g. "1"). Defaults to the latest version. */ apiVersion?: string; + /** Optional factory used to join SDK requests to an application trace. */ + correlationIdFactory?: () => string; + /** Optional browser/session identifier propagated with every request. */ + sessionId?: string; } /* ─── Client class ─── */ @@ -86,6 +94,8 @@ export class FinchippayClient { private fetchFn: typeof fetch; private cacheToken: boolean; private apiVersion: string; + private correlationIdFactory: () => string; + private sessionId?: string; constructor(options: FinchippayClientOptions = {}) { this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ""); @@ -93,10 +103,12 @@ export class FinchippayClient { this.fetchFn = options.fetch || (globalThis as any).fetch; this.cacheToken = options.cacheToken ?? true; this.apiVersion = options.apiVersion || "1"; + this.correlationIdFactory = options.correlationIdFactory || createCorrelationId; + this.sessionId = options.sessionId; if (!this.fetchFn) { throw new Error( - "Fetch API is not available. Pass a custom fetch implementation via the `fetch` option, or use Node.js 18+." + "Fetch API is not available. Pass a custom fetch implementation via the `fetch` option, or use Node.js 18+.", ); } } @@ -148,7 +160,7 @@ export class FinchippayClient { options?: { body?: unknown; params?: Record; - } + }, ): Promise { const versionedPath = this.versionPath(path); const url = new URL(`${this.baseUrl}${versionedPath}`); @@ -171,11 +183,9 @@ export class FinchippayClient { headers["Authorization"] = `Bearer ${this.authToken}`; } - // Correlation IDs (#172) — unique per request; optional session left to - // the caller's fetch wrapper (frontend installs X-Session-ID globally). - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - headers["X-Request-ID"] = crypto.randomUUID(); - } + // Correlation IDs (#172) — unique per request, with optional session scope. + headers["X-Request-ID"] = this.correlationIdFactory(); + if (this.sessionId) headers["X-Session-ID"] = this.sessionId; const res = await this.fetchFn(url.toString(), { method, @@ -189,7 +199,7 @@ export class FinchippayClient { if (process.env.NODE_ENV !== "production" && deprecatedHeader === "true") { console.warn( `[Finchippay SDK] API version ${apiVersionHeader} is deprecated. ` + - `Consider upgrading to the latest version.` + `Consider upgrading to the latest version.`, ); } @@ -241,9 +251,10 @@ export class FinchippayClient { const res = await this.request | TokenResponse>( "POST", "/api/auth", - { body } + { body }, ); - const data = "data" in res ? (res as SuccessResponse).data : (res as TokenResponse); + const data = + "data" in res ? (res as SuccessResponse).data : (res as TokenResponse); if (this.cacheToken) { this.authToken = data.token; } @@ -294,9 +305,11 @@ export class FinchippayClient { /** Fetch payment history for an account. Supports pagination. */ getHistory: ( publicKey: string, - params?: PaymentHistoryParams + params?: PaymentHistoryParams, ): Promise> => - this.request("GET", `/api/payments/${publicKey}`, { params: params as Record }), + this.request("GET", `/api/payments/${publicKey}`, { + params: params as Record, + }), /** Get aggregate payment statistics. */ getStats: (publicKey: string): Promise> => @@ -348,7 +361,7 @@ export class FinchippayClient { /** Create a txFunction signing challenge. */ createChallenge: ( - body: TxFunctionChallengeRequest + body: TxFunctionChallengeRequest, ): Promise> => this.request("POST", "/api/turrets/challenge", { body }), @@ -393,15 +406,11 @@ export class FinchippayClient { sep24 = { /** Initiate an interactive deposit session. */ - initiateDeposit: ( - body: Sep24InitiateRequest - ): Promise => + initiateDeposit: (body: Sep24InitiateRequest): Promise => this.request("POST", "/api/sep24/transactions/deposit/interactive", { body }), /** Initiate an interactive withdrawal session. */ - initiateWithdrawal: ( - body: Sep24InitiateRequest - ): Promise => + initiateWithdrawal: (body: Sep24InitiateRequest): Promise => this.request("POST", "/api/sep24/transactions/withdraw/interactive", { body }), /** Poll transaction status by ID. */ @@ -412,24 +421,18 @@ export class FinchippayClient { /* ─── AI Parsing ─── */ /** Parse natural language into a payment intent. */ - parsePayment = ( - body: ParsePaymentRequest - ): Promise => + parsePayment = (body: ParsePaymentRequest): Promise => this.request("POST", "/api/parse-payment", { body }); /* ─── Federation (SEP-0002) ─── */ federation = { /** Resolve a stellar address to an account ID. */ - resolve: ( - q: string, - type: "name" | "id" - ): Promise => + resolve: (q: string, type: "name" | "id"): Promise => this.request("GET", "/federation", { params: { q, type } }), /** Get the stellar.toml discovery document. */ - getStellarToml: (): Promise => - this.request("GET", "/.well-known/stellar.toml"), + getStellarToml: (): Promise => this.request("GET", "/.well-known/stellar.toml"), }; } @@ -439,7 +442,7 @@ export class ApiHttpError extends Error { constructor( public readonly status: number, message: string, - public readonly headers?: Headers + public readonly headers?: Headers, ) { super(message); this.name = "ApiHttpError"; @@ -465,4 +468,4 @@ export class ApiHttpError extends Error { get isRateLimited(): boolean { return this.status === 429; } -} \ No newline at end of file +}
An unexpected error occurred while rendering this section.
( WrappedComponent: React.ComponentType
, - name: string + name: string, ) { const ComponentWithErrorBoundary = (props: P) => ( diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index e7c45f69..448be300 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,8 +1,10 @@ /** * @file lib/api.ts - * @description API utilities and traceparent context propagation for frontend HTTP requests. + * @description API utilities for correlation-ID and trace context propagation. */ +import { withCorrelation } from "./correlation"; + /** * Generates a standard W3C traceparent header. * Format: 00-traceid-parentid-traceflags @@ -11,11 +13,15 @@ export function generateTraceParent(): string { const version = "00"; // Generate random 16 bytes (32 hex characters) trace ID const traceId = Array.from({ length: 16 }, () => - Math.floor(Math.random() * 256).toString(16).padStart(2, "0") + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, "0"), ).join(""); // Generate random 8 bytes (16 hex characters) parent ID (span ID) const parentId = Array.from({ length: 8 }, () => - Math.floor(Math.random() * 256).toString(16).padStart(2, "0") + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, "0"), ).join(""); const traceFlags = "01"; // Sampled return `${version}-${traceId}-${parentId}-${traceFlags}`; @@ -25,51 +31,46 @@ export function generateTraceParent(): string { * A wrapper around the native fetch API that automatically adds * traceparent headers for outgoing request tracing. */ -export async function apiFetch( - input: RequestInfo | URL, - init?: RequestInit -): Promise { +export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const headers = new Headers(init?.headers); if (!headers.has("traceparent")) { headers.set("traceparent", generateTraceParent()); } - return fetch(input, { + return withCorrelation(fetch)(input, { ...init, headers, }); } -// Automatically patch global fetch in the browser/client-side and Node environment. +// Automatically patch global fetch for backend API calls made outside apiFetch. // Uses a self-executing function to avoid top-level typeof checks that conflict // with certain tsconfig lib configurations. (function patchGlobalFetch() { - const globalObj = ( - typeof window !== 'undefined' ? window : globalThis - ) as typeof globalThis & { __fetchPatched?: boolean }; + const globalObj = (typeof window !== "undefined" ? window : globalThis) as typeof globalThis & { + __correlationFetchPatched?: boolean; + }; - if (globalObj && !(globalObj as any).__fetchPatched) { + if (globalObj && !globalObj.__correlationFetchPatched) { const originalFetch = globalObj.fetch?.bind(globalObj); if (originalFetch) { - (globalObj as any).fetch = async function ( - input: RequestInfo | URL, - init?: RequestInit - ) { + const correlatedFetch = withCorrelation(originalFetch); + globalObj.fetch = async function (input: RequestInfo | URL, init?: RequestInit) { const urlStr = - typeof input === 'string' + typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; // Only inject traceparent headers for relative paths or API endpoints - const isBackendApi = urlStr.includes('/api/') || !urlStr.startsWith('http'); + const isBackendApi = urlStr.includes("/api/") || !urlStr.startsWith("http"); if (isBackendApi) { const headers = new Headers(init?.headers); - if (!headers.has('traceparent')) { - headers.set('traceparent', generateTraceParent()); + if (!headers.has("traceparent")) { + headers.set("traceparent", generateTraceParent()); } - return originalFetch(input, { + return correlatedFetch(input, { ...init, headers, }); @@ -77,7 +78,7 @@ export async function apiFetch( return originalFetch(input, init); }; - (globalObj as any).__fetchPatched = true; + globalObj.__correlationFetchPatched = true; } } })(); diff --git a/frontend/lib/correlation.ts b/frontend/lib/correlation.ts index 1a636620..98b8d9ab 100644 --- a/frontend/lib/correlation.ts +++ b/frontend/lib/correlation.ts @@ -9,6 +9,8 @@ * carries both headers without touching each call site. */ +import { logger } from "./logger"; + const sessionId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() @@ -58,11 +60,12 @@ export function getCorrelationHeaders(): Record { * Existing caller headers win only if they already set these keys — we * always set fresh action IDs unless `X-Request-ID` is already present. */ -export function withCorrelation( - fetchImpl: typeof fetch, -): typeof fetch { +export function withCorrelation(fetchImpl: typeof fetch): typeof fetch { return (input: RequestInfo | URL, init?: RequestInit): Promise => { - const headers = new Headers(init?.headers); + const requestHeaders = + typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined; + const headers = new Headers(requestHeaders); + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); if (!headers.has("X-Request-ID")) { headers.set("X-Request-ID", createActionId()); @@ -108,6 +111,5 @@ export function logRpcCorrelation( operation, ...extra, }; - // Prefer structured JSON so browser/devtools log drains can parse it. - console.info(JSON.stringify(payload)); + logger.info(`${target} RPC call`, payload); } diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 3677b197..04f0e5c5 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -2,6 +2,16 @@ import { withSentryConfig } from "@sentry/nextjs"; import { validateEnv } from "./scripts/validateEnv.mjs"; import bundleAnalyzer from "@next/bundle-analyzer"; +const sentryRelease = + process.env.SENTRY_RELEASE || + process.env.VERCEL_GIT_COMMIT_SHA || + process.env.GITHUB_SHA; + +if (sentryRelease) { + process.env.SENTRY_RELEASE = sentryRelease; + process.env.NEXT_PUBLIC_SENTRY_RELEASE = sentryRelease; +} + const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true", }); @@ -45,8 +55,14 @@ export default withBundleAnalyzer( withSentryConfig(nextConfig, { // Suppress Sentry CLI output during builds silent: true, - // Disable source map upload unless SENTRY_AUTH_TOKEN is set - disableServerWebpackPlugin: !process.env.SENTRY_AUTH_TOKEN, - disableClientWebpackPlugin: !process.env.SENTRY_AUTH_TOKEN, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + release: sentryRelease ? { name: sentryRelease } : undefined, + sourcemaps: { + disable: !process.env.SENTRY_AUTH_TOKEN, + deleteSourcemapsAfterUpload: true, + }, + widenClientFileUpload: true, }) ); diff --git a/frontend/sentry.client.config.ts b/frontend/sentry.client.config.ts index 73894676..270a55e9 100644 --- a/frontend/sentry.client.config.ts +++ b/frontend/sentry.client.config.ts @@ -7,15 +7,12 @@ */ import * as Sentry from "@sentry/nextjs"; -import { - getCorrelationId, - getSessionId, - getLastActionId, -} from "@/lib/correlation"; +import { getCorrelationId, getSessionId, getLastActionId } from "@/lib/correlation"; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV, + release: process.env.NEXT_PUBLIC_SENTRY_RELEASE, // Only enable when a DSN is provided enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 0.2, diff --git a/frontend/sentry.server.config.ts b/frontend/sentry.server.config.ts index ec56e48b..929dcb12 100644 --- a/frontend/sentry.server.config.ts +++ b/frontend/sentry.server.config.ts @@ -7,14 +7,12 @@ */ import * as Sentry from "@sentry/nextjs"; -import { - getCorrelationId, - getSessionId, -} from "@/lib/correlation"; +import { getCorrelationId, getSessionId } from "@/lib/correlation"; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV, + release: process.env.SENTRY_RELEASE || process.env.NEXT_PUBLIC_SENTRY_RELEASE, enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 0.2, beforeSend(event) { diff --git a/sdk/.eslintrc.json b/sdk/.eslintrc.json new file mode 100644 index 00000000..704e9c3d --- /dev/null +++ b/sdk/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": ["plugin:@typescript-eslint/recommended", "prettier"], + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } +} diff --git a/sdk/src/client.ts b/sdk/src/client.ts index c10777cb..000dbbb4 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -59,8 +59,12 @@ import type { /** Default base URL for the Finchippay API. */ const DEFAULT_BASE_URL = "http://localhost:4000"; -/** Storage key for the cached JWT token. */ -const TOKEN_KEY = "finchippay_sdk_token"; +function createCorrelationId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `req-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} /* ─── Client options ─── */ @@ -76,6 +80,10 @@ export interface FinchippayClientOptions { cacheToken?: boolean; /** API version to use (e.g. "1"). Defaults to the latest version. */ apiVersion?: string; + /** Optional factory used to join SDK requests to an application trace. */ + correlationIdFactory?: () => string; + /** Optional browser/session identifier propagated with every request. */ + sessionId?: string; } /* ─── Client class ─── */ @@ -86,6 +94,8 @@ export class FinchippayClient { private fetchFn: typeof fetch; private cacheToken: boolean; private apiVersion: string; + private correlationIdFactory: () => string; + private sessionId?: string; constructor(options: FinchippayClientOptions = {}) { this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ""); @@ -93,10 +103,12 @@ export class FinchippayClient { this.fetchFn = options.fetch || (globalThis as any).fetch; this.cacheToken = options.cacheToken ?? true; this.apiVersion = options.apiVersion || "1"; + this.correlationIdFactory = options.correlationIdFactory || createCorrelationId; + this.sessionId = options.sessionId; if (!this.fetchFn) { throw new Error( - "Fetch API is not available. Pass a custom fetch implementation via the `fetch` option, or use Node.js 18+." + "Fetch API is not available. Pass a custom fetch implementation via the `fetch` option, or use Node.js 18+.", ); } } @@ -148,7 +160,7 @@ export class FinchippayClient { options?: { body?: unknown; params?: Record; - } + }, ): Promise { const versionedPath = this.versionPath(path); const url = new URL(`${this.baseUrl}${versionedPath}`); @@ -171,11 +183,9 @@ export class FinchippayClient { headers["Authorization"] = `Bearer ${this.authToken}`; } - // Correlation IDs (#172) — unique per request; optional session left to - // the caller's fetch wrapper (frontend installs X-Session-ID globally). - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - headers["X-Request-ID"] = crypto.randomUUID(); - } + // Correlation IDs (#172) — unique per request, with optional session scope. + headers["X-Request-ID"] = this.correlationIdFactory(); + if (this.sessionId) headers["X-Session-ID"] = this.sessionId; const res = await this.fetchFn(url.toString(), { method, @@ -189,7 +199,7 @@ export class FinchippayClient { if (process.env.NODE_ENV !== "production" && deprecatedHeader === "true") { console.warn( `[Finchippay SDK] API version ${apiVersionHeader} is deprecated. ` + - `Consider upgrading to the latest version.` + `Consider upgrading to the latest version.`, ); } @@ -241,9 +251,10 @@ export class FinchippayClient { const res = await this.request | TokenResponse>( "POST", "/api/auth", - { body } + { body }, ); - const data = "data" in res ? (res as SuccessResponse).data : (res as TokenResponse); + const data = + "data" in res ? (res as SuccessResponse).data : (res as TokenResponse); if (this.cacheToken) { this.authToken = data.token; } @@ -294,9 +305,11 @@ export class FinchippayClient { /** Fetch payment history for an account. Supports pagination. */ getHistory: ( publicKey: string, - params?: PaymentHistoryParams + params?: PaymentHistoryParams, ): Promise> => - this.request("GET", `/api/payments/${publicKey}`, { params: params as Record }), + this.request("GET", `/api/payments/${publicKey}`, { + params: params as Record, + }), /** Get aggregate payment statistics. */ getStats: (publicKey: string): Promise> => @@ -348,7 +361,7 @@ export class FinchippayClient { /** Create a txFunction signing challenge. */ createChallenge: ( - body: TxFunctionChallengeRequest + body: TxFunctionChallengeRequest, ): Promise> => this.request("POST", "/api/turrets/challenge", { body }), @@ -393,15 +406,11 @@ export class FinchippayClient { sep24 = { /** Initiate an interactive deposit session. */ - initiateDeposit: ( - body: Sep24InitiateRequest - ): Promise => + initiateDeposit: (body: Sep24InitiateRequest): Promise => this.request("POST", "/api/sep24/transactions/deposit/interactive", { body }), /** Initiate an interactive withdrawal session. */ - initiateWithdrawal: ( - body: Sep24InitiateRequest - ): Promise => + initiateWithdrawal: (body: Sep24InitiateRequest): Promise => this.request("POST", "/api/sep24/transactions/withdraw/interactive", { body }), /** Poll transaction status by ID. */ @@ -412,24 +421,18 @@ export class FinchippayClient { /* ─── AI Parsing ─── */ /** Parse natural language into a payment intent. */ - parsePayment = ( - body: ParsePaymentRequest - ): Promise => + parsePayment = (body: ParsePaymentRequest): Promise => this.request("POST", "/api/parse-payment", { body }); /* ─── Federation (SEP-0002) ─── */ federation = { /** Resolve a stellar address to an account ID. */ - resolve: ( - q: string, - type: "name" | "id" - ): Promise => + resolve: (q: string, type: "name" | "id"): Promise => this.request("GET", "/federation", { params: { q, type } }), /** Get the stellar.toml discovery document. */ - getStellarToml: (): Promise => - this.request("GET", "/.well-known/stellar.toml"), + getStellarToml: (): Promise => this.request("GET", "/.well-known/stellar.toml"), }; } @@ -439,7 +442,7 @@ export class ApiHttpError extends Error { constructor( public readonly status: number, message: string, - public readonly headers?: Headers + public readonly headers?: Headers, ) { super(message); this.name = "ApiHttpError"; @@ -465,4 +468,4 @@ export class ApiHttpError extends Error { get isRateLimited(): boolean { return this.status === 429; } -} \ No newline at end of file +}