import { MAX_ATTRIBUTED_REQUEST_KEYS } from '../core/limits'; import { isJourneyRequestPolicy, resolveRequestPolicy, type JourneyHttpPolicyContext, type JourneyRequestPolicy, } from '../core/request-policy'; import { reportBackendRequest } from '../core/api'; import { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime'; import type { JourneyStepStamp } from '../core/step-tag'; import type { StepRecord } from '../core/types'; import type { JourneyTimedMeta } from './request-key'; import { captureRequestCorrelation } from './request-correlation'; export interface HttpCompleteReport { stamp: JourneyStepStamp | undefined; status?: number; durationMs: number; requestKey: string; aborted?: boolean; url?: string; /** Actual request base, used only to validate the log-correlation origin. */ baseURL?: string; method?: string; /** Per-request literal overrides from JourneyTimedMeta. */ meta?: JourneyTimedMeta; /** Transport request (axios config, fetch input/init, jQuery settings). */ request?: unknown; /** Transport response (axios response, fetch Response, jqXHR). */ response?: unknown; /** Rejection reason when the transport failed. */ error?: unknown; } function applyPolicy( policy: JourneyRequestPolicy, step: StepRecord, reason: string, status?: number ): void { if (policy === 'continue') { return; } const journey = step.journey; if (!journey || journey.closed) { return; } const engine = resolveJourneyEngine(journey); if (policy === 'exclude') { engine.exclude(); return; } // 'bad' step.outcome = 'bad'; step.reason ??= reason; if (status !== undefined) { step.httpStatus = status; } engine.failJourney(reason); } function httpScoreReason(report: HttpCompleteReport): string { if (report.aborted) { return 'request-aborted'; } const status = report.status; if (status !== undefined && status >= 400 && status < 500) { return 'request-client-error'; } return 'request-error'; } /** * Attribute a finished HTTP call: abort / 4xx policies, else 5xx+latency via reportBackendRequest. * Ignored endpoint prefixes never change the journey verdict. * 4xx with policy `'continue'` (default) still scores latency. */ export function onHttpComplete(report: HttpCompleteReport): void { const { stamp, requestKey, aborted, meta } = report; if (!stamp) { return; } const step = stamp.step; if (!step?.journey) { return; } /* * Unstamped foreign journeys (older package copies) cannot be closed from * this bundle — do not write onHttpAborted / step.outcome on them. */ if (!canRouteHttpToJourney(step.journey)) { return; } const engine = resolveJourneyEngine(step.journey); if (meta?.ignore === true || stamp.ignore === true) { return; } if (aborted) { step.onHttpAborted = true; } if (requestKey) { step.attributedRequestKeys ??= []; if ( step.attributedRequestKeys.length < MAX_ATTRIBUTED_REQUEST_KEYS && !step.attributedRequestKeys.includes(requestKey) ) { step.attributedRequestKeys.push(requestKey); } } if (requestKey && engine.shouldIgnoreRequest(requestKey)) { return; } // Capture before a policy/latency breach synchronously emits the terminal event. const correlation = captureRequestCorrelation(report); const previous = step.journey.scoringRequest; // Scope this pointer to scoring. Later JS/business failures must not blame the last request. step.journey.scoringRequest = correlation; try { scoreHttpComplete(report, step); } finally { step.journey.scoringRequest = previous; } } function scoreHttpComplete(report: HttpCompleteReport, step: StepRecord): void { const { stamp, status, durationMs, requestKey, aborted, url, method, meta, request, response, error, } = report; const engine = resolveJourneyEngine(step.journey); const ctx: JourneyHttpPolicyContext = { requestKey, status, aborted: aborted === true, url, method, journeyName: step.journey.name, stepName: step.name, }; const score = stamp?.score; if (score) { let policy: JourneyRequestPolicy | undefined; try { const scored = score({ ...ctx, durationMs, request, response, error, }); if (isJourneyRequestPolicy(scored)) { policy = scored; } } catch { policy = undefined; } if (policy !== undefined) { applyPolicy(policy, step, httpScoreReason(report), status); return; } } if (aborted) { const override = meta?.httpAbortedRequests; const policy = isJourneyRequestPolicy(override) ? override : resolveRequestPolicy(engine.getHttpAbortedRequestsPolicy(), 'continue', ctx); applyPolicy(policy, step, 'request-aborted'); return; } if (status !== undefined && status >= 400 && status < 500) { const override = meta?.httpClientErrorRequests; const policy = isJourneyRequestPolicy(override) ? override : resolveRequestPolicy(engine.getHttpClientErrorRequestsPolicy(), 'continue', ctx); if (policy !== 'continue') { applyPolicy(policy, step, 'request-client-error', status); return; } // 'continue' does not count 4xx as an error; still score latency below. } reportBackendRequest(step, status, durationMs, requestKey); } export { isAbortError, isCanceledError } from '../core/abort-error';