import * as Sentry from "@sentry/react"; import { ErrorLink } from "@apollo/client/link/error"; import { CombinedGraphQLErrors, CombinedProtocolErrors } from "@apollo/client/errors"; import { print } from "graphql"; import { getMainDefinition } from "@apollo/client/utilities"; import { auth } from "../firebase/firebase.utils"; import { from, throwError } from "rxjs"; import { catchError, mergeMap } from "rxjs/operators"; const SENSITIVE_KEYS = new Set([ "password", "token", "authorization", "accessToken", "refreshToken", "idToken", "session" ]); let refreshInFlight = null; const refreshTokenOnce = () => { if (!refreshInFlight) { refreshInFlight = auth.currentUser ? auth.currentUser.getIdToken(true) : Promise.resolve(null); refreshInFlight.finally(() => { refreshInFlight = null; }); } return refreshInFlight; }; const redactVariables = (variables) => { if (!variables || typeof variables !== "object") return variables; const out = { ...variables }; for (const key of Object.keys(out)) { if (SENSITIVE_KEYS.has(key)) out[key] = "[REDACTED]"; } return out; }; const safePrintQuery = (query) => { try { return query ? print(query) : undefined; } catch { return undefined; } }; const isSubscriptionOperation = (operation) => { try { const def = getMainDefinition(operation.query); return def?.kind === "OperationDefinition" && def?.operation === "subscription"; } catch { return false; } }; const extractGraphQLErrors = ({ error, result }) => { // Primary: Apollo Client 4+ GraphQL errors if (CombinedGraphQLErrors.is(error)) return error.errors; // Fallback: raw GraphQL response errors (what apollo-link-logger shows) if (Array.isArray(result?.errors) && result.errors.length) return result.errors; // Fallback: some “network-ish” errors still carry result.errors if (Array.isArray(error?.result?.errors) && error.result.errors.length) return error.result.errors; return null; }; const hasUnauthCode = (errs) => Array.isArray(errs) && errs.some((e) => e?.extensions?.code === "UNAUTHENTICATED"); const errorLink = new ErrorLink(({ error, result, operation, forward }) => { const operationName = operation?.operationName || "anonymous"; const ctx = operation?.getContext?.() || {}; const callerStack = ctx?.callerStack; const variables = redactVariables(operation?.variables); const queryText = safePrintQuery(operation?.query); const gqlErrors = extractGraphQLErrors({ error, result }); const protocolErrors = CombinedProtocolErrors.is(error) ? error.errors : null; // ---- Single auth-refresh retry (HTTP only, once) ---- // Only retry if: // - we have GraphQL errors with UNAUTHENTICATED // - not a subscription // - not already retried // - forward exists (so we can re-run) if (forward && !isSubscriptionOperation(operation) && !ctx.__didAuthRetry && hasUnauthCode(gqlErrors)) { operation.setContext({ ...ctx, __didAuthRetry: true }); return from(refreshTokenOnce()).pipe( mergeMap(() => forward(operation)), catchError(() => throwError(() => error)) ); } // ---- GraphQL errors ---- if (gqlErrors?.length) { // Make it show up in the “Errors” filter (red) console.error(`[GraphQL error] ${operationName}`, { message: gqlErrors[0]?.message }); if (import.meta.env.DEV) { console.groupCollapsed?.(`[GraphQL error details] ${operationName}`); console.log({ operationName, variables, query: queryText, callerStack }); gqlErrors.forEach((e, i) => { console.log(`Error #${i + 1}:`, { message: e.message, path: e.path, locations: e.locations, extensions: e.extensions }); }); console.groupEnd?.(); } Sentry.withScope((scope) => { scope.setLevel("error"); scope.setTag("graphql.operationName", operationName); scope.setContext("graphql.request", { operationName, variables, query: import.meta.env.DEV ? queryText : undefined }); if (callerStack) scope.setContext("graphql.caller", { stack: callerStack }); scope.setContext( "graphql.errors", gqlErrors.map((e) => ({ message: e.message, path: e.path, locations: e.locations, extensions: e.extensions })) ); Sentry.captureMessage(`[GraphQL] ${operationName}`); }); // IMPORTANT: do NOT return forward(operation) unless intentionally retrying return; } // ---- Protocol errors (rare, but good to log) ---- if (protocolErrors?.length) { console.error(`[Protocol error] ${operationName}`, { message: protocolErrors[0]?.message }); if (import.meta.env.DEV) { console.groupCollapsed?.(`[Protocol error details] ${operationName}`); console.log({ operationName, variables, query: queryText, callerStack }); protocolErrors.forEach((e, i) => console.log(`Protocol Error #${i + 1}:`, e)); console.groupEnd?.(); } Sentry.withScope((scope) => { scope.setLevel("error"); scope.setTag("graphql.operationName", operationName); scope.setContext("graphql.request", { operationName, variables, query: import.meta.env.DEV ? queryText : undefined }); if (callerStack) scope.setContext("graphql.caller", { stack: callerStack }); scope.setContext("graphql.protocolErrors", protocolErrors); Sentry.captureMessage(`[GraphQL Protocol] ${operationName}`); }); return; } // ---- Network / other errors ---- if (error) { console.error(`[Network error] ${operationName}`, error); Sentry.withScope((scope) => { scope.setLevel("error"); scope.setTag("graphql.operationName", operationName); scope.setContext("graphql.request", { operationName, variables, query: import.meta.env.DEV ? queryText : undefined }); if (callerStack) scope.setContext("graphql.caller", { stack: callerStack }); Sentry.captureException(error); }); } }); export default errorLink;