@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
|
@@ -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
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { MAX_CORRELATED_REQUESTS, sanitizeRequestCorrelation } from '../core/request-correlation';
|
|
2
|
+
import { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime';
|
|
3
|
+
import type { HttpCompleteReport } from './http-report';
|
|
4
|
+
import type { JourneyRequestEvent } from '../core/types';
|
|
5
|
+
|
|
6
|
+
const MONOLITH_ORIGIN = 'https://go.servicetitan.com';
|
|
7
|
+
const capturedResponses = new WeakMap<object, JourneyRequestEvent>();
|
|
8
|
+
|
|
9
|
+
interface ResponseHeaders {
|
|
10
|
+
headers?: { 'cf-ray'?: unknown; 'get'?(name: string): unknown };
|
|
11
|
+
url?: string;
|
|
12
|
+
responseURL?: string;
|
|
13
|
+
request?: { responseURL?: string };
|
|
14
|
+
getResponseHeader?(name: string): unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Observation only. Safe to run before jQuery invokes application callbacks. */
|
|
18
|
+
export function captureRequestCorrelation(
|
|
19
|
+
report: HttpCompleteReport
|
|
20
|
+
): JourneyRequestEvent | undefined {
|
|
21
|
+
try {
|
|
22
|
+
const step = report.stamp?.step;
|
|
23
|
+
if (
|
|
24
|
+
!step?.journey ||
|
|
25
|
+
step.journey.closed ||
|
|
26
|
+
!canRouteHttpToJourney(step.journey) ||
|
|
27
|
+
report.stamp?.ignore ||
|
|
28
|
+
report.meta?.ignore ||
|
|
29
|
+
report.aborted ||
|
|
30
|
+
!report.url ||
|
|
31
|
+
resolveJourneyEngine(step.journey).shouldIgnoreRequest(report.requestKey)
|
|
32
|
+
) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Other environments/backends have not verified this log-field/data-view contract.
|
|
37
|
+
const pageUrl = globalThis.location?.href;
|
|
38
|
+
const base = report.baseURL ? new URL(report.baseURL, pageUrl).href : pageUrl;
|
|
39
|
+
const url = new URL(report.url, base);
|
|
40
|
+
if (url.origin !== MONOLITH_ORIGIN) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const response = report.response as ResponseHeaders | undefined;
|
|
44
|
+
if (!response || typeof response !== 'object') {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const captured = capturedResponses.get(response);
|
|
48
|
+
if (captured) {
|
|
49
|
+
return step.requests?.includes(captured) ? captured : undefined;
|
|
50
|
+
}
|
|
51
|
+
const finalUrl = response.url || response.responseURL || response.request?.responseURL;
|
|
52
|
+
if (finalUrl && new URL(finalUrl).origin !== MONOLITH_ORIGIN) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const rayId =
|
|
56
|
+
typeof response.getResponseHeader === 'function'
|
|
57
|
+
? response.getResponseHeader('cf-ray')
|
|
58
|
+
: typeof response.headers?.get === 'function'
|
|
59
|
+
? response.headers.get('cf-ray')
|
|
60
|
+
: response.headers?.['cf-ray'];
|
|
61
|
+
if (typeof rayId !== 'string') {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const request = sanitizeRequestCorrelation({
|
|
65
|
+
rayId,
|
|
66
|
+
startedAtEpoch: Date.now() - report.durationMs,
|
|
67
|
+
durationMs: report.durationMs,
|
|
68
|
+
status: report.status as number,
|
|
69
|
+
});
|
|
70
|
+
if (!request) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
capturedResponses.set(response, request);
|
|
74
|
+
step.requests ??= [];
|
|
75
|
+
const requests = step.requests;
|
|
76
|
+
if (requests.length >= MAX_CORRELATED_REQUESTS) {
|
|
77
|
+
step.requestsTruncated = true;
|
|
78
|
+
// Keep errors when later successes arrive. A new error replaces a success first.
|
|
79
|
+
const replace = requests.findIndex(item => item.status < 400);
|
|
80
|
+
if (replace < 0 && request.status < 400) {
|
|
81
|
+
return request;
|
|
82
|
+
}
|
|
83
|
+
requests.splice(replace < 0 ? 0 : replace, 1);
|
|
84
|
+
}
|
|
85
|
+
requests.push(request);
|
|
86
|
+
return request;
|
|
87
|
+
} catch {
|
|
88
|
+
// Missing/blocked headers and unusual transport objects must not affect requests.
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/sinks/datadog.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isSanitizedJourneyEvent, sanitizeJourneyEvent } from '../core/sanitize';
|
|
2
2
|
import { getJourneyRumFallback, setJourneyRumFallback } from '../global';
|
|
3
3
|
import type { JourneyEvent, JourneyStepEvent } from '../core/types';
|
|
4
|
+
import { requestLogsUrl } from './kibana';
|
|
4
5
|
|
|
5
6
|
export { normalizeTagValue } from '../core/sanitize';
|
|
6
7
|
|
|
@@ -54,6 +55,18 @@ function toDatadogStep(step: JourneyStepEvent) {
|
|
|
54
55
|
outcome: step.outcome,
|
|
55
56
|
...(step.reason ? { reason: step.reason } : {}),
|
|
56
57
|
...(step.httpStatus !== undefined ? { http_status: step.httpStatus } : {}),
|
|
58
|
+
...(step.requests?.length
|
|
59
|
+
? {
|
|
60
|
+
requests: step.requests.map(request => ({
|
|
61
|
+
request_id: request.rayId,
|
|
62
|
+
started_at: new Date(request.startedAtEpoch).toISOString(),
|
|
63
|
+
duration_ms: request.durationMs,
|
|
64
|
+
http_status: request.status,
|
|
65
|
+
kibana_url: requestLogsUrl(request),
|
|
66
|
+
})),
|
|
67
|
+
}
|
|
68
|
+
: {}),
|
|
69
|
+
...(step.requestsTruncated ? { requests_truncated: true } : {}),
|
|
57
70
|
...(step.attributes && Object.keys(step.attributes).length
|
|
58
71
|
? { attributes: step.attributes }
|
|
59
72
|
: {}),
|
|
@@ -79,6 +92,12 @@ export function sendToDatadog(payload: JourneyEvent): void {
|
|
|
79
92
|
group: j.group,
|
|
80
93
|
service: j.service,
|
|
81
94
|
outcome: j.outcome,
|
|
95
|
+
...(j.failedRequest
|
|
96
|
+
? {
|
|
97
|
+
failed_request_id: j.failedRequest.rayId,
|
|
98
|
+
kibana_url: requestLogsUrl(j.failedRequest),
|
|
99
|
+
}
|
|
100
|
+
: {}),
|
|
82
101
|
...(j.reason ? { reason: j.reason } : {}),
|
|
83
102
|
duration_ms: j.durationMs,
|
|
84
103
|
steps: j.steps.map(toDatadogStep),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { sanitizeRequestCorrelation } from '../core/request-correlation';
|
|
2
|
+
import type { JourneyRequestEvent } from '../core/types';
|
|
3
|
+
|
|
4
|
+
const DISCOVER_URL = 'https://kibana.st.dev/app/discover#/';
|
|
5
|
+
// Production monolith logs; deliberately not the user's default data view.
|
|
6
|
+
const DATA_VIEW_ID = '894110ed-c22d-449c-9dc2-c4816f08712a';
|
|
7
|
+
const WINDOW_PADDING_MS = 60_000;
|
|
8
|
+
|
|
9
|
+
/** Fixed destination and validated Ray ID only — no request URL, query string or user data. */
|
|
10
|
+
export function requestLogsUrl(value: JourneyRequestEvent): string | undefined {
|
|
11
|
+
const request = sanitizeRequestCorrelation(value);
|
|
12
|
+
if (!request) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const from = new Date(request.startedAtEpoch - WINDOW_PADDING_MS).toISOString();
|
|
16
|
+
const to = new Date(
|
|
17
|
+
request.startedAtEpoch + request.durationMs + WINDOW_PADDING_MS
|
|
18
|
+
).toISOString();
|
|
19
|
+
// Origin/edge datacenter suffixes can differ. Match the hex prefix within the request window.
|
|
20
|
+
const rayPrefix = request.rayId.slice(0, 16);
|
|
21
|
+
const globalState = `(refreshInterval:(pause:!t,value:0),time:(from:'${from}',to:'${to}'))`;
|
|
22
|
+
const appState = `(dataSource:(type:dataView,dataViewId:'${DATA_VIEW_ID}'),query:(language:kuery,query:'Request.RequestId: ${rayPrefix}*'))`;
|
|
23
|
+
return `${DISCOVER_URL}?_g=${encodeURIComponent(globalState)}&_a=${encodeURIComponent(appState)}`;
|
|
24
|
+
}
|