@servicetitan/journey 4.0.0 → 4.2.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 (66) hide show
  1. package/README.md +35 -0
  2. package/dist/__tests__/integrations/request-correlation.test.d.ts +2 -0
  3. package/dist/__tests__/integrations/request-correlation.test.d.ts.map +1 -0
  4. package/dist/__tests__/sinks/datadog-links.test.d.ts +2 -0
  5. package/dist/__tests__/sinks/datadog-links.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/index.d.ts +1 -1
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js.map +1 -1
  11. package/dist/core/request-correlation.d.ts +8 -0
  12. package/dist/core/request-correlation.d.ts.map +1 -0
  13. package/dist/core/request-correlation.js +23 -0
  14. package/dist/core/request-correlation.js.map +1 -0
  15. package/dist/core/runtime.d.ts.map +1 -1
  16. package/dist/core/runtime.js +18 -2
  17. package/dist/core/runtime.js.map +1 -1
  18. package/dist/core/sanitize.d.ts.map +1 -1
  19. package/dist/core/sanitize.js +35 -2
  20. package/dist/core/sanitize.js.map +1 -1
  21. package/dist/core/types.d.ts +17 -0
  22. package/dist/core/types.d.ts.map +1 -1
  23. package/dist/core/types.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/integrations/axios.d.ts.map +1 -1
  28. package/dist/integrations/axios.js +2 -1
  29. package/dist/integrations/axios.js.map +1 -1
  30. package/dist/integrations/http-report.d.ts +2 -0
  31. package/dist/integrations/http-report.d.ts.map +1 -1
  32. package/dist/integrations/http-report.js +17 -2
  33. package/dist/integrations/http-report.js.map +1 -1
  34. package/dist/integrations/jquery/instrument.d.ts +2 -0
  35. package/dist/integrations/jquery/instrument.d.ts.map +1 -1
  36. package/dist/integrations/jquery/instrument.js +31 -4
  37. package/dist/integrations/jquery/instrument.js.map +1 -1
  38. package/dist/integrations/request-correlation.d.ts +5 -0
  39. package/dist/integrations/request-correlation.d.ts.map +1 -0
  40. package/dist/integrations/request-correlation.js +64 -0
  41. package/dist/integrations/request-correlation.js.map +1 -0
  42. package/dist/sinks/datadog.d.ts.map +1 -1
  43. package/dist/sinks/datadog.js +26 -0
  44. package/dist/sinks/datadog.js.map +1 -1
  45. package/dist/sinks/kibana.d.ts +11 -0
  46. package/dist/sinks/kibana.d.ts.map +1 -0
  47. package/dist/sinks/kibana.js +92 -0
  48. package/dist/sinks/kibana.js.map +1 -0
  49. package/package.json +2 -2
  50. package/src/__tests__/integrations/axios.test.ts +38 -1
  51. package/src/__tests__/integrations/jquery.test.ts +94 -1
  52. package/src/__tests__/integrations/request-correlation.test.ts +383 -0
  53. package/src/__tests__/sinks/datadog-links.test.ts +58 -0
  54. package/src/__tests__/sinks/kibana.test.ts +267 -0
  55. package/src/core/index.ts +1 -0
  56. package/src/core/request-correlation.ts +51 -0
  57. package/src/core/runtime.ts +7 -0
  58. package/src/core/sanitize.ts +39 -2
  59. package/src/core/types.ts +18 -0
  60. package/src/index.ts +1 -1
  61. package/src/integrations/axios.ts +1 -0
  62. package/src/integrations/http-report.ts +33 -14
  63. package/src/integrations/jquery/instrument.ts +37 -5
  64. package/src/integrations/request-correlation.ts +93 -0
  65. package/src/sinks/datadog.ts +21 -0
  66. package/src/sinks/kibana.ts +140 -0
