diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2a21fd639c..149347f18f 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,19 @@ 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"; +const GLOBAL_INTERCEPTOR_FLAG = Symbol.for("unstract.requestIdInterceptor"); +if (!axios[GLOBAL_INTERCEPTOR_FLAG]) { + attachRequestIdInterceptor(axios); + axios[GLOBAL_INTERCEPTOR_FLAG] = true; +} + let GoogleTagManagerHelper; try { const mod = await import( @@ -53,20 +61,44 @@ 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, 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", }, ]); diff --git a/frontend/src/helpers/requestId.js b/frontend/src/helpers/requestId.js new file mode 100644 index 0000000000..da4e80f097 --- /dev/null +++ b/frontend/src/helpers/requestId.js @@ -0,0 +1,30 @@ +import { v4 as uuidv4 } from "uuid"; + +const REQUEST_ID_HEADER = "X-Request-ID"; + +const setHeaderIfMissing = (headers, value) => { + 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) => { + config.headers ??= {}; + setHeaderIfMissing(config.headers, uuidv4()); + return config; + }); +}; + +const getRequestIdFromError = (err) => { + return ( + err?.response?.headers?.[REQUEST_ID_HEADER.toLowerCase()] ?? + err?.config?.headers?.[REQUEST_ID_HEADER] + ); +}; + +export { REQUEST_ID_HEADER, attachRequestIdInterceptor, getRequestIdFromError }; diff --git a/frontend/src/helpers/requestId.test.js b/frontend/src/helpers/requestId.test.js new file mode 100644 index 0000000000..fc637e489e --- /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?.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?.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/useAxiosPrivate.js b/frontend/src/hooks/useAxiosPrivate.js index 7abc500038..ac57911d2d 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; @@ -34,6 +36,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..62917a8f0a 100644 --- a/frontend/src/hooks/useExceptionHandler.jsx +++ b/frontend/src/hooks/useExceptionHandler.jsx @@ -1,16 +1,10 @@ -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) => ({ - type: "error", - content, - title, - duration, - }); - const handleException = ( err, errMessage = "Something went wrong", @@ -18,17 +12,22 @@ const useExceptionHandler = () => { title = "Failed", duration = 0, ) => { + const requestId = getRequestIdFromError(err) ?? null; + const alert = (content) => ({ + type: "error", + content, + title, + duration, + requestId, + }); + if (!err) { - return buildAlert(errMessage, title, duration); + return alert(errMessage); } if (err.code === "ERR_NETWORK" && !navigator.onLine) { - return buildAlert( - "Please check your internet connection.", - title, - duration, - ); + return alert("Please check your internet connection."); } else if (err.code === "ERR_CANCELED") { - return buildAlert("Request has been canceled.", title, duration); + return alert("Request has been canceled."); } if (err?.response?.data) { @@ -40,7 +39,7 @@ const useExceptionHandler = () => { responseData.error || responseData.detail || responseData.message; if (commonErrorMessage) { - return buildAlert(commonErrorMessage, title, duration); + return alert(commonErrorMessage); } // Then handle specific error types @@ -73,33 +72,24 @@ const useExceptionHandler = () => { .join("\n"); } } - return buildAlert(errorMessage, title, duration); + return alert(errorMessage); } break; case "subscription_error": navigate("/subscription-expired"); - return buildAlert(errors, title, duration); + return alert(errors); case "client_error": case "server_error": - return buildAlert( - errors?.[0]?.detail ? errors[0].detail : errMessage, - title, - duration, - ); + return alert(errors?.[0]?.detail ? errors[0].detail : errMessage); default: - return buildAlert(errMessage, title, duration); + return alert(errMessage); } } else { - return buildAlert(errMessage, title, duration); + 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 244be08cfe..aa670e91d8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -251,3 +251,20 @@ body { background-color: #ffffff; z-index: 2000; } + +.notification-request-id, +.notification-request-id__value { + font-size: 12px; +} + +.notification-request-id { + margin-top: 8px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.notification-request-id__value { + 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, }, };