@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
@@ -84,6 +84,16 @@ describe('[journey] setJourneyRum / sendToDatadog', () => {
84
84
  });
85
85
  });
86
86
 
87
+ describe('when the resolved RUM stub has no addAction', () => {
88
+ test('is a no-op', () => {
89
+ (globalThis as Record<string, unknown>)[DD_RUM_KEY] = {};
90
+
91
+ expect(() => sendToDatadog(sampleEvent())).not.toThrow();
92
+ expect(importedAddAction).not.toHaveBeenCalled();
93
+ expect(addAction).not.toHaveBeenCalled();
94
+ });
95
+ });
96
+
87
97
  describe('when setJourneyRum is called while globalThis.DD_RUM is set', () => {
88
98
  test('logs a redundancy warning', () => {
89
99
  (globalThis as Record<string, unknown>)[DD_RUM_KEY] = { addAction: ddRumAddAction };
@@ -184,6 +194,43 @@ describe('[journey] setJourneyRum / sendToDatadog', () => {
184
194
  expect(normalizeTagValue('Foo@@@Bar___')).toBe('foo_bar');
185
195
  });
186
196
 
197
+ test('drops denied tag keys and normalizes tag keys on the wire', () => {
198
+ setJourneyRum({ addAction });
199
+ sendToDatadog(
200
+ sampleEvent({
201
+ tags: {
202
+ 'token': 'secret',
203
+ 'User Email': 'Pat!',
204
+ },
205
+ steps: [
206
+ {
207
+ name: 'Load Draft!!',
208
+ startMs: 0,
209
+ durationMs: 3,
210
+ outcome: 'good',
211
+ attributes: { token: 'nope', phase: 'Load' },
212
+ },
213
+ ],
214
+ })
215
+ );
216
+
217
+ const ctx = addAction.mock.calls[0][1];
218
+ expect(ctx.journey.tags.token).toBeUndefined();
219
+ expect(ctx.journey.tags.user_email).toBe('pat');
220
+ expect(ctx.journey.steps[0].name).toBe('load_draft');
221
+ expect(ctx.journey.steps[0].attributes.token).toBeUndefined();
222
+ expect(ctx.journey.steps[0].attributes.phase).toBe('load');
223
+ });
224
+
225
+ test('truncates long tag values on the wire', () => {
226
+ setJourneyRum({ addAction });
227
+ const long = `x${'a'.repeat(250)}`;
228
+ sendToDatadog(sampleEvent({ tags: { note: long } }));
229
+
230
+ const ctx = addAction.mock.calls[0][1];
231
+ expect(ctx.journey.tags.note.length).toBeLessThanOrEqual(200);
232
+ });
233
+
187
234
  test('maps camelCase fields to Datadog snake_case wire keys', () => {
188
235
  setJourneyRum({ addAction });
189
236
  sendToDatadog(
package/src/config.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { moduleRuntime } from './core/runtime';
2
2
  import type { JourneySink } from './core/types';
3
- import { installJourneyGlobal, registerJourneyRuntime } from './global';
3
+ import type { RequestPolicyConfig } from './core/request-policy';
4
4
  import {
5
- sortEndpointsByLongestMatch,
6
- type AppRequestTimeouts,
7
- type EndpointPolicy,
8
- } from './core/endpoint-policy';
5
+ areSharedPoliciesLocked,
6
+ getSharedSlowRequests,
7
+ isJourneyEnabled,
8
+ registerJourneyRuntime,
9
+ seedSharedPolicies,
10
+ warnIfSlowRequestsUnset,
11
+ } from './global';
12
+ import { sortEndpointsByLongestMatch, type SlowRequestConfig } from './core/endpoint-policy';
9
13
  import { instrumentAxios, type AxiosLikeInstance } from './integrations/axios';
10
14
  import { instrumentFetch, type InstrumentFetchOptions } from './integrations/fetch';
11
15
  import {
@@ -14,34 +18,73 @@ import {
14
18
  type JqueryStatic,
15
19
  type WindowWithAppJourney,
16
20
  } from './integrations/jquery';
21
+ import { resolveGlobalJquery } from './integrations/jquery/instrument';
17
22
  import { sendToConsole } from './sinks/console';
18
23
  import { sendToDatadog } from './sinks/datadog';
19
24
 
20
- export type { AppRequestTimeouts, EndpointPolicy };
21
-
22
25
  /** Optional app-wide / per-bundle configuration. */
23
26
  export interface JourneyConfig {
24
- /** App-wide request-timeout fallback. Defaults to { defaultMs: 4000, endpoints: [] }. */
25
- requestTimeouts?: AppRequestTimeouts;
27
+ /**
28
+ * App-wide slow-request thresholds (shared on `__stJourney` for all package copies).
29
+ * Package fallback remains `{ defaultMs: 4000, endpoints: [] }` until set.
30
+ * Desktop / KO hosts should set `defaultMs` and ignore noisy endpoint prefixes.
31
+ */
32
+ slowRequests?: SlowRequestConfig;
33
+ /**
34
+ * How attributed aborted/cancelled HTTP counts toward the open journey.
35
+ * Shared on `__stJourney`. Literal or `(ctx) => …`. Default `'continue'`.
36
+ */
37
+ httpAbortedRequests?: RequestPolicyConfig;
38
+ /**
39
+ * How attributed 4xx responses count toward the open journey.
40
+ * Shared on `__stJourney`. Literal or `(ctx) => …`. Default `'continue'` (no verdict change).
41
+ */
42
+ httpClientErrorRequests?: RequestPolicyConfig;
43
+ /**
44
+ * When true (default), untagged instrumented HTTP is attributed to the sole
45
+ * in-flight step (if exactly one). When false, only explicit `step.stamp()` attributes.
46
+ * Prefer false on busy desktop / shared jQuery shells with background traffic.
47
+ * Shared on `__stJourney`.
48
+ */
49
+ autoAttributeRequests?: boolean;
50
+ /**
51
+ * Default cap on recorded steps per journey (default 50). Per-journey
52
+ * `JourneyDef.maxSteps` overrides. Excess steps are dropped and
53
+ * `tags.steps_truncated` is set.
54
+ */
55
+ maxSteps?: number;
56
+ /**
57
+ * Applied when a journey omits `timeoutMs`.
58
+ * Default is 15m and closes as `excluded` with `journey-idle-timeout`.
59
+ * Pass `0` to disable this idle timeout.
60
+ */
61
+ defaultJourneyIdleMs?: number;
26
62
  /**
27
63
  * Replace the sink list used when a journey finishes.
28
64
  * Only applied when provided — omit to leave the current list unchanged.
29
65
  * Package default is `[sendToDatadog, sendToConsole]`; pass `[]` to disable emission.
30
- * `sendToConsole` is a no-op unless `localStorage['st:journey:debug'] === 'true'`.
66
+ * `sendToConsole` is a no-op unless `sessionStorage['st:journey:debug'] === 'true'`.
67
+ * First `sinks` on this bundle wins; a later `configureJourney({ sinks })` is ignored.
31
68
  */
32
69
  sinks?: readonly JourneySink[];
33
70
  /**
34
71
  * Wire this bundle's axios instance (host or MFE). Same as `instrumentAxios(axios)`.
35
72
  * Register any async request interceptors (e.g. auth) before `configureJourney`.
73
+ * Soft-skips when axios is missing / not axios-like — safe to pass a host instance that may be absent.
36
74
  */
37
- axios?: AxiosLikeInstance;
75
+ axios?: AxiosLikeInstance | null;
38
76
  /**
39
77
  * Wire fetch. `true` wraps `globalThis.fetch`; pass options for a scoped target / baseURL.
40
- * Same as `instrumentFetch(...)`.
78
+ * Same as `instrumentFetch(...)`. Soft-skips when the target has no callable `fetch`.
79
+ * Prefer host-only for `globalThis.fetch` — MFEs inherit the host wrapper.
80
+ */
81
+ fetch?: true | InstrumentFetchOptions | null;
82
+ /**
83
+ * Wire jQuery. Pass a `$` instance, or `true` to use `globalThis.$` / `globalThis.jQuery`.
84
+ * `false` skips wiring. Soft-skips when `$` is missing / not real jQuery.
85
+ * Prefer host-only for shared `$`.
41
86
  */
42
- fetch?: true | InstrumentFetchOptions;
43
- /** Wire this bundle's jQuery static. Same as `instrumentJquery($)`. */
44
- jquery?: JqueryStatic;
87
+ jquery?: boolean | JqueryStatic | null;
45
88
  /**
46
89
  * Publish `window.App.Journey` for Knockout / legacy hosts.
47
90
  * `true` uses the real window; pass a target object in tests.
@@ -55,41 +98,122 @@ export interface JourneyConfig {
55
98
  * `__stJourney`. configureJourney only replaces sinks when `sinks` is passed explicitly.
56
99
  */
57
100
  moduleRuntime.setSinks([sendToDatadog, sendToConsole]);
58
- installJourneyGlobal();
59
101
  registerJourneyRuntime(moduleRuntime);
60
102
 
61
103
  /**
62
104
  * Apply bundle options: request thresholds, sinks, and/or HTTP / legacy wiring.
63
105
  * Optional — call once at startup. Pass this bundle's own axios / `$` / fetch target.
106
+ * Policy knobs (`slowRequests`, `httpAbortedRequests`, `httpClientErrorRequests`,
107
+ * `autoAttributeRequests`) are stored on shared `__stJourney` so MFEs inherit host settings.
108
+ * No-ops when `isJourneyEnabled()` is false (host default or force flag).
109
+ * Never throws: missing/invalid config and failures soft-skip (warn / error to console).
64
110
  */
65
111
  export function configureJourney(config: JourneyConfig): void {
66
- if (config.requestTimeouts !== undefined) {
67
- moduleRuntime.setRequestTimeouts({
68
- ...config.requestTimeouts,
69
- endpoints: sortEndpointsByLongestMatch(config.requestTimeouts.endpoints),
70
- });
71
- }
72
- if (config.sinks !== undefined) {
73
- moduleRuntime.setSinks(config.sinks);
74
- }
75
- if (config.axios !== undefined) {
76
- instrumentAxios(config.axios);
77
- }
78
- if (config.fetch !== undefined) {
79
- instrumentFetch(config.fetch === true ? {} : config.fetch);
112
+ if (config == null || typeof config !== 'object') {
113
+ // eslint-disable-next-line no-console -- soft-skip missing host config
114
+ console.warn('[journey] configureJourney: skipped — missing');
115
+ return;
80
116
  }
81
- if (config.jquery !== undefined) {
82
- instrumentJquery(config.jquery);
117
+ if (!isJourneyEnabled()) {
118
+ // eslint-disable-next-line no-console -- soft-skip when host default and force are off
119
+ console.warn(
120
+ '[journey] configureJourney: skipped — disabled (call setJourneyDefaultEnabled(true) or __stJourney.enable(), then configureJourney again / reload)'
121
+ );
122
+ return;
83
123
  }
84
- if (config.exposeAppJourney) {
85
- exposeAppJourney(config.exposeAppJourney === true ? undefined : config.exposeAppJourney);
124
+
125
+ try {
126
+ const overlay: {
127
+ slowRequests?: SlowRequestConfig;
128
+ httpAbortedRequests?: RequestPolicyConfig;
129
+ httpClientErrorRequests?: RequestPolicyConfig;
130
+ autoAttributeRequests?: boolean;
131
+ } = {};
132
+
133
+ const policyPatch: Parameters<typeof seedSharedPolicies>[0] = {};
134
+ let hasPolicyKnob = false;
135
+
136
+ if (config.slowRequests !== undefined) {
137
+ const sorted = {
138
+ ...config.slowRequests,
139
+ endpoints: sortEndpointsByLongestMatch(config.slowRequests.endpoints),
140
+ };
141
+ policyPatch.slowRequests = sorted;
142
+ policyPatch.slowRequestsConfigured = true;
143
+ overlay.slowRequests = sorted;
144
+ hasPolicyKnob = true;
145
+ }
146
+ if (config.httpAbortedRequests !== undefined) {
147
+ policyPatch.httpAbortedRequests = config.httpAbortedRequests;
148
+ overlay.httpAbortedRequests = config.httpAbortedRequests;
149
+ hasPolicyKnob = true;
150
+ }
151
+ if (config.httpClientErrorRequests !== undefined) {
152
+ policyPatch.httpClientErrorRequests = config.httpClientErrorRequests;
153
+ overlay.httpClientErrorRequests = config.httpClientErrorRequests;
154
+ hasPolicyKnob = true;
155
+ }
156
+ if (config.autoAttributeRequests !== undefined) {
157
+ policyPatch.autoAttributeRequests = config.autoAttributeRequests;
158
+ overlay.autoAttributeRequests = config.autoAttributeRequests;
159
+ hasPolicyKnob = true;
160
+ }
161
+
162
+ if (hasPolicyKnob) {
163
+ if (!areSharedPoliciesLocked()) {
164
+ seedSharedPolicies(policyPatch);
165
+ }
166
+ moduleRuntime.setPolicyOverlay(overlay);
167
+ }
168
+ if (config.maxSteps !== undefined) {
169
+ moduleRuntime.setMaxSteps(config.maxSteps);
170
+ }
171
+ if (config.defaultJourneyIdleMs !== undefined) {
172
+ moduleRuntime.setDefaultJourneyIdleMs(config.defaultJourneyIdleMs);
173
+ }
174
+ if (config.sinks !== undefined) {
175
+ if (!moduleRuntime.trySetConfiguredSinks(config.sinks)) {
176
+ // eslint-disable-next-line no-console -- first-writer sinks
177
+ console.warn(
178
+ '[journey] configureJourney: sinks already set on this runtime — ignoring'
179
+ );
180
+ }
181
+ }
182
+
183
+ const wiringTransport =
184
+ config.axios !== undefined ||
185
+ config.fetch !== undefined ||
186
+ (config.jquery !== undefined && config.jquery !== false);
187
+ if (wiringTransport) {
188
+ warnIfSlowRequestsUnset();
189
+ }
190
+
191
+ if (config.axios !== undefined) {
192
+ instrumentAxios(config.axios);
193
+ }
194
+ if (config.fetch !== undefined) {
195
+ instrumentFetch(config.fetch === true ? {} : config.fetch);
196
+ }
197
+ if (config.jquery === true) {
198
+ instrumentJquery(resolveGlobalJquery());
199
+ } else if (config.jquery !== undefined && config.jquery !== false) {
200
+ instrumentJquery(config.jquery);
201
+ }
202
+ if (config.exposeAppJourney) {
203
+ exposeAppJourney(
204
+ config.exposeAppJourney === true ? undefined : config.exposeAppJourney
205
+ );
206
+ }
207
+ } catch (err) {
208
+ // eslint-disable-next-line no-console -- soft-fail host wiring
209
+ console.error('[journey] configureJourney failed', err);
86
210
  }
87
211
  }
88
212
 
89
213
  /**
90
- * @internal Read the module runtime's app-wide request timeout config.
91
- * Prefer `configureJourney({ requestTimeouts })` to set thresholds. Test / introspection only.
214
+ * @internal Read the module runtime's app-wide slow-request config.
215
+ * Prefer `configureJourney({ slowRequests })` to set thresholds. Test / introspection only.
92
216
  */
93
- export function getRequestTimeouts(): AppRequestTimeouts {
94
- return moduleRuntime.getRequestTimeouts();
217
+ export function getSlowRequests(): SlowRequestConfig {
218
+ return getSharedSlowRequests() ?? moduleRuntime.getSlowRequests();
95
219
  }
@@ -0,0 +1,24 @@
1
+ export function isAbortError(error: unknown): boolean {
2
+ if (error == null || typeof error !== 'object') {
3
+ return false;
4
+ }
5
+ const err = error as { name?: string; code?: string | number };
6
+ if (err.name === 'AbortError' || err.name === 'CanceledError') {
7
+ return true;
8
+ }
9
+ return err.code === 'ERR_CANCELED';
10
+ }
11
+
12
+ /** Axios cancel token, CanceledError, AbortError, or ERR_CANCELED. */
13
+ export function isCanceledError(error: unknown): boolean {
14
+ if (isAbortError(error)) {
15
+ return true;
16
+ }
17
+ if (error == null || typeof error !== 'object') {
18
+ return false;
19
+ }
20
+ /* eslint-disable @typescript-eslint/naming-convention -- axios Cancel token wire format */
21
+ const err = error as { __CANCEL__?: boolean };
22
+ return err.__CANCEL__ === true;
23
+ /* eslint-enable @typescript-eslint/naming-convention */
24
+ }
package/src/core/api.ts CHANGED
@@ -1,40 +1,87 @@
1
- import { moduleRuntime } from './runtime';
1
+ import { moduleRuntime, resolveJourneyEngine } from './runtime';
2
+ import { adoptJourneyStepStamp, JourneyStepStamp } from './step-tag';
2
3
  import type {
3
4
  JourneyDef,
4
5
  JourneyHandle,
5
6
  JourneyStepOptions,
6
- JourneyStepRef,
7
7
  JourneyStepTarget,
8
8
  StepHandle,
9
+ StepRecord,
9
10
  TagValue,
10
11
  } from './types';
11
12
 
13
+ let ambientStamp: JourneyStepStamp | null = null;
14
+ let ambientStep: StepRecord | null = null;
15
+
12
16
  /**
13
- * Active step for soft-ambient HTTP attribution, or null when 0 or 2+ steps are in flight
14
- * app-wide. Untagged traffic is attributed to that lone step even if unrelated — prefer
15
- * `step.tag()` when the page has background instrumented requests. Adapters call this when
16
- * a request has no explicit stamp.
17
+ * Unique in-flight step as a stamp-ready {@link JourneyStepStamp}, or null when
18
+ * auto-attribute is disabled, or when 0 or 2+ steps are in flight app-wide.
19
+ * Ambient stamps never carry `score` or `ignore`. Untagged traffic is attributed
20
+ * to that lone step even if unrelated — prefer `step.stamp()` when the page has
21
+ * background instrumented requests. Disable via
22
+ * `configureJourney({ autoAttributeRequests: false })`.
17
23
  */
18
- export function activeJourneyStep(): JourneyStepRef | null {
19
- return moduleRuntime.activeJourneyStep();
24
+ export function singleInFlightJourneyStep(): JourneyStepStamp | null {
25
+ if (ambientStep?.journey.closed) {
26
+ ambientStep = null;
27
+ ambientStamp = null;
28
+ }
29
+ const activeStep = moduleRuntime.activeJourneyStep();
30
+ if (!activeStep) {
31
+ ambientStamp = null;
32
+ ambientStep = null;
33
+ return null;
34
+ }
35
+ if (ambientStep === activeStep && ambientStamp) {
36
+ return ambientStamp;
37
+ }
38
+ ambientStep = activeStep;
39
+ ambientStamp = new JourneyStepStamp(activeStep);
40
+ return ambientStamp;
41
+ }
42
+
43
+ /** @internal Clear cached ambient stamp when runtime state changes. */
44
+ export function invalidateAmbientStamp(step?: StepRecord): void {
45
+ if (!step || step === ambientStep) {
46
+ ambientStamp = null;
47
+ ambientStep = null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Prefer an explicit `step.stamp()` value; else the ambient in-flight stamp.
53
+ * Same-realm stamps are returned as-is (identity + score + ignore). Branded
54
+ * foreign copies are adopted into a local stamp. Raw StepRecords are not stamps.
55
+ */
56
+ export function resolveStamp(explicit?: unknown): JourneyStepStamp | undefined {
57
+ const fromExplicit = adoptJourneyStepStamp(explicit);
58
+ if (fromExplicit) {
59
+ return fromExplicit;
60
+ }
61
+ return singleInFlightJourneyStep() ?? undefined;
20
62
  }
21
63
 
22
64
  /**
23
65
  * Record a finished backend request against a captured step.
24
66
  * 5xx / network / slow (over threshold) mark Bad; 4xx is not counted. Fail-fast on breach.
67
+ * @internal Prefer {@link onHttpComplete} from adapters.
25
68
  */
26
69
  export function reportBackendRequest(
27
- step: JourneyStepRef | undefined,
70
+ step: StepRecord | undefined,
28
71
  status: number | undefined,
29
72
  durationMs: number,
30
73
  requestKey: string
31
74
  ): void {
32
- moduleRuntime.reportBackendRequest(step, status, durationMs, requestKey);
75
+ if (!step?.journey) {
76
+ return;
77
+ }
78
+ resolveJourneyEngine(step.journey).reportBackendRequest(step, status, durationMs, requestKey);
33
79
  }
34
80
 
35
81
  /**
36
82
  * Run one step of a named journey. Runs untraced (noop handle) when no matching journey is open.
37
- * `journey` may be a name, null, or an ordered candidate list.
83
+ * `journey` may be a name, null, an ordered candidate list, a def, or a handle.
84
+ * A def or handle auto-starts when that journey is not already open; a bare name does not.
38
85
  * Prefer `defineJourney(...).step` when you own the journey config.
39
86
  */
40
87
  export async function journeyStep<T>(
@@ -53,7 +100,10 @@ export function setJourneyTags(name: string | null, tags: Record<string, TagValu
53
100
 
54
101
  /** Mark a named journey Bad for a custom reason (fail-fast). Prefer `handle.fail`. */
55
102
  export function failJourney(name: string, reason: string): void {
56
- moduleRuntime.failJourney(name, reason);
103
+ const journey = moduleRuntime.getOpenJourney(name);
104
+ if (journey) {
105
+ moduleRuntime.failJourney(journey, reason);
106
+ }
57
107
  }
58
108
 
59
109
  /** Successful end. Outcome stays Bad if a breach already occurred. No-op when name is null. Prefer `handle.complete`. */
@@ -63,47 +113,47 @@ export function completeJourney(name: string | null, tags?: Record<string, TagVa
63
113
 
64
114
  /** User left before finishing. Stays Bad if a breach already occurred (breach-anchored). Prefer `handle.exclude`. */
65
115
  export function excludeJourney(name: string): void {
66
- moduleRuntime.excludeJourney(name);
116
+ const journey = moduleRuntime.getOpenJourney(name);
117
+ if (journey) {
118
+ moduleRuntime.excludeJourney(journey);
119
+ }
67
120
  }
68
121
 
69
122
  /** Turn a journey config into a handle with bound lifecycle methods. Preferred public API. */
70
- export function defineJourney(def: JourneyDef): JourneyHandle {
71
- const ensureStarted = () => {
72
- if (!moduleRuntime.getOpenJourney(def.name)) {
73
- moduleRuntime.startJourney(def);
74
- }
75
- };
76
-
77
- const handle: JourneyHandle = {
78
- ...def,
123
+ export function defineJourney(config: JourneyDef): JourneyHandle {
124
+ return {
125
+ config,
79
126
  start(tags) {
80
- moduleRuntime.startJourney(def);
127
+ moduleRuntime.startJourney(config);
81
128
  if (tags) {
82
- moduleRuntime.setJourneyTags(def.name, tags);
129
+ moduleRuntime.setJourneyTags(config.name, tags);
83
130
  }
84
131
  },
85
132
  step(stepName, fn, opts) {
86
- ensureStarted();
87
- return journeyStep(def.name, stepName, fn, opts);
133
+ if (!moduleRuntime.getOpenJourney(config.name)) {
134
+ moduleRuntime.startJourney(config);
135
+ }
136
+ return journeyStep(config.name, stepName, fn, opts);
88
137
  },
89
138
  async mountStep(stepName, fn, opts) {
90
139
  // Defer so a parent <JourneyScope> opens first (child effects run before parent).
91
140
  await Promise.resolve();
92
- ensureStarted();
93
- return journeyStep(def.name, stepName, fn, opts);
141
+ if (!moduleRuntime.getOpenJourney(config.name)) {
142
+ moduleRuntime.startJourney(config);
143
+ }
144
+ return journeyStep(config.name, stepName, fn, opts);
94
145
  },
95
146
  complete(tags) {
96
- completeJourney(def.name, tags);
147
+ completeJourney(config.name, tags);
97
148
  },
98
149
  fail(reason) {
99
- failJourney(def.name, reason);
150
+ failJourney(config.name, reason);
100
151
  },
101
152
  exclude() {
102
- excludeJourney(def.name);
153
+ excludeJourney(config.name);
103
154
  },
104
155
  setTags(tags) {
105
- setJourneyTags(def.name, tags);
156
+ setJourneyTags(config.name, tags);
106
157
  },
107
158
  };
108
- return handle;
109
159
  }
@@ -1,3 +1,5 @@
1
+ import type { JourneyState } from './types';
2
+
1
3
  /** Per-endpoint request policy (app-wide or per-journey). Longest matching prefix wins. */
2
4
  export interface EndpointPolicy {
3
5
  /**
@@ -7,20 +9,25 @@ export interface EndpointPolicy {
7
9
  * `match` already supplies the boundary.
8
10
  */
9
11
  match: string;
10
- /** Custom slow-request threshold in ms; omit / 0 falls through. */
11
- timeoutMs?: number;
12
- /** When true, errors and slow responses never mark the journey Bad. */
12
+ /** Custom slow-request threshold in ms; omit to fall through. `0` disables latency scoring for this prefix. */
13
+ slowRequestMs?: number;
14
+ /** When true, this endpoint never changes the journey verdict (errors, latency, aborts, 4xx). */
13
15
  ignore?: boolean;
14
16
  }
15
17
 
16
18
  /** App-wide slow-request threshold fallback (not an axios abort). */
17
- export interface AppRequestTimeouts {
19
+ export interface SlowRequestConfig {
18
20
  /** Fallback threshold when no endpoint or journey override applies. */
19
21
  defaultMs: number;
20
22
  /** App-wide endpoint overrides; journey-specific ones belong on JourneyDef.endpoints. */
21
23
  endpoints?: readonly EndpointPolicy[];
22
24
  }
23
25
 
26
+ export interface ResolvedEndpointPolicy {
27
+ ignore: boolean;
28
+ slowRequestMs: number;
29
+ }
30
+
24
31
  /** Copy sorted longest-match-first so lookup can return on the first hit. */
25
32
  export function sortEndpointsByLongestMatch(
26
33
  endpoints: readonly EndpointPolicy[] | undefined
@@ -64,3 +71,38 @@ export function longestEndpointMatch(
64
71
  }
65
72
  return undefined;
66
73
  }
74
+
75
+ /**
76
+ * Resolve ignore + latency threshold with longest-prefix matching.
77
+ * Journey overrides win over app defaults for slow thresholds.
78
+ */
79
+ export function resolveEndpointPolicy(
80
+ journey: JourneyState,
81
+ appSlow: SlowRequestConfig,
82
+ requestKey: string
83
+ ): ResolvedEndpointPolicy {
84
+ const own = longestEndpointMatch(journey.endpoints, requestKey);
85
+ if (own?.ignore === true) {
86
+ return { ignore: true, slowRequestMs: 0 };
87
+ }
88
+
89
+ const app = longestEndpointMatch(appSlow.endpoints, requestKey);
90
+ if (own) {
91
+ return {
92
+ ignore: false,
93
+ slowRequestMs:
94
+ own.slowRequestMs ??
95
+ app?.slowRequestMs ??
96
+ journey.slowRequestMs ??
97
+ appSlow.defaultMs,
98
+ };
99
+ }
100
+ if (app?.ignore === true) {
101
+ return { ignore: true, slowRequestMs: 0 };
102
+ }
103
+
104
+ return {
105
+ ignore: false,
106
+ slowRequestMs: app?.slowRequestMs ?? journey.slowRequestMs ?? appSlow.defaultMs,
107
+ };
108
+ }
package/src/core/index.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  /** Core barrel — public free helpers, handles, and types. Engine lives in `./runtime`. */
2
2
 
3
3
  export {
4
- activeJourneyStep,
5
4
  completeJourney,
6
5
  defineJourney,
7
6
  excludeJourney,
8
7
  failJourney,
9
8
  journeyStep,
10
9
  reportBackendRequest,
10
+ resolveStamp,
11
11
  setJourneyTags,
12
+ singleInFlightJourneyStep,
12
13
  } from './api';
13
14
  export {
14
- // @internal accidentally public via package index; not a supported consumer API.
15
+ // @internal Test helper not a supported consumer API.
15
16
  resetJourney,
16
17
  } from './runtime';
17
18
  export type {
@@ -22,9 +23,16 @@ export type {
22
23
  JourneySink,
23
24
  JourneyStepEvent,
24
25
  JourneyStepOptions,
25
- JourneyStepRef,
26
26
  JourneyStepTarget,
27
27
  StepHandle,
28
28
  TagValue,
29
29
  } from './types';
30
- export { JourneyStepTag, resolveJourneyStep } from './step-tag';
30
+ export { JourneyStepStamp } from './step-tag';
31
+ export type {
32
+ JourneyHttpPolicyContext,
33
+ JourneyHttpScore,
34
+ JourneyHttpScoreContext,
35
+ JourneyRequestPolicy,
36
+ JourneyStampOptions,
37
+ RequestPolicyConfig,
38
+ } from './request-policy';