@servicetitan/journey 4.0.0 → 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.
- package/README.md +35 -0
- package/dist/__tests__/integrations/request-correlation.test.d.ts +2 -0
- package/dist/__tests__/integrations/request-correlation.test.d.ts.map +1 -0
- package/dist/__tests__/sinks/kibana.test.d.ts +2 -0
- package/dist/__tests__/sinks/kibana.test.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/request-correlation.d.ts +6 -0
- package/dist/core/request-correlation.d.ts.map +1 -0
- package/dist/core/request-correlation.js +15 -0
- package/dist/core/request-correlation.js.map +1 -0
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +18 -2
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/sanitize.d.ts.map +1 -1
- package/dist/core/sanitize.js +36 -3
- package/dist/core/sanitize.js.map +1 -1
- package/dist/core/types.d.ts +17 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/axios.d.ts.map +1 -1
- package/dist/integrations/axios.js +2 -1
- package/dist/integrations/axios.js.map +1 -1
- package/dist/integrations/http-report.d.ts +2 -0
- package/dist/integrations/http-report.d.ts.map +1 -1
- package/dist/integrations/http-report.js +17 -2
- package/dist/integrations/http-report.js.map +1 -1
- package/dist/integrations/jquery/instrument.d.ts +2 -0
- package/dist/integrations/jquery/instrument.d.ts.map +1 -1
- package/dist/integrations/jquery/instrument.js +31 -4
- package/dist/integrations/jquery/instrument.js.map +1 -1
- package/dist/integrations/request-correlation.d.ts +5 -0
- package/dist/integrations/request-correlation.d.ts.map +1 -0
- package/dist/integrations/request-correlation.js +65 -0
- package/dist/integrations/request-correlation.js.map +1 -0
- package/dist/sinks/datadog.d.ts.map +1 -1
- package/dist/sinks/datadog.js +18 -0
- package/dist/sinks/datadog.js.map +1 -1
- package/dist/sinks/kibana.d.ts +4 -0
- package/dist/sinks/kibana.d.ts.map +1 -0
- package/dist/sinks/kibana.js +20 -0
- package/dist/sinks/kibana.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/integrations/axios.test.ts +38 -1
- package/src/__tests__/integrations/jquery.test.ts +94 -1
- package/src/__tests__/integrations/request-correlation.test.ts +315 -0
- package/src/__tests__/sinks/kibana.test.ts +110 -0
- package/src/core/index.ts +1 -0
- package/src/core/request-correlation.ts +32 -0
- package/src/core/runtime.ts +7 -0
- package/src/core/sanitize.ts +41 -3
- package/src/core/types.ts +18 -0
- package/src/index.ts +1 -1
- package/src/integrations/axios.ts +1 -0
- package/src/integrations/http-report.ts +33 -14
- package/src/integrations/jquery/instrument.ts +37 -5
- package/src/integrations/request-correlation.ts +90 -0
- package/src/sinks/datadog.ts +19 -0
- 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/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/core/runtime.ts
CHANGED
|
@@ -336,6 +336,10 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
|
|
|
336
336
|
...(s.reason ? { reason: s.reason } : {}),
|
|
337
337
|
...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}),
|
|
338
338
|
...(Object.keys(s.attributes).length ? { attributes: { ...s.attributes } } : {}),
|
|
339
|
+
...(finalOutcome === 'bad' && s.requests?.length
|
|
340
|
+
? { requests: s.requests.map(request => ({ ...request })) }
|
|
341
|
+
: {}),
|
|
342
|
+
...(finalOutcome === 'bad' && s.requestsTruncated ? { requestsTruncated: true } : {}),
|
|
339
343
|
}));
|
|
340
344
|
|
|
341
345
|
emit({
|
|
@@ -345,6 +349,9 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
|
|
|
345
349
|
group: journey.group,
|
|
346
350
|
service: journey.service,
|
|
347
351
|
outcome: finalOutcome,
|
|
352
|
+
...(finalOutcome === 'bad' && journey.scoringRequest
|
|
353
|
+
? { failedRequest: { ...journey.scoringRequest } }
|
|
354
|
+
: {}),
|
|
348
355
|
...(finalReason ? { reason: finalReason } : {}),
|
|
349
356
|
durationMs,
|
|
350
357
|
steps,
|
package/src/core/sanitize.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { isUnsafeKey, MAX_ATTRIBUTE_KEYS, MAX_TAG_KEYS, MAX_TAG_VALUE_LEN } from './limits';
|
|
2
2
|
import type { JourneyEvent, JourneyStepEvent, TagValue } from './types';
|
|
3
|
+
import {
|
|
4
|
+
MAX_CORRELATED_REQUESTS,
|
|
5
|
+
MAX_JOURNEY_CORRELATED_REQUESTS,
|
|
6
|
+
sanitizeRequestCorrelation,
|
|
7
|
+
} from './request-correlation';
|
|
3
8
|
|
|
4
9
|
const SANITIZED_EVENT = Symbol.for('st.journey.sanitized');
|
|
5
10
|
|
|
@@ -124,10 +129,20 @@ export function setSafeAttribute(
|
|
|
124
129
|
}
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
function sanitizeStep(step: JourneyStepEvent): JourneyStepEvent {
|
|
132
|
+
function sanitizeStep(step: JourneyStepEvent, includeRequests: boolean): JourneyStepEvent {
|
|
133
|
+
const { requests: rawRequests, requestsTruncated, ...rest } = step;
|
|
134
|
+
const requests =
|
|
135
|
+
includeRequests && Array.isArray(rawRequests)
|
|
136
|
+
? rawRequests
|
|
137
|
+
.slice(0, MAX_CORRELATED_REQUESTS)
|
|
138
|
+
.map(sanitizeRequestCorrelation)
|
|
139
|
+
.filter(request => request !== undefined)
|
|
140
|
+
: [];
|
|
128
141
|
const name = normalizeTagValue(step.name);
|
|
129
142
|
return {
|
|
130
|
-
...
|
|
143
|
+
...rest,
|
|
144
|
+
...(requests.length ? { requests } : {}),
|
|
145
|
+
...(includeRequests && requestsTruncated === true ? { requestsTruncated: true } : {}),
|
|
131
146
|
name: name || step.name,
|
|
132
147
|
...(step.reason ? { reason: normalizeTagValue(step.reason) } : {}),
|
|
133
148
|
...(step.attributes
|
|
@@ -149,6 +164,28 @@ export function sanitizeJourneyEvent(event: JourneyEvent): JourneyEvent {
|
|
|
149
164
|
? copyRecord(j.tags, { maxKeys: MAX_TAG_KEYS, normalizeKeys: true })
|
|
150
165
|
: undefined;
|
|
151
166
|
const tagKeys = tags ? Object.keys(tags) : [];
|
|
167
|
+
const steps = j.steps.map(step => sanitizeStep(step, j.outcome === 'bad'));
|
|
168
|
+
// Prefer recently started steps at the cap; failedRequest preserves the scoring request separately.
|
|
169
|
+
let requestBudget = MAX_JOURNEY_CORRELATED_REQUESTS;
|
|
170
|
+
for (let index = steps.length - 1; index >= 0; index--) {
|
|
171
|
+
const step = steps[index];
|
|
172
|
+
if (!step.requests) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (step.requests.length > requestBudget) {
|
|
176
|
+
step.requestsTruncated = true;
|
|
177
|
+
if (requestBudget === 0) {
|
|
178
|
+
delete step.requests;
|
|
179
|
+
} else {
|
|
180
|
+
step.requests = step.requests.slice(-requestBudget);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
requestBudget -= step.requests?.length ?? 0;
|
|
184
|
+
}
|
|
185
|
+
const failedRequest =
|
|
186
|
+
j.outcome === 'bad' && j.failedRequest
|
|
187
|
+
? sanitizeRequestCorrelation(j.failedRequest)
|
|
188
|
+
: undefined;
|
|
152
189
|
const sanitized: JourneyEvent = {
|
|
153
190
|
journey: {
|
|
154
191
|
name: normalizeTagValue(j.name),
|
|
@@ -156,9 +193,10 @@ export function sanitizeJourneyEvent(event: JourneyEvent): JourneyEvent {
|
|
|
156
193
|
group: normalizeTagValue(j.group),
|
|
157
194
|
service: normalizeTagValue(j.service),
|
|
158
195
|
outcome: j.outcome,
|
|
196
|
+
...(failedRequest ? { failedRequest } : {}),
|
|
159
197
|
...(j.reason ? { reason: normalizeTagValue(j.reason) } : {}),
|
|
160
198
|
durationMs: j.durationMs,
|
|
161
|
-
steps
|
|
199
|
+
steps,
|
|
162
200
|
...(expected?.length ? { expected } : {}),
|
|
163
201
|
...(tagKeys.length ? { tags } : {}),
|
|
164
202
|
},
|
package/src/core/types.ts
CHANGED
|
@@ -134,6 +134,16 @@ export interface JourneyState {
|
|
|
134
134
|
/** Frozen snapshot taken at start — not the caller's original object. */
|
|
135
135
|
config: JourneyDef;
|
|
136
136
|
[JOURNEY_ENGINE]?: JourneyEngine;
|
|
137
|
+
/** Present only during synchronous HTTP scoring, so finish can identify the causing request. */
|
|
138
|
+
scoringRequest?: JourneyRequestEvent;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Response correlation for production monolith logs. Never a metric tag. */
|
|
142
|
+
export interface JourneyRequestEvent {
|
|
143
|
+
rayId: string;
|
|
144
|
+
startedAtEpoch: number;
|
|
145
|
+
durationMs: number;
|
|
146
|
+
status: number;
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
/** @internal Engine in-flight / completed step record — not part of the supported public API. */
|
|
@@ -150,6 +160,9 @@ export interface StepRecord {
|
|
|
150
160
|
onHttpAborted?: boolean;
|
|
151
161
|
/** @internal Request keys attributed while this step was in flight (debug). */
|
|
152
162
|
attributedRequestKeys?: string[];
|
|
163
|
+
/** Bounded response metadata; no URLs, bodies, or other headers. */
|
|
164
|
+
requests?: JourneyRequestEvent[];
|
|
165
|
+
requestsTruncated?: boolean;
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
export interface JourneyStepEvent {
|
|
@@ -161,6 +174,9 @@ export interface JourneyStepEvent {
|
|
|
161
174
|
reason?: string;
|
|
162
175
|
httpStatus?: number;
|
|
163
176
|
attributes?: Record<string, TagValue>;
|
|
177
|
+
/** Correlated requests on bad journeys, not necessarily the cause of a business error. */
|
|
178
|
+
requests?: JourneyRequestEvent[];
|
|
179
|
+
requestsTruncated?: boolean;
|
|
164
180
|
}
|
|
165
181
|
|
|
166
182
|
/**
|
|
@@ -200,6 +216,8 @@ export interface JourneyEvent {
|
|
|
200
216
|
reason?: string;
|
|
201
217
|
durationMs: number;
|
|
202
218
|
steps: JourneyStepEvent[];
|
|
219
|
+
/** Present only when a correlated HTTP report synchronously closed the journey as bad. */
|
|
220
|
+
failedRequest?: JourneyRequestEvent;
|
|
203
221
|
expected?: string[];
|
|
204
222
|
tags?: Record<string, TagValue>;
|
|
205
223
|
};
|
package/src/index.ts
CHANGED
|
@@ -60,7 +60,7 @@ export {
|
|
|
60
60
|
export { sendToConsole } from './sinks/console';
|
|
61
61
|
export { normalizeTagValue, sendToDatadog, setJourneyRum } from './sinks/datadog';
|
|
62
62
|
|
|
63
|
-
export type { JourneyEvent, JourneyStepEvent, TagValue } from './core';
|
|
63
|
+
export type { JourneyEvent, JourneyRequestEvent, JourneyStepEvent, TagValue } from './core';
|
|
64
64
|
|
|
65
65
|
export { useJourneyName, useOptionalJourneyName } from './integrations/react/context';
|
|
66
66
|
export { JourneyScope } from './integrations/react/scope';
|