@servicetitan/journey 3.0.1 → 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 (79) hide show
  1. package/README.md +35 -0
  2. package/dist/__tests__/core/persist.test.d.ts +2 -0
  3. package/dist/__tests__/core/persist.test.d.ts.map +1 -0
  4. package/dist/__tests__/integrations/request-correlation.test.d.ts +2 -0
  5. package/dist/__tests__/integrations/request-correlation.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/api.d.ts +13 -1
  9. package/dist/core/api.d.ts.map +1 -1
  10. package/dist/core/api.js +14 -0
  11. package/dist/core/api.js.map +1 -1
  12. package/dist/core/index.d.ts +2 -2
  13. package/dist/core/index.d.ts.map +1 -1
  14. package/dist/core/index.js +1 -1
  15. package/dist/core/index.js.map +1 -1
  16. package/dist/core/persist.d.ts +19 -0
  17. package/dist/core/persist.d.ts.map +1 -0
  18. package/dist/core/persist.js +111 -0
  19. package/dist/core/persist.js.map +1 -0
  20. package/dist/core/request-correlation.d.ts +6 -0
  21. package/dist/core/request-correlation.d.ts.map +1 -0
  22. package/dist/core/request-correlation.js +15 -0
  23. package/dist/core/request-correlation.js.map +1 -0
  24. package/dist/core/runtime.d.ts +16 -1
  25. package/dist/core/runtime.d.ts.map +1 -1
  26. package/dist/core/runtime.js +74 -23
  27. package/dist/core/runtime.js.map +1 -1
  28. package/dist/core/sanitize.d.ts.map +1 -1
  29. package/dist/core/sanitize.js +36 -3
  30. package/dist/core/sanitize.js.map +1 -1
  31. package/dist/core/types.d.ts +42 -0
  32. package/dist/core/types.d.ts.map +1 -1
  33. package/dist/core/types.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/integrations/axios.d.ts.map +1 -1
  39. package/dist/integrations/axios.js +2 -1
  40. package/dist/integrations/axios.js.map +1 -1
  41. package/dist/integrations/http-report.d.ts +2 -0
  42. package/dist/integrations/http-report.d.ts.map +1 -1
  43. package/dist/integrations/http-report.js +17 -2
  44. package/dist/integrations/http-report.js.map +1 -1
  45. package/dist/integrations/jquery/instrument.d.ts +2 -0
  46. package/dist/integrations/jquery/instrument.d.ts.map +1 -1
  47. package/dist/integrations/jquery/instrument.js +31 -4
  48. package/dist/integrations/jquery/instrument.js.map +1 -1
  49. package/dist/integrations/request-correlation.d.ts +5 -0
  50. package/dist/integrations/request-correlation.d.ts.map +1 -0
  51. package/dist/integrations/request-correlation.js +65 -0
  52. package/dist/integrations/request-correlation.js.map +1 -0
  53. package/dist/sinks/datadog.d.ts.map +1 -1
  54. package/dist/sinks/datadog.js +18 -0
  55. package/dist/sinks/datadog.js.map +1 -1
  56. package/dist/sinks/kibana.d.ts +4 -0
  57. package/dist/sinks/kibana.d.ts.map +1 -0
  58. package/dist/sinks/kibana.js +20 -0
  59. package/dist/sinks/kibana.js.map +1 -0
  60. package/package.json +2 -2
  61. package/src/__tests__/core/persist.test.ts +496 -0
  62. package/src/__tests__/integrations/axios.test.ts +38 -1
  63. package/src/__tests__/integrations/jquery.test.ts +94 -1
  64. package/src/__tests__/integrations/request-correlation.test.ts +315 -0
  65. package/src/__tests__/sinks/kibana.test.ts +110 -0
  66. package/src/core/api.ts +19 -0
  67. package/src/core/index.ts +4 -0
  68. package/src/core/persist.ts +131 -0
  69. package/src/core/request-correlation.ts +32 -0
  70. package/src/core/runtime.ts +69 -2
  71. package/src/core/sanitize.ts +41 -3
  72. package/src/core/types.ts +44 -0
  73. package/src/index.ts +4 -1
  74. package/src/integrations/axios.ts +1 -0
  75. package/src/integrations/http-report.ts +33 -14
  76. package/src/integrations/jquery/instrument.ts +37 -5
  77. package/src/integrations/request-correlation.ts +90 -0
  78. package/src/sinks/datadog.ts +19 -0
  79. package/src/sinks/kibana.ts +24 -0
@@ -11,6 +11,7 @@ import {
11
11
  sortEndpointsByLongestMatch,
12
12
  type SlowRequestConfig,
13
13
  } from './endpoint-policy';
