@servicetitan/journey 2.1.0 → 3.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.
Files changed (163) hide show
  1. package/README.md +1 -1
  2. package/dist/__test-utils__/test-runtime.d.ts.map +1 -1
  3. package/dist/__test-utils__/test-runtime.js +9 -2
  4. package/dist/__test-utils__/test-runtime.js.map +1 -1
  5. package/dist/__tests__/core/sanitize.test.d.ts +2 -0
  6. package/dist/__tests__/core/sanitize.test.d.ts.map +1 -0
  7. package/dist/__tests__/hardening.test.d.ts +2 -0
  8. package/dist/__tests__/hardening.test.d.ts.map +1 -0
  9. package/dist/__tests__/integrations/host-mfe.test.d.ts +2 -0
  10. package/dist/__tests__/integrations/host-mfe.test.d.ts.map +1 -0
  11. package/dist/__tests__/integrations/use-journey-step.test.d.ts +2 -0
  12. package/dist/__tests__/integrations/use-journey-step.test.d.ts.map +1 -0
  13. package/dist/config.d.ts +57 -13
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +87 -24
  16. package/dist/config.js.map +1 -1
  17. package/dist/core/abort-error.d.ts +4 -0
  18. package/dist/core/abort-error.d.ts.map +1 -0
  19. package/dist/core/abort-error.js +22 -0
  20. package/dist/core/abort-error.js.map +1 -0
  21. package/dist/core/api.d.ts +22 -9
  22. package/dist/core/api.d.ts.map +1 -1
  23. package/dist/core/api.js +77 -30
  24. package/dist/core/api.js.map +1 -1
  25. package/dist/core/endpoint-policy.d.ts +14 -4
  26. package/dist/core/endpoint-policy.d.ts.map +1 -1
  27. package/dist/core/endpoint-policy.js +32 -1
  28. package/dist/core/endpoint-policy.js.map +1 -1
  29. package/dist/core/index.d.ts +4 -3
  30. package/dist/core/index.d.ts.map +1 -1
  31. package/dist/core/index.js +3 -3
  32. package/dist/core/index.js.map +1 -1
  33. package/dist/core/limits.d.ts +29 -0
  34. package/dist/core/limits.d.ts.map +1 -0
  35. package/dist/core/limits.js +63 -0
  36. package/dist/core/limits.js.map +1 -0
  37. package/dist/core/request-policy.d.ts +39 -0
  38. package/dist/core/request-policy.d.ts.map +1 -0
  39. package/dist/core/request-policy.js +18 -0
  40. package/dist/core/request-policy.js.map +1 -0
  41. package/dist/core/runtime.d.ts +65 -15
  42. package/dist/core/runtime.d.ts.map +1 -1
  43. package/dist/core/runtime.js +403 -126
  44. package/dist/core/runtime.js.map +1 -1
  45. package/dist/core/sanitize.d.ts +23 -0
  46. package/dist/core/sanitize.d.ts.map +1 -0
  47. package/dist/core/sanitize.js +143 -0
  48. package/dist/core/sanitize.js.map +1 -0
  49. package/dist/core/step-tag.d.ts +25 -6
  50. package/dist/core/step-tag.d.ts.map +1 -1
  51. package/dist/core/step-tag.js +56 -10
  52. package/dist/core/step-tag.js.map +1 -1
  53. package/dist/core/types.d.ts +73 -25
  54. package/dist/core/types.d.ts.map +1 -1
  55. package/dist/core/types.js +4 -1
  56. package/dist/core/types.js.map +1 -1
  57. package/dist/global.d.ts +91 -13
  58. package/dist/global.d.ts.map +1 -1
  59. package/dist/global.js +235 -25
  60. package/dist/global.js.map +1 -1
  61. package/dist/index.d.ts +7 -8
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +4 -6
  64. package/dist/index.js.map +1 -1
  65. package/dist/integrations/axios.d.ts +5 -1
  66. package/dist/integrations/axios.d.ts.map +1 -1
  67. package/dist/integrations/axios.js +146 -34
  68. package/dist/integrations/axios.js.map +1 -1
  69. package/dist/integrations/fetch.d.ts +3 -2
  70. package/dist/integrations/fetch.d.ts.map +1 -1
  71. package/dist/integrations/fetch.js +105 -26
  72. package/dist/integrations/fetch.js.map +1 -1
  73. package/dist/integrations/http-report.d.ts +27 -0
  74. package/dist/integrations/http-report.d.ts.map +1 -0
  75. package/dist/integrations/http-report.js +123 -0
  76. package/dist/integrations/http-report.js.map +1 -0
  77. package/dist/integrations/jquery/index.d.ts +8 -4
  78. package/dist/integrations/jquery/index.d.ts.map +1 -1
  79. package/dist/integrations/jquery/index.js +26 -8
  80. package/dist/integrations/jquery/index.js.map +1 -1
  81. package/dist/integrations/jquery/instrument.d.ts +14 -5
  82. package/dist/integrations/jquery/instrument.d.ts.map +1 -1
  83. package/dist/integrations/jquery/instrument.js +111 -29
  84. package/dist/integrations/jquery/instrument.js.map +1 -1
  85. package/dist/integrations/react/context.d.ts +4 -1
  86. package/dist/integrations/react/context.d.ts.map +1 -1
  87. package/dist/integrations/react/context.js +4 -1
  88. package/dist/integrations/react/context.js.map +1 -1
  89. package/dist/integrations/react/scope.d.ts +14 -3
  90. package/dist/integrations/react/scope.d.ts.map +1 -1
  91. package/dist/integrations/react/scope.js +46 -8
  92. package/dist/integrations/react/scope.js.map +1 -1
  93. package/dist/integrations/react/stable-config-keys.d.ts +6 -0
  94. package/dist/integrations/react/stable-config-keys.d.ts.map +1 -0
  95. package/dist/integrations/react/stable-config-keys.js +21 -0
  96. package/dist/integrations/react/stable-config-keys.js.map +1 -0
  97. package/dist/integrations/react/use-journey-step.d.ts +11 -0
  98. package/dist/integrations/react/use-journey-step.d.ts.map +1 -0
  99. package/dist/integrations/react/use-journey-step.js +19 -0
  100. package/dist/integrations/react/use-journey-step.js.map +1 -0
  101. package/dist/integrations/request-key.d.ts +8 -3
  102. package/dist/integrations/request-key.d.ts.map +1 -1
  103. package/dist/integrations/request-key.js +10 -9
  104. package/dist/integrations/request-key.js.map +1 -1
  105. package/dist/integrations/soft-skip.d.ts +7 -0
  106. package/dist/integrations/soft-skip.d.ts.map +1 -0
  107. package/dist/integrations/soft-skip.js +30 -0
  108. package/dist/integrations/soft-skip.js.map +1 -0
  109. package/dist/sinks/console.d.ts.map +1 -1
  110. package/dist/sinks/console.js +2 -1
  111. package/dist/sinks/console.js.map +1 -1
  112. package/dist/sinks/datadog.d.ts +1 -1
  113. package/dist/sinks/datadog.d.ts.map +1 -1
  114. package/dist/sinks/datadog.js +16 -20
  115. package/dist/sinks/datadog.js.map +1 -1
  116. package/package.json +2 -2
  117. package/src/__test-utils__/test-runtime.ts +9 -2
  118. package/src/__tests__/config.test.ts +150 -31
  119. package/src/__tests__/core/ambient.test.ts +16 -10
  120. package/src/__tests__/core/api.test.ts +7 -7
  121. package/src/__tests__/core/define-journey.test.ts +28 -11
  122. package/src/__tests__/core/requests.test.ts +49 -31
  123. package/src/__tests__/core/runtime.test.ts +67 -20
  124. package/src/__tests__/core/sanitize.test.ts +69 -0
  125. package/src/__tests__/core/step.test.ts +132 -36
  126. package/src/__tests__/core/timeouts.test.ts +69 -4
  127. package/src/__tests__/global.test.ts +44 -3
  128. package/src/__tests__/hardening.test.ts +613 -0
  129. package/src/__tests__/integrations/axios.test.ts +247 -27
  130. package/src/__tests__/integrations/expose-app-journey.test.ts +9 -3
  131. package/src/__tests__/integrations/fetch.test.ts +153 -19
  132. package/src/__tests__/integrations/host-mfe.test.ts +548 -0
  133. package/src/__tests__/integrations/jquery.test.ts +209 -34
  134. package/src/__tests__/integrations/scope.test.tsx +160 -2
  135. package/src/__tests__/integrations/use-journey-step.test.tsx +43 -0
  136. package/src/__tests__/sinks/console.test.ts +9 -4
  137. package/src/__tests__/sinks/datadog.test.ts +47 -0
  138. package/src/config.ts +162 -38
  139. package/src/core/abort-error.ts +24 -0
  140. package/src/core/api.ts +83 -33
  141. package/src/core/endpoint-policy.ts +46 -4
  142. package/src/core/index.ts +12 -4
  143. package/src/core/limits.ts +73 -0
  144. package/src/core/request-policy.ts +66 -0
  145. package/src/core/runtime.ts +507 -133
  146. package/src/core/sanitize.ts +177 -0
  147. package/src/core/step-tag.ts +82 -12
  148. package/src/core/types.ts +79 -25
  149. package/src/global.ts +312 -42
  150. package/src/index.ts +17 -17
  151. package/src/integrations/axios.ts +183 -45
  152. package/src/integrations/fetch.ts +125 -39
  153. package/src/integrations/http-report.ts +184 -0
  154. package/src/integrations/jquery/index.ts +34 -10
  155. package/src/integrations/jquery/instrument.ts +126 -42
  156. package/src/integrations/react/context.ts +4 -1
  157. package/src/integrations/react/scope.tsx +59 -9
  158. package/src/integrations/react/stable-config-keys.ts +28 -0
  159. package/src/integrations/react/use-journey-step.ts +31 -0
  160. package/src/integrations/request-key.ts +18 -10
  161. package/src/integrations/soft-skip.ts +35 -0
  162. package/src/sinks/console.ts +2 -1
  163. package/src/sinks/datadog.ts +23 -29
