@servicetitan/journey 3.0.1 → 4.1.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 (79) hide show
  1. package/README.md +35 -0
  2. package/dist/__tests__/core/persist.test.d.ts +2 -0
  3. package/dist/__tests__/core/persist.test.d.ts.map +1 -0
  4. package/dist/__tests__/integrations/request-correlation.test.d.ts +2 -0
  5. package/dist/__tests__/integrations/request-correlation.test.d.ts.map +1 -0
  6. package/dist/__tests__/sinks/kibana.test.d.ts +2 -0
  7. package/dist/__tests__/sinks/kibana.test.d.ts.map +1 -0
  8. package/dist/core/api.d.ts +13 -1
  9. package/dist/core/api.d.ts.map +1 -1
  10. package/dist/core/api.js +14 -0
  11. package/dist/core/api.js.map +1 -1
  12. package/dist/core/index.d.ts +2 -2
  13. package/dist/core/index.d.ts.map +1 -1
  14. package/dist/core/index.js +1 -1
  15. package/dist/core/index.js.map +1 -1
  16. package/dist/core/persist.d.ts +19 -0
  17. package/dist/core/persist.d.ts.map +1 -0
  18. package/dist/core/persist.js +111 -0
  19. package/dist/core/persist.js.map +1 -0
  20. package/dist/core/request-correlation.d.ts +6 -0
  21. package/dist/core/request-correlation.d.ts.map +1 -0
  22. package/dist/core/request-correlation.js +15 -0
  23. package/dist/core/request-correlation.js.map +1 -0
  24. package/dist/core/runtime.d.ts +16 -1
  25. package/dist/core/runtime.d.ts.map +1 -1
  26. package/dist/core/runtime.js +74 -23
  27. package/dist/core/runtime.js.map +1 -1
  28. package/dist/core/sanitize.d.ts.map +1 -1
  29. package/dist/core/sanitize.js +36 -3
  30. package/dist/core/sanitize.js.map +1 -1
  31. package/dist/core/types.d.ts +42 -0
  32. package/dist/core/types.d.ts.map +1 -1
  33. package/dist/core/types.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/integrations/axios.d.ts.map +1 -1
  39. package/dist/integrations/axios.js +2 -1
  40. package/dist/integrations/axios.js.map +1 -1
  41. package/dist/integrations/http-report.d.ts +2 -0
  42. package/dist/integrations/http-report.d.ts.map +1 -1
  43. package/dist/integrations/http-report.js +17 -2
  44. package/dist/integrations/http-report.js.map +1 -1
  45. package/dist/integrations/jquery/instrument.d.ts +2 -0
  46. package/dist/integrations/jquery/instrument.d.ts.map +1 -1
  47. package/dist/integrations/jquery/instrument.js +31 -4
  48. package/dist/integrations/jquery/instrument.js.map +1 -1
  49. package/dist/integrations/request-correlation.d.ts +5 -0
  50. package/dist/integrations/request-correlation.d.ts.map +1 -0
  51. package/dist/integrations/request-correlation.js +65 -0
  52. package/dist/integrations/request-correlation.js.map +1 -0
  53. package/dist/sinks/datadog.d.ts.map +1 -1
  54. package/dist/sinks/datadog.js +18 -0
  55. package/dist/sinks/datadog.js.map +1 -1
  56. package/dist/sinks/kibana.d.ts +4 -0
  57. package/dist/sinks/kibana.d.ts.map +1 -0
  58. package/dist/sinks/kibana.js +20 -0
  59. package/dist/sinks/kibana.js.map +1 -0
  60. package/package.json +2 -2
  61. package/src/__tests__/core/persist.test.ts +496 -0
  62. package/src/__tests__/integrations/axios.test.ts +38 -1
  63. package/src/__tests__/integrations/jquery.test.ts +94 -1
  64. package/src/__tests__/integrations/request-correlation.test.ts +315 -0
  65. package/src/__tests__/sinks/kibana.test.ts +110 -0
  66. package/src/core/api.ts +19 -0
  67. package/src/core/index.ts +4 -0
  68. package/src/core/persist.ts +131 -0
  69. package/src/core/request-correlation.ts +32 -0
  70. package/src/core/runtime.ts +69 -2
  71. package/src/core/sanitize.ts +41 -3
  72. package/src/core/types.ts +44 -0
  73. package/src/index.ts +4 -1
  74. package/src/integrations/axios.ts +1 -0
  75. package/src/integrations/http-report.ts +33 -14
  76. package/src/integrations/jquery/instrument.ts +37 -5
  77. package/src/integrations/request-correlation.ts +90 -0
  78. package/src/sinks/datadog.ts +19 -0
  79. package/src/sinks/kibana.ts +24 -0