14
+ import { deserializeJourneyState, projectJourneyState } from './persist';
14
15
  import {
15
16
  clampMaxSteps,
16
17
  DEFAULT_MAX_STEPS,
@@ -36,6 +37,7 @@ import {
36
37
  type JourneyStepOptions,
37
38
  type JourneyStepTarget,
38
39
  type Outcome,
40
+ type SerializedJourneyState,
39
41
  type StepHandle,
40
42
  type StepRecord,
41
43
  type TagValue,
@@ -106,7 +108,8 @@ export interface JourneyRuntimeDebugSnapshot {
106
108
  };
107
109
  }
108
110
 
109
- function freezeJourneyDef(config: JourneyDef, tags: Record<string, TagValue>): JourneyDef {
111
+ /** @internal Freeze a caller-supplied journey def + tags snapshot. */
112
+ export function freezeJourneyDef(config: JourneyDef, tags: Record<string, TagValue>): JourneyDef {
110
113
  const endpoints = sortEndpointsByLongestMatch(config.endpoints);
111
114
  return Object.freeze({
112
115
  ...config,
@@ -164,6 +167,17 @@ export interface JourneyRuntime {
164
167
  completeJourneyState(journey: JourneyState, tags?: Record<string, TagValue>): void;
165
168
  excludeJourney(journey: JourneyState): void;
166
169
  updateOpenJourney(config: JourneyDef): void;
170
+ /**
171
+ * @internal Snapshot an open journey for cross-page transfer. Does NOT
172
+ * close the journey. Returns `null` when no journey is open under `name`.
173
+ */
174
+ serializeJourneyState(name: string): SerializedJourneyState | null;
175
+ /**
176
+ * @internal Rehydrate a serialized journey onto this runtime. Returns
177
+ * `null` (after emitting a `bad` `journey-timeout` event) when the journey
178
+ * already exhausted its budget in transit.
179
+ */
180
+ restoreJourney(serialized: SerializedJourneyState, config: JourneyDef): JourneyState | null;
167
181
 
168
182
  activeJourneyStep(): StepRecord | null;
169
183
  journeyStep<T>(
@@ -192,7 +206,8 @@ export interface JourneyRuntime {
192
206
  reset(options?: JourneyRuntimeOptions): void;
193
207
  }
194
208
 
195
- function bindJourneyEngine(
209
+ /** @internal Bind the fail/exclude/report hooks for a journey's creating engine. */
210
+ export function bindJourneyEngine(
196
211
  api: JourneyRuntime,
197
212
  journey: JourneyState,
198
213
  shouldIgnoreRequest: (target: JourneyState, key: string) => boolean
@@ -321,6 +336,10 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
321
336
  ...(s.reason ? { reason: s.reason } : {}),
322
337
  ...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}),
323
338
  ...(Object.keys(s.attributes).length ? { attributes: { ...s.attributes } } : {}),
339
+ ...(finalOutcome === 'bad' && s.requests?.length
340
+ ? { requests: s.requests.map(request => ({ ...request })) }
341
+ : {}),
342
+ ...(finalOutcome === 'bad' && s.requestsTruncated ? { requestsTruncated: true } : {}),
324
343
  }));
325
344
 
326
345
  emit({
@@ -330,6 +349,9 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
330
349
  group: journey.group,
331
350
  service: journey.service,
332
351
  outcome: finalOutcome,
352
+ ...(finalOutcome === 'bad' && journey.scoringRequest
353
+ ? { failedRequest: { ...journey.scoringRequest } }
354
+ : {}),
333
355
  ...(finalReason ? { reason: finalReason } : {}),
334
356
  durationMs,
335
357
  steps,
@@ -685,6 +707,51 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
685
707
  armCountdown(journey, remainingMs);
686
708
  },
687
709
 
710
+ serializeJourneyState(name) {
711
+ const journey = journeys.get(name);
712
+ if (!journey || journey.closed) {
713
+ return null;
714
+ }
715
+ return projectJourneyState(journey);
716
+ },
717
+
718
+ restoreJourney(serialized, config) {
719
+ // Refuse double-registration: leave an existing journey under this name in place.
720
+ if (journeys.has(config.name)) {
721
+ return null;
722
+ }
723
+
724
+ const journey = deserializeJourneyState(serialized, config, api, defaultJourneyIdleMs);
725
+
726
+ if (journey.timeoutMs > 0) {
727
+ const elapsedMs = globalThis.performance.now() - journey.startedAt;
728
+ if (elapsedMs >= journey.timeoutMs) {
729
+ // journey not yet in journeys Map — finish() handles unregistered journeys gracefully.
730
+ if (journey.timeoutExplicit) {
731
+ finish(journey, 'bad', 'journey-timeout');
732
+ } else {
733
+ finish(
734
+ journey,
735
+ journey.verdict === 'bad' ? 'bad' : 'excluded',
736
+ 'journey-idle-timeout'
737
+ );
738
+ }
739
+ return null;
740
+ }
741
+
742
+ journeys.set(config.name, journey);
743
+
744
+ const remainingMs = journey.timeoutMs - elapsedMs;
745
+ if (remainingMs > 0) {
746
+ armCountdown(journey, remainingMs);
747
+ }
748
+ } else {
749
+ journeys.set(config.name, journey);
750
+ }
751
+
752
+ return journey;
753
+ },
754
+
688
755
  activeJourneyStep() {
689
756
  if (!api.getAutoAttributeRequests()) {
690
757
  return null;
@@ -1,5 +1,10 @@
1
1
  import { isUnsafeKey, MAX_ATTRIBUTE_KEYS, MAX_TAG_KEYS, MAX_TAG_VALUE_LEN } from './limits';
2
2
  import type { JourneyEvent, JourneyStepEvent, TagValue } from './types';
3
+ import {
4
+ MAX_CORRELATED_REQUESTS,
5
+ MAX_JOURNEY_CORRELATED_REQUESTS,
6
+ sanitizeRequestCorrelation,
7
+ } from './request-correlation';
3
8
 
4
9
  const SANITIZED_EVENT = Symbol.for('st.journey.sanitized');
5
10
 
@@ -124,10 +129,20 @@ export function setSafeAttribute(
124
129
  }
125
130
  }
126
131
 
127
- function sanitizeStep(step: JourneyStepEvent): JourneyStepEvent {
132
+ function sanitizeStep(step: JourneyStepEvent, includeRequests: boolean): JourneyStepEvent {
133
+ const { requests: rawRequests, requestsTruncated, ...rest } = step;
134
+ const requests =
135
+ includeRequests && Array.isArray(rawRequests)
136
+ ? rawRequests
137
+ .slice(0, MAX_CORRELATED_REQUESTS)
138
+ .map(sanitizeRequestCorrelation)
139
+ .filter(request => request !== undefined)
140
+ : [];
128
141
  const name = normalizeTagValue(step.name);
129
142
  return {
130
- ...step,
143
+ ...rest,
144
+ ...(requests.length ? { requests } : {}),
145
+ ...(includeRequests && requestsTruncated === true ? { requestsTruncated: true } : {}),
131
146
  name: name || step.name,
132
147
  ...(step.reason ? { reason: normalizeTagValue(step.reason) } : {}),
133
148
  ...(step.attributes
@@ -149,6 +164,28 @@ export function sanitizeJourneyEvent(event: JourneyEvent): JourneyEvent {
149
164
  ? copyRecord(j.tags, { maxKeys: MAX_TAG_KEYS, normalizeKeys: true })
150
165
  : undefined;
151
166
  const tagKeys = tags ? Object.keys(tags) : [];
167
+ const steps = j.steps.map(step => sanitizeStep(step, j.outcome === 'bad'));
168
+ // Prefer recently started steps at the cap; failedRequest preserves the scoring request separately.
169
+ let requestBudget = MAX_JOURNEY_CORRELATED_REQUESTS;
170
+ for (let index = steps.length - 1; index >= 0; index--) {
171
+ const step = steps[index];
172
+ if (!step.requests) {
173
+ continue;
174
+ }
175
+ if (step.requests.length > requestBudget) {
176
+ step.requestsTruncated = true;
177
+ if (requestBudget === 0) {
178
+ delete step.requests;
179
+ } else {
180
+ step.requests = step.requests.slice(-requestBudget);
181
+ }
182
+ }
183
+ requestBudget -= step.requests?.length ?? 0;
184
+ }
185
+ const failedRequest =
186
+ j.outcome === 'bad' && j.failedRequest
187
+ ? sanitizeRequestCorrelation(j.failedRequest)
188
+ : undefined;
152
189
  const sanitized: JourneyEvent = {
153
190
  journey: {
154
191
  name: normalizeTagValue(j.name),
@@ -156,9 +193,10 @@ export function sanitizeJourneyEvent(event: JourneyEvent): JourneyEvent {
156
193
  group: normalizeTagValue(j.group),
157
194
  service: normalizeTagValue(j.service),
158
195
  outcome: j.outcome,
196
+ ...(failedRequest ? { failedRequest } : {}),
159
197
  ...(j.reason ? { reason: normalizeTagValue(j.reason) } : {}),
160
198
  durationMs: j.durationMs,
161
- steps: j.steps.map(sanitizeStep),
199
+ steps,
162
200
  ...(expected?.length ? { expected } : {}),
163
201
  ...(tagKeys.length ? { tags } : {}),
164
202
  },
package/src/core/types.ts CHANGED
@@ -134,6 +134,16 @@ export interface JourneyState {
134
134
  /** Frozen snapshot taken at start — not the caller's original object. */
135
135
  config: JourneyDef;
136
136
  [JOURNEY_ENGINE]?: JourneyEngine;
137
+ /** Present only during synchronous HTTP scoring, so finish can identify the causing request. */
138
+ scoringRequest?: JourneyRequestEvent;
139
+ }
140
+
141
+ /** Response correlation for production monolith logs. Never a metric tag. */
142
+ export interface JourneyRequestEvent {
143
+ rayId: string;
144
+ startedAtEpoch: number;
145
+ durationMs: number;
146
+ status: number;
137
147
  }
138
148
 
139
149
  /** @internal Engine in-flight / completed step record — not part of the supported public API. */
@@ -150,6 +160,9 @@ export interface StepRecord {
150
160
  onHttpAborted?: boolean;
151
161
  /** @internal Request keys attributed while this step was in flight (debug). */
152
162
  attributedRequestKeys?: string[];
163
+ /** Bounded response metadata; no URLs, bodies, or other headers. */
164
+ requests?: JourneyRequestEvent[];
165
+ requestsTruncated?: boolean;
153
166
  }
154
167
 
155
168
  export interface JourneyStepEvent {
@@ -161,6 +174,35 @@ 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;
180
+ }
181
+
182
+ /**
183
+ * Snapshot of an open journey that can survive a page navigation. Produced by
184
+ * `serializeJourneyState` and rehydrated by `restoreJourney`. Mirrors the
185
+ * `JourneyStepEvent` projection emitted by `finish()`, plus the wall-clock
186
+ * start time, verdict, reason, and tags needed to re-arm the countdown.
187
+ */
188
+ export interface SerializedJourneyState {
189
+ /** `st_journey_id` tag — cross-page correlation key. Empty string when unset. */
190
+ stJourneyId: string;
191
+ /** Wall-clock epoch (Date.now()) when the journey started. */
192
+ startedAtEpoch: number;
193
+ verdict: 'good' | 'bad';
194
+ reason: string | null;
195
+ steps: {
196
+ name: string;
197
+ /** Offset from the journey start, ms. */
198
+ startMs: number;
199
+ durationMs: number;
200
+ outcome: 'good' | 'bad';
201
+ reason?: string;
202
+ httpStatus?: number;
203
+ attributes?: Record<string, TagValue>;
204
+ }[];
205
+ tags: Record<string, TagValue>;
164
206
  }
165
207
 
166
208
  /** Neutral journey event payload (journey-specific fields only; session context is host RUM globals). */
@@ -174,6 +216,8 @@ export interface JourneyEvent {
174
216
  reason?: string;
175
217
  durationMs: number;
176
218
  steps: JourneyStepEvent[];
219
+ /** Present only when a correlated HTTP report synchronously closed the journey as bad. */
220
+ failedRequest?: JourneyRequestEvent;
177
221
  expected?: string[];
178
222
  tags?: Record<string, TagValue>;
179
223
  };
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export {
13
13
  excludeJourney,
14
14
  failJourney,
15
15
  journeyStep,
16
+ restoreJourney,
17
+ serializeJourneyState,
16
18
  setJourneyTags,
17
19
  // Custom transport adapters / request attribution.
18
20
  singleInFlightJourneyStep,
@@ -23,6 +25,7 @@ export {
23
25
  type JourneyNameLike,
24
26
  type JourneyStepOptions,
25
27
  type JourneyStepTarget,
28
+ type SerializedJourneyState,
26
29
  type StepHandle,
27
30
  } from './core';
28
31
 
@@ -57,7 +60,7 @@ export {
57
60
  export { sendToConsole } from './sinks/console';
58
61
  export { normalizeTagValue, sendToDatadog, setJourneyRum } from './sinks/datadog';
59
62
 
60
- export type { JourneyEvent, JourneyStepEvent, TagValue } from './core';
63
+ export type { JourneyEvent, JourneyRequestEvent, JourneyStepEvent, TagValue } from './core';
61
64
 
62
65
  export { useJourneyName, useOptionalJourneyName } from './integrations/react/context';
63
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
  }
@@ -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
+ }