Skip to content
Open
38 changes: 35 additions & 3 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
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";
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(
Expand Down Expand Up @@ -53,20 +61,44 @@ function App() {
return;
}

const showRequestId =
alertDetails?.type === "error" && alertDetails?.requestId;
const description = (
Comment thread
vishnuszipstack marked this conversation as resolved.
<>
<CustomMarkdown text={alertDetails?.content} />
{showRequestId && (
<div className="notification-request-id">
<Typography.Text type="secondary">Request ID:</Typography.Text>{" "}
<Typography.Text
code
copyable={{ text: alertDetails?.requestId }}
className="notification-request-id__value"
>
{alertDetails?.requestId}
</Typography.Text>
</div>
)}
</>
);

notificationAPI.open({
message: alertDetails?.title,
description: <CustomMarkdown text={alertDetails?.content} />,
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",
},
]);
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/helpers/requestId.js
Original file line number Diff line number Diff line change
@@ -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) => {
Comment thread
vishnuszipstack marked this conversation as resolved.
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()] ??
Comment thread
vishnuszipstack marked this conversation as resolved.
err?.config?.headers?.[REQUEST_ID_HEADER]
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.

export { REQUEST_ID_HEADER, attachRequestIdInterceptor, getRequestIdFromError };
95 changes: 95 additions & 0 deletions frontend/src/helpers/requestId.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 3 additions & 0 deletions frontend/src/hooks/useAxiosPrivate.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import axios from "axios";
import { useEffect, useMemo } from "react";

import { attachRequestIdInterceptor } from "../helpers/requestId";
import useLogout from "./useLogout";

function useAxiosPrivate() {
const logout = useLogout();
const axiosPrivate = useMemo(() => axios.create(), []);

useEffect(() => {
const requestInterceptor = attachRequestIdInterceptor(axiosPrivate);
const responseInterceptor = axiosPrivate.interceptors.response.use(
(response) => {
return response;
Expand All @@ -34,6 +36,7 @@ function useAxiosPrivate() {
);

return () => {
axiosPrivate.interceptors.request.eject(requestInterceptor);
axiosPrivate.interceptors.response.eject(responseInterceptor);
};
}, []);
Expand Down
50 changes: 20 additions & 30 deletions frontend/src/hooks/useExceptionHandler.jsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,33 @@
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",
setBackendErrors = undefined,
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) {
Expand All @@ -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
Expand Down Expand Up @@ -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 };
17 changes: 17 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions frontend/src/store/alert-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const STORE_VARIABLES = {
title: "",
duration: DEFAULT_DURATION,
key: null,
requestId: null,
},
};

Expand Down