@servicetitan/journey 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ import {
2
+ clampMaxSteps,
3
+ finiteSlowRequestMs,
4
+ finiteTimeoutMs,
5
+ MAX_ATTRIBUTE_KEYS,
6
+ MAX_TAG_KEYS,
7
+ resolveJourneyTimeoutMs,
8
+ } from './limits';
9
+ import { assignRecord, emptyRecord } from './sanitize';
10
+ import { bindJourneyEngine, freezeJourneyDef, type JourneyRuntime } from './runtime';
11
+ import {
12
+ JOURNEY_ENGINE,
13
+ type JourneyDef,
14
+ type JourneyState,
15
+ type SerializedJourneyState,
16
+ type StepRecord,
17
+ } from './types';
18
+
19
+ /**
20
+ * @internal Project an open {@link JourneyState} into a plain, JSON-safe
21
+ * {@link SerializedJourneyState} that can ride a cookie across page navigations.
22
+ * The step projection mirrors `finish()`; `startedAt` (a `performance.now()`
23
+ * monotonic stamp) is bridged to a wall-clock epoch so it survives the page
24
+ * load where `performance.now()` resets.
25
+ */
26
+ export function projectJourneyState(journey: JourneyState): SerializedJourneyState {
27
+ const steps = journey.steps.map(s => ({
28
+ name: s.name,
29
+ startMs: Math.round(s.startedAt - journey.startedAt),
30
+ durationMs: s.durationMs || Math.round(globalThis.performance.now() - s.startedAt),
31
+ outcome: s.outcome,
32
+ ...(s.reason ? { reason: s.reason } : {}),
33
+ ...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}),
34
+ // Attributes are raw here — final emission sanitization happens in finish().
35
+ ...(Object.keys(s.attributes).length ? { attributes: { ...s.attributes } } : {}),
36
+ }));
37
+
38
+ const startedAtEpoch = Date.now() - (globalThis.performance.now() - journey.startedAt);
39
+ // Coerce any non-null st_journey_id tag to a string so a numeric/boolean id survives the round-trip.
40
+ const stJourneyId =
41
+ journey.tags.st_journey_id != null ? String(journey.tags.st_journey_id) : '';
42
+
43
+ return {
44
+ stJourneyId,
45
+ startedAtEpoch,
46
+ // Open journeys are never 'excluded' — that verdict is only set inside finish().
47
+ verdict: journey.verdict === 'bad' ? 'bad' : 'good',
48
+ reason: journey.reason,
49
+ steps,
50
+ tags: { ...journey.tags },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * @internal Rehydrate a {@link SerializedJourneyState} into a live
56
+ * {@link JourneyState} bound to the supplied runtime. `startedAt` is rebased
57
+ * from the wall-clock epoch back to a `performance.now()` monotonic stamp,
58
+ * `StepRecord[]` is rebuilt with re-linked circular references, and the
59
+ * `JOURNEY_ENGINE` hook is re-bound so HTTP attribution works on the new page.
60
+ */
61
+ export function deserializeJourneyState(
62
+ serialized: SerializedJourneyState,
63
+ config: JourneyDef,
64
+ api: JourneyRuntime,
65
+ defaultJourneyIdleMs: number
66
+ ): JourneyState {
67
+ const tags = emptyRecord();
68
+ // Caller config tags win; serialized tags fill the rest (incl. st_journey_id).
69
+ assignRecord(tags, serialized.tags, MAX_TAG_KEYS);
70
+ assignRecord(tags, config.tags, MAX_TAG_KEYS);
71
+ if (serialized.stJourneyId && tags.st_journey_id === undefined) {
72
+ tags.st_journey_id = serialized.stJourneyId;
73
+ }
74
+
75
+ const snapshot = freezeJourneyDef(config, tags);
76
+ const timeout = resolveJourneyTimeoutMs(snapshot.timeoutMs, defaultJourneyIdleMs);
77
+
78
+ const startedAt = globalThis.performance.now() - (Date.now() - serialized.startedAtEpoch);
79
+
80
+ const journey: JourneyState = {
81
+ config: snapshot,
82
+ name: snapshot.name,
83
+ team: snapshot.team,
84
+ group: snapshot.group,
85
+ service: snapshot.service,
86
+ verdict: serialized.verdict === 'bad' ? 'bad' : 'good',
87
+ startedAt,
88
+ timeoutMs: timeout.ms,
89
+ timeoutExplicit: timeout.explicit,
90
+ stepTimeoutMs:
91
+ snapshot.stepTimeoutMs !== undefined
92
+ ? finiteTimeoutMs(snapshot.stepTimeoutMs)
93
+ : undefined,
94
+ reason: serialized.reason,
95
+ steps: [],
96
+ expected: snapshot.expected ? [...snapshot.expected] : null,
97
+ tags,
98
+ slowRequestMs: finiteSlowRequestMs(snapshot.slowRequestMs),
99
+ endpoints: snapshot.endpoints,
100
+ timer: null,
101
+ closed: false,
102
+ maxSteps: snapshot.maxSteps !== undefined ? clampMaxSteps(snapshot.maxSteps) : undefined,
103
+ };
104
+
105
+ // Rebuild StepRecord[] — re-link the circular `step.journey` reference and rebase `startedAt` from the offset.
106
+ for (const s of serialized.steps) {
107
+ const step: StepRecord = {
108
+ name: s.name,
109
+ startedAt: journey.startedAt + s.startMs,
110
+ durationMs: s.durationMs,
111
+ outcome: s.outcome,
112
+ attributes: emptyRecord(),
113
+ journey,
114
+ };
115
+ if (s.reason !== undefined) {
116
+ step.reason = s.reason;
117
+ }
118
+ if (s.httpStatus !== undefined) {
119
+ step.httpStatus = s.httpStatus;
120
+ }
121
+ if (s.attributes) {
122
+ assignRecord(step.attributes, s.attributes, MAX_ATTRIBUTE_KEYS);
123
+ }
124
+ journey.steps.push(step);
125
+ }
126
+
127
+ // Re-bind the engine so HTTP attribution / fail-fast works on this page.
128
+ journey[JOURNEY_ENGINE] = bindJourneyEngine(api, journey, api.shouldIgnoreRequest);
129
+
130
+ return journey;
131
+ }
@@ -11,6 +11,7 @@ import {
11
11
  sortEndpointsByLongestMatch,
12
12
  type SlowRequestConfig,
13
13
  } from './endpoint-policy';
14
+ import { deserializeJourneyState, projectJourneyState } from './persist';
14
15
  import {
15
16
  clampMaxSteps,
16
17
  DEFAULT_MAX_STEPS,
@@ -36,6 +37,7 @@ import {
36
37
  type JourneyStepOptions,
37
38
  type JourneyStepTarget,
38
39
  type Outcome,
40
+ type SerializedJourneyState,
39
41
  type StepHandle,
40
42
  type StepRecord,
41
43
  type TagValue,
@@ -106,7 +108,8 @@ export interface JourneyRuntimeDebugSnapshot {
106
108
  };
107
109
  }
108
110
 
109
- function freezeJourneyDef(config: JourneyDef, tags: Record<string, TagValue>): JourneyDef {
111
+ /** @internal Freeze a caller-supplied journey def + tags snapshot. */
112
+ export function freezeJourneyDef(config: JourneyDef, tags: Record<string, TagValue>): JourneyDef {
110
113
  const endpoints = sortEndpointsByLongestMatch(config.endpoints);
111
114
  return Object.freeze({
112
115
  ...config,
@@ -164,6 +167,17 @@ export interface JourneyRuntime {
164
167
  completeJourneyState(journey: JourneyState, tags?: Record<string, TagValue>): void;
165
168
  excludeJourney(journey: JourneyState): void;
166
169
  updateOpenJourney(config: JourneyDef): void;
170
+ /**
171
+ * @internal Snapshot an open journey for cross-page transfer. Does NOT
172
+ * close the journey. Returns `null` when no journey is open under `name`.
173
+ */
174
+ serializeJourneyState(name: string): SerializedJourneyState | null;
175
+ /**
176
+ * @internal Rehydrate a serialized journey onto this runtime. Returns
177
+ * `null` (after emitting a `bad` `journey-timeout` event) when the journey
178
+ * already exhausted its budget in transit.
179
+ */
180
+ restoreJourney(serialized: SerializedJourneyState, config: JourneyDef): JourneyState | null;
167
181
 
168
182
  activeJourneyStep(): StepRecord | null;
169
183
  journeyStep<T>(
@@ -192,7 +206,8 @@ export interface JourneyRuntime {
192
206
  reset(options?: JourneyRuntimeOptions): void;
193
207
  }
194
208
 
195
- function bindJourneyEngine(
209
+ /** @internal Bind the fail/exclude/report hooks for a journey's creating engine. */
210
+ export function bindJourneyEngine(
196
211
  api: JourneyRuntime,
197
212
  journey: JourneyState,
198
213
  shouldIgnoreRequest: (target: JourneyState, key: string) => boolean
@@ -685,6 +700,51 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
685
700
  armCountdown(journey, remainingMs);
686
701
  },
687
702
 
703
+ serializeJourneyState(name) {
704
+ const journey = journeys.get(name);
705
+ if (!journey || journey.closed) {
706
+ return null;
707
+ }
708
+ return projectJourneyState(journey);
709
+ },
710
+
711
+ restoreJourney(serialized, config) {
712
+ // Refuse double-registration: leave an existing journey under this name in place.
713
+ if (journeys.has(config.name)) {
714
+ return null;
715
+ }
716
+
717
+ const journey = deserializeJourneyState(serialized, config, api, defaultJourneyIdleMs);
718
+
719
+ if (journey.timeoutMs > 0) {
720
+ const elapsedMs = globalThis.performance.now() - journey.startedAt;
721
+ if (elapsedMs >= journey.timeoutMs) {
722
+ // journey not yet in journeys Map — finish() handles unregistered journeys gracefully.
723
+ if (journey.timeoutExplicit) {
724
+ finish(journey, 'bad', 'journey-timeout');
725
+ } else {
726
+ finish(
727
+ journey,
728
+ journey.verdict === 'bad' ? 'bad' : 'excluded',
729
+ 'journey-idle-timeout'
730
+ );
731
+ }
732
+ return null;
733
+ }
734
+
735
+ journeys.set(config.name, journey);
736
+
737
+ const remainingMs = journey.timeoutMs - elapsedMs;
738
+ if (remainingMs > 0) {
739
+ armCountdown(journey, remainingMs);
740
+ }
741
+ } else {
742
+ journeys.set(config.name, journey);
743
+ }
744
+
745
+ return journey;
746
+ },
747
+
688
748
  activeJourneyStep() {
689
749
  if (!api.getAutoAttributeRequests()) {
690
750
  return null;
package/src/core/types.ts CHANGED
@@ -163,6 +163,32 @@ export interface JourneyStepEvent {
163
163
  attributes?: Record<string, TagValue>;
164
164
  }
165
165
 
166
+ /**
167
+ * Snapshot of an open journey that can survive a page navigation. Produced by
168
+ * `serializeJourneyState` and rehydrated by `restoreJourney`. Mirrors the
169
+ * `JourneyStepEvent` projection emitted by `finish()`, plus the wall-clock
170
+ * start time, verdict, reason, and tags needed to re-arm the countdown.
171
+ */
172
+ export interface SerializedJourneyState {
173
+ /** `st_journey_id` tag — cross-page correlation key. Empty string when unset. */
174
+ stJourneyId: string;
175
+ /** Wall-clock epoch (Date.now()) when the journey started. */
176
+ startedAtEpoch: number;
177
+ verdict: 'good' | 'bad';
178
+ reason: string | null;
179
+ steps: {
180
+ name: string;
181
+ /** Offset from the journey start, ms. */
182
+ startMs: number;
183
+ durationMs: number;
184
+ outcome: 'good' | 'bad';
185
+ reason?: string;
186
+ httpStatus?: number;
187
+ attributes?: Record<string, TagValue>;
188
+ }[];
189
+ tags: Record<string, TagValue>;
190
+ }
191
+
166
192
  /** Neutral journey event payload (journey-specific fields only; session context is host RUM globals). */
167
193
  export interface JourneyEvent {
168
194
  journey: {
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export {
13
13
  excludeJourney,
14
14
  failJourney,
15
15
  journeyStep,
16
+ restoreJourney,
17
+ serializeJourneyState,
16
18
  setJourneyTags,
17
19
  // Custom transport adapters / request attribution.
18
20
  singleInFlightJourneyStep,
@@ -23,6 +25,7 @@ export {
23
25
  type JourneyNameLike,
24
26
  type JourneyStepOptions,
25
27
  type JourneyStepTarget,
28
+ type SerializedJourneyState,
26
29
  type StepHandle,
27
30
  } from './core';
28
31
 
@@ -1,5 +1,3 @@
1
- import { datadogRum } from '@datadog/browser-rum';
2
-
3
1
  import { isSanitizedJourneyEvent, sanitizeJourneyEvent } from '../core/sanitize';
4
2
  import { getJourneyRumFallback, setJourneyRumFallback } from '../global';
5
3
  import type { JourneyEvent, JourneyStepEvent } from '../core/types';
@@ -10,8 +8,16 @@ const JOURNEY_ACTION = 'user_journey';
10
8
 
11
9
  const DD_RUM_KEY = 'DD_RUM';
12
10
 
13
- function getDdRum(): typeof datadogRum | undefined {
14
- return (globalThis as Record<string, unknown>)[DD_RUM_KEY] as typeof datadogRum | undefined;
11
+ /**
12
+ * Not imported from @datadog/browser-rum on purpose: importing it sets globalThis.DD_RUM at
13
+ * module load, which lets an MFE bundle replace the host's initialized RUM instance.
14
+ */
15
+ interface RumLike {
16
+ addAction(name: string, context?: object): void;
17
+ }
18
+
19
+ function getDdRum(): RumLike | undefined {
20
+ return (globalThis as Record<string, unknown>)[DD_RUM_KEY] as RumLike | undefined;
15
21
  }
16
22
 
17
23
  /**
@@ -20,7 +26,7 @@ function getDdRum(): typeof datadogRum | undefined {
20
26
  *
21
27
  * Prefer exposing the initialized instance as `globalThis.DD_RUM` (ST host:
22
28
  * `datadogGuard`). `sendToDatadog` resolves `DD_RUM` first, then `__stJourney.rum`
23
- * (this fallback), then the bundle's imported `datadogRum`.
29
+ * (this fallback); with neither present the event is dropped.
24
30
  *
25
31
  * Calling `setJourneyRum` while `globalThis.DD_RUM` is already set logs a warning —
26
32
  * the call is redundant because `sendToDatadog` prefers `DD_RUM`.
@@ -35,13 +41,8 @@ export function setJourneyRum(rum: unknown): void {
35
41
  setJourneyRumFallback(rum);
36
42
  }
37
43
 
38
- function resolveRum(): typeof datadogRum {
39
- const ddRum = getDdRum();
40
- if (ddRum) {
41
- return ddRum;
42
- }
43
- const shared = getJourneyRumFallback();
44
- return (shared as typeof datadogRum | undefined) ?? datadogRum;
44
+ function resolveRum(): RumLike | undefined {
45
+ return getDdRum() ?? (getJourneyRumFallback() as RumLike | undefined);
45
46
  }
46
47
 
47
48
  function toDatadogStep(step: JourneyStepEvent) {
@@ -63,8 +64,7 @@ function toDatadogStep(step: JourneyStepEvent) {
63
64
  /** Ship one terminal journey as a RUM custom action (`user_journey`). */
64
65
  export function sendToDatadog(payload: JourneyEvent): void {
65
66
  const rum = resolveRum();
66
- // Skip bad/missing host stubs — uninit RUM still buffers addAction safely.
67
- if (typeof rum.addAction !== 'function') {
67
+ if (!rum || typeof rum.addAction !== 'function') {
68
68
  return;
69
69
  }
70
70