feature/IO-3499-React-19 checkpoint
This commit is contained in:
@@ -1,45 +1,201 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { onError } from "@apollo/client/link/error";
|
||||
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 errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||
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;
|
||||
|
||||
if (graphQLErrors?.length) {
|
||||
graphQLErrors.forEach(({ message, locations, path }) => {
|
||||
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
|
||||
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
|
||||
});
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setLevel("error");
|
||||
scope.setTag("graphql.operationName", operationName);
|
||||
scope.setContext("graphql", {
|
||||
operationName
|
||||
// variables can contain PII; include only if you’re comfortable with it
|
||||
// variables: operation.variables
|
||||
if (import.meta.env.DEV) {
|
||||
console.groupCollapsed?.(`[GraphQL error details] ${operationName}`);
|
||||
console.log({
|
||||
operationName,
|
||||
variables,
|
||||
query: queryText,
|
||||
callerStack
|
||||
});
|
||||
|
||||
graphQLErrors.forEach((err) => {
|
||||
scope.setContext("graphql.error", {
|
||||
message: err.message,
|
||||
path: err.path,
|
||||
locations: err.locations
|
||||
gqlErrors.forEach((e, i) => {
|
||||
console.log(`Error #${i + 1}:`, {
|
||||
message: e.message,
|
||||
path: e.path,
|
||||
locations: e.locations,
|
||||
extensions: e.extensions
|
||||
});
|
||||
Sentry.captureMessage(`[GraphQL] ${err.message}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (networkError) {
|
||||
console.log(`[Network error]: ${JSON.stringify(networkError)}`);
|
||||
console.groupEnd?.();
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setLevel("error");
|
||||
scope.setTag("graphql.operationName", operationName);
|
||||
Sentry.captureException(networkError);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return forward(operation);
|
||||
// ---- 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;
|
||||
|
||||
Reference in New Issue
Block a user