From 35ac3fa0a7b86aab8e1c11cd0bfbdaf83efab1f0 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:02:16 +0200 Subject: [PATCH] Add session ID to server logs for user reporting (#2720) --- app/components/Catcher.tsx | 17 ++++++++----- app/entry.client.tsx | 9 +++++++ app/entry.server.tsx | 5 ++++ .../session-id/session-id-context.server.ts | 18 ++++++++++++++ .../session-id-middleware.server.ts | 17 +++++++++++++ app/root.tsx | 2 ++ app/utils/logger.ts | 24 ++++++++++++++++--- app/utils/session-id.ts | 15 ++++++++++++ 8 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 app/features/session-id/session-id-context.server.ts create mode 100644 app/features/session-id/session-id-middleware.server.ts create mode 100644 app/utils/session-id.ts diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx index 1d9bbc2b7..635f9a014 100644 --- a/app/components/Catcher.tsx +++ b/app/components/Catcher.tsx @@ -6,6 +6,7 @@ import { } from "react-router"; import { useLocation } from "react-use"; import { useUser } from "~/features/auth/core/user"; +import { getSessionId } from "~/utils/session-id"; import { ERROR_GIRL_IMAGE_PATH, LOG_IN_URL, @@ -52,10 +53,11 @@ export function Catcher() { } if (!isRouteErrorResponse(error)) { + const sessionId = getSessionId(); const errorText = (() => { if (!(error instanceof Error)) return; - return `Time: ${new Date().toISOString()}\nURL: ${location.href}\nUser ID: ${user?.id ?? "Not logged in"}\n${error.stack ?? error.message}`; + return `Session ID: ${sessionId}\nTime: ${new Date().toISOString()}\nURL: ${location.href}\nUser ID: ${user?.id ?? "Not logged in"}\n${error.stack ?? error.message}`; })(); return ( @@ -124,12 +126,15 @@ export function Catcher() {

Error {error.status}

- Please include the message below if any and an explanation on what - you were doing: + Please include the session ID and message below if any and an + explanation on what you were doing:
- {error.data ? ( -
{JSON.stringify(JSON.parse(error.data), null, 2)}
- ) : null} +
+						Session ID: {getSessionId()}
+						{error.data
+							? `\n${JSON.stringify(JSON.parse(error.data), null, 2)}`
+							: null}
+					
); } diff --git a/app/entry.client.tsx b/app/entry.client.tsx index c6ad81199..563a1553a 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -4,6 +4,15 @@ import { I18nextProvider } from "react-i18next"; import { HydratedRouter } from "react-router/dom"; import { i18nLoader } from "./modules/i18n/loader"; import { logger } from "./utils/logger"; +import { getSessionId } from "./utils/session-id"; + +const originalFetch = window.fetch; +window.fetch = (input, init) => { + const sessionId = getSessionId(); + const headers = new Headers(init?.headers); + headers.set("Sendou-Session-Id", sessionId); + return originalFetch(input, { ...init, headers }); +}; if ("serviceWorker" in navigator) { window.addEventListener("load", () => { diff --git a/app/entry.server.tsx b/app/entry.server.tsx index e6d5e79ca..70507f5ce 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -108,3 +108,8 @@ if (!global.appStartSignal && process.env.NODE_ENV === "production") { process.on("unhandledRejection", (reason: string, p: Promise) => { logger.error("Unhandled Rejection at:", p, "reason:", reason); }); + +// wrapper so we get request id shown in the server logs +export function handleError(error: unknown) { + logger.error(error); +} diff --git a/app/features/session-id/session-id-context.server.ts b/app/features/session-id/session-id-context.server.ts new file mode 100644 index 000000000..6a98f5462 --- /dev/null +++ b/app/features/session-id/session-id-context.server.ts @@ -0,0 +1,18 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +interface SessionIdContext { + sessionId: string | undefined; +} + +export const sessionIdAsyncLocalStorage = + new AsyncLocalStorage(); + +function getSessionId(): string | undefined { + return sessionIdAsyncLocalStorage.getStore()?.sessionId; +} + +declare global { + var __getServerSessionId: (() => string | undefined) | undefined; +} + +globalThis.__getServerSessionId = getSessionId; diff --git a/app/features/session-id/session-id-middleware.server.ts b/app/features/session-id/session-id-middleware.server.ts new file mode 100644 index 000000000..b60e2b705 --- /dev/null +++ b/app/features/session-id/session-id-middleware.server.ts @@ -0,0 +1,17 @@ +import { sessionIdAsyncLocalStorage } from "./session-id-context.server"; + +type MiddlewareArgs = { + request: Request; + context: unknown; +}; + +type MiddlewareFn = ( + args: MiddlewareArgs, + next: () => Promise, +) => Promise; + +export const sessionIdMiddleware: MiddlewareFn = async ({ request }, next) => { + const sessionId = request.headers.get("Sendou-Session-Id") ?? undefined; + + return sessionIdAsyncLocalStorage.run({ sessionId }, () => next()); +}; diff --git a/app/root.tsx b/app/root.tsx index 945340717..e7fdd17f3 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -37,6 +37,7 @@ import { Ramp } from "./components/ramp/Ramp"; import { apiCorsMiddleware } from "./features/api-public/api-cors-middleware.server"; import { getUser } from "./features/auth/core/user.server"; import { userMiddleware } from "./features/auth/core/user-middleware.server"; +import { sessionIdMiddleware } from "./features/session-id/session-id-middleware.server"; import { isTheme, Theme, @@ -53,6 +54,7 @@ import { allI18nNamespaces } from "./utils/i18n"; import { isRevalidation, metaTags, type SerializeFrom } from "./utils/remix"; export const middleware: Route.MiddlewareFunction[] = [ + sessionIdMiddleware, apiCorsMiddleware, userMiddleware, ]; diff --git a/app/utils/logger.ts b/app/utils/logger.ts index 667eb0b83..631261fdb 100644 --- a/app/utils/logger.ts +++ b/app/utils/logger.ts @@ -1,7 +1,25 @@ /** biome-ignore-all lint/suspicious/noConsole: stub file to enable different solution later */ +import { getSessionId as getClientSessionId } from "./session-id"; + +declare global { + var __getServerSessionId: (() => string | undefined) | undefined; +} + +function getSessionIdForLog(): string { + if (typeof window !== "undefined") { + return getClientSessionId(); + } + + return globalThis.__getServerSessionId?.() ?? "no-session"; +} + +function formatLog(...args: unknown[]) { + const sessionId = getSessionIdForLog(); + return [`[${sessionId}]`, ...args]; +} export const logger = { - info: console.log, - error: console.error, - warn: console.warn, + info: (...args: unknown[]) => console.log(...formatLog(...args)), + error: (...args: unknown[]) => console.error(...formatLog(...args)), + warn: (...args: unknown[]) => console.warn(...formatLog(...args)), }; diff --git a/app/utils/session-id.ts b/app/utils/session-id.ts new file mode 100644 index 000000000..72e38715e --- /dev/null +++ b/app/utils/session-id.ts @@ -0,0 +1,15 @@ +import { customAlphabet } from "nanoid"; + +const nanoid = customAlphabet( + // avoid 1/I and 0/O + "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", +); + +let sessionId: string | undefined; + +export function getSessionId(): string { + if (!sessionId) { + sessionId = nanoid(10); + } + return sessionId; +}