import { clampMaxSteps, finiteSlowRequestMs, finiteTimeoutMs, MAX_ATTRIBUTE_KEYS, MAX_TAG_KEYS, resolveJourneyTimeoutMs, } from './limits'; import { assignRecord, emptyRecord } from './sanitize'; import { bindJourneyEngine, freezeJourneyDef, type JourneyRuntime } from './runtime'; import { JOURNEY_ENGINE, type JourneyDef, type JourneyState, type SerializedJourneyState, type StepRecord, } from './types'; /** * @internal Project an open {@link JourneyState} into a plain, JSON-safe * {@link SerializedJourneyState} that can ride a cookie across page navigations. * The step projection mirrors `finish()`; `startedAt` (a `performance.now()` * monotonic stamp) is bridged to a wall-clock epoch so it survives the page * load where `performance.now()` resets. */ export function projectJourneyState(journey: JourneyState): SerializedJourneyState { const steps = journey.steps.map(s => ({ name: s.name, startMs: Math.round(s.startedAt - journey.startedAt), durationMs: s.durationMs || Math.round(globalThis.performance.now() - s.startedAt), outcome: s.outcome, ...(s.reason ? { reason: s.reason } : {}), ...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}), // Attributes are raw here — final emission sanitization happens in finish(). ...(Object.keys(s.attributes).length ? { attributes: { ...s.attributes } } : {}), })); const startedAtEpoch = Date.now() - (globalThis.performance.now() - journey.startedAt); // Coerce any non-null st_journey_id tag to a string so a numeric/boolean id survives the round-trip. const stJourneyId = journey.tags.st_journey_id != null ? String(journey.tags.st_journey_id) : ''; return { stJourneyId, startedAtEpoch, // Open journeys are never 'excluded' — that verdict is only set inside finish(). verdict: journey.verdict === 'bad' ? 'bad' : 'good', reason: journey.reason, steps, tags: { ...journey.tags }, }; } /** * @internal Rehydrate a {@link SerializedJourneyState} into a live * {@link JourneyState} bound to the supplied runtime. `startedAt` is rebased * from the wall-clock epoch back to a `performance.now()` monotonic stamp, * `StepRecord[]` is rebuilt with re-linked circular references, and the * `JOURNEY_ENGINE` hook is re-bound so HTTP attribution works on the new page. */ export function deserializeJourneyState( serialized: SerializedJourneyState, config: JourneyDef, api: JourneyRuntime, defaultJourneyIdleMs: number ): JourneyState { const tags = emptyRecord(); // Caller config tags win; serialized tags fill the rest (incl. st_journey_id). assignRecord(tags, serialized.tags, MAX_TAG_KEYS); assignRecord(tags, config.tags, MAX_TAG_KEYS); if (serialized.stJourneyId && tags.st_journey_id === undefined) { tags.st_journey_id = serialized.stJourneyId; } const snapshot = freezeJourneyDef(config, tags); const timeout = resolveJourneyTimeoutMs(snapshot.timeoutMs, defaultJourneyIdleMs); const startedAt = globalThis.performance.now() - (Date.now() - serialized.startedAtEpoch); const journey: JourneyState = { config: snapshot, name: snapshot.name, team: snapshot.team, group: snapshot.group, service: snapshot.service, verdict: serialized.verdict === 'bad' ? 'bad' : 'good', startedAt, timeoutMs: timeout.ms, timeoutExplicit: timeout.explicit, stepTimeoutMs: snapshot.stepTimeoutMs !== undefined ? finiteTimeoutMs(snapshot.stepTimeoutMs) : undefined, reason: serialized.reason, 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, }; // Rebuild StepRecord[] — re-link the circular `step.journey` reference and rebase `startedAt` from the offset. for (const s of serialized.steps) { const step: StepRecord = { name: s.name, startedAt: journey.startedAt + s.startMs, durationMs: s.durationMs, outcome: s.outcome, attributes: emptyRecord(), journey, }; if (s.reason !== undefined) { step.reason = s.reason; } if (s.httpStatus !== undefined) { step.httpStatus = s.httpStatus; } if (s.attributes) { assignRecord(step.attributes, s.attributes, MAX_ATTRIBUTE_KEYS); } journey.steps.push(step); } // Re-bind the engine so HTTP attribution / fail-fast works on this page. journey[JOURNEY_ENGINE] = bindJourneyEngine(api, journey, api.shouldIgnoreRequest); return journey; }