Files
jrshikoku/lib/observability/network/observedFetch.ts

378 lines
13 KiB
TypeScript

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<string, unknown>; 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<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, 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<string> {
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<T>(url: string, options: ObservedFetchOptions): Promise<T> {
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<T>(url, { ...options, retryCount: retryCount + 1 });
}
}