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
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