@@ -11,17 +11,21 @@ import { moduleRuntime } from '../../core/runtime';
11
11
 
12
12
  /**
13
13
  * Knockout / desktop-legacy bridge on `window.App.Journey`.
14
- * Prefer `defineJourney` handles; `journeyStep` / `firstOpenJourney` cover multi-journey steps.
14
+ * Prefer `defineJourney` handles; `journeyStep` / `firstOpenJourneyName` cover multi-journey steps.
15
+ * Use `getJourneyHandle(name)` when you need the handle from another module.
15
16
  */
16
17
  interface AppJourneyApi {
17
- defineJourney(def: JourneyDef): JourneyHandle;
18
+ defineJourney(config: JourneyDef): JourneyHandle;
18
19
  journeyStep<T>(
19
20
  journey: JourneyStepTarget,
20
21
  stepName: string,
21
22
  fn: (step: StepHandle) => T | Promise<T>,
22
23
  opts?: JourneyStepOptions
23
24
  ): Promise<T>;
24
- firstOpenJourney(names: readonly string[]): string | null;
25
+ /** First open journey **name** among candidates, or null. */
26
+ firstOpenJourneyName(names: readonly string[]): string | null;
27
+ /** `defineJourney` handle for an open name, or null. */
28
+ getJourneyHandle(name: string): JourneyHandle | null;
25
29
  }
