@servicetitan/journey 2.0.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 (233) hide show
  1. package/README.md +1 -1
  2. package/dist/__test-utils__/test-runtime.d.ts.map +1 -0
  3. package/dist/__test-utils__/test-runtime.js +54 -0
  4. package/dist/__test-utils__/test-runtime.js.map +1 -0
  5. package/dist/__tests__/core/ambient.test.d.ts.map +1 -0
  6. package/dist/__tests__/core/api.test.d.ts +2 -0
  7. package/dist/__tests__/core/api.test.d.ts.map +1 -0
  8. package/dist/__tests__/core/define-journey.test.d.ts.map +1 -0
  9. package/dist/__tests__/core/requests.test.d.ts.map +1 -0
  10. package/dist/__tests__/core/runtime.test.d.ts.map +1 -0
  11. package/dist/__tests__/core/sanitize.test.d.ts +2 -0
  12. package/dist/__tests__/core/sanitize.test.d.ts.map +1 -0
  13. package/dist/__tests__/core/step.test.d.ts.map +1 -0
  14. package/dist/__tests__/core/timeouts.test.d.ts.map +1 -0
  15. package/dist/__tests__/global.test.d.ts +3 -0
  16. package/dist/__tests__/global.test.d.ts.map +1 -0
  17. package/dist/__tests__/hardening.test.d.ts +2 -0
  18. package/dist/__tests__/hardening.test.d.ts.map +1 -0
  19. package/dist/__tests__/integrations/axios.test.d.ts.map +1 -0
  20. package/dist/__tests__/integrations/context.test.d.ts.map +1 -0
  21. package/dist/__tests__/integrations/expose-app-journey.test.d.ts.map +1 -0
  22. package/dist/__tests__/integrations/fetch.test.d.ts.map +1 -0
  23. package/dist/__tests__/integrations/host-mfe.test.d.ts +2 -0
  24. package/dist/__tests__/integrations/host-mfe.test.d.ts.map +1 -0
  25. package/dist/__tests__/integrations/jquery.test.d.ts.map +1 -0
  26. package/dist/__tests__/integrations/request-key.test.d.ts.map +1 -0
  27. package/dist/__tests__/integrations/scope.test.d.ts.map +1 -0
  28. package/dist/__tests__/integrations/use-journey-step.test.d.ts +2 -0
  29. package/dist/__tests__/integrations/use-journey-step.test.d.ts.map +1 -0
  30. package/dist/__tests__/sinks/console.test.d.ts +2 -0
  31. package/dist/__tests__/sinks/console.test.d.ts.map +1 -0
  32. package/dist/__tests__/sinks/datadog.test.d.ts.map +1 -0
  33. package/dist/__tests__/sinks/sinks.test.d.ts.map +1 -0
  34. package/dist/config.d.ts +61 -13
  35. package/dist/config.d.ts.map +1 -1
  36. package/dist/config.js +98 -29
  37. package/dist/config.js.map +1 -1
  38. package/dist/core/abort-error.d.ts +4 -0
  39. package/dist/core/abort-error.d.ts.map +1 -0
  40. package/dist/core/abort-error.js +22 -0
  41. package/dist/core/abort-error.js.map +1 -0
  42. package/dist/core/api.d.ts +43 -0
  43. package/dist/core/api.d.ts.map +1 -0
  44. package/dist/core/api.js +122 -0
  45. package/dist/core/api.js.map +1 -0
  46. package/dist/{endpoint-policy.d.ts → core/endpoint-policy.d.ts} +14 -4
  47. package/dist/core/endpoint-policy.d.ts.map +1 -0
  48. package/dist/core/endpoint-policy.js +70 -0
  49. package/dist/core/endpoint-policy.js.map +1 -0
  50. package/dist/core/index.d.ts +6 -5
  51. package/dist/core/index.d.ts.map +1 -1
  52. package/dist/core/index.js +4 -3
  53. package/dist/core/index.js.map +1 -1
  54. package/dist/core/limits.d.ts +29 -0
  55. package/dist/core/limits.d.ts.map +1 -0
  56. package/dist/core/limits.js +63 -0
  57. package/dist/core/limits.js.map +1 -0
  58. package/dist/core/request-policy.d.ts +39 -0
  59. package/dist/core/request-policy.d.ts.map +1 -0
  60. package/dist/core/request-policy.js +18 -0
  61. package/dist/core/request-policy.js.map +1 -0
  62. package/dist/core/runtime.d.ts +95 -17
  63. package/dist/core/runtime.d.ts.map +1 -1
  64. package/dist/core/runtime.js +435 -124
  65. package/dist/core/runtime.js.map +1 -1
  66. package/dist/core/sanitize.d.ts +23 -0
  67. package/dist/core/sanitize.d.ts.map +1 -0
  68. package/dist/core/sanitize.js +143 -0
  69. package/dist/core/sanitize.js.map +1 -0
  70. package/dist/core/step-tag.d.ts +31 -6
  71. package/dist/core/step-tag.d.ts.map +1 -1
  72. package/dist/core/step-tag.js +62 -9
  73. package/dist/core/step-tag.js.map +1 -1
  74. package/dist/core/types.d.ts +76 -26
  75. package/dist/core/types.d.ts.map +1 -1
  76. package/dist/core/types.js +4 -1
  77. package/dist/core/types.js.map +1 -1
  78. package/dist/global.d.ts +145 -0
  79. package/dist/global.d.ts.map +1 -0
  80. package/dist/global.js +326 -0
  81. package/dist/global.js.map +1 -0
  82. package/dist/index.d.ts +9 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +9 -5
  85. package/dist/index.js.map +1 -1
  86. package/dist/integrations/axios.d.ts +5 -1
  87. package/dist/integrations/axios.d.ts.map +1 -1
  88. package/dist/integrations/axios.js +146 -34
  89. package/dist/integrations/axios.js.map +1 -1
  90. package/dist/integrations/fetch.d.ts +6 -4
  91. package/dist/integrations/fetch.d.ts.map +1 -1
  92. package/dist/integrations/fetch.js +105 -26
  93. package/dist/integrations/fetch.js.map +1 -1
  94. package/dist/integrations/http-report.d.ts +27 -0
  95. package/dist/integrations/http-report.d.ts.map +1 -0
  96. package/dist/integrations/http-report.js +123 -0
  97. package/dist/integrations/http-report.js.map +1 -0
  98. package/dist/integrations/jquery/index.d.ts +10 -6
  99. package/dist/integrations/jquery/index.d.ts.map +1 -1
  100. package/dist/integrations/jquery/index.js +28 -9
  101. package/dist/integrations/jquery/index.js.map +1 -1
  102. package/dist/integrations/jquery/instrument.d.ts +14 -5
  103. package/dist/integrations/jquery/instrument.d.ts.map +1 -1
  104. package/dist/integrations/jquery/instrument.js +111 -29
  105. package/dist/integrations/jquery/instrument.js.map +1 -1
  106. package/dist/integrations/react/context.d.ts +4 -1
  107. package/dist/integrations/react/context.d.ts.map +1 -1
  108. package/dist/integrations/react/context.js +4 -1
  109. package/dist/integrations/react/context.js.map +1 -1
  110. package/dist/integrations/react/scope.d.ts +14 -3
  111. package/dist/integrations/react/scope.d.ts.map +1 -1
  112. package/dist/integrations/react/scope.js +48 -9
  113. package/dist/integrations/react/scope.js.map +1 -1
  114. package/dist/integrations/react/stable-config-keys.d.ts +6 -0
  115. package/dist/integrations/react/stable-config-keys.d.ts.map +1 -0
  116. package/dist/integrations/react/stable-config-keys.js +21 -0
  117. package/dist/integrations/react/stable-config-keys.js.map +1 -0
  118. package/dist/integrations/react/use-journey-step.d.ts +11 -0
  119. package/dist/integrations/react/use-journey-step.d.ts.map +1 -0
  120. package/dist/integrations/react/use-journey-step.js +19 -0
  121. package/dist/integrations/react/use-journey-step.js.map +1 -0
  122. package/dist/integrations/request-key.d.ts +8 -3
  123. package/dist/integrations/request-key.d.ts.map +1 -1
  124. package/dist/integrations/request-key.js +10 -9
  125. package/dist/integrations/request-key.js.map +1 -1
  126. package/dist/integrations/soft-skip.d.ts +7 -0
  127. package/dist/integrations/soft-skip.d.ts.map +1 -0
  128. package/dist/integrations/soft-skip.js +30 -0
  129. package/dist/integrations/soft-skip.js.map +1 -0
  130. package/dist/sinks/console.d.ts +11 -0
  131. package/dist/sinks/console.d.ts.map +1 -0
  132. package/dist/sinks/console.js +19 -0
  133. package/dist/sinks/console.js.map +1 -0
  134. package/dist/sinks/datadog.d.ts +12 -1
  135. package/dist/sinks/datadog.d.ts.map +1 -1
  136. package/dist/sinks/datadog.js +42 -25
  137. package/dist/sinks/datadog.js.map +1 -1
  138. package/package.json +2 -2
  139. package/src/{__mocks__ → __test-utils__}/test-runtime.ts +15 -4
  140. package/src/__tests__/config.test.ts +158 -42
  141. package/src/__tests__/{ambient.test.ts → core/ambient.test.ts} +23 -16
  142. package/src/__tests__/{registry.test.ts → core/api.test.ts} +35 -30
  143. package/src/__tests__/{define-journey.test.ts → core/define-journey.test.ts} +33 -15
  144. package/src/__tests__/{requests.test.ts → core/requests.test.ts} +61 -39
  145. package/src/__tests__/{runtime.test.ts → core/runtime.test.ts} +84 -37
  146. package/src/__tests__/core/sanitize.test.ts +69 -0
  147. package/src/__tests__/{step.test.ts → core/step.test.ts} +146 -52
  148. package/src/__tests__/{timeouts.test.ts → core/timeouts.test.ts} +83 -13
  149. package/src/__tests__/global.test.ts +154 -0
  150. package/src/__tests__/hardening.test.ts +613 -0
  151. package/src/__tests__/integrations/axios.test.ts +616 -0
  152. package/src/__tests__/{context.test.tsx → integrations/context.test.tsx} +1 -1
  153. package/src/__tests__/{expose-app-journey.test.ts → integrations/expose-app-journey.test.ts} +11 -5
  154. package/src/__tests__/{fetch.test.ts → integrations/fetch.test.ts} +159 -25
  155. package/src/__tests__/integrations/host-mfe.test.ts +548 -0
  156. package/src/__tests__/integrations/jquery.test.ts +539 -0
  157. package/src/__tests__/{request-key.test.ts → integrations/request-key.test.ts} +1 -1
  158. package/src/__tests__/integrations/scope.test.tsx +286 -0
  159. package/src/__tests__/integrations/use-journey-step.test.tsx +43 -0
  160. package/src/__tests__/sinks/console.test.ts +103 -0
  161. package/src/__tests__/{datadog.test.ts → sinks/datadog.test.ts} +109 -5
  162. package/src/__tests__/{sinks.test.ts → sinks/sinks.test.ts} +13 -12
  163. package/src/config.ts +173 -41
  164. package/src/core/abort-error.ts +24 -0
  165. package/src/core/api.ts +159 -0
  166. package/src/{endpoint-policy.ts → core/endpoint-policy.ts} +46 -4
  167. package/src/core/index.ts +16 -17
  168. package/src/core/limits.ts +73 -0
  169. package/src/core/request-policy.ts +66 -0
  170. package/src/core/runtime.ts +560 -137
  171. package/src/core/sanitize.ts +177 -0
  172. package/src/core/step-tag.ts +90 -11
  173. package/src/core/types.ts +82 -26
  174. package/src/global.ts +466 -0
  175. package/src/index.ts +31 -18
  176. package/src/integrations/axios.ts +183 -45
  177. package/src/integrations/fetch.ts +127 -41
  178. package/src/integrations/http-report.ts +184 -0
  179. package/src/integrations/jquery/index.ts +37 -18
  180. package/src/integrations/jquery/instrument.ts +126 -42
  181. package/src/integrations/react/context.ts +4 -1
  182. package/src/integrations/react/scope.tsx +61 -10
  183. package/src/integrations/react/stable-config-keys.ts +28 -0
  184. package/src/integrations/react/use-journey-step.ts +31 -0
  185. package/src/integrations/request-key.ts +18 -10
  186. package/src/integrations/soft-skip.ts +35 -0
  187. package/src/sinks/console.ts +21 -0
  188. package/src/sinks/datadog.ts +52 -33
  189. package/dist/__mocks__/test-runtime.d.ts.map +0 -1
  190. package/dist/__tests__/ambient.test.d.ts.map +0 -1
  191. package/dist/__tests__/axios.test.d.ts.map +0 -1
  192. package/dist/__tests__/context.test.d.ts.map +0 -1
  193. package/dist/__tests__/datadog.test.d.ts.map +0 -1
  194. package/dist/__tests__/define-journey.test.d.ts.map +0 -1
  195. package/dist/__tests__/expose-app-journey.test.d.ts.map +0 -1
  196. package/dist/__tests__/fetch.test.d.ts.map +0 -1
  197. package/dist/__tests__/jquery.test.d.ts.map +0 -1
  198. package/dist/__tests__/registry.test.d.ts +0 -2
  199. package/dist/__tests__/registry.test.d.ts.map +0 -1
  200. package/dist/__tests__/request-key.test.d.ts.map +0 -1
  201. package/dist/__tests__/requests.test.d.ts.map +0 -1
  202. package/dist/__tests__/runtime.test.d.ts.map +0 -1
  203. package/dist/__tests__/scope.test.d.ts.map +0 -1
  204. package/dist/__tests__/sinks.test.d.ts.map +0 -1
  205. package/dist/__tests__/step.test.d.ts.map +0 -1
  206. package/dist/__tests__/timeouts.test.d.ts.map +0 -1
  207. package/dist/core/registry.d.ts +0 -50
  208. package/dist/core/registry.d.ts.map +0 -1
  209. package/dist/core/registry.js +0 -102
  210. package/dist/core/registry.js.map +0 -1
  211. package/dist/endpoint-policy.d.ts.map +0 -1
  212. package/dist/endpoint-policy.js +0 -39
  213. package/dist/endpoint-policy.js.map +0 -1
  214. package/src/__tests__/axios.test.ts +0 -365
  215. package/src/__tests__/jquery.test.ts +0 -327
  216. package/src/__tests__/scope.test.tsx +0 -128
  217. package/src/core/registry.ts +0 -162
  218. /package/dist/{__mocks__ → __test-utils__}/test-runtime.d.ts +0 -0
  219. /package/dist/__tests__/{ambient.test.d.ts → core/ambient.test.d.ts} +0 -0
  220. /package/dist/__tests__/{define-journey.test.d.ts → core/define-journey.test.d.ts} +0 -0
  221. /package/dist/__tests__/{requests.test.d.ts → core/requests.test.d.ts} +0 -0
  222. /package/dist/__tests__/{runtime.test.d.ts → core/runtime.test.d.ts} +0 -0
  223. /package/dist/__tests__/{step.test.d.ts → core/step.test.d.ts} +0 -0
  224. /package/dist/__tests__/{timeouts.test.d.ts → core/timeouts.test.d.ts} +0 -0
  225. /package/dist/__tests__/{axios.test.d.ts → integrations/axios.test.d.ts} +0 -0
  226. /package/dist/__tests__/{context.test.d.ts → integrations/context.test.d.ts} +0 -0
  227. /package/dist/__tests__/{expose-app-journey.test.d.ts → integrations/expose-app-journey.test.d.ts} +0 -0
  228. /package/dist/__tests__/{fetch.test.d.ts → integrations/fetch.test.d.ts} +0 -0
  229. /package/dist/__tests__/{jquery.test.d.ts → integrations/jquery.test.d.ts} +0 -0
  230. /package/dist/__tests__/{request-key.test.d.ts → integrations/request-key.test.d.ts} +0 -0
  231. /package/dist/__tests__/{scope.test.d.ts → integrations/scope.test.d.ts} +0 -0
  232. /package/dist/__tests__/{datadog.test.d.ts → sinks/datadog.test.d.ts} +0 -0
  233. /package/dist/__tests__/{sinks.test.d.ts → sinks/sinks.test.d.ts} +0 -0