@@ -0,0 +1,267 @@
1
+ /** @jest-environment jsdom */
2
+ import { sanitizeJourneyEvent } from '../../core/sanitize';
3
+ import {
4
+ isMonolithLogOrigin,
5
+ MAX_JOURNEY_CORRELATED_REQUESTS,
6
+ } from '../../core/request-correlation';
7
+ import type { JourneyEvent, JourneyRequestEvent } from '../../core/types';
8
+ import { journeyLogsLink, requestLogsUrl } from '../../sinks/kibana';
9
+
10
+ const request: JourneyRequestEvent = {
11
+ rayId: '0123456789abcdef-IAD',
12
+ startedAtEpoch: Date.parse('2026-09-17T18:00:00.000Z'),
13
+ durationMs: 2000,
14
+ status: 500,
15
+ };
16
+
17
+ function event(outcome: JourneyEvent['journey']['outcome'] = 'bad'): JourneyEvent {
18
+ return {
19
+ journey: {
20
+ name: 'test',
21
+ team: 'test',
22
+ group: 'test',
23
+ service: 'test',
24
+ outcome,
25
+ durationMs: 2000,
26
+ steps: [
27
+ { name: 'save', startMs: 0, durationMs: 2000, outcome: 'bad', requests: [request] },
28
+ ],
29
+ },
30
+ };
31
+ }
32
+
33
+ describe('[journey] request logs link', () => {
34
+ test('uses the verified production view and an absolute request window', () => {
35
+ const url = new URL(requestLogsUrl(request)!);
36
+ expect(url.origin).toBe('https://kibana.st.dev');
37
+ expect(url.pathname).toBe('/app/discover');
38
+ const params = new URLSearchParams(url.hash.slice(3));
39
+ expect(params.get('_g')).toBe(
40
+ "(refreshInterval:(pause:!t,value:0),time:(from:'2026-09-17T17:59:00.000Z',to:'2026-09-17T18:01:02.000Z'))"
41
+ );
42
+ expect(params.get('_a')).toBe(
43
+ "(dataSource:(type:dataView,dataViewId:'894110ed-c22d-449c-9dc2-c4816f08712a'),query:(language:kuery,query:'Request.RequestId: 0123456789abcdef*'))"
44
+ );
45
+ expect(url.href).not.toContain('IAD');
46
+ });
47
+
48
+ test.each([
49
+ { rayId: "0123456789abcdef') OR *" },
50
+ { rayId: 'https://attacker.example' },
51
+ { rayId: 'a'.repeat(1000) },
52
+ { rayId: '0123456789abcde' },
53
+ { rayId: '0123456789abcdef-INVALID' },
54
+ { startedAtEpoch: NaN },
55
+ { startedAtEpoch: Infinity },
56
+ { startedAtEpoch: -1 },
57
+ { startedAtEpoch: 8.64e15 },
58
+ { durationMs: -1 },
59
+ { durationMs: Infinity },
60
+ { status: 0 },
61
+ { status: 600 },
62
+ { status: 200.5 },
63
+ ])('rejects invalid correlation %j', invalid => {
64
+ expect(requestLogsUrl({ ...request, ...invalid })).toBeUndefined();
65
+ const payload = event();
66
+ payload.journey.steps[0].requests = [{ ...request, ...invalid }];
67
+ expect(sanitizeJourneyEvent(payload).journey.steps[0].requests).toBeUndefined();
68
+ });
69
+
70
+ test('sanitizes correlation by allowlist, not the metric-tag string normalizer', () => {
71
+ const payload = event();
72
+ payload.journey.steps[0].requests = [
73
+ {
74
+ ...request,
75
+ url: '/test?password=secret',
76
+ headers: { authorization: 'secret' },
77
+ } as JourneyRequestEvent,
78
+ ];
79
+ expect(sanitizeJourneyEvent(payload).journey.steps[0].requests).toEqual([
80
+ { ...request, rayId: request.rayId.toLowerCase() },
81
+ ]);
82
+ });
83
+
84
+ test.each(['good', 'excluded'] as const)('keeps request links on %s events', outcome => {
85
+ expect(sanitizeJourneyEvent(event(outcome)).journey.steps[0].requests).toEqual([
86
+ { ...request, rayId: request.rayId.toLowerCase() },
87
+ ]);
88
+ });
89
+
90
+ test('caps the event payload and preserves links on the last failing step', () => {
91
+ const payload = event();
92
+ payload.journey.steps = Array.from({ length: 50 }, (_, index) => ({
93
+ ...payload.journey.steps[0],
94
+ name: `step-${index}`,
95
+ }));
96
+ const steps = sanitizeJourneyEvent(payload).journey.steps;
97
+ expect(steps.reduce((count, step) => count + (step.requests?.length ?? 0), 0)).toBe(
98
+ MAX_JOURNEY_CORRELATED_REQUESTS
99
+ );
100
+ expect(steps[0].requestsTruncated).toBe(true);
101
+ expect(steps[49].requests).toHaveLength(1);
102
+ });
103
+ test('validates the top-level failure request and strips it from non-bad events', () => {
104
+ const payload = event();
105
+ payload.journey.failedRequest = { ...request, rayId: "0123456789abcdef') OR *" };
106
+ expect(sanitizeJourneyEvent(payload).journey.failedRequest).toBeUndefined();
107
+ payload.journey.failedRequest = request;
108
+ expect(sanitizeJourneyEvent(payload).journey.failedRequest).toEqual({
109
+ ...request,
110
+ rayId: request.rayId.toLowerCase(),
111
+ });
112
+ payload.journey.outcome = 'good';
113
+ expect(sanitizeJourneyEvent(payload).journey.failedRequest).toBeUndefined();
114
+ });
115
+ });
116
+
117
+ describe('[journey] visible Kibana link', () => {
118
+ const now = Date.parse('2026-09-17T18:00:02.000Z');
119
+ const user = { tenant: 'test_tenant', name: 'test-agent', email: 'agent@example.test' };
120
+ const page = 'https://go.servicetitan.com/';
121
+
122
+ beforeEach(() => {
123
+ jest.spyOn(Date, 'now').mockReturnValue(now);
124
+ });
125
+ afterEach(() => jest.restoreAllMocks());
126
+
127
+ function queryState(url: string): string {
128
+ return new URLSearchParams(new URL(url).hash.slice(3)).get('_a')!;
129
+ }
130
+
131
+ test.each(['step-error', 'request-error', 'step-timeout', 'journey-timeout', 'custom-failure'])(
132
+ 'links %s without an HTTP response to scoped related logs',
133
+ reason => {
134
+ const journey = { ...event().journey, reason, steps: [] };
135
+ const link = journeyLogsLink(journey, () => user, page)!;
136
+ expect(link.scope).toBe('related_logs');
137
+ expect(queryState(link.url)).toContain('Context.TenantName: "test_tenant"');
138
+ expect(queryState(link.url)).toContain('Request.User: "test-agent"');
139
+ expect(queryState(link.url)).toContain('Request.User: "agent@example.test"');
140
+ expect(queryState(link.url)).not.toContain('Request.RequestId');
141
+ const params = new URLSearchParams(new URL(link.url).hash.slice(3));
142
+ expect(params.get('_g')).toContain("from:'2026-09-17T17:59:00.000Z'");
143
+ expect(params.get('_g')).toContain("to:'2026-09-17T18:01:02.000Z'");
144
+ }
145
+ );
146
+
147
+ test.each(['good', 'excluded'] as const)('includes a visible link for %s journeys', outcome => {
148
+ const link = journeyLogsLink(event(outcome).journey, () => user, page)!;
149
+ expect(link.scope).toBe('related_logs');
150
+ });
151
+
152
+ test('keeps the existing precise failure link ahead of the fallback', () => {
153
+ const getUser = jest.fn(() => user);
154
+ const journey = { ...event().journey, failedRequest: request };
155
+ expect(journeyLogsLink(journey, getUser, page)).toEqual({
156
+ url: requestLogsUrl(request),
157
+ scope: 'request',
158
+ });
159
+ expect(getUser).not.toHaveBeenCalled();
160
+ });
161
+
162
+ test('does not blame an earlier successful request for a later network error', () => {
163
+ const journey = event().journey;
164
+ journey.reason = 'request-error';
165
+ journey.steps[0].requests![0] = { ...request, status: 200 };
166
+ const link = journeyLogsLink(journey, () => user, page)!;
167
+ expect(link.scope).toBe('related_logs');
168
+ expect(queryState(link.url)).not.toContain('Request.RequestId');
169
+ });
170
+
171
+ test('makes nested captured requests visible when host user context is unavailable', () => {
172
+ const journey = event().journey;
173
+ const other = { ...request, rayId: 'fedcba9876543210', status: 200 };
174
+ journey.steps[0].requests = [request, other, request];
175
+ const link = journeyLogsLink(journey)!;
176
+ expect(link.scope).toBe('related_requests');
177
+ expect(queryState(link.url)).toContain(
178
+ 'Request.RequestId: 0123456789abcdef* OR Request.RequestId: fedcba9876543210*'
179
+ );
180
+ expect(queryState(link.url).match(/0123456789abcdef/g)).toHaveLength(1);
181
+ });
182
+
183
+ test.each([
184
+ {},
185
+ { tenant: 'test_tenant' },
186
+ { name: 'test-agent' },
187
+ { tenant: 'test_tenant OR *', name: 'test-agent' },
188
+ { tenant: 'test_tenant', name: 'agent\nOR *' },
189
+ { tenant: 'test_tenant', name: 'x'.repeat(255) },
190
+ ])('does not create a broad cross-tenant search for %j', invalid => {
191
+ const journey = { ...event().journey, steps: [] };
192
+ expect(journeyLogsLink(journey, () => invalid, page)).toBeUndefined();
193
+ });
194
+
195
+ test.each([-1, Infinity, NaN, 86_400_001])('rejects invalid duration %s', durationMs => {
196
+ const journey = { ...event().journey, durationMs, steps: [] };
197
+ expect(journeyLogsLink(journey, () => user, page)).toBeUndefined();
198
+ });
199
+
200
+ test('deduplicates identical user and email values', () => {
201
+ const link = journeyLogsLink(event().journey, () => ({ ...user, email: user.name }), page)!;
202
+ expect(queryState(link.url).match(/Request.User/g)).toHaveLength(1);
203
+ });
204
+
205
+ test('escapes quotes and exclamation marks at both KQL and Rison layers', () => {
206
+ const name = `O'Neil!" OR *`;
207
+ const link = journeyLogsLink(
208
+ event().journey,
209
+ () => ({ ...user, name, email: undefined }),
210
+ page
211
+ )!;
212
+ expect(queryState(link.url)).toContain(`Request.User: "O!'Neil!!\\" OR *"`);
213
+ expect(queryState(link.url)).toContain('Context.TenantName: "test_tenant" AND (');
214
+ });
215
+
216
+ test('request caps still apply on good journeys', () => {
217
+ const payload = event('good');
218
+ payload.journey.steps = Array.from({ length: 50 }, (_, index) => ({
219
+ ...payload.journey.steps[0],
220
+ name: `step-${index}`,
221
+ }));
222
+ const safe = sanitizeJourneyEvent(payload);
223
+ expect(
224
+ safe.journey.steps.reduce((count, step) => count + (step.requests?.length ?? 0), 0)
225
+ ).toBe(MAX_JOURNEY_CORRELATED_REQUESTS);
226
+ expect(safe.journey.outcome).toBe('good');
227
+ expect(safe.journey.steps[0].requestsTruncated).toBe(true);
228
+ });
229
+
230
+ test('does not turn non-production user context into a production log link', () => {
231
+ const journey = { ...event().journey, steps: [] };
232
+ expect(
233
+ journeyLogsLink(journey, () => user, 'https://qa.servicetitan.com/')
234
+ ).toBeUndefined();
235
+ });
236
+
237
+ test('a throwing getUser cannot prevent captured request links', () => {
238
+ expect(
239
+ journeyLogsLink(
240
+ event().journey,
241
+ () => {
242
+ throw new Error('unavailable');
243
+ },
244
+ page
245
+ )?.scope
246
+ ).toBe('related_requests');
247
+ });
248
+ });
249
+
250
+ describe('[journey] verified monolith origins', () => {
251
+ test.each([
252
+ 'https://go.servicetitan.com/path',
253
+ 'https://testtenant.eh.go.servicetitan.com/path',
254
+ 'https://test_tenant.eh.go.servicetitan.com/path',
255
+ ])('accepts %s', url => expect(isMonolithLogOrigin(url)).toBe(true));
256
+
257
+ test.each([
258
+ 'https://go.servicetitan.com.attacker.test',
259
+ 'https://test.eh.go.servicetitan.com.attacker.test',
260
+ 'https://qa.servicetitan.com',
261
+ 'https://foo.go.servicetitan.com',
262
+ 'https://go.servicetitan.com:8443',
263
+ 'https://user:secret@go.servicetitan.com',
264
+ 'http://go.servicetitan.com',
265
+ 'not-a-url',
266
+ ])('rejects %s', url => expect(isMonolithLogOrigin(url)).toBe(false));
267
+ });
package/src/core/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  export type {
21
21
  JourneyDef,
22
22
  JourneyEvent,
23
+ JourneyRequestEvent,
23
24
  JourneyHandle,
24
25
  JourneyNameLike,
25
26
  JourneySink,
@@ -0,0 +1,51 @@
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
+ }
33
+
34
+ /** Production monolith origins whose Ray IDs have been verified against the log data view. */
35
+ export function isMonolithLogOrigin(value: string): boolean {
36
+ try {
37
+ const url = new URL(value);
38
+ return (
39
+ url.protocol === 'https:' &&
40
+ !url.port &&
41
+ !url.username &&
42
+ !url.password &&
43
+ (url.hostname === 'go.servicetitan.com' ||
44
+ /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:_[a-z0-9-]+)*\.eh\.go\.servicetitan\.com$/i.test(
45
+ url.hostname
46
+ ))
47
+ );
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
@@ -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
+ ...(s.requests?.length
340
+ ? { requests: s.requests.map(request => ({ ...request })) }
341
+ : {}),
342
+ ...(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,
@@ -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
 
@@ -125,9 +130,18 @@ export function setSafeAttribute(
125
130
  }
126
131
 
127
132
  function sanitizeStep(step: JourneyStepEvent): JourneyStepEvent {
133
+ const { requests: rawRequests, requestsTruncated, ...rest } = step;
134
+ const requests = Array.isArray(rawRequests)
135
+ ? rawRequests
136
+ .slice(0, MAX_CORRELATED_REQUESTS)
137
+ .map(sanitizeRequestCorrelation)
138
+ .filter(request => request !== undefined)
139
+ : [];
128
140
  const name = normalizeTagValue(step.name);
129
141
  return {
130
- ...step,
142
+ ...rest,
143
+ ...(requests.length ? { requests } : {}),
144
+ ...(requestsTruncated === true ? { requestsTruncated: true } : {}),
131
145
  name: name || step.name,
132
146
  ...(step.reason ? { reason: normalizeTagValue(step.reason) } : {}),
133
147
  ...(step.attributes
@@ -149,6 +163,28 @@ export function sanitizeJourneyEvent(event: JourneyEvent): JourneyEvent {
149
163
  ? copyRecord(j.tags, { maxKeys: MAX_TAG_KEYS, normalizeKeys: true })
150
164
  : undefined;
151
165
  const tagKeys = tags ? Object.keys(tags) : [];
166
+ const steps = j.steps.map(sanitizeStep);
167
+ // Prefer recently started steps at the cap; failedRequest preserves the scoring request separately.
168
+ let requestBudget = MAX_JOURNEY_CORRELATED_REQUESTS;
169
+ for (let index = steps.length - 1; index >= 0; index--) {
170
+ const step = steps[index];
171
+ if (!step.requests) {
172
+ continue;
173
+ }
174
+ if (step.requests.length > requestBudget) {
175
+ step.requestsTruncated = true;
176
+ if (requestBudget === 0) {
177
+ delete step.requests;
178
+ } else {
179
+ step.requests = step.requests.slice(-requestBudget);
180
+ }
181
+ }
182
+ requestBudget -= step.requests?.length ?? 0;
183
+ }
184
+ const failedRequest =
185
+ j.outcome === 'bad' && j.failedRequest
186
+ ? sanitizeRequestCorrelation(j.failedRequest)
187
+ : undefined;
152
188
  const sanitized: JourneyEvent = {
153
189
  journey: {
154
190
  name: normalizeTagValue(j.name),
@@ -156,9 +192,10 @@ export function sanitizeJourneyEvent(event: JourneyEvent): JourneyEvent {
156
192
  group: normalizeTagValue(j.group),
157
193
  service: normalizeTagValue(j.service),
158
194
  outcome: j.outcome,
195
+ ...(failedRequest ? { failedRequest } : {}),
159
196
  ...(j.reason ? { reason: normalizeTagValue(j.reason) } : {}),
160
197
  durationMs: j.durationMs,
161
- steps: j.steps.map(sanitizeStep),
198
+ steps,
162
199
  ...(expected?.length ? { expected } : {}),
163
200
  ...(tagKeys.length ? { tags } : {}),
164
201
  },
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';
@@ -60,6 +60,7 @@ function reportTiming(
60
60
  requestKey: key,
61
61
  aborted,
62
62
  url: config.url,
63
+ baseURL: config.baseURL ?? instance.defaults.baseURL,
63
64
  method: config.method,
64
65
  meta: config,
65
66
  request: extras?.request ?? config,
@@ -10,6 +10,7 @@ import { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime';
10
10
  import type { JourneyStepStamp } from '../core/step-tag';
11
11
  import type { StepRecord } from '../core/types';
12
12
  import type { JourneyTimedMeta } from './request-key';
13
+ import { captureRequestCorrelation } from './request-correlation';
13
14
 
14
15
  export interface HttpCompleteReport {
15
16
  stamp: JourneyStepStamp | undefined;
@@ -18,6 +19,8 @@ export interface HttpCompleteReport {
18
19
  requestKey: string;
19
20
  aborted?: boolean;
20
21
  url?: string;
22
+ /** Actual request base, used only to validate the log-correlation origin. */
23
+ baseURL?: string;
21
24
  method?: string;
22
25
  /** Per-request literal overrides from JourneyTimedMeta. */
23
26
  meta?: JourneyTimedMeta;
@@ -73,19 +76,7 @@ function httpScoreReason(report: HttpCompleteReport): string {
73
76
  * 4xx with policy `'continue'` (default) still scores latency.
74
77
  */
75
78
  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;
79
+ const { stamp, requestKey, aborted, meta } = report;
89
80
  if (!stamp) {
90
81
  return;
91
82
  }
@@ -124,6 +115,34 @@ export function onHttpComplete(report: HttpCompleteReport): void {
124
115
  return;
125
116
  }
126
117
 
118
+ // Capture before a policy/latency breach synchronously emits the terminal event.
119
+ const correlation = captureRequestCorrelation(report);
120
+ const previous = step.journey.scoringRequest;
121
+ // Scope this pointer to scoring. Later JS/business failures must not blame the last request.
122
+ step.journey.scoringRequest = correlation;
123
+ try {
124
+ scoreHttpComplete(report, step);
125
+ } finally {
126
+ step.journey.scoringRequest = previous;
127
+ }
128
+ }
129
+
130
+ function scoreHttpComplete(report: HttpCompleteReport, step: StepRecord): void {
131
+ const {
132
+ stamp,
133
+ status,
134
+ durationMs,
135
+ requestKey,
136
+ aborted,
137
+ url,
138
+ method,
139
+ meta,
140
+ request,
141
+ response,
142
+ error,
143
+ } = report;
144
+ const engine = resolveJourneyEngine(step.journey);
145
+
127
146
  const ctx: JourneyHttpPolicyContext = {
128
147
  requestKey,
129
148
  status,
@@ -134,7 +153,7 @@ export function onHttpComplete(report: HttpCompleteReport): void {
134
153
  stepName: step.name,
135
154
  };
136
155
 
137
- const score = stamp.score;
156
+ const score = stamp?.score;
138
157
  if (score) {
139
158
  let policy: JourneyRequestPolicy | undefined;
140
159
  try {
@@ -5,7 +5,8 @@ import {
5
5
  registerInstrumentRecord,
6
6
  type InstrumentRecord,
7
7
  } from '../../global';
8
- import { onHttpComplete } from '../http-report';
8
+ import { onHttpComplete, type HttpCompleteReport } from '../http-report';
9
+ import { captureRequestCorrelation } from '../request-correlation';
9
10
  import { requestKey, type JourneyTimedMeta } from '../request-key';
10
11
  import { acceptInstrumentTarget } from '../soft-skip';
11
12
 
@@ -14,6 +15,8 @@ export interface JqueryXHR {
14
15
  statusText?: string;
15
16
  stamp?: unknown;
16
17
  journeyStart?: number;
18
+ getResponseHeader?(name: string): string | null;
19
+ always?(callback: () => void): unknown;
17
20
  }
18
21
 
19
22
  export interface JqueryAjaxSettings extends JourneyTimedMeta {
@@ -39,7 +42,10 @@ export interface JqueryStatic {
39
42
  };
40
43
  }
41
44
 
42
- function reportTiming(jqXHR: JqueryXHR, settings: JqueryAjaxSettings): void {
45
+ function timingReport(
46
+ jqXHR: JqueryXHR,
47
+ settings: JqueryAjaxSettings
48
+ ): HttpCompleteReport | undefined {
43
49
  const stamp = jqueryStamps.get(jqXHR) ?? adoptJourneyStepStamp(jqXHR.stamp);
44
50
  if (!stamp || stamp.ignore || settings.ignore === true) {
45
51
  return;
@@ -48,7 +54,7 @@ function reportTiming(jqXHR: JqueryXHR, settings: JqueryAjaxSettings): void {
48
54
  const aborted = jqXHR.statusText === 'abort';
49
55
  // jQuery uses status 0 for network / abort failures — treat like a missing status.
50
56
  const status = aborted || jqXHR.status === 0 ? undefined : jqXHR.status;
51
- onHttpComplete({
57
+ return {
52
58
  stamp,
53
59
  status,
54
60
  durationMs: globalThis.performance.now() - start,
@@ -59,7 +65,7 @@ function reportTiming(jqXHR: JqueryXHR, settings: JqueryAjaxSettings): void {
59
65
  meta: settings,
60
66
  request: settings,
61
67
  response: jqXHR,
62
- });
68
+ };
63
69
  }
64
70
 
65
71
  function isJqueryStatic(value: unknown): value is JqueryStatic {
@@ -118,6 +124,29 @@ export function instrumentJquery($: JqueryStatic | null | undefined): () => void
118
124
  return;
119
125
  }
120
126
  stampAjax(jqXHR, options);
127
+ /*
128
+ * Prefilters run before application success/error callbacks; ajaxComplete runs after.
129
+ * Capture headers here, but keep verdict scoring in ajaxComplete as before.
130
+ */
131
+ try {
132
+ if (jqueryStamps.has(jqXHR)) {
133
+ jqXHR.always?.(() => {
134
+ if (current.disposed) {
135
+ return;
136
+ }
137
+ try {
138
+ const report = timingReport(jqXHR, options);
139
+ if (report) {
140
+ captureRequestCorrelation(report);
141
+ }
142
+ } catch {
143
+ // This observer must never interrupt application callbacks.
144
+ }
145
+ });
146
+ }
147
+ } catch {
148
+ // Partial jQuery-compatible clients may not support response observers.
149
+ }
121
150
  });
122
151
  jqueryPrefiltersInstalled.add(key);
123
152
  }
@@ -127,7 +156,10 @@ export function instrumentJquery($: JqueryStatic | null | undefined): () => void
127
156
  if (!current || current.disposed) {
128
157
  return;
129
158
  }
130
- reportTiming(jqXHR, settings);
159
+ const report = timingReport(jqXHR, settings);
160
+ if (report) {
161
+ onHttpComplete(report);
162
+ }
131
163
  });
132
164
  jqueryCompletesInstalled.add(key);
133
165
  }