import { resolveStamp } from '../core'; import { adoptJourneyStepStamp, type JourneyStepStamp } from '../core/step-tag'; import { getInstrumentedAxiosSet, registerInstrumentRecord, type InstrumentRecord, } from '../global'; import { isCanceledError, onHttpComplete } from './http-report'; import { requestKey, type JourneyTimedMeta } from './request-key'; import { acceptInstrumentTarget } from './soft-skip'; /** * Minimal structural axios surface. No axios dependency — works across 0.31 and 1.x. */ export interface AxiosLikeInstance { defaults: { baseURL?: string }; interceptors: { request: { use(onFulfilled: (config: any) => any): unknown; eject?(id: unknown): void; }; response: { use(onFulfilled: (response: any) => any, onRejected?: (error: any) => any): unknown; eject?(id: unknown): void; }; }; } type TimedConfig = JourneyTimedMeta & { url?: string; baseURL?: string; method?: string }; const axiosStamps = new WeakMap(); function stampFromConfig(config: TimedConfig | undefined): JourneyStepStamp | undefined { if (!config) { return undefined; } return axiosStamps.get(config) ?? adoptJourneyStepStamp(config.stamp); } function reportTiming( instance: AxiosLikeInstance, config: TimedConfig | undefined, status: number | undefined, aborted: boolean, extras?: { request?: unknown; response?: unknown; error?: unknown } ): void { if (!config || config.ignore === true) { return; } const stamp = stampFromConfig(config); if (!stamp || stamp.ignore) { return; } const start = config.journeyStart ?? globalThis.performance.now(); const key = requestKey(config.url, config.baseURL, instance.defaults.baseURL); onHttpComplete({ stamp, status, durationMs: globalThis.performance.now() - start, requestKey: key, aborted, url: config.url, baseURL: config.baseURL ?? instance.defaults.baseURL, method: config.method, meta: config, request: extras?.request ?? config, response: extras?.response, error: extras?.error, }); } function isAxiosLikeInstance(value: unknown): value is AxiosLikeInstance { if (value == null || (typeof value !== 'object' && typeof value !== 'function')) { return false; } const instance = value as AxiosLikeInstance; return ( instance.defaults != null && typeof instance.defaults === 'object' && typeof instance.interceptors?.request?.use === 'function' && typeof instance.interceptors?.response?.use === 'function' ); } function ejectInterceptor( interceptors: { eject?: (id: unknown) => void } | undefined, id: unknown ): boolean { if (id === undefined || typeof interceptors?.eject !== 'function') { return false; } try { interceptors.eject(id); return true; } catch { return false; } } /** * Wire an axios instance into the journey engine (observation-only). * Call once per instance (idempotent). Register any async request interceptors * (e.g. auth) before this call so this one runs first. * Soft-skips when `instance` is missing or not axios-like — hosts can pass axios as-is. * Returns a restore function that disposes handlers (they no-op after restore). */ export function instrumentAxios(instance: AxiosLikeInstance | null | undefined): () => void { const axios = acceptInstrumentTarget('Axios', instance, isAxiosLikeInstance); const instrumented = getInstrumentedAxiosSet(); if (!axios || instrumented.has(axios as object)) { return () => {}; } const record: InstrumentRecord = { disposed: false }; let requestId: unknown; let responseId: unknown; let requestAdded = false; let responseAdded = false; try { requestId = axios.interceptors.request.use(config => { if (record.disposed) { return config; } const timed = config as TimedConfig; if (timed.ignore === true) { return config; } const stamp = resolveStamp(timed.stamp); if (stamp) { axiosStamps.set(timed, stamp); /* * Keep config.stamp so later interceptors that clone/spread config * still carry attribution. JourneyStepStamp.toJSON keeps this safe * for AxiosError serialization. */ } if (stamp?.ignore === true) { return config; } if (stamp) { timed.journeyStart = globalThis.performance.now(); } return config; }); requestAdded = true; responseId = axios.interceptors.response.use( response => { if (!record.disposed) { reportTiming( axios, response?.config as TimedConfig | undefined, response?.status, false, { request: response?.config, response } ); } return response; }, (error: unknown) => { if (!record.disposed) { const axiosError = error as { config?: TimedConfig; response?: { status?: number }; }; reportTiming( axios, axiosError?.config, axiosError?.response?.status, isCanceledError(error), { request: axiosError?.config, response: axiosError?.response, error, } ); } /* * Passthrough original reason (AxiosError or test doubles). Do not wrap — * callers match on .response / AxiosError identity. */ return Promise.reject(error as Error); } ); responseAdded = true; instrumented.add(axios as object); } catch (err) { const unwound = (!responseAdded || ejectInterceptor(axios.interceptors.response, responseId)) && (!requestAdded || ejectInterceptor(axios.interceptors.request, requestId)); if (!unwound) { /* * Interceptors may still be registered and cannot be ejected. Mark disposed * and keep the WeakSet slot, but register a restore so teardownJourney can * unmark and a later retry can proceed. */ record.disposed = true; instrumented.add(axios as object); const restore = () => { record.disposed = true; instrumented.delete(axios as object); }; record.restore = restore; registerInstrumentRecord(record); // eslint-disable-next-line no-console -- soft-fail host wiring console.error('[journey] instrumentAxios failed', err); return restore; } // eslint-disable-next-line no-console -- soft-fail host wiring console.error('[journey] instrumentAxios failed', err); return () => {}; } const restore = () => { record.disposed = true; ejectInterceptor(axios.interceptors.request, requestId); ejectInterceptor(axios.interceptors.response, responseId); instrumented.delete(axios as object); }; record.restore = restore; registerInstrumentRecord(record); return restore; }