@@ -0,0 +1,315 @@
1
+ /** @jest-environment jsdom */
2
+ import { baseJourneyDef, useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
3
+ import { journeyStep } from '../../core';
4
+ import { createJourneyRuntime, moduleRuntime } from '../../core/runtime';
5
+ import type { StepHandle } from '../../core/types';
6
+ import { instrumentFetch } from '../../integrations/fetch';
7
+ import { onHttpComplete, type HttpCompleteReport } from '../../integrations/http-report';
8
+ import { captureRequestCorrelation } from '../../integrations/request-correlation';
9
+ import { sendToDatadog } from '../../sinks/datadog';
10
+
11
+ const emit = jest.fn();
12
+ const addAction = jest.fn();
13
+ const ray = '0123456789abcdef-IAD';
14
+ const epoch = 1_800_000_000_000;
15
+
16
+ function report(step: StepHandle, overrides: Partial<HttpCompleteReport> = {}): HttpCompleteReport {
17
+ return {
18
+ stamp: step.stamp(),
19
+ status: 500,
20
+ durationMs: 250,
21
+ requestKey: '/test',
22
+ url: 'https://go.servicetitan.com/test?secret=do-not-emit',
23
+ response: { headers: { 'cf-ray': ray, 'authorization': 'do-not-emit' } },
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function event() {
29
+ return emit.mock.calls[emit.mock.calls.length - 1][0].journey;
30
+ }
31
+
32
+ describe('[journey] request correlation', () => {
33
+ useJourneyTestRuntime(emit);
34
+ beforeEach(() => {
35
+ jest.spyOn(Date, 'now').mockReturnValue(epoch);
36
+ addAction.mockClear();
37
+ (globalThis as Record<string, unknown>).DD_RUM = { addAction };
38
+ });
39
+ afterEach(() => {
40
+ delete (globalThis as Record<string, unknown>).DD_RUM;
41
+ jest.restoreAllMocks();
42
+ });
43
+
44
+ test('captures the response before a 5xx synchronously emits, with a usable URL', async () => {
45
+ await journeyStep(baseJourneyDef, 'save', step => {
46
+ onHttpComplete(report(step));
47
+ expect(emit).toHaveBeenCalledTimes(1);
48
+ });
49
+ expect(event().outcome).toBe('bad');
50
+ expect(event().reason).toBe('request-error');
51
+ expect(event().steps[0].requests).toEqual([
52
+ {
53
+ rayId: ray.toLowerCase(),
54
+ startedAtEpoch: epoch - 250,
55
+ durationMs: 250,
56
+ status: 500,
57
+ },
58
+ ]);
59
+ sendToDatadog(emit.mock.calls[0][0]);
60
+ const context = addAction.mock.calls[0][1];
61
+ const request = context.journey.steps[0].requests[0];
62
+ expect(request.request_id).toBe(ray.toLowerCase());
63
+ expect(context.journey.failed_request_id).toBe(ray.toLowerCase());
64
+ expect(context.journey.kibana_url).toBe(request.kibana_url);
65
+ expect(decodeURIComponent(request.kibana_url)).toContain(
66
+ 'Request.RequestId: 0123456789abcdef*'
67
+ );
68
+ expect(request.kibana_url).toContain('https://kibana.st.dev/app/discover#/?');
69
+ expect(JSON.stringify(context)).not.toMatch(/secret|authorization|do-not-emit/);
70
+ expect(context.journey.tags).toBeUndefined();
71
+ });
72
+
73
+ test.each([
74
+ { status: 200, durationMs: 5000, reason: 'request-latency' },
75
+ {
76
+ status: 400,
77
+ meta: { httpClientErrorRequests: 'bad' as const },
78
+ reason: 'request-client-error',
79
+ },
80
+ ])('captures before %j is scored', async ({ reason, ...overrides }) => {
81
+ await journeyStep(baseJourneyDef, 'save', step => onHttpComplete(report(step, overrides)));
82
+ expect(event().reason).toBe(reason);
83
+ expect(event().steps[0].requests[0].status).toBe(overrides.status);
84
+ });
85
+
86
+ test.each([200, 400])('does not change the default verdict for status %s', async status => {
87
+ await journeyStep(baseJourneyDef, 'save', step => {
88
+ onHttpComplete(report(step, { status }));
89
+ expect(emit).not.toHaveBeenCalled();
90
+ step.completeJourney();
91
+ });
92
+ expect(event().outcome).toBe('good');
93
+ expect(event().steps[0].requests).toBeUndefined();
94
+ expect(event().failedRequest).toBeUndefined();
95
+ });
96
+
97
+ test.each([
98
+ { status: undefined, response: undefined },
99
+ { response: { headers: {} } },
100
+ { response: { headers: { 'cf-ray': 'invalid OR Request.User:*' } } },
101
+ {
102
+ response: {
103
+ getResponseHeader() {
104
+ throw new Error('blocked');
105
+ },
106
+ },
107
+ },
108
+ { url: 'https://third-party.example/test' },
109
+ { url: 'https://go.servicetitan.com.attacker.invalid/test' },
110
+ { url: '/test', baseURL: 'https://qa.servicetitan.com' },
111
+ { response: { headers: { 'cf-ray': ray }, url: 'https://third-party.example/test' } },
112
+ ])('does not invent a link or change failure scoring for %j', async overrides => {
113
+ await journeyStep(baseJourneyDef, 'save', step => onHttpComplete(report(step, overrides)));
114
+ expect(event().outcome).toBe('bad');
115
+ expect(event().reason).toBe('request-error');
116
+ expect(event().steps[0].requests).toBeUndefined();
117
+ expect(event().failedRequest).toBeUndefined();
118
+ });
119
+
120
+ test.each([{ meta: { ignore: true } }, { aborted: true, status: undefined }])(
121
+ 'skips ignored/aborted metadata and preserves outcome for %j',
122
+ async overrides => {
123
+ await journeyStep(baseJourneyDef, 'save', step => {
124
+ onHttpComplete(report(step, overrides));
125
+ step.completeJourney();
126
+ });
127
+ expect(event().outcome).toBe('good');
128
+ expect(event().steps[0].requests).toBeUndefined();
129
+ }
130
+ );
131
+
132
+ test('does not record a journey-level ignored endpoint', async () => {
133
+ const config = { ...baseJourneyDef, endpoints: [{ match: '/test', ignore: true }] };
134
+ await journeyStep(config, 'save', step => {
135
+ onHttpComplete(report(step));
136
+ step.failJourney('different-failure');
137
+ });
138
+ expect(event().steps[0].requests).toBeUndefined();
139
+ expect(event().failedRequest).toBeUndefined();
140
+ });
141
+
142
+ test('captures before a request-specific score closes the journey', async () => {
143
+ await journeyStep(baseJourneyDef, 'save', step => {
144
+ onHttpComplete(report(step, { status: 200, stamp: step.stamp(() => 'bad') }));
145
+ });
146
+ expect(event().outcome).toBe('bad');
147
+ expect(event().steps[0].requests).toHaveLength(1);
148
+ });
149
+
150
+ test('bounds requests, keeps errors, and deduplicates early/global jQuery observations', async () => {
151
+ await journeyStep(baseJourneyDef, 'save', step => {
152
+ const first = report(step, { status: 400 });
153
+ captureRequestCorrelation(first);
154
+ onHttpComplete(first);
155
+ for (let n = 1; n < 9; n++) {
156
+ onHttpComplete(
157
+ report(step, {
158
+ status: 200,
159
+ response: { headers: { 'cf-ray': n.toString(16).padStart(16, '0') } },
160
+ })
161
+ );
162
+ }
163
+ onHttpComplete(
164
+ report(step, { response: { headers: { 'cf-ray': 'fedcba9876543210-LAX' } } })
165
+ );
166
+ });
167
+ const { requests, requestsTruncated } = event().steps[0];
168
+ expect(requests).toHaveLength(5);
169
+ expect(
170
+ requests.filter((item: { rayId: string }) => item.rayId === ray.toLowerCase())
171
+ ).toHaveLength(1);
172
+ expect(requests[4].rayId).toBe('fedcba9876543210-lax');
173
+ expect(requestsTruncated).toBe(true);
174
+ });
175
+
176
+ test('an old request cannot attach its ID to a restarted journey', async () => {
177
+ let old: HttpCompleteReport;
178
+ await journeyStep(baseJourneyDef, 'old', step => {
179
+ old = report(step);
180
+ });
181
+ moduleRuntime.startJourney(baseJourneyDef);
182
+ emit.mockClear();
183
+ onHttpComplete(old!);
184
+ expect(emit).not.toHaveBeenCalled();
185
+ await journeyStep(baseJourneyDef, 'new', step => {
186
+ onHttpComplete(report(step, { response: { headers: {} } }));
187
+ });
188
+ expect(event().steps[0].requests).toBeUndefined();
189
+ expect(event().failedRequest).toBeUndefined();
190
+ });
191
+
192
+ test('routes an explicit stamp to its owning runtime, not a same-named host journey', async () => {
193
+ const foreignEmit = jest.fn();
194
+ const foreign = createJourneyRuntime({ sinks: [foreignEmit] });
195
+ moduleRuntime.startJourney(baseJourneyDef);
196
+ await foreign.journeyStep(baseJourneyDef, 'foreign-save', step =>
197
+ onHttpComplete(report(step))
198
+ );
199
+ expect(foreignEmit.mock.calls[0][0].journey.steps[0].requests).toHaveLength(1);
200
+ expect(moduleRuntime.getOpenJourney(baseJourneyDef.name)).toBeDefined();
201
+ expect(emit).not.toHaveBeenCalled();
202
+ foreign.reset();
203
+ });
204
+
205
+ test('fetch preserves response identity and captures Headers.get before caller settlement', async () => {
206
+ const response = { status: 500, headers: new Headers({ 'cf-ray': ray }) };
207
+ const target = { fetch: jest.fn().mockResolvedValue(response) };
208
+ const restore = instrumentFetch({ target });
209
+ try {
210
+ await journeyStep(baseJourneyDef, 'fetch', async step => {
211
+ expect(
212
+ await target.fetch('https://go.servicetitan.com/test', { stamp: step.stamp() })
213
+ ).toBe(response);
214
+ expect(emit).toHaveBeenCalledTimes(1);
215
+ });
216
+ expect(event().steps[0].requests).toHaveLength(1);
217
+ } finally {
218
+ restore();
219
+ }
220
+ });
221
+
222
+ test('unrelated concurrent steps receive only their explicitly stamped requests', async () => {
223
+ let settleFirst!: () => void;
224
+ const first = journeyStep({ ...baseJourneyDef, name: 'first' }, 'save', async step => {
225
+ await new Promise<void>(resolve => {
226
+ settleFirst = resolve;
227
+ });
228
+ onHttpComplete(report(step));
229
+ });
230
+ await journeyStep({ ...baseJourneyDef, name: 'second' }, 'save', step => {
231
+ onHttpComplete(
232
+ report(step, { response: { headers: { 'cf-ray': 'fedcba9876543210-IAD' } } })
233
+ );
234
+ });
235
+ settleFirst();
236
+ await first;
237
+ expect(
238
+ emit.mock.calls.map(call => [
239
+ call[0].journey.name,
240
+ call[0].journey.steps[0].requests[0].rayId,
241
+ ])
242
+ ).toEqual([
243
+ ['second', 'fedcba9876543210-iad'],
244
+ ['first', ray.toLowerCase()],
245
+ ]);
246
+ });
247
+ test('does not blame a previous HTTP response for a later business failure', async () => {
248
+ await journeyStep(baseJourneyDef, 'save', step => {
249
+ onHttpComplete(report(step, { status: 200 }));
250
+ step.failJourney('business-validation');
251
+ });
252
+ expect(event().steps[0].requests).toHaveLength(1);
253
+ expect(event().failedRequest).toBeUndefined();
254
+ sendToDatadog(emit.mock.calls[0][0]);
255
+ expect(addAction.mock.calls[0][1].journey.kibana_url).toBeUndefined();
256
+ });
257
+
258
+ test('a successful response never replaces an earlier captured failure', async () => {
259
+ await journeyStep(baseJourneyDef, 'save', step => {
260
+ for (let n = 1; n <= 5; n++) {
261
+ captureRequestCorrelation(
262
+ report(step, {
263
+ response: { headers: { 'cf-ray': n.toString(16).padStart(16, '0') } },
264
+ })
265
+ );
266
+ }
267
+ captureRequestCorrelation(report(step, { status: 200 }));
268
+ step.failJourney('application-error');
269
+ });
270
+ expect(event().steps[0].requests).toHaveLength(5);
271
+ expect(
272
+ event().steps[0].requests.every((item: { status: number }) => item.status === 500)
273
+ ).toBe(true);
274
+ });
275
+ test('a later network failure has no top-level link to an earlier successful request', async () => {
276
+ await journeyStep(baseJourneyDef, 'save', step => {
277
+ onHttpComplete(report(step, { status: 200 }));
278
+ onHttpComplete(report(step, { status: undefined, response: undefined }));
279
+ });
280
+ expect(event().reason).toBe('request-error');
281
+ expect(event().failedRequest).toBeUndefined();
282
+ expect(event().steps[0].requests).toHaveLength(1);
283
+ });
284
+
285
+ test('a slow successful response still gets its failure link when the step history is full', async () => {
286
+ await journeyStep(baseJourneyDef, 'save', step => {
287
+ for (let n = 1; n <= 5; n++) {
288
+ captureRequestCorrelation(
289
+ report(step, {
290
+ response: { headers: { 'cf-ray': n.toString(16).padStart(16, '0') } },
291
+ })
292
+ );
293
+ }
294
+ onHttpComplete(report(step, { status: 200, durationMs: 5000 }));
295
+ });
296
+ expect(event().reason).toBe('request-latency');
297
+ expect(event().failedRequest.rayId).toBe(ray.toLowerCase());
298
+ });
299
+
300
+ test.each(['continue', 'exclude'] as const)(
301
+ 'preserves the request-specific %s policy',
302
+ async policy => {
303
+ await journeyStep(baseJourneyDef, 'save', step => {
304
+ onHttpComplete(report(step, { stamp: step.stamp(() => policy) }));
305
+ if (policy === 'continue') {
306
+ expect(emit).not.toHaveBeenCalled();
307
+ step.completeJourney();
308
+ }
309
+ });
310
+ expect(event().outcome).toBe(policy === 'continue' ? 'good' : 'excluded');
311
+ expect(event().failedRequest).toBeUndefined();
312
+ expect(event().steps[0].requests).toBeUndefined();
313
+ }
314
+ );
315
+ });
@@ -0,0 +1,110 @@
1
+ /** @jest-environment jsdom */
2
+ import { sanitizeJourneyEvent } from '../../core/sanitize';
3
+ import { MAX_JOURNEY_CORRELATED_REQUESTS } from '../../core/request-correlation';
4
+ import type { JourneyEvent, JourneyRequestEvent } from '../../core/types';
5
+ import { requestLogsUrl } from '../../sinks/kibana';
6
+
7
+ const request: JourneyRequestEvent = {
8
+ rayId: '0123456789abcdef-IAD',
9
+ startedAtEpoch: Date.parse('2026-09-17T18:00:00.000Z'),
10
+ durationMs: 2000,
11
+ status: 500,
12
+ };
13
+
14
+ function event(outcome: JourneyEvent['journey']['outcome'] = 'bad'): JourneyEvent {
15
+ return {
16
+ journey: {
17
+ name: 'test',
18
+ team: 'test',
19
+ group: 'test',
20
+ service: 'test',
21
+ outcome,
22
+ durationMs: 2000,
23
+ steps: [
24
+ { name: 'save', startMs: 0, durationMs: 2000, outcome: 'bad', requests: [request] },
25
+ ],
26
+ },
27
+ };
28
+ }
29
+
30
+ describe('[journey] request logs link', () => {
31
+ test('uses the verified production view and an absolute request window', () => {
32
+ const url = new URL(requestLogsUrl(request)!);
33
+ expect(url.origin).toBe('https://kibana.st.dev');
34
+ expect(url.pathname).toBe('/app/discover');
35
+ const params = new URLSearchParams(url.hash.slice(3));
36
+ expect(params.get('_g')).toBe(
37
+ "(refreshInterval:(pause:!t,value:0),time:(from:'2026-09-17T17:59:00.000Z',to:'2026-09-17T18:01:02.000Z'))"
38
+ );
39
+ expect(params.get('_a')).toBe(
40
+ "(dataSource:(type:dataView,dataViewId:'894110ed-c22d-449c-9dc2-c4816f08712a'),query:(language:kuery,query:'Request.RequestId: 0123456789abcdef*'))"
41
+ );
42
+ expect(url.href).not.toContain('IAD');
43
+ });
44
+
45
+ test.each([
46
+ { rayId: "0123456789abcdef') OR *" },
47
+ { rayId: 'https://attacker.example' },
48
+ { rayId: 'a'.repeat(1000) },
49
+ { rayId: '0123456789abcde' },
50
+ { rayId: '0123456789abcdef-INVALID' },
51
+ { startedAtEpoch: NaN },
52
+ { startedAtEpoch: Infinity },
53
+ { startedAtEpoch: -1 },
54
+ { startedAtEpoch: 8.64e15 },
55
+ { durationMs: -1 },
56
+ { durationMs: Infinity },
57
+ { status: 0 },
58
+ { status: 600 },
59
+ { status: 200.5 },
60
+ ])('rejects invalid correlation %j', invalid => {
61
+ expect(requestLogsUrl({ ...request, ...invalid })).toBeUndefined();
62
+ const payload = event();
63
+ payload.journey.steps[0].requests = [{ ...request, ...invalid }];
64
+ expect(sanitizeJourneyEvent(payload).journey.steps[0].requests).toBeUndefined();
65
+ });
66
+
67
+ test('sanitizes correlation by allowlist, not the metric-tag string normalizer', () => {
68
+ const payload = event();
69
+ payload.journey.steps[0].requests = [
70
+ {
71
+ ...request,
72
+ url: '/test?password=secret',
73
+ headers: { authorization: 'secret' },
74
+ } as JourneyRequestEvent,
75
+ ];
76
+ expect(sanitizeJourneyEvent(payload).journey.steps[0].requests).toEqual([
77
+ { ...request, rayId: request.rayId.toLowerCase() },
78
+ ]);
79
+ });
80
+
81
+ test.each(['good', 'excluded'] as const)('omits correlation on %s events', outcome => {
82
+ expect(sanitizeJourneyEvent(event(outcome)).journey.steps[0].requests).toBeUndefined();
83
+ });
84
+
85
+ test('caps the event payload and preserves links on the last failing step', () => {
86
+ const payload = event();
87
+ payload.journey.steps = Array.from({ length: 50 }, (_, index) => ({
88
+ ...payload.journey.steps[0],
89
+ name: `step-${index}`,
90
+ }));
91
+ const steps = sanitizeJourneyEvent(payload).journey.steps;
92
+ expect(steps.reduce((count, step) => count + (step.requests?.length ?? 0), 0)).toBe(
93
+ MAX_JOURNEY_CORRELATED_REQUESTS
94
+ );
95
+ expect(steps[0].requestsTruncated).toBe(true);
96
+ expect(steps[49].requests).toHaveLength(1);
97
+ });
98
+ test('validates the top-level failure request and strips it from non-bad events', () => {
99
+ const payload = event();
100
+ payload.journey.failedRequest = { ...request, rayId: "0123456789abcdef') OR *" };
101
+ expect(sanitizeJourneyEvent(payload).journey.failedRequest).toBeUndefined();
102
+ payload.journey.failedRequest = request;
103
+ expect(sanitizeJourneyEvent(payload).journey.failedRequest).toEqual({
104
+ ...request,
105
+ rayId: request.rayId.toLowerCase(),
106
+ });
107
+ payload.journey.outcome = 'good';
108
+ expect(sanitizeJourneyEvent(payload).journey.failedRequest).toBeUndefined();
109
+ });
110
+ });
package/src/core/api.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  JourneyHandle,
6
6
  JourneyStepOptions,
7
7
  JourneyStepTarget,
8
+ SerializedJourneyState,
8
9
  StepHandle,
9
10
  StepRecord,
10
11
  TagValue,
@@ -157,3 +158,21 @@ export function defineJourney(config: JourneyDef): JourneyHandle {
157
158
  },
158
159
  };
159
160
  }
161
+
162
+ /**
163
+ * Snapshot an open journey for cross-page transfer (e.g. into a cookie). Does
164
+ * NOT close the journey. Returns `null` when no journey is open under `name`.
165
+ */
166
+ export function serializeJourneyState(name: string): SerializedJourneyState | null {
167
+ return moduleRuntime.serializeJourneyState(name);
168
+ }
169
+
170
+ /**
171
+ * Rehydrate a serialized journey onto this runtime. Returns `true` when the
172
+ * journey is now open; `false` when it exhausted its budget in transit (a
173
+ * `bad` `journey-timeout` event is emitted) or when a journey under the
174
+ * same name is already open.
175
+ */
176
+ export function restoreJourney(serialized: SerializedJourneyState, config: JourneyDef): boolean {
177
+ return moduleRuntime.restoreJourney(serialized, config) !== null;
178
+ }
package/src/core/index.ts CHANGED
@@ -8,6 +8,8 @@ export {
8
8
  journeyStep,
9
9
  reportBackendRequest,
10
10
  resolveStamp,
11
+ restoreJourney,
12
+ serializeJourneyState,
11
13
  setJourneyTags,
12
14
  singleInFlightJourneyStep,
13
15
  } from './api';
@@ -18,12 +20,14 @@ export {
18
20
  export type {
19
21
  JourneyDef,
20
22
  JourneyEvent,
23
+ JourneyRequestEvent,
21
24
  JourneyHandle,
22
25
  JourneyNameLike,
23
26
  JourneySink,
24
27
  JourneyStepEvent,
25
28
  JourneyStepOptions,
26
29
  JourneyStepTarget,
30
+ SerializedJourneyState,
27
31
  StepHandle,
28
32
  TagValue,
29
33
  } from './types';
@@ -0,0 +1,131 @@
1
+ import {
2
+ clampMaxSteps,
3
+ finiteSlowRequestMs,
4
+ finiteTimeoutMs,
5
+ MAX_ATTRIBUTE_KEYS,
6
+ MAX_TAG_KEYS,
7
+ resolveJourneyTimeoutMs,
8
+ } from './limits';
9
+ import { assignRecord, emptyRecord } from './sanitize';
10
+ import { bindJourneyEngine, freezeJourneyDef, type JourneyRuntime } from './runtime';
11
+ import {
12
+ JOURNEY_ENGINE,
13
+ type JourneyDef,
14
+ type JourneyState,
15
+ type SerializedJourneyState,
16
+ type StepRecord,
17
+ } from './types';
18
+
19
+ /**
20
+ * @internal Project an open {@link JourneyState} into a plain, JSON-safe
21
+ * {@link SerializedJourneyState} that can ride a cookie across page navigations.
22
+ * The step projection mirrors `finish()`; `startedAt` (a `performance.now()`
23
+ * monotonic stamp) is bridged to a wall-clock epoch so it survives the page
24
+ * load where `performance.now()` resets.
25
+ */
26
+ export function projectJourneyState(journey: JourneyState): SerializedJourneyState {
27
+ const steps = journey.steps.map(s => ({
28
+ name: s.name,
29
+ startMs: Math.round(s.startedAt - journey.startedAt),
30
+ durationMs: s.durationMs || Math.round(globalThis.performance.now() - s.startedAt),
31
+ outcome: s.outcome,
32
+ ...(s.reason ? { reason: s.reason } : {}),
33
+ ...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}),
34
+ // Attributes are raw here — final emission sanitization happens in finish().
35
+ ...(Object.keys(s.attributes).length ? { attributes: { ...s.attributes } } : {}),
36
+ }));
37
+
38
+ const startedAtEpoch = Date.now() - (globalThis.performance.now() - journey.startedAt);
39
+ // Coerce any non-null st_journey_id tag to a string so a numeric/boolean id survives the round-trip.
40
+ const stJourneyId =
41
+ journey.tags.st_journey_id != null ? String(journey.tags.st_journey_id) : '';
42
+
43
+ return {
44
+ stJourneyId,
45
+ startedAtEpoch,
46
+ // Open journeys are never 'excluded' — that verdict is only set inside finish().
47
+ verdict: journey.verdict === 'bad' ? 'bad' : 'good',
48
+ reason: journey.reason,
49
+ steps,
50
+ tags: { ...journey.tags },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * @internal Rehydrate a {@link SerializedJourneyState} into a live
56
+ * {@link JourneyState} bound to the supplied runtime. `startedAt` is rebased
57
+ * from the wall-clock epoch back to a `performance.now()` monotonic stamp,
58
+ * `StepRecord[]` is rebuilt with re-linked circular references, and the
59
+ * `JOURNEY_ENGINE` hook is re-bound so HTTP attribution works on the new page.
60
+ */
61
+ export function deserializeJourneyState(
62
+ serialized: SerializedJourneyState,
63
+ config: JourneyDef,
64
+ api: JourneyRuntime,
65
+ defaultJourneyIdleMs: number
66
+ ): JourneyState {
67
+ const tags = emptyRecord();
68
+ // Caller config tags win; serialized tags fill the rest (incl. st_journey_id).
69
+ assignRecord(tags, serialized.tags, MAX_TAG_KEYS);
70
+ assignRecord(tags, config.tags, MAX_TAG_KEYS);
71
+ if (serialized.stJourneyId && tags.st_journey_id === undefined) {
72
+ tags.st_journey_id = serialized.stJourneyId;
73
+ }
74
+
75
+ const snapshot = freezeJourneyDef(config, tags);
76
+ const timeout = resolveJourneyTimeoutMs(snapshot.timeoutMs, defaultJourneyIdleMs);
77
+
78
+ const startedAt = globalThis.performance.now() - (Date.now() - serialized.startedAtEpoch);
79
+
80
+ const journey: JourneyState = {
81
+ config: snapshot,
82
+ name: snapshot.name,
83
+ team: snapshot.team,
84
+ group: snapshot.group,
85
+ service: snapshot.service,
86
+ verdict: serialized.verdict === 'bad' ? 'bad' : 'good',
87
+ startedAt,
88
+ timeoutMs: timeout.ms,
89
+ timeoutExplicit: timeout.explicit,
90
+ stepTimeoutMs:
91
+ snapshot.stepTimeoutMs !== undefined
92
+ ? finiteTimeoutMs(snapshot.stepTimeoutMs)
93
+ : undefined,
94
+ reason: serialized.reason,
95
+ steps: [],
96
+ expected: snapshot.expected ? [...snapshot.expected] : null,
97
+ tags,
98
+ slowRequestMs: finiteSlowRequestMs(snapshot.slowRequestMs),
99
+ endpoints: snapshot.endpoints,
100
+ timer: null,
101
+ closed: false,
102
+ maxSteps: snapshot.maxSteps !== undefined ? clampMaxSteps(snapshot.maxSteps) : undefined,
103
+ };
104
+
105
+ // Rebuild StepRecord[] — re-link the circular `step.journey` reference and rebase `startedAt` from the offset.
106
+ for (const s of serialized.steps) {
107
+ const step: StepRecord = {
108
+ name: s.name,
109
+ startedAt: journey.startedAt + s.startMs,
110
+ durationMs: s.durationMs,
111
+ outcome: s.outcome,
112
+ attributes: emptyRecord(),
113
+ journey,
114
+ };
115
+ if (s.reason !== undefined) {
116
+ step.reason = s.reason;
117
+ }
118
+ if (s.httpStatus !== undefined) {
119
+ step.httpStatus = s.httpStatus;
120
+ }
121
+ if (s.attributes) {
122
+ assignRecord(step.attributes, s.attributes, MAX_ATTRIBUTE_KEYS);
123
+ }
124
+ journey.steps.push(step);
125
+ }
126
+
127
+ // Re-bind the engine so HTTP attribution / fail-fast works on this page.
128
+ journey[JOURNEY_ENGINE] = bindJourneyEngine(api, journey, api.shouldIgnoreRequest);
129
+
130
+ return journey;
131
+ }
@@ -0,0 +1,32 @@
1
+ import type { JourneyRequestEvent } from './types';
2
+
3
+ export const MAX_CORRELATED_REQUESTS = 5;
4
+ export const MAX_JOURNEY_CORRELATED_REQUESTS = 20;
5
+
6
+ /** Only allow a Cloudflare Ray ID and bounded numeric response metadata onto the wire. */
7
+ export function sanitizeRequestCorrelation(
8
+ value: JourneyRequestEvent
9
+ ): JourneyRequestEvent | undefined {
10
+ if (
11
+ !value ||
12
+ typeof value.rayId !== 'string' ||
13
+ !/^[a-f0-9]{16}(?:-[a-z]{3})?$/i.test(value.rayId) ||
14
+ !Number.isFinite(value.startedAtEpoch) ||
15
+ value.startedAtEpoch <= 0 ||
16
+ value.startedAtEpoch > 8.64e15 - 2 * 86_400_000 ||
17
+ !Number.isFinite(value.durationMs) ||
18
+ value.durationMs < 0 ||
19
+ value.durationMs > 86_400_000 ||
20
+ !Number.isInteger(value.status) ||
21
+ value.status < 100 ||
22
+ value.status > 599
23
+ ) {
24
+ return undefined;
25
+ }
26
+ return {
27
+ rayId: value.rayId.toLowerCase(),
28
+ startedAtEpoch: Math.round(value.startedAtEpoch),
29
+ durationMs: Math.round(value.durationMs),
30
+ status: value.status,
31
+ };
32
+ }