import type { SlowRequestConfig } from './core/endpoint-policy'; import { type RequestPolicyConfig } from './core/request-policy'; import type { JourneyRuntime, JourneyRuntimeDebugSnapshot } from './core/runtime'; /** Force-enable journey host wiring outside go* hosts (`__stJourney.enable()`). */ export const JOURNEY_ENABLED_STORAGE_KEY = 'st:journey:enabled'; /** Opt-in journey console logging (`__stJourney.enableDebugging()`). */ export const JOURNEY_DEBUG_STORAGE_KEY = 'st:journey:debug'; /** @internal Shared cross-bundle bag on globalThis (`rum` fallback + DevTools helpers). */ export const ST_JOURNEY_GLOBAL_KEY = '__stJourney'; /** Bump when shared bag shape / policy semantics change incompatibly. */ export const ST_JOURNEY_SCHEMA_VERSION = 2; export interface JourneyDebugState { /** Host default or force-enable (`isJourneyEnabled()`). */ enabled: boolean; /** Console-logging flag (`st:journey:debug`). */ debug: boolean; hasRum: boolean; runtimes: JourneyRuntimeDebugSnapshot[]; } /** Shared host policies visible to every package copy on the page. */ export interface JourneySharedPolicies { slowRequests?: SlowRequestConfig; /** Whether shared slowRequests were explicitly set via configureJourney. */ slowRequestsConfigured: boolean; httpAbortedRequests: RequestPolicyConfig; httpClientErrorRequests: RequestPolicyConfig; autoAttributeRequests: boolean; /** True after the first configureJourney that set any shared policy knob. */ locked: boolean; } /** DevTools registration — `{ id, getSnapshot }` so older package copies can still list runtimes. */ export interface RuntimeRegistration { id: string; getSnapshot: () => JourneyRuntimeDebugSnapshot; teardown?: () => void; } /** @internal Restore/dispose record for HTTP instrumentation. */ export interface InstrumentRecord { disposed: boolean; restore?: () => void; } /** * Cross-bundle surface on `globalThis.__stJourney`. * Prefer importing `isJourneyEnabled` / `isJourneyDebugEnabled` from the package in app code. */ export interface StJourneyGlobal { /** * Fallback RUM instance from `setJourneyRum` when `globalThis.DD_RUM` is absent. * Prefer `DD_RUM` (ST host: `datadogGuard`). */ rum?: unknown; /** Shared bag schema; mismatched copies warn but continue. */ schemaVersion?: number; /** Host default enable (e.g. RUM active). Set via `setJourneyDefaultEnabled`. */ defaultEnabled?: boolean; /** Shared configureJourney policies. */ policies: JourneySharedPolicies; /** * Bundle runtimes registered for DevTools `getDebugState()`. * `{ id, getSnapshot }` so mixed package copies can list each other. */ runtimeRegistrations: RuntimeRegistration[]; /** @internal Cross-bundle instrumented axios/fetch/jquery targets. */ instrumentedAxios: WeakSet; instrumentedFetch: WeakSet; instrumentedJquery: WeakSet; /** @internal Active instrument records for teardown. */ instrumentRecords: InstrumentRecord[]; fetchRestore?: () => void; enable(): void; disable(): void; isEnabled(): boolean; enableDebugging(): void; disableDebugging(): void; isDebugEnabled(): boolean; /** * Open-journey snapshot for DevTools. Returns empty `runtimes` unless debugging * is enabled (`enableDebugging()` / `st:journey:debug`). */ getDebugState(): JourneyDebugState; } let warnedMissingSlowRequests = false; let debugEnabledCache: boolean | undefined; const MAX_INSTRUMENT_RECORDS = 32; function storageFor(key: string): Storage | undefined { return key === JOURNEY_DEBUG_STORAGE_KEY ? globalThis.sessionStorage : globalThis.localStorage; } function readFlag(key: string): boolean { try { return storageFor(key)?.getItem(key) === 'true'; } catch { return false; } } function writeFlag(key: string, on: boolean): void { try { const storage = storageFor(key); if (on) { storage?.setItem(key, 'true'); } else { storage?.removeItem(key); } } catch { // ignore storage failures (privacy mode, non-browser) } } /** * Host default for journey wiring (e.g. RUM initialized). Stored on `__stJourney` so * MFEs see the same value without importing Datadog. Call once after RUM init. */ export function setJourneyDefaultEnabled(enabled: boolean): void { ensureGlobal().defaultEnabled = enabled === true; } /** * True when the host default is on or force-enable is set * (`st:journey:enabled` / `__stJourney.enable()`). */ export function isJourneyEnabled(): boolean { return ensureGlobal().defaultEnabled === true || readFlag(JOURNEY_ENABLED_STORAGE_KEY); } /** Set the force-enable flag. Reload after calling from DevTools. */ export function enableJourney(): void { writeFlag(JOURNEY_ENABLED_STORAGE_KEY, true); } /** Clear the force-enable flag. Reload after calling from DevTools. */ export function disableJourney(): void { writeFlag(JOURNEY_ENABLED_STORAGE_KEY, false); } /** True when `sessionStorage['st:journey:debug'] === 'true'`. */ export function isJourneyDebugEnabled(): boolean { const next = readFlag(JOURNEY_DEBUG_STORAGE_KEY); if (debugEnabledCache !== next) { debugEnabledCache = next; } return debugEnabledCache; } /** Set the console-debug flag. Reload after calling from DevTools. */ export function enableJourneyDebugging(): void { writeFlag(JOURNEY_DEBUG_STORAGE_KEY, true); debugEnabledCache = true; } /** Clear the console-debug flag. Reload after calling from DevTools. */ export function disableJourneyDebugging(): void { writeFlag(JOURNEY_DEBUG_STORAGE_KEY, false); debugEnabledCache = false; } function hasRum(): boolean { return ( (globalThis as Record).DD_RUM != null || getJourneyRumFallback() != null ); } function snapshotOf(entry: unknown): JourneyRuntimeDebugSnapshot | undefined { if (entry == null || typeof entry !== 'object') { return undefined; } const rec = entry as { id?: string; getSnapshot?: () => JourneyRuntimeDebugSnapshot; getDebugSnapshot?: () => JourneyRuntimeDebugSnapshot; }; try { if (typeof rec.getSnapshot === 'function') { const snap = rec.getSnapshot(); const withId = snap.id ? snap : { ...snap, id: rec.id ?? '' }; return { ...withId, policyOverlay: withId.policyOverlay ?? { slowRequests: false, httpAbortedRequests: false, httpClientErrorRequests: false, autoAttributeRequests: false, }, }; } if (typeof rec.getDebugSnapshot === 'function') { return rec.getDebugSnapshot(); } } catch { return undefined; } return undefined; } function buildDebugState(runtimes: readonly unknown[]): JourneyDebugState { if (!isJourneyDebugEnabled()) { return { enabled: isJourneyEnabled(), debug: false, hasRum: hasRum(), runtimes: [], }; } return { enabled: isJourneyEnabled(), debug: true, hasRum: hasRum(), runtimes: runtimes .map(snapshotOf) .filter((snap): snap is JourneyRuntimeDebugSnapshot => snap != null), }; } function createEmptyPolicies(): JourneySharedPolicies { return { slowRequestsConfigured: false, httpAbortedRequests: 'continue', httpClientErrorRequests: 'continue', autoAttributeRequests: true, locked: false, }; } function ensureGlobal(): StJourneyGlobal { const g = globalThis as Record; const existing = g[ST_JOURNEY_GLOBAL_KEY] as StJourneyGlobal | undefined; // Reuse when a prior load already installed the full surface (incl. enable). if (existing?.runtimeRegistrations && typeof existing.enable === 'function') { if ( existing.schemaVersion != null && existing.schemaVersion !== ST_JOURNEY_SCHEMA_VERSION ) { // eslint-disable-next-line no-console -- cross-bundle version skew console.warn( `[journey] __stJourney.schemaVersion mismatch: bag=${existing.schemaVersion}, package=${ST_JOURNEY_SCHEMA_VERSION}` ); } else { existing.schemaVersion ??= ST_JOURNEY_SCHEMA_VERSION; } existing.policies ??= createEmptyPolicies(); if (existing.policies.locked !== true && existing.policies.locked !== false) { existing.policies.locked = false; } existing.instrumentedAxios ??= new WeakSet(); existing.instrumentedFetch ??= new WeakSet(); existing.instrumentedJquery ??= new WeakSet(); existing.instrumentRecords ??= []; return existing; } const runtimes: RuntimeRegistration[] = existing?.runtimeRegistrations ?? []; const api: StJourneyGlobal = { rum: existing?.rum, schemaVersion: ST_JOURNEY_SCHEMA_VERSION, defaultEnabled: existing?.defaultEnabled === true, runtimeRegistrations: runtimes, policies: createEmptyPolicies(), instrumentedAxios: new WeakSet(), instrumentedFetch: new WeakSet(), instrumentedJquery: new WeakSet(), instrumentRecords: [], enable: enableJourney, disable: disableJourney, isEnabled: isJourneyEnabled, enableDebugging: enableJourneyDebugging, disableDebugging: disableJourneyDebugging, isDebugEnabled: isJourneyDebugEnabled, getDebugState() { return buildDebugState(api.runtimeRegistrations); }, }; g[ST_JOURNEY_GLOBAL_KEY] = api; return api; } /** @internal Shared policies bag. */ export function getSharedPolicies(): JourneySharedPolicies { return ensureGlobal().policies; } export function areSharedPoliciesLocked(): boolean { return ensureGlobal().policies.locked === true; } function freezePolicies(policies: JourneySharedPolicies): JourneySharedPolicies { const next: JourneySharedPolicies = { ...policies, locked: true }; if (next.slowRequests) { const endpoints = next.slowRequests.endpoints ? Object.freeze([...next.slowRequests.endpoints]) : next.slowRequests.endpoints; next.slowRequests = Object.freeze({ ...next.slowRequests, endpoints }); } return Object.freeze(next); } /** * First writer: merge `patch` into the shared bag and freeze it. * Returns false when already locked (caller should overlay locally instead). */ export function seedSharedPolicies(patch: Partial>): boolean { const api = ensureGlobal(); if (api.policies.locked) { return false; } api.policies = freezePolicies({ ...api.policies, ...patch }); return true; } export function getAutoAttributeRequests(): boolean { return ensureGlobal().policies.autoAttributeRequests !== false; } export function getHttpAbortedRequestsPolicy(): RequestPolicyConfig { return ensureGlobal().policies.httpAbortedRequests; } export function getHttpClientErrorRequestsPolicy(): RequestPolicyConfig { return ensureGlobal().policies.httpClientErrorRequests; } export function getSharedSlowRequests(): SlowRequestConfig | undefined { return ensureGlobal().policies.slowRequests; } export function areSharedSlowRequestsConfigured(): boolean { return ensureGlobal().policies.slowRequestsConfigured === true; } /** One-time warn when transports are wired without explicit slowRequests. */ export function warnIfSlowRequestsUnset(): void { if (warnedMissingSlowRequests || areSharedSlowRequestsConfigured()) { return; } warnedMissingSlowRequests = true; // eslint-disable-next-line no-console -- host config guidance console.warn( '[journey] using default slowRequests.defaultMs=4000 — set configureJourney({ slowRequests }) to tune' ); } /** @internal Reset shared policies (tests / teardown). */ export function resetSharedPolicies(): void { const api = ensureGlobal(); api.policies = createEmptyPolicies(); warnedMissingSlowRequests = false; debugEnabledCache = undefined; } export function getInstrumentedAxiosSet(): WeakSet { return ensureGlobal().instrumentedAxios; } export function getInstrumentedFetchSet(): WeakSet { return ensureGlobal().instrumentedFetch; } export function getInstrumentedJquerySet(): WeakSet { return ensureGlobal().instrumentedJquery; } export function registerInstrumentRecord(record: InstrumentRecord): void { const api = ensureGlobal(); if (api.instrumentRecords.length >= MAX_INSTRUMENT_RECORDS) { // eslint-disable-next-line no-console -- defensive cap for repeated instrumentation console.warn('[journey] instrumentRecords cap reached — call teardownJourney()'); return; } api.instrumentRecords.push(record); } export function setFetchRestore(restore: (() => void) | undefined): void { ensureGlobal().fetchRestore = restore; } /** * @internal Register this bundle's engine on `__stJourney` so DevTools can list * every host/MFE copy on the page. Auto-assigns `runtime-N` when `runtime.id` is empty. * Replaces an existing registration with the same id (HMR / test re-register). */ export function registerJourneyRuntime( runtime: JourneyRuntime, options?: { teardown?: () => void } ): void { const bag = ensureGlobal(); if (runtime.id === '') { (runtime as { id: string }).id = `runtime-${bag.runtimeRegistrations.length + 1}`; } const entry: RuntimeRegistration = { id: runtime.id, getSnapshot: () => runtime.getDebugSnapshot(), teardown: options?.teardown, }; const existing = bag.runtimeRegistrations.findIndex(reg => reg.id === runtime.id); if (existing >= 0) { bag.runtimeRegistrations[existing] = entry; } else { bag.runtimeRegistrations.push(entry); } } /** * @internal Store a fallback RUM instance on `__stJourney.rum` when `DD_RUM` is absent. * Used by `setJourneyRum`. */ export function setJourneyRumFallback(rum: unknown): void { ensureGlobal().rum = rum; } /** @internal Read `__stJourney.rum` (setJourneyRum fallback). */ export function getJourneyRumFallback(): unknown { const existing = (globalThis as Record)[ST_JOURNEY_GLOBAL_KEY] as StJourneyGlobal | undefined; return existing?.rum; } /** * Dispose HTTP instrumentation hooks and reset registered engines that supplied a * teardown callback. Intended for tests (e.g. afterEach). Prefer `resetJourney` * when you only need this bundle's engine state. */ export function teardownJourney(): void { const api = ensureGlobal(); for (const record of api.instrumentRecords) { record.disposed = true; try { record.restore?.(); } catch { // ignore restore failures } } api.instrumentRecords = []; try { api.fetchRestore?.(); } catch { // ignore } api.fetchRestore = undefined; const kept: RuntimeRegistration[] = []; for (const entry of api.runtimeRegistrations) { if (entry.teardown) { try { entry.teardown(); } catch { // ignore } continue; } kept.push(entry); } api.runtimeRegistrations = kept; }