import { getAutoAttributeRequests as getSharedAutoAttributeRequests, getHttpAbortedRequestsPolicy as getSharedHttpAbortedRequestsPolicy, getHttpClientErrorRequestsPolicy as getSharedHttpClientErrorRequestsPolicy, getSharedSlowRequests, isJourneyDebugEnabled, } from '../global'; import { isCanceledError } from './abort-error'; import { resolveEndpointPolicy, sortEndpointsByLongestMatch, type SlowRequestConfig, } from './endpoint-policy'; import { deserializeJourneyState, projectJourneyState } from './persist'; import { clampMaxSteps, DEFAULT_MAX_STEPS, DEFAULT_JOURNEY_IDLE_MS, finiteSlowRequestMs, finiteTimeoutMs, MAX_ATTRIBUTED_REQUEST_KEYS, MAX_TAG_KEYS, resolveJourneyTimeoutMs, } from './limits'; import { assignRecord, emptyRecord, sanitizeJourneyEvent, setSafeAttribute } from './sanitize'; import { JourneyStepStamp, resolveStampOpts } from './step-tag'; import type { RequestPolicyConfig } from './request-policy'; import { JOURNEY_ENGINE, type JourneyDef, type JourneyEngine, type JourneyEvent, type JourneyNameLike, type JourneySink, type JourneyState, type JourneyStepEvent, type JourneyStepOptions, type JourneyStepTarget, type Outcome, type SerializedJourneyState, type StepHandle, type StepRecord, type TagValue, } from './types'; const DEFAULT_SLOW_REQUESTS: SlowRequestConfig = { defaultMs: 4_000, endpoints: [] }; const noopStep: StepHandle = { journeyName: null, setAttribute() {}, setTags() {}, completeJourney() {}, failJourney() {}, stamp: () => undefined, }; function toName(target: JourneyNameLike): string { if (typeof target === 'string') { return target; } return 'config' in target ? target.config.name : target.name; } /** @internal Private — not part of the public package API. */ export interface JourneyRuntimeOptions { /** * DevTools label for this engine copy (`__stJourney.getDebugState().runtimes`). * Auto-assigned `runtime-N` from the current `__stJourney` registration count * when omitted at `registerJourneyRuntime`. */ id?: string; /** Initial sinks. Defaults to `[]` — package bootstrap installs Datadog on the module runtime. */ sinks?: readonly JourneySink[]; slowRequests?: SlowRequestConfig; /** Cap recorded steps; default 50. */ maxSteps?: number; /** Default timeout when JourneyDef.timeoutMs is omitted. 0 disables idle countdown. */ defaultJourneyIdleMs?: number; } /** @internal Read-only snapshot for DevTools (`__stJourney.getDebugState`; empty unless debugging). */ export interface JourneyRuntimeDebugSnapshot { id: string; openJourneys: readonly { name: string; team: string; group: string; service: string; verdict: Outcome; reason: string | null; tags: Record; stepsInFlight: readonly string[]; steps: readonly { name: string; outcome: 'good' | 'bad'; durationMs: number; reason?: string; attributedRequestKeys?: readonly string[]; }[]; }[]; activeStep: { journey: string; name: string } | null; /** Which shared policy knobs this runtime overrode (false = inherit global). */ policyOverlay: { slowRequests: boolean; httpAbortedRequests: boolean; httpClientErrorRequests: boolean; autoAttributeRequests: boolean; }; } /** @internal Freeze a caller-supplied journey def + tags snapshot. */ export function freezeJourneyDef(config: JourneyDef, tags: Record): JourneyDef { const endpoints = sortEndpointsByLongestMatch(config.endpoints); return Object.freeze({ ...config, timeoutMs: config.timeoutMs !== undefined ? finiteTimeoutMs(config.timeoutMs) : undefined, stepTimeoutMs: config.stepTimeoutMs !== undefined ? finiteTimeoutMs(config.stepTimeoutMs) : undefined, slowRequestMs: finiteSlowRequestMs(config.slowRequestMs), maxSteps: config.maxSteps !== undefined ? clampMaxSteps(config.maxSteps) : undefined, endpoints: endpoints ? Object.freeze([...endpoints]) : undefined, expected: config.expected ? Object.freeze([...config.expected]) : undefined, tags: Object.freeze({ ...tags }), }); } /** * @internal Private — not part of the public package API. * Isolated journey engine (open journeys, ambient steps, sinks, request thresholds). * App code should keep using free helpers (`defineJourney`, `journeyStep`, …) — they * bind to the module runtime. Prefer `resetJourney()` in tests. */ export interface JourneyRuntime { /** DevTools id for this engine copy. Auto `runtime-N` when registered without an id. */ readonly id: string; getSlowRequests(): SlowRequestConfig; setSlowRequests(next: SlowRequestConfig): void; getMaxSteps(): number; setMaxSteps(next: number): void; getDefaultJourneyIdleMs(): number; setDefaultJourneyIdleMs(next: number): void; setSinks(next: readonly JourneySink[]): void; /** First `configureJourney({ sinks })` on this runtime wins. Returns false if already set. */ trySetConfiguredSinks(next: readonly JourneySink[]): boolean; addSink(sink: JourneySink): void; emit(payload: JourneyEvent): void; setPolicyOverlay(next: { slowRequests?: SlowRequestConfig; httpAbortedRequests?: RequestPolicyConfig; httpClientErrorRequests?: RequestPolicyConfig; autoAttributeRequests?: boolean; }): void; getAutoAttributeRequests(): boolean; getHttpAbortedRequestsPolicy(): RequestPolicyConfig; getHttpClientErrorRequestsPolicy(): RequestPolicyConfig; getOpenJourney(name: string): JourneyState | undefined; /** @internal DevTools snapshot of open journeys / ambient step. */ getDebugSnapshot(): JourneyRuntimeDebugSnapshot; firstOpenJourneyName(names: readonly string[]): string | null; setJourneyTags(name: string | null, tags: Record): void; startJourney(config: JourneyDef): void; failJourney(journey: JourneyState, reason: string): void; completeJourney(name: string | null, tags?: Record): void; completeJourneyState(journey: JourneyState, tags?: Record): void; excludeJourney(journey: JourneyState): void; updateOpenJourney(config: JourneyDef): void; /** * @internal Snapshot an open journey for cross-page transfer. Does NOT * close the journey. Returns `null` when no journey is open under `name`. */ serializeJourneyState(name: string): SerializedJourneyState | null; /** * @internal Rehydrate a serialized journey onto this runtime. Returns * `null` (after emitting a `bad` `journey-timeout` event) when the journey * already exhausted its budget in transit. */ restoreJourney(serialized: SerializedJourneyState, config: JourneyDef): JourneyState | null; activeJourneyStep(): StepRecord | null; journeyStep( journey: JourneyStepTarget, stepName: string, fn: (step: StepHandle) => T | Promise, opts?: JourneyStepOptions ): Promise; journeyMountStep( journey: JourneyStepTarget, stepName: string, fn: (step: StepHandle) => T | Promise, opts?: JourneyStepOptions ): Promise; reportBackendRequest( step: StepRecord | undefined, status: number | undefined, durationMs: number, requestKey: string ): void; /** True when this request key is ignored for the step's journey (never changes verdict). */ shouldIgnoreRequest(journey: JourneyState, key: string): boolean; /** Close open journeys, clear ambient steps, restore default timeouts/sinks. */ reset(options?: JourneyRuntimeOptions): void; } /** @internal Bind the fail/exclude/report hooks for a journey's creating engine. */ export function bindJourneyEngine( api: JourneyRuntime, journey: JourneyState, shouldIgnoreRequest: (target: JourneyState, key: string) => boolean ): JourneyEngine { return { failJourney(reason) { api.failJourney(journey, reason); }, exclude() { api.excludeJourney(journey); }, shouldIgnoreRequest(key) { return shouldIgnoreRequest(journey, key); }, reportBackendRequest(step, status, durationMs, requestKey) { api.reportBackendRequest(step, status, durationMs, requestKey); }, getHttpAbortedRequestsPolicy() { return api.getHttpAbortedRequestsPolicy(); }, getHttpClientErrorRequestsPolicy() { return api.getHttpClientErrorRequestsPolicy(); }, }; } /** * @internal Private — not part of the public package API. * Build an isolated engine. Rarely needed — apps use the default via free helpers. */ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): JourneyRuntime { let id = typeof options.id === 'string' && options.id !== '' ? options.id : ''; const journeys = new Map(); const inFlight = new Set(); let sinks: JourneySink[] = [...(options.sinks ?? [])]; let sinksConfigured = false; let overlay: { slowRequests?: SlowRequestConfig; httpAbortedRequests?: RequestPolicyConfig; httpClientErrorRequests?: RequestPolicyConfig; autoAttributeRequests?: boolean; } = {}; let slowRequests: SlowRequestConfig = { defaultMs: options.slowRequests?.defaultMs ?? DEFAULT_SLOW_REQUESTS.defaultMs, endpoints: sortEndpointsByLongestMatch( options.slowRequests?.endpoints ?? DEFAULT_SLOW_REQUESTS.endpoints ), }; let maxSteps = clampMaxSteps(options.maxSteps ?? DEFAULT_MAX_STEPS); let defaultJourneyIdleMs = finiteTimeoutMs( options.defaultJourneyIdleMs ?? DEFAULT_JOURNEY_IDLE_MS ); function getInFlightSteps(journey: JourneyState): StepRecord[] { const out: StepRecord[] = []; for (const step of inFlight) { if (step.journey === journey) { out.push(step); } } return out; } function enterStep(step: StepRecord): void { inFlight.add(step); } function leaveStep(step: StepRecord): void { inFlight.delete(step); } function releaseInFlightSteps(journey: JourneyState): void { for (const step of [...inFlight]) { if (step.journey !== journey) { continue; } if (step.durationMs === 0) { step.durationMs = Math.round(globalThis.performance.now() - step.startedAt); } inFlight.delete(step); } } function clearCountdown(journey: JourneyState): void { if (journey.timer != null) { clearTimeout(journey.timer); journey.timer = null; } } function emit(payload: JourneyEvent): void { const safePayload = sanitizeJourneyEvent(payload); for (const sink of sinks) { try { sink(safePayload); } catch { // One sinking throw must not block the remaining sinks. } } } function finish(journey: JourneyState, outcome: Outcome, reason?: string): void { if (journey.closed) { return; } journey.closed = true; if (journeys.get(journey.name) === journey) { journeys.delete(journey.name); } clearCountdown(journey); releaseInFlightSteps(journey); let finalReason = journey.reason ?? reason; let finalOutcome = outcome; const durationMs = Math.round(globalThis.performance.now() - journey.startedAt); if (journey.timeoutExplicit && journey.timeoutMs > 0 && durationMs > journey.timeoutMs) { finalOutcome = 'bad'; finalReason = finalReason ?? 'journey-timeout'; } const steps: JourneyStepEvent[] = journey.steps.map(s => ({ name: s.name, startMs: Math.round(s.startedAt - journey.startedAt), durationMs: s.durationMs, outcome: s.outcome, ...(s.reason ? { reason: s.reason } : {}), ...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}), ...(Object.keys(s.attributes).length ? { attributes: { ...s.attributes } } : {}), ...(s.requests?.length ? { requests: s.requests.map(request => ({ ...request })) } : {}), ...(s.requestsTruncated ? { requestsTruncated: true } : {}), })); emit({ journey: { name: journey.name, team: journey.team, group: journey.group, service: journey.service, outcome: finalOutcome, ...(finalOutcome === 'bad' && journey.scoringRequest ? { failedRequest: { ...journey.scoringRequest } } : {}), ...(finalReason ? { reason: finalReason } : {}), durationMs, steps, ...(journey.expected ? { expected: journey.expected } : {}), ...(Object.keys(journey.tags).length ? { tags: { ...journey.tags } } : {}), }, }); } function armCountdown(journey: JourneyState, delayMs: number = journey.timeoutMs): void { if (delayMs <= 0) { return; } journey.timer = setTimeout(() => { if (journey.timeoutExplicit) { finish(journey, 'bad', 'journey-timeout'); return; } finish(journey, journey.verdict === 'bad' ? 'bad' : 'excluded', 'journey-idle-timeout'); }, delayMs); } function effectiveSlowRequests(): SlowRequestConfig { return overlay.slowRequests ?? getSharedSlowRequests() ?? slowRequests; } function shouldIgnoreRequest(journey: JourneyState, key: string): boolean { return resolveEndpointPolicy(journey, effectiveSlowRequests(), key).ignore; } function trackAttributedKey(step: StepRecord, requestKey: string): void { if (!requestKey) { return; } step.attributedRequestKeys ??= []; if (step.attributedRequestKeys.length >= MAX_ATTRIBUTED_REQUEST_KEYS) { return; } if (!step.attributedRequestKeys.includes(requestKey)) { step.attributedRequestKeys.push(requestKey); } } function resolveJourneyName(journey: JourneyStepTarget): string | null { if (journey == null) { return null; } if (typeof journey === 'string' || !Array.isArray(journey)) { return toName(journey as JourneyNameLike); } return api.firstOpenJourneyName(journey.map(toName)); } function configFromTarget(target: JourneyNameLike): JourneyDef | undefined { if (typeof target === 'string') { return undefined; } if ('config' in target && target.config != null && typeof target.config === 'object') { return target.config; } const rec = target as { name?: unknown; team?: unknown; group?: unknown; service?: unknown; }; if ( typeof rec.name === 'string' && typeof rec.team === 'string' && typeof rec.group === 'string' && typeof rec.service === 'string' ) { return target as JourneyDef; } return undefined; } function maybeAutoStart(target: JourneyStepTarget): void { if (target == null || typeof target === 'string' || Array.isArray(target)) { return; } const def = configFromTarget(target as JourneyNameLike); if (def && !api.getOpenJourney(def.name)) { api.startJourney(def); } } function createStepHandle(journeyName: string, step: StepRecord): StepHandle { return { journeyName, setAttribute(key, value) { setSafeAttribute(step.attributes, key, value); }, setTags(tags) { if (step.journey.closed) { return; } assignRecord(step.journey.tags, tags, MAX_TAG_KEYS); }, completeJourney(tags) { api.completeJourneyState(step.journey, tags); }, failJourney(reason) { api.failJourney(step.journey, reason); }, // Box in JourneyStepStamp — raw StepRecord is circular and blows jQuery/axios deep-merge. stamp: scoreOrOpts => new JourneyStepStamp(step, resolveStampOpts(scoreOrOpts)), }; } const api: JourneyRuntime = { get id() { return id; }, set id(next: string) { id = next; }, getSlowRequests() { return effectiveSlowRequests(); }, setSlowRequests(next) { slowRequests = { ...next, endpoints: sortEndpointsByLongestMatch(next.endpoints), }; }, getMaxSteps() { return maxSteps; }, setMaxSteps(next) { maxSteps = clampMaxSteps(next); }, getDefaultJourneyIdleMs() { return defaultJourneyIdleMs; }, setDefaultJourneyIdleMs(next) { defaultJourneyIdleMs = finiteTimeoutMs(next); }, setSinks(next) { sinks = [...next]; }, trySetConfiguredSinks(next) { if (sinksConfigured) { return false; } sinks = [...next]; sinksConfigured = true; return true; }, addSink(sink) { sinks.push(sink); }, emit, setPolicyOverlay(next) { overlay = { ...overlay, ...next }; }, getAutoAttributeRequests() { if (overlay.autoAttributeRequests !== undefined) { return overlay.autoAttributeRequests; } return getSharedAutoAttributeRequests(); }, getHttpAbortedRequestsPolicy() { return overlay.httpAbortedRequests ?? getSharedHttpAbortedRequestsPolicy(); }, getHttpClientErrorRequestsPolicy() { return overlay.httpClientErrorRequests ?? getSharedHttpClientErrorRequestsPolicy(); }, getOpenJourney(name) { return journeys.get(name); }, getDebugSnapshot() { const active = api.activeJourneyStep(); return { id: api.id, openJourneys: [...journeys.values()].map(journey => ({ name: journey.name, team: journey.team, group: journey.group, service: journey.service, verdict: journey.verdict, reason: journey.reason, tags: { ...journey.tags }, stepsInFlight: getInFlightSteps(journey).map(step => step.name), steps: journey.steps.map(step => ({ name: step.name, outcome: step.outcome, durationMs: step.durationMs, ...(step.reason ? { reason: step.reason } : {}), ...(step.attributedRequestKeys?.length ? { attributedRequestKeys: [...step.attributedRequestKeys] } : {}), })), })), activeStep: active ? { journey: active.journey.name, name: active.name } : null, policyOverlay: { slowRequests: overlay.slowRequests != null, httpAbortedRequests: overlay.httpAbortedRequests != null, httpClientErrorRequests: overlay.httpClientErrorRequests != null, autoAttributeRequests: overlay.autoAttributeRequests != null, }, }; }, firstOpenJourneyName(names) { for (const name of names) { if (journeys.has(name)) { return name; } } return null; }, setJourneyTags(name, tags) { const journey = name ? journeys.get(name) : undefined; if (journey) { assignRecord(journey.tags, tags, MAX_TAG_KEYS); } }, startJourney(config) { const existing = journeys.get(config.name); if (existing) { finish(existing, existing.verdict === 'bad' ? 'bad' : 'excluded'); } const tags = emptyRecord(); assignRecord(tags, config.tags, MAX_TAG_KEYS); const snapshot = freezeJourneyDef(config, tags); const timeout = resolveJourneyTimeoutMs(snapshot.timeoutMs, defaultJourneyIdleMs); const journey: JourneyState = { config: snapshot, name: snapshot.name, team: snapshot.team, group: snapshot.group, service: snapshot.service, verdict: 'good', startedAt: globalThis.performance.now(), timeoutMs: timeout.ms, timeoutExplicit: timeout.explicit, stepTimeoutMs: snapshot.stepTimeoutMs !== undefined ? finiteTimeoutMs(snapshot.stepTimeoutMs) : undefined, reason: null, steps: [], expected: snapshot.expected ? [...snapshot.expected] : null, tags, slowRequestMs: finiteSlowRequestMs(snapshot.slowRequestMs), endpoints: snapshot.endpoints, timer: null, closed: false, maxSteps: snapshot.maxSteps !== undefined ? clampMaxSteps(snapshot.maxSteps) : undefined, }; journeys.set(config.name, journey); journey[JOURNEY_ENGINE] = bindJourneyEngine(api, journey, shouldIgnoreRequest); armCountdown(journey); }, failJourney(journey, reason) { if (journey.closed) { return; } journey.verdict = 'bad'; journey.reason ??= reason; finish(journey, 'bad'); }, completeJourney(name, tags) { if (!name) { return; } const journey = journeys.get(name); if (journey) { api.completeJourneyState(journey, tags); } }, completeJourneyState(journey, tags) { if (journey.closed) { return; } if (tags) { assignRecord(journey.tags, tags, MAX_TAG_KEYS); } finish(journey, journey.verdict === 'bad' ? 'bad' : 'good'); }, excludeJourney(journey) { if (journey.closed) { return; } finish(journey, journey.verdict === 'bad' ? 'bad' : 'excluded'); }, updateOpenJourney(config) { const journey = journeys.get(config.name); if (!journey || journey.closed) { return; } journey.team = config.team; journey.group = config.group; journey.service = config.service; const timeout = resolveJourneyTimeoutMs(config.timeoutMs, defaultJourneyIdleMs); journey.timeoutMs = timeout.ms; journey.timeoutExplicit = timeout.explicit; journey.stepTimeoutMs = config.stepTimeoutMs !== undefined ? finiteTimeoutMs(config.stepTimeoutMs) : undefined; journey.slowRequestMs = finiteSlowRequestMs(config.slowRequestMs); journey.endpoints = sortEndpointsByLongestMatch(config.endpoints); journey.expected = config.expected ? [...config.expected] : null; journey.maxSteps = config.maxSteps !== undefined ? clampMaxSteps(config.maxSteps) : undefined; if (config.tags) { assignRecord(journey.tags, config.tags, MAX_TAG_KEYS); } clearCountdown(journey); if (journey.timeoutMs <= 0) { return; } const elapsedMs = globalThis.performance.now() - journey.startedAt; const remainingMs = journey.timeoutMs - elapsedMs; if (remainingMs <= 0) { if (journey.timeoutExplicit) { finish(journey, 'bad', 'journey-timeout'); return; } finish( journey, journey.verdict === 'bad' ? 'bad' : 'excluded', 'journey-idle-timeout' ); return; } armCountdown(journey, remainingMs); }, serializeJourneyState(name) { const journey = journeys.get(name); if (!journey || journey.closed) { return null; } return projectJourneyState(journey); }, restoreJourney(serialized, config) { // Refuse double-registration: leave an existing journey under this name in place. if (journeys.has(config.name)) { return null; } const journey = deserializeJourneyState(serialized, config, api, defaultJourneyIdleMs); if (journey.timeoutMs > 0) { const elapsedMs = globalThis.performance.now() - journey.startedAt; if (elapsedMs >= journey.timeoutMs) { // journey not yet in journeys Map — finish() handles unregistered journeys gracefully. if (journey.timeoutExplicit) { finish(journey, 'bad', 'journey-timeout'); } else { finish( journey, journey.verdict === 'bad' ? 'bad' : 'excluded', 'journey-idle-timeout' ); } return null; } journeys.set(config.name, journey); const remainingMs = journey.timeoutMs - elapsedMs; if (remainingMs > 0) { armCountdown(journey, remainingMs); } } else { journeys.set(config.name, journey); } return journey; }, activeJourneyStep() { if (!api.getAutoAttributeRequests()) { return null; } if (inFlight.size !== 1) { return null; } return inFlight.values().next().value ?? null; }, async journeyStep(journey, stepName, fn, opts) { maybeAutoStart(journey); const journeyName = resolveJourneyName(journey); const open = journeyName ? journeys.get(journeyName) : undefined; if (!open || !journeyName) { if (isJourneyDebugEnabled()) { // eslint-disable-next-line no-console -- debug-only untraced hint console.debug(`[journey] step "${stepName}" ran untraced (no open journey)`); } return fn(noopStep); } const step: StepRecord = { name: stepName, startedAt: globalThis.performance.now(), durationMs: 0, outcome: 'good', attributes: emptyRecord(), journey: open, }; const cap = open.maxSteps ?? maxSteps; if (open.steps.length >= cap) { open.tags.steps_truncated = true; } else { open.steps.push(step); } const handle = createStepHandle(journeyName, step); const stepTimeoutMs = opts?.timeoutMs ?? open.stepTimeoutMs ?? 0; let timeoutTimer: ReturnType | null = null; if (stepTimeoutMs > 0) { timeoutTimer = setTimeout(() => { if (open.closed) { return; } step.outcome = 'bad'; step.reason ??= 'step-timeout'; step.attributes.timeout_ms ??= stepTimeoutMs; step.durationMs = Math.round(globalThis.performance.now() - step.startedAt); api.failJourney(open, 'step-timeout'); }, stepTimeoutMs); } enterStep(step); try { return await fn(handle); } catch (err) { if (isCanceledError(err)) { /* * Attributed abort is scored in onHttpComplete. Unattributed / * ignored abort throws must not apply policy (empty requestKey). */ } else { step.outcome = 'bad'; step.reason ??= 'step-error'; api.failJourney(open, 'step-error'); } throw err; } finally { if (timeoutTimer != null) { clearTimeout(timeoutTimer); } if (step.durationMs === 0) { step.durationMs = Math.round(globalThis.performance.now() - step.startedAt); } leaveStep(step); } }, async journeyMountStep(journey, stepName, fn, opts) { await Promise.resolve(); return api.journeyStep(journey, stepName, fn, opts); }, shouldIgnoreRequest, reportBackendRequest(step, status, durationMs, requestKey) { if (!step?.journey) { return; } const journey = step.journey; if (journey.closed) { return; } trackAttributedKey(step, requestKey); const endpointPolicy = resolveEndpointPolicy( journey, effectiveSlowRequests(), requestKey ); if (endpointPolicy.ignore) { return; } const failed = status === undefined || status >= 500; const threshold = endpointPolicy.slowRequestMs; const slow = threshold > 0 && durationMs > threshold; if (!failed && !slow) { return; } const category = failed ? 'request-error' : 'request-latency'; step.outcome = 'bad'; step.reason ??= category; if (failed && status !== undefined) { step.httpStatus = status; } api.failJourney(journey, category); }, reset(next = {}) { for (const journey of [...journeys.values()]) { clearCountdown(journey); journey.closed = true; } journeys.clear(); inFlight.clear(); overlay = {}; sinksConfigured = false; sinks = [...(next.sinks ?? [])]; slowRequests = { defaultMs: next.slowRequests?.defaultMs ?? DEFAULT_SLOW_REQUESTS.defaultMs, endpoints: sortEndpointsByLongestMatch( next.slowRequests?.endpoints ?? DEFAULT_SLOW_REQUESTS.endpoints ), }; maxSteps = next.maxSteps !== undefined ? clampMaxSteps(next.maxSteps) : DEFAULT_MAX_STEPS; defaultJourneyIdleMs = finiteTimeoutMs( next.defaultJourneyIdleMs ?? DEFAULT_JOURNEY_IDLE_MS ); }, }; return api; } /** * @internal Private — not part of the public package API. * Module-scoped engine used by free helpers. App code does not need to touch this. * Each host/MFE bundle gets its own copy when the package is duplicated. */ export const moduleRuntime: JourneyRuntime = createJourneyRuntime(); const noopEngine: JourneyEngine = { failJourney() {}, exclude() {}, shouldIgnoreRequest() { return false; }, reportBackendRequest() {}, getHttpAbortedRequestsPolicy() { return 'continue'; }, getHttpClientErrorRequestsPolicy() { return 'continue'; }, }; /** * True when this bundle can fail/exclude/report the journey: stamped engine, or * this runtime's own unstamped object. False for a foreign older-copy journey. */ export function canRouteHttpToJourney(journey: JourneyState): boolean { return ( journey[JOURNEY_ENGINE] != null || moduleRuntime.getOpenJourney(journey.name) === journey ); } /** * @internal Prefer the creating engine on a journey (MFE `step.stamp()` on a host transport). * Falls back to this bundle's module runtime only when this runtime owns the object. * Unstamped foreign journeys (older package copies) are a no-op so a same-named * host journey is not deleted from this map. */ export function resolveJourneyEngine(journey: JourneyState): JourneyEngine { const stamped = journey[JOURNEY_ENGINE]; if (stamped) { return stamped; } if (moduleRuntime.getOpenJourney(journey.name) !== journey) { return noopEngine; } return { failJourney(reason) { moduleRuntime.failJourney(journey, reason); }, exclude() { moduleRuntime.excludeJourney(journey); }, shouldIgnoreRequest(key) { return moduleRuntime.shouldIgnoreRequest(journey, key); }, reportBackendRequest(step, status, durationMs, requestKey) { moduleRuntime.reportBackendRequest(step, status, durationMs, requestKey); }, getHttpAbortedRequestsPolicy() { return moduleRuntime.getHttpAbortedRequestsPolicy(); }, getHttpClientErrorRequestsPolicy() { return moduleRuntime.getHttpClientErrorRequestsPolicy(); }, }; } /** * @internal Reset the module engine (open journeys, ambient steps, sinks, slow-request thresholds). * Intended for tests — call in `beforeEach` / `afterEach` instead of manual cleanup. */ export function resetJourney(options?: JourneyRuntimeOptions): void { moduleRuntime.reset(options); }