26
30
 
27
31
  export type WindowWithAppJourney = Window & {
@@ -31,9 +35,17 @@ export type WindowWithAppJourney = Window & {
31
35
  let journeyExposed = false;
32
36
  let windowJourneyApi: AppJourneyApi | undefined;
33
37
 
38
+ function handleForOpenJourney(name: string): JourneyHandle | null {
39
+ const open = moduleRuntime.getOpenJourney(name);
40
+ if (!open) {
41
+ return null;
42
+ }
43
+ return defineJourney(open.config);
44
+ }
45
+
34
46
  /**
35
47
  * Publish the Knockout bridge on window.App.Journey. Idempotent for the real window.
36
- * Optional `target` is for tests.
48
+ * Optional `target` is for tests. Never throws — logs and returns a usable API if publish fails.
37
49
  */
38
50
  export function exposeAppJourney(target: WindowWithAppJourney = window): AppJourneyApi {
39
51
  if (journeyExposed && target === window && windowJourneyApi) {
@@ -43,13 +55,25 @@ export function exposeAppJourney(target: WindowWithAppJourney = window): AppJour
43
55
  const api: AppJourneyApi = {
44
56
  defineJourney,
45
57
  journeyStep,
46
- firstOpenJourney: names => moduleRuntime.firstOpenJourney(names),
58
+ firstOpenJourneyName: names => moduleRuntime.firstOpenJourneyName(names),
59
+ getJourneyHandle: handleForOpenJourney,
47
60
  };
48
- target.App = target.App ?? {};
49
- target.App.Journey = api;
50
- if (target === window) {
51
- journeyExposed = true;
52
- windowJourneyApi = api;
61
+
62
+ try {
63
+ if (target == null || (typeof target !== 'object' && typeof target !== 'function')) {
64
+ // eslint-disable-next-line no-console -- soft-skip missing host window
65
+ console.warn('[journey] exposeAppJourney: skipped — missing');
66
+ return api;
67
+ }
68
+ target.App = target.App ?? {};
69
+ target.App.Journey = Object.freeze(api);
70
+ if (target === window) {
71
+ journeyExposed = true;
72
+ windowJourneyApi = api;
73
+ }
74
+ } catch (err) {
75
+ // eslint-disable-next-line no-console -- soft-fail host wiring
76
+ console.error('[journey] exposeAppJourney failed', err);
53
77
  }
54
78
  return api;
55
79
  }
@@ -1,22 +1,29 @@
1
+ import { resolveStamp } from '../../core';
2
+ import { adoptJourneyStepStamp, type JourneyStepStamp } from '../../core/step-tag';
1
3
  import {
2
- activeJourneyStep,
3
- JourneyStepTag,
4
- reportBackendRequest,
5
- resolveJourneyStep,
6
- } from '../../core';
7
- import { requestKey } from '../request-key';
4
+ getInstrumentedJquerySet,
5
+ registerInstrumentRecord,
6
+ type InstrumentRecord,
7
+ } from '../../global';
8
+ import { onHttpComplete } from '../http-report';
9
+ import { requestKey, type JourneyTimedMeta } from '../request-key';
10
+ import { acceptInstrumentTarget } from '../soft-skip';
8
11
 
9
12
  export interface JqueryXHR {
10
13
  status: number;
11
- journeyStep?: unknown;
14
+ statusText?: string;
15
+ stamp?: unknown;
12
16
  journeyStart?: number;
13
17
  }
14
18
 
15
- export interface JqueryAjaxSettings {
19
+ export interface JqueryAjaxSettings extends JourneyTimedMeta {
16
20
  url?: string;
17
- journeyStep?: unknown;
21
+ type?: string;
22
+ method?: string;
18
23
  }
19
24
 
25
+ const jqueryStamps = new WeakMap<object, JourneyStepStamp>();
26
+
20
27
  export interface JqueryStatic {
21
28
  ajaxPrefilter(
22
29
  handler: (
@@ -32,53 +39,130 @@ export interface JqueryStatic {
32
39
  };
33
40
  }
34
41
 
35
- const instrumented = new WeakSet<object>();
36
-
37
42
  function reportTiming(jqXHR: JqueryXHR, settings: JqueryAjaxSettings): void {
38
- const step = resolveJourneyStep(jqXHR.journeyStep);
39
- if (!step) {
43
+ const stamp = jqueryStamps.get(jqXHR) ?? adoptJourneyStepStamp(jqXHR.stamp);
44
+ if (!stamp || stamp.ignore || settings.ignore === true) {
40
45
  return;
41
46
  }
42
47
  const start = jqXHR.journeyStart ?? globalThis.performance.now();
48
+ const aborted = jqXHR.statusText === 'abort';
43
49
  // jQuery uses status 0 for network / abort failures — treat like a missing status.
44
- const status = jqXHR.status === 0 ? undefined : jqXHR.status;
45
- reportBackendRequest(
46
- step,
50
+ const status = aborted || jqXHR.status === 0 ? undefined : jqXHR.status;
51
+ onHttpComplete({
52
+ stamp,
47
53
  status,
48
- globalThis.performance.now() - start,
49
- requestKey(settings.url, undefined, undefined)
54
+ durationMs: globalThis.performance.now() - start,
55
+ requestKey: requestKey(settings.url, undefined, undefined),
56
+ aborted,
57
+ url: settings.url,
58
+ method: settings.type ?? settings.method,
59
+ meta: settings,
60
+ request: settings,
61
+ response: jqXHR,
62
+ });
63
+ }
64
+
65
+ function isJqueryStatic(value: unknown): value is JqueryStatic {
66
+ return (
67
+ typeof value === 'function' && typeof (value as JqueryStatic).ajaxPrefilter === 'function'
50
68
  );
51
69
  }
52
70
 
53
- /** Wire a jQuery static into the journey engine. Call once per $ (idempotent). Observation-only. */
54
- export function instrumentJquery($: JqueryStatic): void {
55
- if (instrumented.has($ as object)) {
71
+ /** Resolve `globalThis.$`, then `globalThis.jQuery`. */
72
+ export function resolveGlobalJquery(): JqueryStatic | null | undefined {
73
+ const g = globalThis as typeof globalThis & { $?: unknown; jQuery?: unknown };
74
+ return (g.$ ?? g.jQuery) as JqueryStatic | null | undefined;
75
+ }
76
+
77
+ /** Live record per $ — handlers look this up so restore+rewire does not stack prefilters. */
78
+ const jqueryLiveRecords = new WeakMap<object, InstrumentRecord>();
79
+ /** jQuery cannot unregister these; track each so a partial-wire retry does not stack. */
80
+ const jqueryPrefiltersInstalled = new WeakSet<object>();
81
+ const jqueryCompletesInstalled = new WeakSet<object>();
82
+
83
+ function stampAjax(jqXHR: JqueryXHR, options: JqueryAjaxSettings): void {
84
+ if (options.ignore === true) {
56
85
  return;
57
86
  }
58
- instrumented.add($ as object);
87
+ const stamp = resolveStamp(jqXHR.stamp ?? options.stamp);
88
+ if (stamp) {
89
+ jqueryStamps.set(jqXHR, stamp);
90
+ delete jqXHR.stamp;
91
+ delete options.stamp;
92
+ }
93
+ if (stamp && !stamp.ignore) {
94
+ jqXHR.journeyStart = globalThis.performance.now();
95
+ }
96
+ }
59
97
 
60
- $.ajaxPrefilter((options, _originalOptions, jqXHR) => {
61
- /*
62
- * Prefer explicit step.tag() (settings or XHR); else soft ambient.
63
- * Box ambient steps in JourneyStepTag raw StepRecord is circular and blows jQuery deep-merge.
64
- */
65
- if (!jqXHR.journeyStep) {
66
- const fromSettings = options.journeyStep;
67
- if (fromSettings) {
68
- jqXHR.journeyStep = fromSettings;
69
- } else {
70
- const step = activeJourneyStep();
71
- if (step) {
72
- jqXHR.journeyStep = new JourneyStepTag(step);
98
+ /**
99
+ * Wire a jQuery static into the journey engine. Call once per $ (idempotent). Observation-only.
100
+ * Soft-skips when `$` is missing or not a real jQuery (no `ajaxPrefilter`) so hosts can pass `$` as-is.
101
+ * Returns a restore that disposes handlers (they no-op after restore).
102
+ */
103
+ export function instrumentJquery($: JqueryStatic | null | undefined): () => void {
104
+ const jquery = acceptInstrumentTarget('Jquery', $, isJqueryStatic);
105
+ const instrumented = getInstrumentedJquerySet();
106
+ if (!jquery || instrumented.has(jquery as object)) {
107
+ return () => {};
108
+ }
109
+
110
+ const key = jquery as object;
111
+ const record: InstrumentRecord = { disposed: false };
112
+
113
+ try {
114
+ if (!jqueryPrefiltersInstalled.has(key)) {
115
+ jquery.ajaxPrefilter((options, _originalOptions, jqXHR) => {
116
+ const current = jqueryLiveRecords.get(key);
117
+ if (!current || current.disposed) {
118
+ return;
73
119
  }
74
- }
120
+ stampAjax(jqXHR, options);
121
+ });
122
+ jqueryPrefiltersInstalled.add(key);
75
123
  }
76
- if (resolveJourneyStep(jqXHR.journeyStep)) {
77
- jqXHR.journeyStart = globalThis.performance.now();
124
+ if (!jqueryCompletesInstalled.has(key)) {
125
+ jquery(document).ajaxComplete((_event, jqXHR, settings) => {
126
+ const current = jqueryLiveRecords.get(key);
127
+ if (!current || current.disposed) {
128
+ return;
129
+ }
130
+ reportTiming(jqXHR, settings);
131
+ });
132
+ jqueryCompletesInstalled.add(key);
78
133
  }
79
- });
134
+ jqueryLiveRecords.set(key, record);
135
+ instrumented.add(key);
136
+ } catch (err) {
137
+ /*
138
+ * Keep per-handler installed flags so a retry cannot double-register
139
+ * prefilters. Restore deletes the shared instrumented slot so teardown /
140
+ * retry can finish whichever handler never succeeded.
141
+ */
142
+ record.disposed = true;
143
+ instrumented.add(key);
144
+ const restore = () => {
145
+ record.disposed = true;
146
+ if (jqueryLiveRecords.get(key) === record) {
147
+ jqueryLiveRecords.delete(key);
148
+ }
149
+ instrumented.delete(key);
150
+ };
151
+ record.restore = restore;
152
+ registerInstrumentRecord(record);
153
+ // eslint-disable-next-line no-console -- soft-fail host wiring
154
+ console.error('[journey] instrumentJquery failed', err);
155
+ return restore;
156
+ }
80
157
 
81
- $(document).ajaxComplete((_event, jqXHR, settings) => {
82
- reportTiming(jqXHR, settings);
83
- });
158
+ const restore = () => {
159
+ record.disposed = true;
160
+ if (jqueryLiveRecords.get(key) === record) {
161
+ jqueryLiveRecords.delete(key);
162
+ }
163
+ instrumented.delete(key);
164
+ };
165
+ record.restore = restore;
166
+ registerInstrumentRecord(record);
167
+ return restore;
84
168
  }
@@ -7,7 +7,10 @@ export function useOptionalJourneyName(): string | null {
7
7
  return useContext(JourneyNameContext);
8
8
  }
9
9
 
10
- /** Active journey name; throws outside a `<JourneyScope>`. */
10
+ /**
11
+ * Active journey name; throws outside a `<JourneyScope>`.
12
+ * Prefer {@link useOptionalJourneyName} when the shell may omit a scope.
13
+ */
11
14
  export function useJourneyName(): string {
12
15
  const name = useContext(JourneyNameContext);
13
16
  if (!name) {
@@ -1,33 +1,83 @@
1
1
  import { useEffect, type ReactNode } from 'react';
2
2
 
3
- import { excludeJourney, setJourneyTags, type JourneyDef } from '../../core';
3
+ import { setJourneyTags, type JourneyDef } from '../../core';
4
4
  import { moduleRuntime } from '../../core/runtime';
5
5
  import type { TagValue } from '../../core/types';
6
6
  import { JourneyNameContext } from './context';
7
+ import { stableEndpointKey, stableExpectedKey, stableTagsKey } from './stable-config-keys';
8
+
9
+ type JourneyScopeDef = JourneyDef | { config: JourneyDef };
7
10
 
8
11
  /**
9
12
  * Opens a journey while the subtree is mounted; Excludes on unmount.
10
- * Config (timeouts, endpoints, tags, …) is captured when the journey opens;
11
- * only a `name` change re-opens.
13
+ * `name` is the lifecycle key changing it excludes the previous instance
14
+ * and opens a new one. Other config fields and `tags` update the open instance
15
+ * in place (timeouts re-arm; they do not restart).
16
+ *
17
+ * Pass a {@link JourneyDef} (`<JourneyScope {...def}>`) or spread a handle
18
+ * (`<JourneyScope {...makeASaleJourney}>` reads `handle.config`).
19
+ *
20
+ * React 18 Strict Mode (development) remounts once: expect an `excluded` emission
21
+ * then a fresh open. Production single-mount does not.
12
22
  */
13
23
  export function JourneyScope({
14
24
  tags,
15
25
  children,
16
- ...def
17
- }: JourneyDef & {
26
+ ...rest
27
+ }: JourneyScopeDef & {
18
28
  tags?: Record<string, TagValue>;
19
29
  children: ReactNode;
20
30
  }) {
21
- const { name } = def;
31
+ const def: JourneyDef = 'config' in rest ? rest.config : rest;
32
+ const {
33
+ name,
34
+ timeoutMs,
35
+ stepTimeoutMs,
36
+ slowRequestMs,
37
+ endpoints,
38
+ expected,
39
+ service,
40
+ team,
41
+ group,
42
+ maxSteps,
43
+ } = def;
44
+ const endpointKey = stableEndpointKey(endpoints);
45
+ const expectedKey = stableExpectedKey(expected);
46
+ const configTagsKey = stableTagsKey(def.tags);
22
47
 
23
48
  useEffect(() => {
24
49
  moduleRuntime.startJourney(def);
50
+ const opened = moduleRuntime.getOpenJourney(name);
51
+ return () => {
52
+ if (opened) {
53
+ moduleRuntime.excludeJourney(opened);
54
+ }
55
+ };
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- name is the lifecycle key
57
+ }, [name]);
58
+
59
+ useEffect(() => {
25
60
  if (tags) {
26
61
  setJourneyTags(name, tags);
27
62
  }
28
- return () => excludeJourney(name);
29
- // eslint-disable-next-line react-hooks/exhaustive-deps -- freeze-on-mount; name is the lifecycle key
30
- }, [name]);
63
+ }, [name, tags]);
64
+
65
+ useEffect(() => {
66
+ moduleRuntime.updateOpenJourney(def);
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- field-level live updates; name-change is the other effect
68
+ }, [
69
+ name,
70
+ timeoutMs,
71
+ stepTimeoutMs,
72
+ slowRequestMs,
73
+ endpointKey,
74
+ expectedKey,
75
+ service,
76
+ team,
77
+ group,
78
+ maxSteps,
79
+ configTagsKey,
80
+ ]);
31
81
 
32
82
  return <JourneyNameContext.Provider value={name}>{children}</JourneyNameContext.Provider>;
33
83
  }
@@ -0,0 +1,28 @@
1
+ import type { EndpointPolicy } from '../../core/endpoint-policy';
2
+ import type { TagValue } from '../../core/types';
3
+
4
+ export function stableEndpointKey(endpoints?: readonly EndpointPolicy[]): string {
5
+ if (!endpoints?.length) {
6
+ return '';
7
+ }
8
+ return endpoints
9
+ .map(
10
+ endpoint =>
11
+ `${endpoint.match}\0${endpoint.slowRequestMs ?? ''}\0${endpoint.ignore === true}`
12
+ )
13
+ .join('\n');
14
+ }
15
+
16
+ export function stableExpectedKey(expected?: readonly string[]): string {
17
+ return expected?.join('\0') ?? '';
18
+ }
19
+
20
+ export function stableTagsKey(tags?: Record<string, TagValue>): string {
21
+ if (!tags) {
22
+ return '';
23
+ }
24
+ return Object.keys(tags)
25
+ .sort()
26
+ .map(key => `${key}\0${String(tags[key])}`)
27
+ .join('\n');
28
+ }
@@ -0,0 +1,31 @@
1
+ import { useCallback } from 'react';
2
+
3
+ import {
4
+ journeyStep,
5
+ type JourneyHandle,
6
+ type JourneyStepOptions,
7
+ type StepHandle,
8
+ } from '../../core';
9
+ import { useOptionalJourneyName } from './context';
10
+
11
+ /**
12
+ * Run a step on the active `<JourneyScope>` journey, an explicit name, or a handle.
13
+ * A handle auto-starts when the journey is not open; a bare name does not.
14
+ *
15
+ * @example
16
+ * const runStep = useJourneyStep();
17
+ * await runStep('load', async step => { await api.get('/x', { stamp: step.stamp() }); });
18
+ */
19
+ export function useJourneyStep(journey?: string | JourneyHandle | null) {
20
+ const fromScope = useOptionalJourneyName();
21
+ const resolved = journey !== undefined ? journey : fromScope;
22
+
23
+ return useCallback(
24
+ <T>(
25
+ stepName: string,
26
+ fn: (step: StepHandle) => T | Promise<T>,
27
+ opts?: JourneyStepOptions
28
+ ): Promise<T> => journeyStep(resolved, stepName, fn, opts),
29
+ [resolved]
30
+ );
31
+ }
@@ -2,12 +2,17 @@
2
2
  * Timing metadata stamped onto a request config so the response handler can attribute
3
3
  * and measure the round trip. Intersect with your transport's config type.
4
4
  *
5
- * `journeyStep` is a JourneyStepTag (from `step.tag()` or ambient instrumentation)
6
- * or, historically, a raw StepRecord. Adapters unwrap via `resolveJourneyStep`.
5
+ * `stamp` is a JourneyStepStamp from `step.stamp()` or ambient instrumentation.
7
6
  */
8
7
  export interface JourneyTimedMeta {
9
8
  journeyStart?: number;
10
- journeyStep?: unknown;
9
+ stamp?: unknown;
10
+ /** Skip attribution for this request (no ambient stamp, no scoring). */
11
+ ignore?: boolean;
12
+ /** Per-request override for aborted/cancelled HTTP policy. */
13
+ httpAbortedRequests?: 'continue' | 'bad' | 'exclude';
14
+ /** Per-request override for 4xx HTTP policy. */
15
+ httpClientErrorRequests?: 'continue' | 'bad' | 'exclude';
11
16
  }
12
17
 
13
18
  function pageOrigin(): string {
@@ -18,6 +23,15 @@ function pageOrigin(): string {
18
23
  }
19
24
  }
20
25
 
26
+ function resolveHomeOrigin(instanceBaseURL: string | undefined, origin: string): string {
27
+ try {
28
+ const homeBase = instanceBaseURL ?? origin;
29
+ return new URL(homeBase || 'http://localhost').origin;
30
+ } catch {
31
+ return origin;
32
+ }
33
+ }
34
+
21
35
  /**
22
36
  * Endpoint key for timeout / ignore lookup: PATH for same-origin (vs instance baseURL),
23
37
  * or "host/path" for cross-domain.
@@ -35,12 +49,6 @@ export function requestKey(
35
49
  } catch {
36
50
  return url ?? '';
37
51
  }
38
- let homeOrigin: string;
39
- try {
40
- const homeBase = instanceBaseURL ?? origin;
41
- homeOrigin = new URL(homeBase || 'http://localhost').origin;
42
- } catch {
43
- homeOrigin = origin;
44
- }
52
+ const homeOrigin = resolveHomeOrigin(instanceBaseURL, origin);
45
53
  return abs.origin === homeOrigin ? abs.pathname : abs.host + abs.pathname;
46
54
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Soft-accept an instrumentation target. Never throws.
3
+ * - `null` / `undefined` → `console.warn` and return `undefined`
4
+ * - fails `isValid` → `console.error` and return `undefined`
5
+ */
6
+ export function acceptInstrumentTarget<T>(
7
+ kind: string,
8
+ value: unknown,
9
+ isValid: (value: unknown) => value is T
10
+ ): T | undefined {
11
+ if (value == null) {
12
+ // eslint-disable-next-line no-console -- soft-skip missing host wiring
13
+ console.warn(`[journey] instrument${kind}: skipped — missing`);
14
+ return undefined;
15
+ }
16
+ if (!isValid(value)) {
17
+ // eslint-disable-next-line no-console -- soft-skip invalid host wiring
18
+ console.error(
19
+ `[journey] instrument${kind}: skipped — invalid shape (${describeTarget(value)})`
20
+ );
21
+ return undefined;
22
+ }
23
+ return value;
24
+ }
25
+
26
+ function describeTarget(value: unknown): string {
27
+ const kind = typeof value;
28
+ if (value != null && (kind === 'object' || kind === 'function')) {
29
+ const name = (value as { constructor?: { name?: string } }).constructor?.name;
30
+ if (typeof name === 'string' && name !== '' && name !== 'Object') {
31
+ return `${kind} ${name}`;
32
+ }
33
+ }
34
+ return kind;
35
+ }
@@ -1,3 +1,4 @@
1
+ import { isSanitizedJourneyEvent, sanitizeJourneyEvent } from '../core/sanitize';
1
2
  import { isJourneyDebugEnabled } from '../global';
2
3
  import type { JourneyEvent } from '../core/types';
3
4
 
@@ -14,7 +15,7 @@ export function sendToConsole(event: JourneyEvent): void {
14
15
  return;
15
16
  }
16
17
 
17
- const j = event.journey;
18
+ const j = isSanitizedJourneyEvent(event) ? event.journey : sanitizeJourneyEvent(event).journey;
18
19
  // eslint-disable-next-line no-console -- intentional debug sink
19
20
  console.debug(`[journey] ${j.name} → ${j.outcome}`, j);
20
21
  }
@@ -1,7 +1,10 @@
1
1
  import { datadogRum } from '@datadog/browser-rum';
2
2
 
3
+ import { isSanitizedJourneyEvent, sanitizeJourneyEvent } from '../core/sanitize';
3
4
  import { getJourneyRumFallback, setJourneyRumFallback } from '../global';
4
- import type { JourneyEvent, JourneyStepEvent, TagValue } from '../core/types';
5
+ import type { JourneyEvent, JourneyStepEvent } from '../core/types';
6
+
7
+ export { normalizeTagValue } from '../core/sanitize';
5
8
 
6
9
  const JOURNEY_ACTION = 'user_journey';
7
10
 
@@ -41,25 +44,6 @@ function resolveRum(): typeof datadogRum {
41
44
  return (shared as typeof datadogRum | undefined) ?? datadogRum;
42
45
  }
43
46
 
44
- /** Match Datadog metric-tag value rules so RUM attributes / globals join derived SLO tags. */
45
- const MAX_TAG_VALUE_LEN = 200;
46
- export function normalizeTagValue(value: string): string {
47
- return value
48
- .toLowerCase()
49
- .replace(/[^a-z0-9_\-:./]/g, '_')
50
- .replace(/_+/g, '_')
51
- .slice(0, MAX_TAG_VALUE_LEN)
52
- .replace(/_+$/, '');
53
- }
54
-
55
- function normalizeTags(tags: Record<string, TagValue>): Record<string, TagValue> {
56
- const out: Record<string, TagValue> = {};
57
- for (const [key, value] of Object.entries(tags)) {
58
- out[key] = typeof value === 'string' ? normalizeTagValue(value) : value;
59
- }
60
- return out;
61
- }
62
-
63
47
  function toDatadogStep(step: JourneyStepEvent) {
64
48
  /* eslint-disable @typescript-eslint/naming-convention -- Datadog RUM wire format (snake_case) */
65
49
  return {
@@ -69,27 +53,37 @@ function toDatadogStep(step: JourneyStepEvent) {
69
53
  outcome: step.outcome,
70
54
  ...(step.reason ? { reason: step.reason } : {}),
71
55
  ...(step.httpStatus !== undefined ? { http_status: step.httpStatus } : {}),
72
- ...(step.attributes ? { attributes: step.attributes } : {}),
56
+ ...(step.attributes && Object.keys(step.attributes).length
57
+ ? { attributes: step.attributes }
58
+ : {}),
73
59
  };
74
60
  /* eslint-enable @typescript-eslint/naming-convention */
75
61
  }
76
62
 
77
63
  /** Ship one terminal journey as a RUM custom action (`user_journey`). */
78
64
  export function sendToDatadog(payload: JourneyEvent): void {
79
- const j = payload.journey;
65
+ const rum = resolveRum();
66
+ // Skip bad/missing host stubs — uninit RUM still buffers addAction safely.
67
+ if (typeof rum.addAction !== 'function') {
68
+ return;
69
+ }
70
+
71
+ const j = isSanitizedJourneyEvent(payload)
72
+ ? payload.journey
73
+ : sanitizeJourneyEvent(payload).journey;
80
74
  /* eslint-disable @typescript-eslint/naming-convention -- Datadog RUM wire format (snake_case) */
81
- resolveRum().addAction(JOURNEY_ACTION, {
75
+ rum.addAction(JOURNEY_ACTION, {
82
76
  journey: {
83
- name: normalizeTagValue(j.name),
84
- team: normalizeTagValue(j.team),
85
- group: normalizeTagValue(j.group),
86
- service: normalizeTagValue(j.service),
77
+ name: j.name,
78
+ team: j.team,
79
+ group: j.group,
80
+ service: j.service,
87
81
  outcome: j.outcome,
88
- ...(j.reason ? { reason: normalizeTagValue(j.reason) } : {}),
82
+ ...(j.reason ? { reason: j.reason } : {}),
89
83
  duration_ms: j.durationMs,
90
84
  steps: j.steps.map(toDatadogStep),
91
85
  ...(j.expected ? { expected: j.expected } : {}),
92
- ...(j.tags ? { tags: normalizeTags(j.tags) } : {}),
86
+ ...(j.tags ? { tags: j.tags } : {}),
93
87
  },
94
88
  });
95
89
  /* eslint-enable @typescript-eslint/naming-convention */