@servicetitan/journey 1.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 (136) hide show
  1. package/README.md +7 -0
  2. package/dist/__mocks__/test-runtime.d.ts +16 -0
  3. package/dist/__mocks__/test-runtime.d.ts.map +1 -0
  4. package/dist/__tests__/ambient.test.d.ts +2 -0
  5. package/dist/__tests__/ambient.test.d.ts.map +1 -0
  6. package/dist/__tests__/axios.test.d.ts +2 -0
  7. package/dist/__tests__/axios.test.d.ts.map +1 -0
  8. package/dist/__tests__/config.test.d.ts +2 -0
  9. package/dist/__tests__/config.test.d.ts.map +1 -0
  10. package/dist/__tests__/context.test.d.ts +2 -0
  11. package/dist/__tests__/context.test.d.ts.map +1 -0
  12. package/dist/__tests__/datadog.test.d.ts +2 -0
  13. package/dist/__tests__/datadog.test.d.ts.map +1 -0
  14. package/dist/__tests__/define-journey.test.d.ts +2 -0
  15. package/dist/__tests__/define-journey.test.d.ts.map +1 -0
  16. package/dist/__tests__/expose-app-journey.test.d.ts +2 -0
  17. package/dist/__tests__/expose-app-journey.test.d.ts.map +1 -0
  18. package/dist/__tests__/fetch.test.d.ts +2 -0
  19. package/dist/__tests__/fetch.test.d.ts.map +1 -0
  20. package/dist/__tests__/jquery.test.d.ts +2 -0
  21. package/dist/__tests__/jquery.test.d.ts.map +1 -0
  22. package/dist/__tests__/registry.test.d.ts +2 -0
  23. package/dist/__tests__/registry.test.d.ts.map +1 -0
  24. package/dist/__tests__/request-key.test.d.ts +2 -0
  25. package/dist/__tests__/request-key.test.d.ts.map +1 -0
  26. package/dist/__tests__/requests.test.d.ts +2 -0
  27. package/dist/__tests__/requests.test.d.ts.map +1 -0
  28. package/dist/__tests__/runtime.test.d.ts +2 -0
  29. package/dist/__tests__/runtime.test.d.ts.map +1 -0
  30. package/dist/__tests__/scope.test.d.ts +2 -0
  31. package/dist/__tests__/scope.test.d.ts.map +1 -0
  32. package/dist/__tests__/sinks.test.d.ts +2 -0
  33. package/dist/__tests__/sinks.test.d.ts.map +1 -0
  34. package/dist/__tests__/step.test.d.ts +2 -0
  35. package/dist/__tests__/step.test.d.ts.map +1 -0
  36. package/dist/__tests__/timeouts.test.d.ts +2 -0
  37. package/dist/__tests__/timeouts.test.d.ts.map +1 -0
  38. package/dist/config.d.ts +42 -0
  39. package/dist/config.d.ts.map +1 -0
  40. package/dist/config.js +46 -0
  41. package/dist/config.js.map +1 -0
  42. package/dist/core/index.d.ts +6 -0
  43. package/dist/core/index.d.ts.map +1 -0
  44. package/dist/core/index.js +5 -0
  45. package/dist/core/index.js.map +1 -0
  46. package/dist/core/registry.d.ts +50 -0
  47. package/dist/core/registry.d.ts.map +1 -0
  48. package/dist/core/registry.js +102 -0
  49. package/dist/core/registry.js.map +1 -0
  50. package/dist/core/runtime.d.ts +51 -0
  51. package/dist/core/runtime.d.ts.map +1 -0
  52. package/dist/core/runtime.js +401 -0
  53. package/dist/core/runtime.js.map +1 -0
  54. package/dist/core/step-tag.d.ts +16 -0
  55. package/dist/core/step-tag.d.ts.map +1 -0
  56. package/dist/core/step-tag.js +35 -0
  57. package/dist/core/step-tag.js.map +1 -0
  58. package/dist/core/types.d.ts +143 -0
  59. package/dist/core/types.d.ts.map +1 -0
  60. package/dist/core/types.js +3 -0
  61. package/dist/core/types.js.map +1 -0
  62. package/dist/endpoint-policy.d.ts +29 -0
  63. package/dist/endpoint-policy.d.ts.map +1 -0
  64. package/dist/endpoint-policy.js +39 -0
  65. package/dist/endpoint-policy.js.map +1 -0
  66. package/dist/index.d.ts +15 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +19 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/integrations/axios.d.ts +23 -0
  71. package/dist/integrations/axios.d.ts.map +1 -0
  72. package/dist/integrations/axios.js +51 -0
  73. package/dist/integrations/axios.js.map +1 -0
  74. package/dist/integrations/fetch.d.ts +21 -0
  75. package/dist/integrations/fetch.d.ts.map +1 -0
  76. package/dist/integrations/fetch.js +63 -0
  77. package/dist/integrations/fetch.js.map +1 -0
  78. package/dist/integrations/jquery/index.d.ts +22 -0
  79. package/dist/integrations/jquery/index.d.ts.map +1 -0
  80. package/dist/integrations/jquery/index.js +27 -0
  81. package/dist/integrations/jquery/index.js.map +1 -0
  82. package/dist/integrations/jquery/instrument.d.ts +18 -0
  83. package/dist/integrations/jquery/instrument.d.ts.map +1 -0
  84. package/dist/integrations/jquery/instrument.js +44 -0
  85. package/dist/integrations/jquery/instrument.js.map +1 -0
  86. package/dist/integrations/react/context.d.ts +6 -0
  87. package/dist/integrations/react/context.d.ts.map +1 -0
  88. package/dist/integrations/react/context.js +14 -0
  89. package/dist/integrations/react/context.js.map +1 -0
  90. package/dist/integrations/react/scope.d.ts +13 -0
  91. package/dist/integrations/react/scope.d.ts.map +1 -0
  92. package/dist/integrations/react/scope.js +27 -0
  93. package/dist/integrations/react/scope.js.map +1 -0
  94. package/dist/integrations/request-key.d.ts +17 -0
  95. package/dist/integrations/request-key.d.ts.map +1 -0
  96. package/dist/integrations/request-key.js +39 -0
  97. package/dist/integrations/request-key.js.map +1 -0
  98. package/dist/sinks/datadog.d.ts +6 -0
  99. package/dist/sinks/datadog.d.ts.map +1 -0
  100. package/dist/sinks/datadog.js +64 -0
  101. package/dist/sinks/datadog.js.map +1 -0
  102. package/package.json +44 -0
  103. package/src/__mocks__/test-runtime.ts +37 -0
  104. package/src/__tests__/ambient.test.ts +101 -0
  105. package/src/__tests__/axios.test.ts +365 -0
  106. package/src/__tests__/config.test.ts +261 -0
  107. package/src/__tests__/context.test.tsx +84 -0
  108. package/src/__tests__/datadog.test.ts +160 -0
  109. package/src/__tests__/define-journey.test.ts +176 -0
  110. package/src/__tests__/expose-app-journey.test.ts +149 -0
  111. package/src/__tests__/fetch.test.ts +264 -0
  112. package/src/__tests__/jquery.test.ts +327 -0
  113. package/src/__tests__/registry.test.ts +265 -0
  114. package/src/__tests__/request-key.test.ts +81 -0
  115. package/src/__tests__/requests.test.ts +337 -0
  116. package/src/__tests__/runtime.test.ts +273 -0
  117. package/src/__tests__/scope.test.tsx +128 -0
  118. package/src/__tests__/sinks.test.ts +99 -0
  119. package/src/__tests__/step.test.ts +305 -0
  120. package/src/__tests__/timeouts.test.ts +226 -0
  121. package/src/config.ts +87 -0
  122. package/src/core/index.ts +39 -0
  123. package/src/core/registry.ts +162 -0
  124. package/src/core/runtime.ts +489 -0
  125. package/src/core/step-tag.ts +24 -0
  126. package/src/core/types.ts +160 -0
  127. package/src/endpoint-policy.ts +66 -0
  128. package/src/index.ts +51 -0
  129. package/src/integrations/axios.ts +85 -0
  130. package/src/integrations/fetch.ts +97 -0
  131. package/src/integrations/jquery/index.ts +62 -0
  132. package/src/integrations/jquery/instrument.ts +84 -0
  133. package/src/integrations/react/context.ts +17 -0
  134. package/src/integrations/react/scope.tsx +32 -0
  135. package/src/integrations/request-key.ts +46 -0
  136. package/src/sinks/datadog.ts +71 -0
