@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.
- 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/datadog-links.test.d.ts +2 -0
- package/dist/__tests__/sinks/datadog-links.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 +8 -0
- package/dist/core/request-correlation.d.ts.map +1 -0
- package/dist/core/request-correlation.js +23 -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 +35 -2
- 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 +64 -0
- package/dist/integrations/request-correlation.js.map +1 -0
- package/dist/sinks/datadog.d.ts.map +1 -1
- package/dist/sinks/datadog.js +26 -0
- package/dist/sinks/datadog.js.map +1 -1
- package/dist/sinks/kibana.d.ts +11 -0
- package/dist/sinks/kibana.d.ts.map +1 -0
- package/dist/sinks/kibana.js +92 -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 +383 -0
- package/src/__tests__/sinks/datadog-links.test.ts +58 -0
- package/src/__tests__/sinks/kibana.test.ts +267 -0
- package/src/core/index.ts +1 -0
- package/src/core/request-correlation.ts +51 -0
- package/src/core/runtime.ts +7 -0
- package/src/core/sanitize.ts +39 -2
- 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 +93 -0
- package/src/sinks/datadog.ts +21 -0
- 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
|
@@ -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
|
+
}
|
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
|
+
...(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,
|
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
|
|
|
@@ -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
|
-
...
|
|
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
|
|
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';
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
159
|
+
const report = timingReport(jqXHR, settings);
|
|
160
|
+
if (report) {
|
|
161
|
+
onHttpComplete(report);
|
|
162
|
+
}
|
|
131
163
|
});
|
|
132
164
|
jqueryCompletesInstalled.add(key);
|
|
133
165
|
}
|