@@ -1,10 +1,13 @@
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';
4
+ getInstrumentedAxiosSet,
5
+ registerInstrumentRecord,
6
+ type InstrumentRecord,
7
+ } from '../global';
8
+ import { isCanceledError, onHttpComplete } from './http-report';
7
9
  import { requestKey, type JourneyTimedMeta } from './request-key';
10
+ import { acceptInstrumentTarget } from './soft-skip';
8
11
 
9
12
  /**
10
13
  * Minimal structural axios surface. No axios dependency — works across 0.31 and 1.x.
@@ -12,74 +15,209 @@ import { requestKey, type JourneyTimedMeta } from './request-key';
12
15
  export interface AxiosLikeInstance {
13
16
  defaults: { baseURL?: string };
14
17
  interceptors: {
15
- request: { use(onFulfilled: (config: any) => any): unknown };
18
+ request: {
19
+ use(onFulfilled: (config: any) => any): unknown;
20
+ eject?(id: unknown): void;
21
+ };
16
22
  response: {
17
23
  use(onFulfilled: (response: any) => any, onRejected?: (error: any) => any): unknown;
24
+ eject?(id: unknown): void;
18
25
  };
19
26
  };
20
27
  }
21
28
 
22
- type TimedConfig = JourneyTimedMeta & { url?: string; baseURL?: string };
29
+ type TimedConfig = JourneyTimedMeta & { url?: string; baseURL?: string; method?: string };
30
+
31
+ const axiosStamps = new WeakMap<object, JourneyStepStamp>();
32
+
33
+ function stampFromConfig(config: TimedConfig | undefined): JourneyStepStamp | undefined {
34
+ if (!config) {
35
+ return undefined;
36
+ }
37
+ return axiosStamps.get(config) ?? adoptJourneyStepStamp(config.stamp);
38
+ }
23
39
 
24
40
  function reportTiming(
25
41
  instance: AxiosLikeInstance,
26
42
  config: TimedConfig | undefined,
27
- status: number | undefined
43
+ status: number | undefined,
44
+ aborted: boolean,
45
+ extras?: { request?: unknown; response?: unknown; error?: unknown }
28
46
  ): void {
29
- const step = resolveJourneyStep(config?.journeyStep);
30
- if (!step || !config) {
47
+ if (!config || config.ignore === true) {
48
+ return;
49
+ }
50
+ const stamp = stampFromConfig(config);
51
+ if (!stamp || stamp.ignore) {
31
52
  return;
32
53
  }
33
54
  const start = config.journeyStart ?? globalThis.performance.now();
34
55
  const key = requestKey(config.url, config.baseURL, instance.defaults.baseURL);
35
- reportBackendRequest(step, status, globalThis.performance.now() - start, key);
56
+ onHttpComplete({
57
+ stamp,
58
+ status,
59
+ durationMs: globalThis.performance.now() - start,
60
+ requestKey: key,
61
+ aborted,
62
+ url: config.url,
63
+ method: config.method,
64
+ meta: config,
65
+ request: extras?.request ?? config,
66
+ response: extras?.response,
67
+ error: extras?.error,
68
+ });
36
69
  }
37
70
 
38
- const instrumented = new WeakSet<object>();
71
+ function isAxiosLikeInstance(value: unknown): value is AxiosLikeInstance {
72
+ if (value == null || (typeof value !== 'object' && typeof value !== 'function')) {
73
+ return false;
74
+ }
75
+ const instance = value as AxiosLikeInstance;
76
+ return (
77
+ instance.defaults != null &&
78
+ typeof instance.defaults === 'object' &&
79
+ typeof instance.interceptors?.request?.use === 'function' &&
80
+ typeof instance.interceptors?.response?.use === 'function'
81
+ );
82
+ }
83
+
84
+ function ejectInterceptor(
85
+ interceptors: { eject?: (id: unknown) => void } | undefined,
86
+ id: unknown
87
+ ): boolean {
88
+ if (id === undefined || typeof interceptors?.eject !== 'function') {
89
+ return false;
90
+ }
91
+ try {
92
+ interceptors.eject(id);
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
39
98
 
40
99
  /**
41
100
  * Wire an axios instance into the journey engine (observation-only).
42
101
  * Call once per instance (idempotent). Register any async request interceptors
43
102
  * (e.g. auth) before this call so this one runs first.
103
+ * Soft-skips when `instance` is missing or not axios-like — hosts can pass axios as-is.
104
+ * Returns a restore function that disposes handlers (they no-op after restore).
44
105
  */