@@ -0,0 +1,160 @@
1
+ import type { EndpointPolicy } from '../endpoint-policy';
2
+
3
+ /** Arbitrary tag values a developer can attach. */
4
+ export type TagValue = string | number | boolean;
5
+
6
+ /** Config for one journey — supplied by the caller (typically `<JourneyScope>` props). */
7
+ export interface JourneyDef {
8
+ /** Journey name — lifecycle key, event field, and concurrency map key. */
9
+ name: string;
10
+ /** Owner team (required tag). */
11
+ team: string;
12
+ /** Functional area, e.g. "sales" (required tag). */
13
+ group: string;
14
+ /** Business component label, e.g. "checkout" — not the Datadog RUM service. */
15
+ service: string;
16
+ /** Whole-journey budget in ms; omit / 0 to disable. */
17
+ timeoutMs?: number;
18
+ /** Default per-step timeout in ms; per-call option overrides. Omit / 0 to disable. */
19
+ stepTimeoutMs?: number;
20
+ /** Slow-request threshold for this journey; falls back to app-wide default. */
21
+ requestTimeoutMs?: number;
22
+ /** Per-endpoint overrides for this journey (longest prefix wins over app-wide). */
23
+ endpoints?: readonly EndpointPolicy[];
24
+ /** Optional expected step names (rides as `expected` on the event). */
25
+ steps?: readonly string[];
26
+ /** Custom tags on every event of this journey. */
27
+ tags?: Record<string, TagValue>;
28
+ }
29
+
30
+ export type Outcome = 'good' | 'bad' | 'excluded';
31
+
32
+ /** Handle passed to each step function. */
33
+ export interface StepHandle {
34
+ /** Resolved journey name for this step, or null if untraced. */
35
+ readonly name: string | null;
36
+ setAttribute(key: string, value: string | number | boolean): void;
37
+ /** Journey-level tags without closing. */
38
+ setTags(tags: Record<string, TagValue>): void;
39
+ /** Apply optional tags then completeJourney. No-op if name is null. */
40
+ complete(tags?: Record<string, TagValue>): void;
41
+ /** Soft business Bad without throw. No-op if name is null. */
42
+ fail(reason: string): void;
43
+ /**
44
+ * Bind this step to a request explicitly (spread into axios config / fetch init /
45
+ * jQuery ajax settings). Needed when requests fire after an await or while steps
46
+ * overlap — see docs soft ambient. Untraced (noop) steps omit `journeyStep`.
47
+ *
48
+ * The value is a non-plain {@link JourneyStepTag} so jQuery/axios deep-merge will
49
+ * not recurse into the circular StepRecord graph.
50
+ */
51
+ tag(): { journeyStep?: unknown };
52
+ }
53
+
54
+ /** A journey name, or anything with a `name` (e.g. a JourneyHandle / JourneyDef). */
55
+ export type JourneyNameLike = string | { readonly name: string };
56
+
57
+ /** First argument to journeyStep / journeyMountStep. */
58
+ export type JourneyStepTarget = JourneyNameLike | null | readonly JourneyNameLike[];
59
+
60
+ /** Per-call options for journeyStep. */
61
+ export interface JourneyStepOptions {
62
+ /** Timeout for this step in ms; overrides journey stepTimeoutMs. Omit / 0 to disable. */
63
+ timeoutMs?: number;
64
+ }
65
+
66
+ export interface JourneyState {
67
+ name: string;
68
+ team: string;
69
+ group: string;
70
+ service: string;
71
+ verdict: Outcome;
72
+ startedAt: number;
73
+ timeoutMs: number;
74
+ stepTimeoutMs?: number;
75
+ /**
76
+ * Why the journey closed as bad, else null.
77
+ * Engine: request-error | request-latency | journey-timeout | step-timeout | step-error.
78
+ * Also any custom business reason (e.g. call-not-associated).
79
+ */
80
+ reason: string | null;
81
+ steps: StepRecord[];
82
+ expected: string[] | null;
83
+ tags: Record<string, TagValue>;
84
+ requestTimeoutMs?: number;
85
+ endpoints?: readonly EndpointPolicy[];
86
+ timer: ReturnType<typeof setTimeout> | null;
87
+ closed: boolean;
88
+ }
89
+
90
+ export interface StepRecord {
91
+ name: string;
92
+ startedAt: number;
93
+ durationMs: number;
94
+ outcome: 'good' | 'bad';
95
+ reason?: string;
96
+ httpStatus?: number;
97
+ attributes: Record<string, string | number | boolean>;
98
+ journey: JourneyState;
99
+ }
100
+
101
+ /** Opaque handle to a step, captured at request initiation for response attribution. */
102
+ export type JourneyStepRef = StepRecord;
103
+
104
+ export interface JourneyStepEvent {
105
+ name: string;
106
+ /** Offset from the journey start, ms. */
107
+ startMs: number;
108
+ durationMs: number;
109
+ outcome: 'good' | 'bad';
110
+ reason?: string;
111
+ httpStatus?: number;
112
+ attributes?: Record<string, TagValue>;
113
+ }
114
+
115
+ /** Neutral journey event payload (journey-specific fields only; session context is host RUM globals). */
116
+ export interface JourneyEvent {
117
+ journey: {
118
+ name: string;
119
+ team: string;
120
+ group: string;
121
+ service: string;
122
+ outcome: Outcome;
123
+ reason?: string;
124
+ durationMs: number;
125
+ steps: JourneyStepEvent[];
126
+ expected?: string[];
127
+ tags?: Record<string, TagValue>;
128
+ };
129
+ }
130
+
131
+ /** Destination for a terminal journey event. */
132
+ export type JourneySink = (event: JourneyEvent) => void;
133
+
134
+ /** Callable journey — config plus lifecycle helpers bound to `name`. Prefer this over free helpers. */
135
+ export interface JourneyHandle extends JourneyDef {
136
+ /** Open this journey (restarts if already open). */
137
+ start(tags?: Record<string, TagValue>): void;
138
+ /**
139
+ * Record a step. Opens the journey first when it isn't already open
140
+ * (one-shot store actions); no-op start when a `<JourneyScope>` already opened it.
141
+ */
142
+ step<T>(
143
+ stepName: string,
144
+ fn: (step: StepHandle) => T | Promise<T>,
145
+ opts?: JourneyStepOptions
146
+ ): Promise<T>;
147
+ /**
148
+ * Like `step`, deferred one microtask for mount-effect use under `<JourneyScope>`
149
+ * (React runs child effects before parent). Also auto-starts if not already open.
150
+ */
151
+ mountStep<T>(
152
+ stepName: string,
153
+ fn: (step: StepHandle) => T | Promise<T>,
154
+ opts?: JourneyStepOptions
155
+ ): Promise<T>;
156
+ complete(tags?: Record<string, TagValue>): void;
157
+ fail(reason: string): void;
158
+ exclude(): void;
159
+ setTags(tags: Record<string, TagValue>): void;
160
+ }
@@ -0,0 +1,66 @@
1
+ /** Per-endpoint request policy (app-wide or per-journey). Longest matching prefix wins. */
2
+ export interface EndpointPolicy {
3
+ /**
4
+ * Path/host prefix against the request key (`"/path"` same-origin, `"host/path"` cross-domain).
5
+ * Match requires an exact hit or a `/` boundary after the prefix — `/api/user` matches
6
+ * `/api/user` and `/api/user/42`, but not `/api/user-preferences`. A trailing `/` on
7
+ * `match` already supplies the boundary.
8
+ */
9
+ 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. */
13
+ ignore?: boolean;
14
+ }
15
+
16
+ /** App-wide slow-request threshold fallback (not an axios abort). */
17
+ export interface AppRequestTimeouts {
18
+ /** Fallback threshold when no endpoint or journey override applies. */
19
+ defaultMs: number;
20
+ /** App-wide endpoint overrides; journey-specific ones belong on JourneyDef.endpoints. */
21
+ endpoints?: readonly EndpointPolicy[];
22
+ }
23
+
24
+ /** Copy sorted longest-match-first so lookup can return on the first hit. */
25
+ export function sortEndpointsByLongestMatch(
26
+ endpoints: readonly EndpointPolicy[] | undefined
27
+ ): EndpointPolicy[] | undefined {
28
+ if (endpoints == null) {
29
+ return undefined;
30
+ }
31
+ if (endpoints.length <= 1) {
32
+ return endpoints.length === 0 ? [] : [...endpoints];
33
+ }
34
+ return [...endpoints].sort((a, b) => b.match.length - a.match.length);
35
+ }
36
+
37
+ /**
38
+ * True when `key` equals `match` or continues past a path boundary.
39
+ * `/api/user` matches `/api/user` and `/api/user/42`, not `/api/user-preferences`.
40
+ */
41
+ function matchesEndpoint(key: string, match: string): boolean {
42
+ if (!key.startsWith(match)) {
43
+ return false;
44
+ }
45
+ if (match.endsWith('/')) {
46
+ return true;
47
+ }
48
+ const next = key[match.length];
49
+ return next === undefined || next === '/';
50
+ }
51
+
52
+ /**
53
+ * Longest-prefix match of an endpoint list against a request key.
54
+ * Expects `endpoints` sorted longest-match-first (see `sortEndpointsByLongestMatch`).
55
+ */
56
+ export function longestEndpointMatch(
57
+ endpoints: readonly EndpointPolicy[] | undefined,
58
+ key: string
59
+ ): EndpointPolicy | undefined {
60
+ for (const e of endpoints ?? []) {
61
+ if (matchesEndpoint(key, e.match)) {
62
+ return e;
63
+ }
64
+ }
65
+ return undefined;
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ /*
2
+ * Force-evaluate config so the default Datadog sink registers even when consumers only
3
+ * import core APIs (defineJourney, etc.). Paired with package.json "sideEffects" for
4
+ * config — otherwise bundlers with sideEffects:false would drop an unused re-export.
5
+ */
6
+ import './config';
7
+
8
+ export {
9
+ // Custom transport adapters only.
10
+ activeJourneyStep,
11
+ // Prefer defineJourney(...).complete/fail/… — free helpers for name-from-context call sites.
12
+ completeJourney,
13
+ defineJourney,
14
+ excludeJourney,
15
+ failJourney,
16
+ journeyStep,
17
+ reportBackendRequest,
18
+ resolveJourneyStep,
19
+ setJourneyTags,
20
+ JourneyStepTag,
21
+ type JourneyDef,
22
+ type JourneyHandle,
23
+ type JourneyNameLike,
24
+ type JourneyStepOptions,
25
+ type JourneyStepRef,
26
+ type JourneyStepTarget,
27
+ type StepHandle,
28
+ } from './core';
29
+
30
+ export {
31
+ configureJourney,
32
+ type AppRequestTimeouts,
33
+ type EndpointPolicy,
34
+ type JourneyConfig,
35
+ } from './config';
36
+
37
+ export type { JourneySink } from './core';
38
+
39
+ /** Test helper — clears open journeys / ambient steps on the default engine. */
40
+ export { resetJourney } from './core';
41
+
42
+ export { instrumentAxios } from './integrations/axios';
43
+ export { instrumentFetch } from './integrations/fetch';
44
+ export { exposeAppJourney, instrumentJquery } from './integrations/jquery';
45
+ export { requestKey, type JourneyTimedMeta } from './integrations/request-key';
46
+ export { sendToDatadog, setJourneyRum } from './sinks/datadog';
47
+
48
+ export type { JourneyEvent, JourneyStepEvent, TagValue } from './core';
49
+
50
+ export { useJourneyName, useOptionalJourneyName } from './integrations/react/context';
51
+ export { JourneyScope } from './integrations/react/scope';
@@ -0,0 +1,85 @@
1
+ import {
2
+ activeJourneyStep,
3
+ JourneyStepTag,
4
+ reportBackendRequest,
5
+ resolveJourneyStep,
6
+ } from '../core';
7
+ import { requestKey, type JourneyTimedMeta } from './request-key';
8
+
9
+ /**
10
+ * Minimal structural axios surface. No axios dependency — works across 0.31 and 1.x.
11
+ */
12
+ export interface AxiosLikeInstance {
13
+ defaults: { baseURL?: string };
14
+ interceptors: {
15
+ request: { use(onFulfilled: (config: any) => any): unknown };
16
+ response: {
17
+ use(onFulfilled: (response: any) => any, onRejected?: (error: any) => any): unknown;
18
+ };
19
+ };
20
+ }
21
+
22
+ type TimedConfig = JourneyTimedMeta & { url?: string; baseURL?: string };
23
+
24
+ function reportTiming(
25
+ instance: AxiosLikeInstance,
26
+ config: TimedConfig | undefined,
27
+ status: number | undefined
28
+ ): void {
29
+ const step = resolveJourneyStep(config?.journeyStep);
30
+ if (!step || !config) {
31
+ return;
32
+ }
33
+ const start = config.journeyStart ?? globalThis.performance.now();
34
+ const key = requestKey(config.url, config.baseURL, instance.defaults.baseURL);
35
+ reportBackendRequest(step, status, globalThis.performance.now() - start, key);
36
+ }
37
+
38
+ const instrumented = new WeakSet<object>();
39
+
40
+ /**
41
+ * Wire an axios instance into the journey engine (observation-only).
42
+ * Call once per instance (idempotent). Register any async request interceptors
43
+ * (e.g. auth) before this call so this one runs first.
44
+ */
45
+ export function instrumentAxios(instance: AxiosLikeInstance): void {
46
+ if (instrumented.has(instance as object)) {
47
+ return;
48
+ }
49
+ instrumented.add(instance as object);
50
+
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);
61
+ }
62
+ }
63
+ timed.journeyStart = globalThis.performance.now();
64
+ return config;
65
+ });
66
+
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);
78
+ /*
79
+ * Passthrough original reason (AxiosError or test doubles). Do not wrap —
80
+ * callers match on .response / AxiosError identity.
81
+ */
82
+ return Promise.reject(error as Error);
83
+ }
84
+ );
85
+ }
@@ -0,0 +1,97 @@
1
+ import { activeJourneyStep, reportBackendRequest, resolveJourneyStep } from '../core';
2
+ import { requestKey, type JourneyTimedMeta } from './request-key';
3
+
4
+ /** Minimal structural fetch shape. */
5
+ export type FetchLike = (input: any, init?: any) => Promise<{ status: number }>;
6
+
7
+ /** Holder of a `fetch` property — globalThis by default, or a scoped object. */
8
+ export interface FetchTarget {
9
+ fetch: FetchLike;
10
+ }
11
+
12
+ export interface InstrumentFetchOptions {
13
+ /** Object whose `fetch` is wrapped. Defaults to globalThis. */
14
+ target?: FetchTarget;
15
+ /** Home API base URL for request-key origin resolution. */
16
+ baseURL?: string;
17
+ }
18
+
19
+ const instrumented = new WeakSet<object>();
20
+ const originals = new WeakMap<object, FetchLike>();
21
+
22
+ function resolveUrl(input: any): string | undefined {
23
+ if (typeof input === 'string') {
24
+ return input;
25
+ }
26
+ if (input && typeof input.url === 'string') {
27
+ return input.url;
28
+ }
29
+ if (input && typeof input.href === 'string') {
30
+ return input.href;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ /**
36
+ * Wrap a target's fetch so requests inside a journeyStep are attributed and timed.
37
+ * Call once per target (idempotent — second call is a no-op). Returns a restore
38
+ * function that puts the original fetch back. Observation-only.
39
+ */
40
+ export function instrumentFetch(options: InstrumentFetchOptions = {}): () => void {
41
+ const target = options.target ?? (globalThis as unknown as FetchTarget);
42
+ const key = target as object;
43
+
44
+ if (instrumented.has(key)) {
45
+ return () => {};
46
+ }
47
+
48
+ const original = target.fetch;
49
+ originals.set(key, original);
50
+ instrumented.add(key);
51
+
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
+ }
60
+
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);
83
+ }
84
+ );
85
+ };
86
+
87
+ target.fetch = wrapped;
88
+
89
+ return () => {
90
+ if (!instrumented.has(key)) {
91
+ return;
92
+ }
93
+ target.fetch = originals.get(key) ?? original;
94
+ instrumented.delete(key);
95
+ originals.delete(key);
96
+ };
97
+ }
@@ -0,0 +1,62 @@
1
+ import {
2
+ defineJourney,
3
+ firstOpenJourney,
4
+ journeyStep,
5
+ type JourneyDef,
6
+ type JourneyHandle,
7
+ type JourneyStepOptions,
8
+ type JourneyStepTarget,
9
+ type StepHandle,
10
+ } from '../../core';
11
+
12
+ /**
13
+ * Knockout / desktop-legacy bridge on `window.App.Journey`.
14
+ * Prefer `defineJourney` handles; `journeyStep` / `firstOpenJourney` cover multi-journey steps.
15
+ */
16
+ export interface AppJourneyApi {
17
+ defineJourney(def: JourneyDef): JourneyHandle;
18
+ journeyStep<T>(
19
+ journey: JourneyStepTarget,
20
+ stepName: string,
21
+ fn: (step: StepHandle) => T | Promise<T>,
22
+ opts?: JourneyStepOptions
23
+ ): Promise<T>;
24
+ firstOpenJourney(names: readonly string[]): string | null;
25
+ }
26
+
27
+ export type WindowWithAppJourney = Window & {
28
+ App?: { Journey?: AppJourneyApi } & Record<string, unknown>;
29
+ };
30
+
31
+ let journeyExposed = false;
32
+ let windowJourneyApi: AppJourneyApi | undefined;
33
+
34
+ /**
35
+ * Publish the Knockout bridge on window.App.Journey. Idempotent for the real window.
36
+ * Optional `target` is for tests.
37
+ */
38
+ export function exposeAppJourney(target: WindowWithAppJourney = window): AppJourneyApi {
39
+ if (journeyExposed && target === window && windowJourneyApi) {
40
+ return windowJourneyApi;
41
+ }
42
+
43
+ const api: AppJourneyApi = {
44
+ defineJourney,
45
+ journeyStep,
46
+ firstOpenJourney,
47
+ };
48
+ target.App = target.App ?? {};
49
+ target.App.Journey = api;
50
+ if (target === window) {
51
+ journeyExposed = true;
52
+ windowJourneyApi = api;
53
+ }
54
+ return api;
55
+ }
56
+
57
+ export {
58
+ instrumentJquery,
59
+ type JqueryAjaxSettings,
60
+ type JqueryStatic,
61
+ type JqueryXHR,
62
+ } from './instrument';
@@ -0,0 +1,84 @@
1
+ import {
2
+ activeJourneyStep,
3
+ JourneyStepTag,
4
+ reportBackendRequest,
5
+ resolveJourneyStep,
6
+ } from '../../core';
7
+ import { requestKey } from '../request-key';
8
+
9
+ export interface JqueryXHR {
10
+ status: number;
11
+ journeyStep?: unknown;
12
+ journeyStart?: number;
13
+ }
14
+
15
+ export interface JqueryAjaxSettings {
16
+ url?: string;
17
+ journeyStep?: unknown;
18
+ }
19
+
20
+ export interface JqueryStatic {
21
+ ajaxPrefilter(
22
+ handler: (
23
+ options: JqueryAjaxSettings,
24
+ originalOptions: JqueryAjaxSettings,
25
+ jqXHR: JqueryXHR
26
+ ) => void
27
+ ): void;
28
+ (selector: Document): {
29
+ ajaxComplete(
30
+ handler: (event: unknown, jqXHR: JqueryXHR, settings: JqueryAjaxSettings) => void
31
+ ): void;
32
+ };
33
+ }
34
+
35
+ const instrumented = new WeakSet<object>();
36
+
37
+ function reportTiming(jqXHR: JqueryXHR, settings: JqueryAjaxSettings): void {
38
+ const step = resolveJourneyStep(jqXHR.journeyStep);
39
+ if (!step) {
40
+ return;
41
+ }
42
+ const start = jqXHR.journeyStart ?? globalThis.performance.now();
43
+ // 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,
47
+ status,
48
+ globalThis.performance.now() - start,
49
+ requestKey(settings.url, undefined, undefined)
50
+ );
51
+ }
52
+
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)) {
56
+ return;
57
+ }
58
+ instrumented.add($ as object);
59
+
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);
73
+ }
74
+ }
75
+ }
76
+ if (resolveJourneyStep(jqXHR.journeyStep)) {
77
+ jqXHR.journeyStart = globalThis.performance.now();
78
+ }
79
+ });
80
+
81
+ $(document).ajaxComplete((_event, jqXHR, settings) => {
82
+ reportTiming(jqXHR, settings);
83
+ });
84
+ }
@@ -0,0 +1,17 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ export const JourneyNameContext = createContext<string | null>(null);
4
+
5
+ /** Active journey name, or null outside a `<JourneyScope>` (steps run untraced). */
6
+ export function useOptionalJourneyName(): string | null {
7
+ return useContext(JourneyNameContext);
8
+ }
9
+
10
+ /** Active journey name; throws outside a `<JourneyScope>`. */
11
+ export function useJourneyName(): string {
12
+ const name = useContext(JourneyNameContext);
13
+ if (!name) {
14
+ throw new Error('useJourneyName must be used within a <JourneyScope>');
15
+ }
16
+ return name;
17
+ }
@@ -0,0 +1,32 @@
1
+ import { useEffect, type ReactNode } from 'react';
2
+
3
+ import { excludeJourney, setJourneyTags, startJourney, type JourneyDef } from '../../core';
4
+ import type { TagValue } from '../../core/types';
5
+ import { JourneyNameContext } from './context';
6
+
7
+ /**
8
+ * Opens a journey while the subtree is mounted; Excludes on unmount.
9
+ * Config (timeouts, endpoints, tags, …) is captured when the journey opens;
10
+ * only a `name` change re-opens.
11
+ */
12
+ export function JourneyScope({
13
+ tags,
14
+ children,
15
+ ...def
16
+ }: JourneyDef & {
17
+ tags?: Record<string, TagValue>;
18
+ children: ReactNode;
19
+ }) {
20
+ const { name } = def;
21
+
22
+ useEffect(() => {
23
+ startJourney(def);
24
+ if (tags) {
25
+ setJourneyTags(name, tags);
26
+ }
27
+ return () => excludeJourney(name);
28
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- freeze-on-mount; name is the lifecycle key
29
+ }, [name]);
30
+
31
+ return <JourneyNameContext.Provider value={name}>{children}</JourneyNameContext.Provider>;
32
+ }