From d40a95dff57bf3377c2bd108647159d600aad4ca Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 15 Sep 2026 01:52:35 -0700 Subject: [PATCH] fix: give the dashboard's auth errors a real stack and stop reporting an ended session as a crash, by building AuthError per throw instead of sharing two module-level instances whose stack was captured at module evaluation, so every report pointed at "module code" rather than the call that failed, and by dropping AuthError in before_send since normalizeError already turns it into a redirect and UserProvider sends the user to sign in (#527) --- web/src/lib/api/client/Request.ts | 14 +++++++------- web/src/lib/errors/auth.ts | 7 +++++-- web/src/lib/posthog.ts | 9 +++++++++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/web/src/lib/api/client/Request.ts b/web/src/lib/api/client/Request.ts index 95673df41..2c8e894b1 100644 --- a/web/src/lib/api/client/Request.ts +++ b/web/src/lib/api/client/Request.ts @@ -2,7 +2,7 @@ import type { AxiosRequestConfig } from "axios" import Client from "./Client" import getToken from "@/lib/helper/getToken" import isExpired from "@/lib/helper/isExpired"; -import { NoToken, SessionExpired } from "@/lib/errors/auth"; +import { noToken, sessionExpired } from "@/lib/errors/auth"; import refreshTokenFn from "./auth/refreshToken"; import setToken from "@/lib/helper/setToken"; import reviveDates from "@/lib/helper/reviveDates"; @@ -20,7 +20,7 @@ let refreshPromise: Promise | null = null; async function ensureValidToken(): Promise { const token = getToken(); if (!token) { - throw NoToken; + throw noToken(); } if (token.access_token && !isExpired(token.access_token_expires_at)) { @@ -30,7 +30,7 @@ async function ensureValidToken(): Promise { // Access token expired — need to refresh if (!token.refresh_token || isExpired(token.refresh_token_expires_at)) { clearTokens(); - throw SessionExpired; + throw sessionExpired(); } // If a refresh is already in progress, wait for it @@ -41,9 +41,9 @@ async function ensureValidToken(): Promise { if (updated && updated.access_token && !isExpired(updated.access_token_expires_at)) { return updated; } - throw SessionExpired; + throw sessionExpired(); } catch { - throw SessionExpired; + throw sessionExpired(); } } @@ -55,7 +55,7 @@ async function ensureValidToken(): Promise { return newToken; } catch { clearTokens(); - throw SessionExpired; + throw sessionExpired(); } finally { refreshPromise = null; } @@ -90,7 +90,7 @@ export default async function Request(config: AuthRequestConfig): Promise return reviveDates(res.data) } catch { clearTokens(); - throw SessionExpired; + throw sessionExpired(); } } diff --git a/web/src/lib/errors/auth.ts b/web/src/lib/errors/auth.ts index 76a7a2dd5..b47c95f98 100644 --- a/web/src/lib/errors/auth.ts +++ b/web/src/lib/errors/auth.ts @@ -5,5 +5,8 @@ export class AuthError extends Error { } } -export const SessionExpired = new AuthError("Session expired") -export const NoToken = new AuthError("No authorization token") +// Built per throw, not shared. A module-level instance captures its stack once, +// at module evaluation, so every report pointed at "module code" instead of the +// call that failed, and one mutable Error was shared across concurrent requests. +export const sessionExpired = () => new AuthError("Session expired") +export const noToken = () => new AuthError("No authorization token") diff --git a/web/src/lib/posthog.ts b/web/src/lib/posthog.ts index 97c68d333..d1a9c3e6e 100644 --- a/web/src/lib/posthog.ts +++ b/web/src/lib/posthog.ts @@ -166,7 +166,16 @@ const NOISE = [ "ResizeObserver loop limit exceeded", ]; +// A session ending is a lifecycle event, not a crash: normalizeError turns an +// AuthError into a redirect and UserProvider sends the user to sign in. It +// reached error tracking only by also escaping to the global rejection handler. +const NOISE_TYPES = ["AuthError"]; + function isNoise(properties: Properties): boolean { + const types = properties.$exception_types; + if (Array.isArray(types) && types.some((t) => typeof t === "string" && NOISE_TYPES.includes(t))) { + return true; + } const values = properties.$exception_values; if (!Array.isArray(values)) return false; return values.some((v) => typeof v === "string" && NOISE.includes(v.trim()));