45
- export function instrumentAxios(instance: AxiosLikeInstance): void {
46
- if (instrumented.has(instance as object)) {
47
- return;
106
+ export function instrumentAxios(instance: AxiosLikeInstance | null | undefined): () => void {
107
+ const axios = acceptInstrumentTarget('Axios', instance, isAxiosLikeInstance);
108
+ const instrumented = getInstrumentedAxiosSet();
109
+ if (!axios || instrumented.has(axios as object)) {
110
+ return () => {};
48
111
  }
49
- instrumented.add(instance as object);
50
112
 
51
- instance.interceptors.request.use(config => {
52
- const timed = config as TimedConfig;
53
- /*
54
- * Prefer explicit step.tag(); else soft ambient (null when 0 or 2+ steps in flight).
55
- * Box ambient steps in JourneyStepTag — raw StepRecord is circular and blows axios mergeConfig.
56
- */
57
- if (!resolveJourneyStep(timed.journeyStep)) {
58
- const step = activeJourneyStep();
59
- if (step) {
60
- timed.journeyStep = new JourneyStepTag(step);
113
+ const record: InstrumentRecord = { disposed: false };
114
+ let requestId: unknown;
115
+ let responseId: unknown;
116
+ let requestAdded = false;
117
+ let responseAdded = false;
118
+
119
+ try {
120
+ requestId = axios.interceptors.request.use(config => {
121
+ if (record.disposed) {
122
+ return config;
61
123
  }
62
- }
63
- timed.journeyStart = globalThis.performance.now();
64
- return config;
65
- });
124
+ const timed = config as TimedConfig;
125
+ if (timed.ignore === true) {
126
+ return config;
127
+ }
128
+ const stamp = resolveStamp(timed.stamp);
129
+ if (stamp) {
130
+ axiosStamps.set(timed, stamp);
131
+ /*
132
+ * Keep config.stamp so later interceptors that clone/spread config
133
+ * still carry attribution. JourneyStepStamp.toJSON keeps this safe
134
+ * for AxiosError serialization.
135
+ */
136
+ }
137
+ if (stamp?.ignore === true) {
138
+ return config;
139
+ }
140
+ if (stamp) {
141
+ timed.journeyStart = globalThis.performance.now();
142
+ }
143
+ return config;
144
+ });
145
+ requestAdded = true;
66
146
 
67
- instance.interceptors.response.use(
68
- response => {
69
- reportTiming(instance, response?.config as TimedConfig | undefined, response?.status);
70
- return response;
71
- },
72
- (error: unknown) => {
73
- const axiosError = error as {
74
- config?: TimedConfig;
75
- response?: { status?: number };
76
- };
77
- reportTiming(instance, axiosError?.config, axiosError?.response?.status);
147
+ responseId = axios.interceptors.response.use(
148
+ response => {
149
+ if (!record.disposed) {
150
+ reportTiming(
151
+ axios,
152
+ response?.config as TimedConfig | undefined,
153
+ response?.status,
154
+ false,
155
+ { request: response?.config, response }
156
+ );
157
+ }
158
+ return response;
159
+ },
160
+ (error: unknown) => {
161
+ if (!record.disposed) {
162
+ const axiosError = error as {
163
+ config?: TimedConfig;
164
+ response?: { status?: number };
165
+ };
166
+ reportTiming(
167
+ axios,
168
+ axiosError?.config,
169
+ axiosError?.response?.status,
170
+ isCanceledError(error),
171
+ {
172
+ request: axiosError?.config,
173
+ response: axiosError?.response,
174
+ error,
175
+ }
176
+ );
177
+ }
178
+ /*
179
+ * Passthrough original reason (AxiosError or test doubles). Do not wrap —
180
+ * callers match on .response / AxiosError identity.
181
+ */
182
+ return Promise.reject(error as Error);
183
+ }
184
+ );
185
+ responseAdded = true;
186
+ instrumented.add(axios as object);
187
+ } catch (err) {
188
+ const unwound =
189
+ (!responseAdded || ejectInterceptor(axios.interceptors.response, responseId)) &&
190
+ (!requestAdded || ejectInterceptor(axios.interceptors.request, requestId));
191
+ if (!unwound) {
78
192
  /*
79
- * Passthrough original reason (AxiosError or test doubles). Do not wrap
80
- * callers match on .response / AxiosError identity.
193
+ * Interceptors may still be registered and cannot be ejected. Mark disposed
194
+ * and keep the WeakSet slot, but register a restore so teardownJourney can
195
+ * unmark and a later retry can proceed.
81
196
  */
82
- return Promise.reject(error as Error);
197
+ record.disposed = true;
198
+ instrumented.add(axios as object);
199
+ const restore = () => {
200
+ record.disposed = true;
201
+ instrumented.delete(axios as object);
202
+ };
203
+ record.restore = restore;
204
+ registerInstrumentRecord(record);
205
+ // eslint-disable-next-line no-console -- soft-fail host wiring
206
+ console.error('[journey] instrumentAxios failed', err);
207
+ return restore;
83
208
  }
84
- );
209
+ // eslint-disable-next-line no-console -- soft-fail host wiring
210
+ console.error('[journey] instrumentAxios failed', err);
211
+ return () => {};
212
+ }
213
+
214
+ const restore = () => {
215
+ record.disposed = true;
216
+ ejectInterceptor(axios.interceptors.request, requestId);
217
+ ejectInterceptor(axios.interceptors.response, responseId);
218
+ instrumented.delete(axios as object);
219
+ };
220
+ record.restore = restore;
221
+ registerInstrumentRecord(record);
222
+ return restore;
85
223
  }
@@ -1,22 +1,29 @@
1
- import { activeJourneyStep, reportBackendRequest, resolveJourneyStep } from '../core';
1
+ import { resolveStamp } from '../core';
2
+ import {
3
+ getInstrumentedFetchSet,
4
+ registerInstrumentRecord,
5
+ setFetchRestore,
6
+ type InstrumentRecord,
7
+ } from '../global';
8
+ import { isAbortError, onHttpComplete } from './http-report';
2
9
  import { requestKey, type JourneyTimedMeta } from './request-key';
10
+ import { acceptInstrumentTarget } from './soft-skip';
3
11
 
4
12
  /** Minimal structural fetch shape. */
5
- export type FetchLike = (input: any, init?: any) => Promise<{ status: number }>;
13
+ type FetchLike = (input: any, init?: any) => Promise<{ status: number }>;
6
14
 
7
15
  /** Holder of a `fetch` property — globalThis by default, or a scoped object. */
8
- export interface FetchTarget {
16
+ interface FetchTarget {
9
17
  fetch: FetchLike;
10
18
  }
11
19
 
12
20
  export interface InstrumentFetchOptions {
13
21
  /** Object whose `fetch` is wrapped. Defaults to globalThis. */
14
- target?: FetchTarget;
22
+ target?: FetchTarget | null;
15
23
  /** Home API base URL for request-key origin resolution. */
16
24
  baseURL?: string;
17
25
  }
18
26
 
19
- const instrumented = new WeakSet<object>();
20
27
  const originals = new WeakMap<object, FetchLike>();
21
28
 
22
29
  function resolveUrl(input: any): string | undefined {
@@ -32,14 +39,47 @@ function resolveUrl(input: any): string | undefined {
32
39
  return undefined;
33
40
  }
34
41
 
42
+ /** Fetch defaults to GET; `init.method` overrides a Request's method. */
43
+ function resolveMethod(input: any, init?: any): string {
44
+ if (typeof init?.method === 'string' && init.method !== '') {
45
+ return init.method;
46
+ }
47
+ if (typeof input?.method === 'string' && input.method !== '') {
48
+ return input.method;
49
+ }
50
+ return 'GET';
51
+ }
52
+
53
+ function isFetchTarget(value: unknown): value is FetchTarget {
54
+ return (
55
+ value != null &&
56
+ (typeof value === 'object' || typeof value === 'function') &&
57
+ typeof (value as FetchTarget).fetch === 'function'
58
+ );
59
+ }
60
+
35
61
  /**
36
62
  * Wrap a target's fetch so requests inside a journeyStep are attributed and timed.
37
63
  * Call once per target (idempotent — second call is a no-op). Returns a restore
38
64
  * function that puts the original fetch back. Observation-only.
65
+ * Soft-skips when the resolved target is missing or has no callable `fetch`.
39
66
  */
40
- export function instrumentFetch(options: InstrumentFetchOptions = {}): () => void {
41
- const target = options.target ?? (globalThis as unknown as FetchTarget);
67
+ export function instrumentFetch(options?: InstrumentFetchOptions | null): () => void {
68
+ if (options === null) {
69
+ acceptInstrumentTarget('Fetch', null, isFetchTarget);
70
+ return () => {};
71
+ }
72
+
73
+ const opts = options ?? {};
74
+ const candidate =
75
+ opts.target !== undefined ? opts.target : (globalThis as unknown as FetchTarget);
76
+ const target = acceptInstrumentTarget('Fetch', candidate, isFetchTarget);
77
+ if (!target) {
78
+ return () => {};
79
+ }
80
+
42
81
  const key = target as object;
82
+ const instrumented = getInstrumentedFetchSet();
43
83
 
44
84
  if (instrumented.has(key)) {
45
85
  return () => {};
@@ -49,49 +89,95 @@ export function instrumentFetch(options: InstrumentFetchOptions = {}): () => voi
49
89
  originals.set(key, original);
50
90
  instrumented.add(key);
51
91
 
52
- // Call through `target` native fetch throws "Illegal invocation" with the wrong receiver.
53
- const wrapped: FetchLike = (input, init) => {
54
- // Prefer explicit step.tag(); else soft ambient.
55
- const explicit = resolveJourneyStep((init as JourneyTimedMeta | undefined)?.journeyStep);
56
- const step = explicit ?? activeJourneyStep();
57
- if (!step) {
58
- return original.call(target, input, init);
59
- }
92
+ const record: InstrumentRecord = { disposed: false };
60
93
 
61
- const start = globalThis.performance.now();
62
- const endpointKey = requestKey(resolveUrl(input), undefined, options.baseURL);
63
- const promise = original.call(target, input, init);
64
- return promise.then(
65
- response => {
66
- reportBackendRequest(
67
- step,
68
- response.status,
69
- globalThis.performance.now() - start,
70
- endpointKey
71
- );
72
- return response;
73
- },
74
- (error: unknown) => {
75
- reportBackendRequest(
76
- step,
77
- undefined,
78
- globalThis.performance.now() - start,
79
- endpointKey
80
- );
81
- // Passthrough original rejection reason; do not wrap (identity / AbortError).
82
- return Promise.reject(error as Error);
94
+ try {
95
+ // Call through `target` native fetch throws "Illegal invocation" with the wrong receiver.
96
+ const wrapped: FetchLike = (input, init) => {
97
+ if (record.disposed) {
98
+ return original.call(target, input, init);
99
+ }
100
+ // Prefer explicit step.stamp(); else auto-attribute when enabled.
101
+ const meta = init as JourneyTimedMeta | undefined;
102
+ if (meta?.ignore === true) {
103
+ return original.call(target, input, init);
104
+ }
105
+ const stamp = resolveStamp(meta?.stamp);
106
+ if (!stamp || stamp.ignore) {
107
+ return original.call(target, input, init);
83
108
  }
84
- );
85
- };
86
109
 
87
- target.fetch = wrapped;
110
+ const start = globalThis.performance.now();
111
+ const url = resolveUrl(input);
112
+ const method = resolveMethod(input, init);
113
+ const endpointKey = requestKey(url, undefined, opts.baseURL);
114
+ const promise = original.call(target, input, init);
115
+ return promise.then(
116
+ response => {
117
+ if (!record.disposed) {
118
+ onHttpComplete({
119
+ stamp,
120
+ status: response.status,
121
+ durationMs: globalThis.performance.now() - start,
122
+ requestKey: endpointKey,
123
+ url,
124
+ method,
125
+ meta,
126
+ request: init ?? input,
127
+ response,
128
+ });
129
+ }
130
+ return response;
131
+ },
132
+ (error: unknown) => {
133
+ if (!record.disposed) {
134
+ onHttpComplete({
135
+ stamp,
136
+ status: undefined,
137
+ durationMs: globalThis.performance.now() - start,
138
+ requestKey: endpointKey,
139
+ aborted: isAbortError(error),
140
+ url,
141
+ method,
142
+ meta,
143
+ request: init ?? input,
144
+ error,
145
+ });
146
+ }
147
+ // Passthrough original rejection reason; do not wrap (identity / AbortError).
148
+ return Promise.reject(error as Error);
149
+ }
150
+ );
151
+ };
152
+
153
+ target.fetch = wrapped;
154
+ } catch (err) {
155
+ target.fetch = original;
156
+ instrumented.delete(key);
157
+ originals.delete(key);
158
+ // eslint-disable-next-line no-console -- soft-fail host wiring
159
+ console.error('[journey] instrumentFetch failed', err);
160
+ return () => {};
161
+ }
88
162
 
89
- return () => {
163
+ const restore = () => {
90
164
  if (!instrumented.has(key)) {
91
165
  return;
92
166
  }
167
+ record.disposed = true;
93
168
  target.fetch = originals.get(key) ?? original;
94
169
  instrumented.delete(key);
95
170
  originals.delete(key);
171
+ if (target === (globalThis as unknown as FetchTarget)) {
172
+ setFetchRestore(undefined);
173
+ }
96
174
  };
175
+
176
+ record.restore = restore;
177
+ registerInstrumentRecord(record);
178
+ if (target === (globalThis as unknown as FetchTarget)) {
179
+ setFetchRestore(restore);
180
+ }
181
+
182
+ return restore;
97
183
  }
@@ -0,0 +1,184 @@
1
+ import { MAX_ATTRIBUTED_REQUEST_KEYS } from '../core/limits';
2
+ import {
3
+ isJourneyRequestPolicy,
4
+ resolveRequestPolicy,
5
+ type JourneyHttpPolicyContext,
6
+ type JourneyRequestPolicy,
7
+ } from '../core/request-policy';
8
+ import { reportBackendRequest } from '../core/api';
9
+ import { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime';
10
+ import type { JourneyStepStamp } from '../core/step-tag';
11
+ import type { StepRecord } from '../core/types';
12
+ import type { JourneyTimedMeta } from './request-key';
13
+
14
+ export interface HttpCompleteReport {
15
+ stamp: JourneyStepStamp | undefined;
16
+ status?: number;
17
+ durationMs: number;
18
+ requestKey: string;
19
+ aborted?: boolean;
20
+ url?: string;
21
+ method?: string;
22
+ /** Per-request literal overrides from JourneyTimedMeta. */
23
+ meta?: JourneyTimedMeta;
24
+ /** Transport request (axios config, fetch input/init, jQuery settings). */
25
+ request?: unknown;
26
+ /** Transport response (axios response, fetch Response, jqXHR). */
27
+ response?: unknown;
28
+ /** Rejection reason when the transport failed. */
29
+ error?: unknown;
30
+ }
31
+
32
+ function applyPolicy(
33
+ policy: JourneyRequestPolicy,
34
+ step: StepRecord,
35
+ reason: string,
36
+ status?: number
37
+ ): void {
38
+ if (policy === 'continue') {
39
+ return;
40
+ }
41
+ const journey = step.journey;
42
+ if (!journey || journey.closed) {
43
+ return;
44
+ }
45
+ const engine = resolveJourneyEngine(journey);
46
+ if (policy === 'exclude') {
47
+ engine.exclude();
48
+ return;
49
+ }
50
+ // 'bad'
51
+ step.outcome = 'bad';
52
+ step.reason ??= reason;
53
+ if (status !== undefined) {
54
+ step.httpStatus = status;
55
+ }
56
+ engine.failJourney(reason);
57
+ }
58
+
59
+ function httpScoreReason(report: HttpCompleteReport): string {
60
+ if (report.aborted) {
61
+ return 'request-aborted';
62
+ }
63
+ const status = report.status;
64
+ if (status !== undefined && status >= 400 && status < 500) {
65
+ return 'request-client-error';
66
+ }
67
+ return 'request-error';
68
+ }
69
+
70
+ /**
71
+ * Attribute a finished HTTP call: abort / 4xx policies, else 5xx+latency via reportBackendRequest.
72
+ * Ignored endpoint prefixes never change the journey verdict.
73
+ * 4xx with policy `'continue'` (default) still scores latency.
74
+ */
75
+ export function onHttpComplete(report: HttpCompleteReport): void {
76
+ const {
77
+ stamp,
78
+ status,
79
+ durationMs,
80
+ requestKey,
81
+ aborted,
82
+ url,
83
+ method,
84
+ meta,
85
+ request,
86
+ response,
87
+ error,
88
+ } = report;
89
+ if (!stamp) {
90
+ return;
91
+ }
92
+ const step = stamp.step;
93
+ if (!step?.journey) {
94
+ return;
95
+ }
96
+ /*
97
+ * Unstamped foreign journeys (older package copies) cannot be closed from
98
+ * this bundle — do not write onHttpAborted / step.outcome on them.
99
+ */
100
+ if (!canRouteHttpToJourney(step.journey)) {
101
+ return;
102
+ }
103
+ const engine = resolveJourneyEngine(step.journey);
104
+
105
+ if (meta?.ignore === true || stamp.ignore === true) {
106
+ return;
107
+ }
108
+
109
+ if (aborted) {
110
+ step.onHttpAborted = true;
111
+ }
112
+
113
+ if (requestKey) {
114
+ step.attributedRequestKeys ??= [];
115
+ if (
116
+ step.attributedRequestKeys.length < MAX_ATTRIBUTED_REQUEST_KEYS &&
117
+ !step.attributedRequestKeys.includes(requestKey)
118
+ ) {
119
+ step.attributedRequestKeys.push(requestKey);
120
+ }
121
+ }
122
+
123
+ if (requestKey && engine.shouldIgnoreRequest(requestKey)) {
124
+ return;
125
+ }
126
+
127
+ const ctx: JourneyHttpPolicyContext = {
128
+ requestKey,
129
+ status,
130
+ aborted: aborted === true,
131
+ url,
132
+ method,
133
+ journeyName: step.journey.name,
134
+ stepName: step.name,
135
+ };
136
+
137
+ const score = stamp.score;
138
+ if (score) {
139
+ let policy: JourneyRequestPolicy | undefined;
140
+ try {
141
+ const scored = score({
142
+ ...ctx,
143
+ durationMs,
144
+ request,
145
+ response,
146
+ error,
147
+ });
148
+ if (isJourneyRequestPolicy(scored)) {
149
+ policy = scored;
150
+ }
151
+ } catch {
152
+ policy = undefined;
153
+ }
154
+ if (policy !== undefined) {
155
+ applyPolicy(policy, step, httpScoreReason(report), status);
156
+ return;
157
+ }
158
+ }
159
+
160
+ if (aborted) {
161
+ const override = meta?.httpAbortedRequests;
162
+ const policy = isJourneyRequestPolicy(override)
163
+ ? override
164
+ : resolveRequestPolicy(engine.getHttpAbortedRequestsPolicy(), 'continue', ctx);
165
+ applyPolicy(policy, step, 'request-aborted');
166
+ return;
167
+ }
168
+
169
+ if (status !== undefined && status >= 400 && status < 500) {
170
+ const override = meta?.httpClientErrorRequests;
171
+ const policy = isJourneyRequestPolicy(override)
172
+ ? override
173
+ : resolveRequestPolicy(engine.getHttpClientErrorRequestsPolicy(), 'continue', ctx);
174
+ if (policy !== 'continue') {
175
+ applyPolicy(policy, step, 'request-client-error', status);
176
+ return;
177
+ }
178
+ // 'continue' does not count 4xx as an error; still score latency below.
179
+ }
180
+
181
+ reportBackendRequest(step, status, durationMs, requestKey);
182
+ }
183
+
184
+ export { isAbortError, isCanceledError } from '../core/abort-error';