import { AppState, Platform } from "react-native"; import * as Sentry from "@sentry/react-native"; import { DATA_ENDPOINT_CONFIG } from "./endpoints"; import { ObservedFetchOptions } from "./types"; import { ObservedFetchError, shouldRetryNetworkError } from "./networkError"; import { reportDataFetchFailure, reportDataFetchSuccess, truncateResponseHead } from "./sentryNetwork"; import { lastObservedRootRouteRef, rootNavigationRef } from "@/lib/rootNavigation"; type ParsedUrl = { host: string; pathTemplate: string }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const getRootTab = (fallback?: string) => { if (fallback) return fallback; try { const state = rootNavigationRef.getState?.(); const routeName = state?.routes?.[state.index ?? 0]?.name; if (routeName) { lastObservedRootRouteRef.current = routeName; return routeName; } } catch {} return lastObservedRootRouteRef.current ?? "unknown"; }; const getKnownOnline = () => { const online = (globalThis as any)?.navigator?.onLine; return typeof online === "boolean" ? online : true; }; const getRuntimeState = () => ({ appState: AppState.currentState, online: getKnownOnline(), }); const canStartFetch = (options: ObservedFetchOptions) => { const runtime = getRuntimeState(); return (options.allowBackground || runtime.appState !== "background") && runtime.online !== false; }; const shouldCaptureRuntimeFailure = () => { const runtime = getRuntimeState(); return runtime.appState !== "background" && runtime.online !== false; }; const getOptionTags = (options: ObservedFetchOptions) => ({ source: options.source ?? "rn_fetch", user_visible: String(options.userVisible ?? false), preload: String(options.preload ?? false), fetch_priority: options.fetchPriority ?? "medium", }); const safeParseUrl = (url: string, urlPathTemplate?: string): ParsedUrl => { try { const parsed = new URL(url); return { host: parsed.host, pathTemplate: urlPathTemplate ?? parsed.pathname, }; } catch { return { host: "unknown", pathTemplate: urlPathTemplate ?? "unknown" }; } }; const estimateBytes = (text: string) => { if (typeof Blob !== "undefined") { return new Blob([text]).size; } return text.length; }; const looksJson = (contentType: string, text: string) => { const trimmed = text.trimStart(); return contentType.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("["); }; const getFetchSignal = (controller: AbortController, externalSignal?: AbortSignal) => { if (!externalSignal) return controller.signal; if (externalSignal.aborted) controller.abort(); const abort = () => controller.abort(); externalSignal.addEventListener("abort", abort, { once: true }); return controller.signal; }; const buildContext = (args: { endpoint: string; status?: number; ok?: boolean; durationMs: number; timeoutMs: number; bytes?: number; contentType?: string; responseHead?: string; urlHost: string; urlPathTemplate: string; retryCount: number; }) => ({ endpoint: args.endpoint, method: "GET", status: args.status, ok: args.ok, durationMs: args.durationMs, timeoutMs: args.timeoutMs, bytes: args.bytes, contentType: args.contentType, responseHead: truncateResponseHead(args.responseHead), retryCount: args.retryCount, urlHost: args.urlHost, urlPathTemplate: args.urlPathTemplate, ...getRuntimeState(), }); async function observedFetchTextOnce(url: string, options: ObservedFetchOptions, reportSuccess = true): Promise<{ text: string; response: Response; durationMs: number; bytes: number; contentType: string; context: Record; slow: boolean; statusTag: string }> { const endpoint = options.endpoint ?? "unknown"; const config = DATA_ENDPOINT_CONFIG[endpoint]; const timeoutMs = options.timeoutMs ?? config.timeoutMs; const slowMs = options.slowMs ?? config.slowMs; const startedAt = Date.now(); const parsedUrl = safeParseUrl(url, options.urlPathTemplate); const retryCount = options.retryCount ?? 0; const method = options.method ?? "GET"; const rootTab = getRootTab(options.rootTab); const baseTags = { area: "data_fetch", endpoint, result: "unknown", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), }; if (!canStartFetch(options)) { const runtime = getRuntimeState(); const error = new ObservedFetchError( runtime.online === false ? "Data fetch skipped while offline" : "Data fetch skipped while app is background", "aborted", { retryable: false } ); reportDataFetchFailure({ kind: "aborted", error, tags: { ...baseTags, result: "aborted" }, context: buildContext({ endpoint, durationMs: 0, timeoutMs, urlHost: parsedUrl.host, urlPathTemplate: parsedUrl.pathTemplate, retryCount, }), capture: false, }); throw error; } const controller = new AbortController(); const signal = getFetchSignal(controller, options.signal); let timedOut = false; let timeoutId: ReturnType | null = null; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { timedOut = true; controller.abort(); reject(new Error("Network request timed out")); }, timeoutMs); }); Sentry.addBreadcrumb({ category: "data_fetch", level: "info", message: "fetch:start", data: { endpoint, method, urlHost: parsedUrl.host, urlPathTemplate: parsedUrl.pathTemplate, timeoutMs, retryCount }, }); const run = async () => { try { const { endpoint: _endpoint, expectedContentType: _expectedContentType, timeoutMs: _timeoutMs, slowMs: _slowMs, rootTab: _rootTab, source: _source, userVisible: _userVisible, preload: _preload, fetchPriority: _fetchPriority, urlPathTemplate: _urlPathTemplate, retryCount: _retryCount, retry: _retry, allowBackground: _allowBackground, signal: _signal, ...requestOptions } = options; const { response, text, contentType } = await Promise.race([ (async () => { const response = await fetch(url, { ...requestOptions, signal, }); const contentType = response.headers.get("content-type") ?? ""; const text = await response.text(); return { response, text, contentType }; })(), timeoutPromise, ]); const durationMs = Date.now() - startedAt; const bytes = estimateBytes(text); const context = buildContext({ endpoint, status: response.status, ok: response.ok, durationMs, timeoutMs, bytes, contentType, urlHost: parsedUrl.host, urlPathTemplate: parsedUrl.pathTemplate, retryCount, }); if (!response.ok) { const retryable = [502, 503, 504].includes(response.status); const error = new ObservedFetchError("HTTP " + response.status, "http_error", { status: response.status, retryable }); reportDataFetchFailure({ kind: "http_error", error, tags: { ...baseTags, result: "http_error", status: String(response.status) }, context: { ...context, responseHead: truncateResponseHead(text) }, }); throw error; } const slow = durationMs >= slowMs; if (reportSuccess) { reportDataFetchSuccess({ tags: { ...baseTags, result: slow ? "slow_success" : "success", status: String(response.status) }, context, slow, }); } return { text, response, durationMs, bytes, contentType, context, slow, statusTag: String(response.status) }; } catch (error) { if (error instanceof ObservedFetchError) throw error; const durationMs = Date.now() - startedAt; const isAbort = timedOut || (error as any)?.name === "AbortError"; const kind = isAbort ? "timeout" : String((error as any)?.message ?? "").includes("Network request failed") ? "network_error" : "unknown"; const retryable = kind === "timeout" || kind === "network_error"; const wrapped = new ObservedFetchError( isAbort ? "Network request timed out" : String((error as any)?.message ?? error), kind, isAbort ? { retryable } : { retryable, cause: error } ); reportDataFetchFailure({ kind, error: wrapped, tags: { ...baseTags, result: kind }, context: buildContext({ endpoint, durationMs, timeoutMs, urlHost: parsedUrl.host, urlPathTemplate: parsedUrl.pathTemplate, retryCount, }), capture: shouldCaptureRuntimeFailure(), }); throw wrapped; } finally { if (timeoutId) clearTimeout(timeoutId); } }; const spanOptions = { name: "fetch " + endpoint, description: "fetch " + endpoint, op: "http.client", attributes: { "app.area": "data_fetch", "app.endpoint": endpoint, "app.root_tab": rootTab, "http.method": method, "server.address": parsedUrl.host, }, }; if (typeof (Sentry as any).startSpan === "function") { return (Sentry as any).startSpan(spanOptions, run); } return run(); } export async function observedFetchText(url: string, options: ObservedFetchOptions): Promise { const retryCount = options.retryCount ?? 0; try { const result = await observedFetchTextOnce(url, { ...options, retryCount }); return result.text; } catch (error) { if (!options.retry || retryCount >= 1 || !shouldRetryNetworkError(error)) { throw error; } if (!canStartFetch(options)) { throw error; } await sleep(750 + Math.random() * 500); return observedFetchText(url, { ...options, retryCount: retryCount + 1 }); } } export async function observedFetchJson(url: string, options: ObservedFetchOptions): Promise { const endpoint = options.endpoint ?? "unknown"; const config = DATA_ENDPOINT_CONFIG[endpoint]; const expectedContentType = options.expectedContentType ?? config.expectedContentType; const parsedUrl = safeParseUrl(url, options.urlPathTemplate); const retryCount = options.retryCount ?? 0; const rootTab = getRootTab(options.rootTab); try { const result = await observedFetchTextOnce(url, { ...options, retryCount }, false); const trimmed = result.text.trim(); const context = buildContext({ endpoint, status: result.response.status, ok: result.response.ok, durationMs: result.durationMs, timeoutMs: options.timeoutMs ?? config.timeoutMs, bytes: result.bytes, contentType: result.contentType, urlHost: parsedUrl.host, urlPathTemplate: parsedUrl.pathTemplate, retryCount, }); if (trimmed.length === 0) { const error = new ObservedFetchError("Empty response", "empty_response", { retryable: false }); reportDataFetchFailure({ kind: "empty_response", error, tags: { area: "data_fetch", endpoint, result: "empty_response", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) }, context, }); throw error; } if (expectedContentType === "json" && !looksJson(result.contentType, result.text)) { const error = new ObservedFetchError("Non JSON response", "non_json_response", { retryable: false }); reportDataFetchFailure({ kind: "non_json_response", error, tags: { area: "data_fetch", endpoint, result: "non_json", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) }, context: { ...context, responseHead: truncateResponseHead(result.text) }, }); throw error; } try { const parsed = JSON.parse(result.text) as T; reportDataFetchSuccess({ tags: { area: "data_fetch", endpoint, result: result.slow ? "slow_success" : "success", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: result.statusTag, }, context, slow: result.slow, }); return parsed; } catch (error) { const wrapped = new ObservedFetchError("JSON parse error", "json_parse_error", { retryable: false, cause: error }); reportDataFetchFailure({ kind: "json_parse_error", error: wrapped, tags: { area: "data_fetch", endpoint, result: "parse_error", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) }, context: { ...context, responseHead: truncateResponseHead(result.text) }, }); throw wrapped; } } catch (error) { if (!options.retry || retryCount >= 1 || !shouldRetryNetworkError(error)) { throw error; } if (!canStartFetch(options)) { throw error; } await sleep(750 + Math.random() * 500); return observedFetchJson(url, { ...options, retryCount: retryCount + 1 }); } }