202 lines
6.0 KiB
JavaScript
202 lines
6.0 KiB
JavaScript
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;
|