import { resolveStamp } from '../core'; import { getInstrumentedFetchSet, registerInstrumentRecord, setFetchRestore, type InstrumentRecord, } from '../global'; import { isAbortError, onHttpComplete } from './http-report'; import { requestKey, type JourneyTimedMeta } from './request-key'; import { acceptInstrumentTarget } from './soft-skip'; /** Minimal structural fetch shape. */ type FetchLike = (input: any, init?: any) => Promise<{ status: number }>; /** Holder of a `fetch` property — globalThis by default, or a scoped object. */ interface FetchTarget { fetch: FetchLike; } export interface InstrumentFetchOptions { /** Object whose `fetch` is wrapped. Defaults to globalThis. */ target?: FetchTarget | null; /** Home API base URL for request-key origin resolution. */ baseURL?: string; } const originals = new WeakMap(); function resolveUrl(input: any): string | undefined { if (typeof input === 'string') { return input; } if (input && typeof input.url === 'string') { return input.url; } if (input && typeof input.href === 'string') { return input.href; } return undefined; } /** Fetch defaults to GET; `init.method` overrides a Request's method. */ function resolveMethod(input: any, init?: any): string { if (typeof init?.method === 'string' && init.method !== '') { return init.method; } if (typeof input?.method === 'string' && input.method !== '') { return input.method; } return 'GET'; } function isFetchTarget(value: unknown): value is FetchTarget { return ( value != null && (typeof value === 'object' || typeof value === 'function') && typeof (value as FetchTarget).fetch === 'function' ); } /** * Wrap a target's fetch so requests inside a journeyStep are attributed and timed. * Call once per target (idempotent — second call is a no-op). Returns a restore * function that puts the original fetch back. Observation-only. * Soft-skips when the resolved target is missing or has no callable `fetch`. */ export function instrumentFetch(options?: InstrumentFetchOptions | null): () => void { if (options === null) { acceptInstrumentTarget('Fetch', null, isFetchTarget); return () => {}; } const opts = options ?? {}; const candidate = opts.target !== undefined ? opts.target : (globalThis as unknown as FetchTarget); const target = acceptInstrumentTarget('Fetch', candidate, isFetchTarget); if (!target) { return () => {}; } const key = target as object; const instrumented = getInstrumentedFetchSet(); if (instrumented.has(key)) { return () => {}; } const original = target.fetch; originals.set(key, original); instrumented.add(key); const record: InstrumentRecord = { disposed: false }; try { // Call through `target` — native fetch throws "Illegal invocation" with the wrong receiver. const wrapped: FetchLike = (input, init) => { if (record.disposed) { return original.call(target, input, init); } // Prefer explicit step.stamp(); else auto-attribute when enabled. const meta = init as JourneyTimedMeta | undefined; if (meta?.ignore === true) { return original.call(target, input, init); } const stamp = resolveStamp(meta?.stamp); if (!stamp || stamp.ignore) { return original.call(target, input, init); } const start = globalThis.performance.now(); const url = resolveUrl(input); const method = resolveMethod(input, init); const endpointKey = requestKey(url, undefined, opts.baseURL); const promise = original.call(target, input, init); return promise.then( response => { if (!record.disposed) { onHttpComplete({ stamp, status: response.status, durationMs: globalThis.performance.now() - start, requestKey: endpointKey, url, method, meta, request: init ?? input, response, }); } return response; }, (error: unknown) => { if (!record.disposed) { onHttpComplete({ stamp, status: undefined, durationMs: globalThis.performance.now() - start, requestKey: endpointKey, aborted: isAbortError(error), url, method, meta, request: init ?? input, error, }); } // Passthrough original rejection reason; do not wrap (identity / AbortError). return Promise.reject(error as Error); } ); }; target.fetch = wrapped; } catch (err) { target.fetch = original; instrumented.delete(key); originals.delete(key); // eslint-disable-next-line no-console -- soft-fail host wiring console.error('[journey] instrumentFetch failed', err); return () => {}; } const restore = () => { if (!instrumented.has(key)) { return; } record.disposed = true; target.fetch = originals.get(key) ?? original; instrumented.delete(key); originals.delete(key); if (target === (globalThis as unknown as FetchTarget)) { setFetchRestore(undefined); } }; record.restore = restore; registerInstrumentRecord(record); if (target === (globalThis as unknown as FetchTarget)) { setFetchRestore(restore); } return restore; }