From d2428355b1c6a9bf45bd77a8a6df56430b1bb6a2 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Tue, 12 May 2026 09:15:49 +0530 Subject: [PATCH 1/5] UN-3444 [FEAT] Propagate frontend request ID and surface it on error notifications - Add `X-Request-ID` request interceptor (uuidv4) on the global axios instance and on each `useAxiosPrivate` axios instance so every API call carries a client-generated correlation ID. Django/Flask backends already honor incoming `X-Request-ID`, so backend logs reuse the same value. - Extract the request ID in `useExceptionHandler` from response headers with a fallback to the outgoing request headers (covers network/cancel errors). - Thread `requestId` through the alert store and render it in the error notification with an antd `Typography.Text copyable` for a one-click copy. --- frontend/src/App.jsx | 33 ++++++++++++++++++++-- frontend/src/helpers/requestId.js | 22 +++++++++++++++ frontend/src/hooks/useAxiosPrivate.js | 3 ++ frontend/src/hooks/useExceptionHandler.jsx | 27 ++++++++++++------ frontend/src/index.css | 18 ++++++++++++ frontend/src/store/alert-store.js | 1 + 6 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 frontend/src/helpers/requestId.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2a21fd639c..1e6f72a4f7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,5 @@ -import { Button, ConfigProvider, notification, theme } from "antd"; +import { Button, ConfigProvider, notification, Typography, theme } from "antd"; +import axios from "axios"; import { useEffect } from "react"; import { HelmetProvider } from "react-helmet-async"; import { BrowserRouter } from "react-router-dom"; @@ -6,12 +7,15 @@ import { GenericLoader } from "./components/generic-loader/GenericLoader"; import CustomMarkdown from "./components/helpers/custom-markdown/CustomMarkdown.jsx"; import { PageTitle } from "./components/widgets/page-title/PageTitle.jsx"; import { THEME } from "./helpers/GetStaticData.js"; +import { attachRequestIdInterceptor } from "./helpers/requestId.js"; import PostHogPageviewTracker from "./PostHogPageviewTracker.js"; import { Router } from "./routes/Router.jsx"; import { useAlertStore } from "./store/alert-store.js"; import { useSessionStore } from "./store/session-store.js"; import { useSocketLogsStore } from "./store/socket-logs-store.js"; +attachRequestIdInterceptor(axios); + let GoogleTagManagerHelper; try { const mod = await import( @@ -53,9 +57,34 @@ function App() { return; } + const showRequestId = + alertDetails?.type === "error" && alertDetails?.requestId; + const description = ( + <> + + {showRequestId && ( +
+ + Request ID: + {" "} + + {alertDetails?.requestId} + +
+ )} + + ); + notificationAPI.open({ message: alertDetails?.title, - description: , + description, type: alertDetails?.type, duration: alertDetails?.duration, btn, diff --git a/frontend/src/helpers/requestId.js b/frontend/src/helpers/requestId.js new file mode 100644 index 0000000000..1ca57febae --- /dev/null +++ b/frontend/src/helpers/requestId.js @@ -0,0 +1,22 @@ +import { v4 as uuidv4 } from "uuid"; + +const REQUEST_ID_HEADER = "X-Request-ID"; + +const attachRequestIdInterceptor = (axiosInstance) => { + return axiosInstance.interceptors.request.use((config) => { + if (!config.headers[REQUEST_ID_HEADER]) { + config.headers[REQUEST_ID_HEADER] = uuidv4(); + } + return config; + }); +}; + +const getRequestIdFromError = (err) => { + return ( + err?.response?.headers?.[REQUEST_ID_HEADER.toLowerCase()] || + err?.response?.headers?.[REQUEST_ID_HEADER] || + err?.config?.headers?.[REQUEST_ID_HEADER] + ); +}; + +export { REQUEST_ID_HEADER, attachRequestIdInterceptor, getRequestIdFromError }; diff --git a/frontend/src/hooks/useAxiosPrivate.js b/frontend/src/hooks/useAxiosPrivate.js index 9d86b2f653..283ce55e24 100644 --- a/frontend/src/hooks/useAxiosPrivate.js +++ b/frontend/src/hooks/useAxiosPrivate.js @@ -1,6 +1,7 @@ import axios from "axios"; import { useEffect, useMemo } from "react"; +import { attachRequestIdInterceptor } from "../helpers/requestId"; import useLogout from "./useLogout"; function useAxiosPrivate() { @@ -8,6 +9,7 @@ function useAxiosPrivate() { const axiosPrivate = useMemo(() => axios.create(), []); useEffect(() => { + const requestInterceptor = attachRequestIdInterceptor(axiosPrivate); const responseInterceptor = axiosPrivate.interceptors.response.use( (response) => { return response; @@ -22,6 +24,7 @@ function useAxiosPrivate() { ); return () => { + axiosPrivate.interceptors.request.eject(requestInterceptor); axiosPrivate.interceptors.response.eject(responseInterceptor); }; }, []); diff --git a/frontend/src/hooks/useExceptionHandler.jsx b/frontend/src/hooks/useExceptionHandler.jsx index 1a43811d34..16500322d3 100644 --- a/frontend/src/hooks/useExceptionHandler.jsx +++ b/frontend/src/hooks/useExceptionHandler.jsx @@ -1,14 +1,17 @@ import PropTypes from "prop-types"; import { useNavigate } from "react-router-dom"; +import { getRequestIdFromError } from "../helpers/requestId"; + const useExceptionHandler = () => { const navigate = useNavigate(); - const buildAlert = (content, title, duration) => ({ + const buildAlert = (content, title, duration, requestId) => ({ type: "error", content, title, duration, + requestId, }); const handleException = ( @@ -18,17 +21,24 @@ const useExceptionHandler = () => { title = "Failed", duration = 0, ) => { + const requestId = getRequestIdFromError(err); if (!err) { - return buildAlert(errMessage, title, duration); + return buildAlert(errMessage, title, duration, requestId); } if (err.code === "ERR_NETWORK" && !navigator.onLine) { return buildAlert( "Please check your internet connection.", title, duration, + requestId, ); } else if (err.code === "ERR_CANCELED") { - return buildAlert("Request has been canceled.", title, duration); + return buildAlert( + "Request has been canceled.", + title, + duration, + requestId, + ); } if (err?.response?.data) { @@ -40,7 +50,7 @@ const useExceptionHandler = () => { responseData.error || responseData.detail || responseData.message; if (commonErrorMessage) { - return buildAlert(commonErrorMessage, title, duration); + return buildAlert(commonErrorMessage, title, duration, requestId); } // Then handle specific error types @@ -73,24 +83,25 @@ const useExceptionHandler = () => { .join("\n"); } } - return buildAlert(errorMessage, title, duration); + return buildAlert(errorMessage, title, duration, requestId); } break; case "subscription_error": navigate("/subscription-expired"); - return buildAlert(errors, title, duration); + return buildAlert(errors, title, duration, requestId); case "client_error": case "server_error": return buildAlert( errors?.[0]?.detail ? errors[0].detail : errMessage, title, duration, + requestId, ); default: - return buildAlert(errMessage, title, duration); + return buildAlert(errMessage, title, duration, requestId); } } else { - return buildAlert(errMessage, title, duration); + return buildAlert(errMessage, title, duration, requestId); } }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 244be08cfe..2af318dbb9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -251,3 +251,21 @@ body { background-color: #ffffff; z-index: 2000; } + +.notification-request-id { + margin-top: 8px; + font-size: 12px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.notification-request-id__label { + font-size: 12px; +} + +.notification-request-id__value { + font-size: 12px; + word-break: break-all; +} diff --git a/frontend/src/store/alert-store.js b/frontend/src/store/alert-store.js index 24549e09ce..25827813ab 100644 --- a/frontend/src/store/alert-store.js +++ b/frontend/src/store/alert-store.js @@ -13,6 +13,7 @@ const STORE_VARIABLES = { title: "", duration: DEFAULT_DURATION, key: null, + requestId: null, }, }; From 0fa515b8e950a84ee859e538d2ea7f8751a19d27 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Tue, 12 May 2026 09:40:27 +0530 Subject: [PATCH 2/5] UN-3444 [FIX] Address PR review on request-ID helpers - Use nullish coalescing in `getRequestIdFromError` so an empty-string backend-echoed header isn't silently shadowed by the request-side header. - Drop the redundant mixed-case response-header lookup; browser/axios response headers are always lower-cased. - Export the global axios interceptor ID so HMR-friendly cleanup is possible (and the chain doesn't leak across module re-evals). --- frontend/src/App.jsx | 2 +- frontend/src/helpers/requestId.js | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1e6f72a4f7..9534dae9e9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,7 +14,7 @@ import { useAlertStore } from "./store/alert-store.js"; import { useSessionStore } from "./store/session-store.js"; import { useSocketLogsStore } from "./store/socket-logs-store.js"; -attachRequestIdInterceptor(axios); +export const globalRequestIdInterceptor = attachRequestIdInterceptor(axios); let GoogleTagManagerHelper; try { diff --git a/frontend/src/helpers/requestId.js b/frontend/src/helpers/requestId.js index 1ca57febae..c483993745 100644 --- a/frontend/src/helpers/requestId.js +++ b/frontend/src/helpers/requestId.js @@ -13,8 +13,7 @@ const attachRequestIdInterceptor = (axiosInstance) => { const getRequestIdFromError = (err) => { return ( - err?.response?.headers?.[REQUEST_ID_HEADER.toLowerCase()] || - err?.response?.headers?.[REQUEST_ID_HEADER] || + err?.response?.headers?.[REQUEST_ID_HEADER.toLowerCase()] ?? err?.config?.headers?.[REQUEST_ID_HEADER] ); }; From 381a1d91c7a8b17dac8c4b55c5f1b3a9f076efb6 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Tue, 19 May 2026 10:01:01 +0530 Subject: [PATCH 3/5] UN-3444 [FEAT] Append request ID to error log rows in the bottom Logs panel Notification rows in the Logs & Notifications table now include the request ID on a second line (markdown inline-code) so users can reference it without opening the toast. --- frontend/src/App.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9534dae9e9..99204c129d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -91,11 +91,15 @@ function App() { key: alertDetails?.key, }); + const logMessage = showRequestId + ? `${alertDetails.content}\nRequest ID: \`${alertDetails.requestId}\`` + : alertDetails.content; + pushLogMessages([ { timestamp: Math.floor(Date.now() / 1000), level: alertDetails?.type ? alertDetails?.type.toUpperCase() : "", - message: alertDetails.content, + message: logMessage, type: "NOTIFICATION", }, ]); From a07d5487cd24f8ba513a3b186cf178f7b415629e Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Tue, 19 May 2026 11:29:49 +0530 Subject: [PATCH 4/5] UN-3444 [FIX] Address review on request-ID helpers - Use AxiosHeaders.set(name, value, false) when available so the case-insensitive normalization is respected and caller-supplied IDs are preserved; fall back to bracket access for plain-object headers. - Guard against undefined config.headers on hand-built request configs. - Collapse buildAlert into a local alert(content) closure inside handleException so each return site stops repeating title/duration/ requestId. Default requestId to null to match the alert-store shape. - Drop the stale, function-shaped useExceptionHandler.propTypes block. - Make the global axios attach HMR-safe with a Symbol-based idempotency guard; remove the unused exported handle. - Consolidate the .notification-request-id font-size rules and remove the now-empty __label class. - Add vitest coverage for the helper (injection, no-overwrite, distinct IDs per request, fallback chain, null/empty inputs). --- frontend/src/App.jsx | 13 ++- frontend/src/helpers/requestId.js | 15 +++- frontend/src/helpers/requestId.test.js | 95 ++++++++++++++++++++++ frontend/src/hooks/useExceptionHandler.jsx | 57 ++++--------- frontend/src/index.css | 11 ++- 5 files changed, 136 insertions(+), 55 deletions(-) create mode 100644 frontend/src/helpers/requestId.test.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 99204c129d..149347f18f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,7 +14,11 @@ import { useAlertStore } from "./store/alert-store.js"; import { useSessionStore } from "./store/session-store.js"; import { useSocketLogsStore } from "./store/socket-logs-store.js"; -export const globalRequestIdInterceptor = attachRequestIdInterceptor(axios); +const GLOBAL_INTERCEPTOR_FLAG = Symbol.for("unstract.requestIdInterceptor"); +if (!axios[GLOBAL_INTERCEPTOR_FLAG]) { + attachRequestIdInterceptor(axios); + axios[GLOBAL_INTERCEPTOR_FLAG] = true; +} let GoogleTagManagerHelper; try { @@ -64,12 +68,7 @@ function App() { {showRequestId && (
- - Request ID: - {" "} + Request ID:{" "} { + if (typeof headers.set === "function") { + headers.set(REQUEST_ID_HEADER, value, false); + return; + } + if (!headers[REQUEST_ID_HEADER]) { + headers[REQUEST_ID_HEADER] = value; + } +}; + const attachRequestIdInterceptor = (axiosInstance) => { return axiosInstance.interceptors.request.use((config) => { - if (!config.headers[REQUEST_ID_HEADER]) { - config.headers[REQUEST_ID_HEADER] = uuidv4(); - } + config.headers ??= {}; + setHeaderIfMissing(config.headers, uuidv4()); return config; }); }; diff --git a/frontend/src/helpers/requestId.test.js b/frontend/src/helpers/requestId.test.js new file mode 100644 index 0000000000..b38837bafc --- /dev/null +++ b/frontend/src/helpers/requestId.test.js @@ -0,0 +1,95 @@ +import axios from "axios"; +import { describe, expect, it } from "vitest"; + +import { + attachRequestIdInterceptor, + getRequestIdFromError, + REQUEST_ID_HEADER, +} from "./requestId"; + +const UUID_V4_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const runRequestInterceptors = async (instance, config = {}) => { + let current = { ...config, headers: { ...(config.headers || {}) } }; + for (const handler of instance.interceptors.request.handlers) { + if (handler && handler.fulfilled) { + current = await handler.fulfilled(current); + } + } + return current; +}; + +describe("attachRequestIdInterceptor", () => { + it("injects a v4 UUID when the header is absent", async () => { + const instance = axios.create(); + attachRequestIdInterceptor(instance); + + const result = await runRequestInterceptors(instance); + + expect(result.headers[REQUEST_ID_HEADER]).toMatch(UUID_V4_REGEX); + }); + + it("does NOT overwrite a caller-supplied X-Request-ID", async () => { + const instance = axios.create(); + attachRequestIdInterceptor(instance); + + const supplied = "caller-provided-id"; + const result = await runRequestInterceptors(instance, { + headers: { [REQUEST_ID_HEADER]: supplied }, + }); + + expect(result.headers[REQUEST_ID_HEADER]).toBe(supplied); + }); + + it("generates a distinct ID per request", async () => { + const instance = axios.create(); + attachRequestIdInterceptor(instance); + + const first = await runRequestInterceptors(instance); + const second = await runRequestInterceptors(instance); + + expect(first.headers[REQUEST_ID_HEADER]).not.toBe( + second.headers[REQUEST_ID_HEADER], + ); + }); + + it("creates a headers object when one is missing on the config", async () => { + const instance = axios.create(); + attachRequestIdInterceptor(instance); + + let current = {}; + for (const handler of instance.interceptors.request.handlers) { + if (handler && handler.fulfilled) { + current = await handler.fulfilled(current); + } + } + + expect(current.headers[REQUEST_ID_HEADER]).toMatch(UUID_V4_REGEX); + }); +}); + +describe("getRequestIdFromError", () => { + it("reads the lowercased response header", () => { + const err = { + response: { headers: { "x-request-id": "from-response" } }, + config: { headers: { [REQUEST_ID_HEADER]: "from-config" } }, + }; + + expect(getRequestIdFromError(err)).toBe("from-response"); + }); + + it("falls back to the request config header when no response header exists", () => { + const err = { + config: { headers: { [REQUEST_ID_HEADER]: "from-config" } }, + }; + + expect(getRequestIdFromError(err)).toBe("from-config"); + }); + + it("returns undefined for null/empty error inputs without throwing", () => { + expect(getRequestIdFromError(null)).toBeUndefined(); + expect(getRequestIdFromError(undefined)).toBeUndefined(); + expect(getRequestIdFromError({})).toBeUndefined(); + }); +}); diff --git a/frontend/src/hooks/useExceptionHandler.jsx b/frontend/src/hooks/useExceptionHandler.jsx index 16500322d3..62917a8f0a 100644 --- a/frontend/src/hooks/useExceptionHandler.jsx +++ b/frontend/src/hooks/useExceptionHandler.jsx @@ -1,4 +1,3 @@ -import PropTypes from "prop-types"; import { useNavigate } from "react-router-dom"; import { getRequestIdFromError } from "../helpers/requestId"; @@ -6,14 +5,6 @@ import { getRequestIdFromError } from "../helpers/requestId"; const useExceptionHandler = () => { const navigate = useNavigate(); - const buildAlert = (content, title, duration, requestId) => ({ - type: "error", - content, - title, - duration, - requestId, - }); - const handleException = ( err, errMessage = "Something went wrong", @@ -21,24 +12,22 @@ const useExceptionHandler = () => { title = "Failed", duration = 0, ) => { - const requestId = getRequestIdFromError(err); + const requestId = getRequestIdFromError(err) ?? null; + const alert = (content) => ({ + type: "error", + content, + title, + duration, + requestId, + }); + if (!err) { - return buildAlert(errMessage, title, duration, requestId); + return alert(errMessage); } if (err.code === "ERR_NETWORK" && !navigator.onLine) { - return buildAlert( - "Please check your internet connection.", - title, - duration, - requestId, - ); + return alert("Please check your internet connection."); } else if (err.code === "ERR_CANCELED") { - return buildAlert( - "Request has been canceled.", - title, - duration, - requestId, - ); + return alert("Request has been canceled."); } if (err?.response?.data) { @@ -50,7 +39,7 @@ const useExceptionHandler = () => { responseData.error || responseData.detail || responseData.message; if (commonErrorMessage) { - return buildAlert(commonErrorMessage, title, duration, requestId); + return alert(commonErrorMessage); } // Then handle specific error types @@ -83,34 +72,24 @@ const useExceptionHandler = () => { .join("\n"); } } - return buildAlert(errorMessage, title, duration, requestId); + return alert(errorMessage); } break; case "subscription_error": navigate("/subscription-expired"); - return buildAlert(errors, title, duration, requestId); + return alert(errors); case "client_error": case "server_error": - return buildAlert( - errors?.[0]?.detail ? errors[0].detail : errMessage, - title, - duration, - requestId, - ); + return alert(errors?.[0]?.detail ? errors[0].detail : errMessage); default: - return buildAlert(errMessage, title, duration, requestId); + return alert(errMessage); } } else { - return buildAlert(errMessage, title, duration, requestId); + return alert(errMessage); } }; return handleException; }; -useExceptionHandler.propTypes = { - err: PropTypes.object, // Assuming err is an object - errMessage: PropTypes.string, -}; - export { useExceptionHandler }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 2af318dbb9..aa670e91d8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -252,20 +252,19 @@ body { z-index: 2000; } +.notification-request-id, +.notification-request-id__value { + font-size: 12px; +} + .notification-request-id { margin-top: 8px; - font-size: 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; } -.notification-request-id__label { - font-size: 12px; -} - .notification-request-id__value { - font-size: 12px; word-break: break-all; } From adc2a47cd561cd77661f4476db960db91a558922 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Tue, 19 May 2026 11:53:07 +0530 Subject: [PATCH 5/5] UN-3444 [FIX] Sonar cleanup on requestId.test.js - Drop the useless `|| {}` fallback when spreading config.headers. - Use optional chaining `handler?.fulfilled` instead of `handler && handler.fulfilled`. --- frontend/src/helpers/requestId.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/helpers/requestId.test.js b/frontend/src/helpers/requestId.test.js index b38837bafc..fc637e489e 100644 --- a/frontend/src/helpers/requestId.test.js +++ b/frontend/src/helpers/requestId.test.js @@ -11,9 +11,9 @@ const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const runRequestInterceptors = async (instance, config = {}) => { - let current = { ...config, headers: { ...(config.headers || {}) } }; + let current = { ...config, headers: { ...config.headers } }; for (const handler of instance.interceptors.request.handlers) { - if (handler && handler.fulfilled) { + if (handler?.fulfilled) { current = await handler.fulfilled(current); } } @@ -60,7 +60,7 @@ describe("attachRequestIdInterceptor", () => { let current = {}; for (const handler of instance.interceptors.request.handlers) { - if (handler && handler.fulfilled) { + if (handler?.fulfilled) { current = await handler.fulfilled(current); } }