@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.
Files changed (63) 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/kibana.test.d.ts +2 -0
  5. package/dist/__tests__/sinks/kibana.test.d.ts.map +1 -0
  6. package/dist/core/index.d.ts +1 -1
  7. package/dist/core/index.d.ts.map +1 -1
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/request-correlation.d.ts +6 -0
  10. package/dist/core/request-correlation.d.ts.map +1 -0
  11. package/dist/core/request-correlation.js +15 -0
  12. package/dist/core/request-correlation.js.map +1 -0
  13. package/dist/core/runtime.d.ts.map +1 -1
  14. package/dist/core/runtime.js +18 -2
  15. package/dist/core/runtime.js.map +1 -1
  16. package/dist/core/sanitize.d.ts.map +1 -1
  17. package/dist/core/sanitize.js +36 -3
  18. package/dist/core/sanitize.js.map +1 -1
  19. package/dist/core/types.d.ts +17 -0
  20. package/dist/core/types.d.ts.map +1 -1
  21. package/dist/core/types.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/axios.d.ts.map +1 -1
  26. package/dist/integrations/axios.js +2 -1
  27. package/dist/integrations/axios.js.map +1 -1
  28. package/dist/integrations/http-report.d.ts +2 -0
  29. package/dist/integrations/http-report.d.ts.map +1 -1
  30. package/dist/integrations/http-report.js +17 -2
  31. package/dist/integrations/http-report.js.map +1 -1
  32. package/dist/integrations/jquery/instrument.d.ts +2 -0
  33. package/dist/integrations/jquery/instrument.d.ts.map +1 -1
  34. package/dist/integrations/jquery/instrument.js +31 -4
  35. package/dist/integrations/jquery/instrument.js.map +1 -1
  36. package/dist/integrations/request-correlation.d.ts +5 -0
  37. package/dist/integrations/request-correlation.d.ts.map +1 -0
  38. package/dist/integrations/request-correlation.js +65 -0
  39. package/dist/integrations/request-correlation.js.map +1 -0
  40. package/dist/sinks/datadog.d.ts.map +1 -1
  41. package/dist/sinks/datadog.js +18 -0
  42. package/dist/sinks/datadog.js.map +1 -1
  43. package/dist/sinks/kibana.d.ts +4 -0
  44. package/dist/sinks/kibana.d.ts.map +1 -0
  45. package/dist/sinks/kibana.js +20 -0
  46. package/dist/sinks/kibana.js.map +1 -0
  47. package/package.json +2 -2
  48. package/src/__tests__/integrations/axios.test.ts +38 -1
  49. package/src/__tests__/integrations/jquery.test.ts +94 -1
  50. package/src/__tests__/integrations/request-correlation.test.ts +315 -0
  51. package/src/__tests__/sinks/kibana.test.ts +110 -0
  52. package/src/core/index.ts +1 -0
  53. package/src/core/request-correlation.ts +32 -0
  54. package/src/core/runtime.ts +7 -0
  55. package/src/core/sanitize.ts +41 -3
  56. package/src/core/types.ts +18 -0
  57. package/src/index.ts +1 -1
  58. package/src/integrations/axios.ts +1 -0
  59. package/src/integrations/http-report.ts +33 -14
  60. package/src/integrations/jquery/instrument.ts +37 -5
  61. package/src/integrations/request-correlation.ts +90 -0
  62. package/src/sinks/datadog.ts +19 -0
  63. 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.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
  }
@@ -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
+ }
@@ -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
+ }