@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
@@ -0,0 +1,65 @@
1
+ import { MAX_CORRELATED_REQUESTS, sanitizeRequestCorrelation } from '../core/request-correlation';
2
+ import { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime';
3
+ const MONOLITH_ORIGIN = 'https://go.servicetitan.com';
4
+ const capturedResponses = new WeakMap();
5
+ /** Observation only. Safe to run before jQuery invokes application callbacks. */ export function captureRequestCorrelation(report) {
6
+ try {
7
+ var _step, _requests;
8
+ var _report_stamp, _report_stamp1, _report_meta, _globalThis_location, _response_request, _response_headers, _response_headers1;
9
+ const step = (_report_stamp = report.stamp) === null || _report_stamp === void 0 ? void 0 : _report_stamp.step;
10
+ if (!(step === null || step === void 0 ? void 0 : step.journey) || step.journey.closed || !canRouteHttpToJourney(step.journey) || ((_report_stamp1 = report.stamp) === null || _report_stamp1 === void 0 ? void 0 : _report_stamp1.ignore) || ((_report_meta = report.meta) === null || _report_meta === void 0 ? void 0 : _report_meta.ignore) || report.aborted || !report.url || resolveJourneyEngine(step.journey).shouldIgnoreRequest(report.requestKey)) {
11
+ return;
12
+ }
13
+ // Other environments/backends have not verified this log-field/data-view contract.
14
+ const pageUrl = (_globalThis_location = globalThis.location) === null || _globalThis_location === void 0 ? void 0 : _globalThis_location.href;
15
+ const base = report.baseURL ? new URL(report.baseURL, pageUrl).href : pageUrl;
16
+ const url = new URL(report.url, base);
17
+ if (url.origin !== MONOLITH_ORIGIN) {
18
+ return;
19
+ }
20
+ const response = report.response;
21
+ if (!response || typeof response !== 'object') {
22
+ return;
23
+ }
24
+ const captured = capturedResponses.get(response);
25
+ if (captured) {
26
+ var _step_requests;
27
+ return ((_step_requests = step.requests) === null || _step_requests === void 0 ? void 0 : _step_requests.includes(captured)) ? captured : undefined;
28
+ }
29
+ const finalUrl = response.url || response.responseURL || ((_response_request = response.request) === null || _response_request === void 0 ? void 0 : _response_request.responseURL);
30
+ if (finalUrl && new URL(finalUrl).origin !== MONOLITH_ORIGIN) {
31
+ return;
32
+ }
33
+ const rayId = typeof response.getResponseHeader === 'function' ? response.getResponseHeader('cf-ray') : typeof ((_response_headers = response.headers) === null || _response_headers === void 0 ? void 0 : _response_headers.get) === 'function' ? response.headers.get('cf-ray') : (_response_headers1 = response.headers) === null || _response_headers1 === void 0 ? void 0 : _response_headers1['cf-ray'];
34
+ if (typeof rayId !== 'string') {
35
+ return;
36
+ }
37
+ const request = sanitizeRequestCorrelation({
38
+ rayId,
39
+ startedAtEpoch: Date.now() - report.durationMs,
40
+ durationMs: report.durationMs,
41
+ status: report.status
42
+ });
43
+ if (!request) {
44
+ return;
45
+ }
46
+ capturedResponses.set(response, request);
47
+ (_requests = (_step = step).requests) !== null && _requests !== void 0 ? _requests : _step.requests = [];
48
+ const requests = step.requests;
49
+ if (requests.length >= MAX_CORRELATED_REQUESTS) {
50
+ step.requestsTruncated = true;
51
+ // Keep errors when later successes arrive. A new error replaces a success first.
52
+ const replace = requests.findIndex((item)=>item.status < 400);
53
+ if (replace < 0 && request.status < 400) {
54
+ return request;
55
+ }
56
+ requests.splice(replace < 0 ? 0 : replace, 1);
57
+ }
58
+ requests.push(request);
59
+ return request;
60
+ } catch (unused) {
61
+ // Missing/blocked headers and unusual transport objects must not affect requests.
62
+ }
63
+ }
64
+
65
+ //# sourceMappingURL=request-correlation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/integrations/request-correlation.ts"],"sourcesContent":["import { MAX_CORRELATED_REQUESTS, sanitizeRequestCorrelation } from '../core/request-correlation';\nimport { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime';\nimport type { HttpCompleteReport } from './http-report';\nimport type { JourneyRequestEvent } from '../core/types';\n\nconst MONOLITH_ORIGIN = 'https://go.servicetitan.com';\nconst capturedResponses = new WeakMap<object, JourneyRequestEvent>();\n\ninterface ResponseHeaders {\n headers?: { 'cf-ray'?: unknown; 'get'?(name: string): unknown };\n url?: string;\n responseURL?: string;\n request?: { responseURL?: string };\n getResponseHeader?(name: string): unknown;\n}\n\n/** Observation only. Safe to run before jQuery invokes application callbacks. */\nexport function captureRequestCorrelation(\n report: HttpCompleteReport\n): JourneyRequestEvent | undefined {\n try {\n const step = report.stamp?.step;\n if (\n !step?.journey ||\n step.journey.closed ||\n !canRouteHttpToJourney(step.journey) ||\n report.stamp?.ignore ||\n report.meta?.ignore ||\n report.aborted ||\n !report.url ||\n resolveJourneyEngine(step.journey).shouldIgnoreRequest(report.requestKey)\n ) {\n return;\n }\n\n // Other environments/backends have not verified this log-field/data-view contract.\n const pageUrl = globalThis.location?.href;\n const base = report.baseURL ? new URL(report.baseURL, pageUrl).href : pageUrl;\n const url = new URL(report.url, base);\n if (url.origin !== MONOLITH_ORIGIN) {\n return;\n }\n const response = report.response as ResponseHeaders | undefined;\n if (!response || typeof response !== 'object') {\n return;\n }\n const captured = capturedResponses.get(response);\n if (captured) {\n return step.requests?.includes(captured) ? captured : undefined;\n }\n const finalUrl = response.url || response.responseURL || response.request?.responseURL;\n if (finalUrl && new URL(finalUrl).origin !== MONOLITH_ORIGIN) {\n return;\n }\n const rayId =\n typeof response.getResponseHeader === 'function'\n ? response.getResponseHeader('cf-ray')\n : typeof response.headers?.get === 'function'\n ? response.headers.get('cf-ray')\n : response.headers?.['cf-ray'];\n if (typeof rayId !== 'string') {\n return;\n }\n const request = sanitizeRequestCorrelation({\n rayId,\n startedAtEpoch: Date.now() - report.durationMs,\n durationMs: report.durationMs,\n status: report.status as number,\n });\n if (!request) {\n return;\n }\n capturedResponses.set(response, request);\n step.requests ??= [];\n const requests = step.requests;\n if (requests.length >= MAX_CORRELATED_REQUESTS) {\n step.requestsTruncated = true;\n // Keep errors when later successes arrive. A new error replaces a success first.\n const replace = requests.findIndex(item => item.status < 400);\n if (replace < 0 && request.status < 400) {\n return request;\n }\n requests.splice(replace < 0 ? 0 : replace, 1);\n }\n requests.push(request);\n return request;\n } catch {\n // Missing/blocked headers and unusual transport objects must not affect requests.\n }\n}\n"],"names":["MAX_CORRELATED_REQUESTS","sanitizeRequestCorrelation","canRouteHttpToJourney","resolveJourneyEngine","MONOLITH_ORIGIN","capturedResponses","WeakMap","captureRequestCorrelation","report","step","globalThis","response","stamp","journey","closed","ignore","meta","aborted","url","shouldIgnoreRequest","requestKey","pageUrl","location","href","base","baseURL","URL","origin","captured","get","requests","includes","undefined","finalUrl","responseURL","request","rayId","getResponseHeader","headers","startedAtEpoch","Date","now","durationMs","status","set","length","requestsTruncated","replace","findIndex","item","splice","push"],"mappings":"AAAA,SAASA,uBAAuB,EAAEC,0BAA0B,QAAQ,8BAA8B;AAClG,SAASC,qBAAqB,EAAEC,oBAAoB,QAAQ,kBAAkB;AAI9E,MAAMC,kBAAkB;AACxB,MAAMC,oBAAoB,IAAIC;AAU9B,+EAA+E,GAC/E,OAAO,SAASC,0BACZC,MAA0B;IAE1B,IAAI;YAqDAC,OAAAA;YApDaD,eAKTA,gBACAA,cASYE,sBAcyCC,mBAOxCA,mBAELA;QAtCZ,MAAMF,QAAOD,gBAAAA,OAAOI,KAAK,cAAZJ,oCAAAA,cAAcC,IAAI;QAC/B,IACI,EAACA,iBAAAA,2BAAAA,KAAMI,OAAO,KACdJ,KAAKI,OAAO,CAACC,MAAM,IACnB,CAACZ,sBAAsBO,KAAKI,OAAO,OACnCL,iBAAAA,OAAOI,KAAK,cAAZJ,qCAAAA,eAAcO,MAAM,OACpBP,eAAAA,OAAOQ,IAAI,cAAXR,mCAAAA,aAAaO,MAAM,KACnBP,OAAOS,OAAO,IACd,CAACT,OAAOU,GAAG,IACXf,qBAAqBM,KAAKI,OAAO,EAAEM,mBAAmB,CAACX,OAAOY,UAAU,GAC1E;YACE;QACJ;QAEA,mFAAmF;QACnF,MAAMC,WAAUX,uBAAAA,WAAWY,QAAQ,cAAnBZ,2CAAAA,qBAAqBa,IAAI;QACzC,MAAMC,OAAOhB,OAAOiB,OAAO,GAAG,IAAIC,IAAIlB,OAAOiB,OAAO,EAAEJ,SAASE,IAAI,GAAGF;QACtE,MAAMH,MAAM,IAAIQ,IAAIlB,OAAOU,GAAG,EAAEM;QAChC,IAAIN,IAAIS,MAAM,KAAKvB,iBAAiB;YAChC;QACJ;QACA,MAAMO,WAAWH,OAAOG,QAAQ;QAChC,IAAI,CAACA,YAAY,OAAOA,aAAa,UAAU;YAC3C;QACJ;QACA,MAAMiB,WAAWvB,kBAAkBwB,GAAG,CAAClB;QACvC,IAAIiB,UAAU;gBACHnB;YAAP,OAAOA,EAAAA,iBAAAA,KAAKqB,QAAQ,cAAbrB,qCAAAA,eAAesB,QAAQ,CAACH,aAAYA,WAAWI;QAC1D;QACA,MAAMC,WAAWtB,SAASO,GAAG,IAAIP,SAASuB,WAAW,MAAIvB,oBAAAA,SAASwB,OAAO,cAAhBxB,wCAAAA,kBAAkBuB,WAAW;QACtF,IAAID,YAAY,IAAIP,IAAIO,UAAUN,MAAM,KAAKvB,iBAAiB;YAC1D;QACJ;QACA,MAAMgC,QACF,OAAOzB,SAAS0B,iBAAiB,KAAK,aAChC1B,SAAS0B,iBAAiB,CAAC,YAC3B,SAAO1B,oBAAAA,SAAS2B,OAAO,cAAhB3B,wCAAAA,kBAAkBkB,GAAG,MAAK,aAC/BlB,SAAS2B,OAAO,CAACT,GAAG,CAAC,aACrBlB,qBAAAA,SAAS2B,OAAO,cAAhB3B,yCAAAA,kBAAkB,CAAC,SAAS;QACxC,IAAI,OAAOyB,UAAU,UAAU;YAC3B;QACJ;QACA,MAAMD,UAAUlC,2BAA2B;YACvCmC;YACAG,gBAAgBC,KAAKC,GAAG,KAAKjC,OAAOkC,UAAU;YAC9CA,YAAYlC,OAAOkC,UAAU;YAC7BC,QAAQnC,OAAOmC,MAAM;QACzB;QACA,IAAI,CAACR,SAAS;YACV;QACJ;QACA9B,kBAAkBuC,GAAG,CAACjC,UAAUwB;SAChC1B,YAAAA,CAAAA,QAAAA,MAAKqB,QAAQ,cAAbrB,uBAAAA,YAAAA,MAAKqB,QAAQ,GAAK,EAAE;QACpB,MAAMA,WAAWrB,KAAKqB,QAAQ;QAC9B,IAAIA,SAASe,MAAM,IAAI7C,yBAAyB;YAC5CS,KAAKqC,iBAAiB,GAAG;YACzB,iFAAiF;YACjF,MAAMC,UAAUjB,SAASkB,SAAS,CAACC,CAAAA,OAAQA,KAAKN,MAAM,GAAG;YACzD,IAAII,UAAU,KAAKZ,QAAQQ,MAAM,GAAG,KAAK;gBACrC,OAAOR;YACX;YACAL,SAASoB,MAAM,CAACH,UAAU,IAAI,IAAIA,SAAS;QAC/C;QACAjB,SAASqB,IAAI,CAAChB;QACd,OAAOA;IACX,EAAE,eAAM;IACJ,kFAAkF;IACtF;AACJ"}
@@ -1 +1 @@
1
- {"version":3,"file":"datadog.d.ts","sourceRoot":"","sources":["../../src/sinks/datadog.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,eAAe,CAAC;AAEpE,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAkBrD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAQhD;AAsBD,yEAAyE;AACzE,wBAAgB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAyBzD"}
1
+ {"version":3,"file":"datadog.d.ts","sourceRoot":"","sources":["../../src/sinks/datadog.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,eAAe,CAAC;AAGpE,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAkBrD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAQhD;AAkCD,yEAAyE;AACzE,wBAAgB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CA+BzD"}
@@ -1,5 +1,6 @@
1
1
  import { isSanitizedJourneyEvent, sanitizeJourneyEvent } from '../core/sanitize';
2
2
  import { getJourneyRumFallback, setJourneyRumFallback } from '../global';
3
+ import { requestLogsUrl } from './kibana';
3
4
  export { normalizeTagValue } from '../core/sanitize';
4
5
  const JOURNEY_ACTION = 'user_journey';
5
6
  const DD_RUM_KEY = 'DD_RUM';
@@ -28,6 +29,7 @@ function resolveRum() {
28
29
  return (_getDdRum = getDdRum()) !== null && _getDdRum !== void 0 ? _getDdRum : getJourneyRumFallback();
29
30
  }
30
31
  function toDatadogStep(step) {
32
+ var _step_requests;
31
33
  /* eslint-disable @typescript-eslint/naming-convention -- Datadog RUM wire format (snake_case) */ return {
32
34
  name: step.name,
33
35
  start_ms: step.startMs,
@@ -39,6 +41,18 @@ function toDatadogStep(step) {
39
41
  ...step.httpStatus !== undefined ? {
40
42
  http_status: step.httpStatus
41
43
  } : {},
44
+ ...((_step_requests = step.requests) === null || _step_requests === void 0 ? void 0 : _step_requests.length) ? {
45
+ requests: step.requests.map((request)=>({
46
+ request_id: request.rayId,
47
+ started_at: new Date(request.startedAtEpoch).toISOString(),
48
+ duration_ms: request.durationMs,
49
+ http_status: request.status,
50
+ kibana_url: requestLogsUrl(request)
51
+ }))
52
+ } : {},
53
+ ...step.requestsTruncated ? {
54
+ requests_truncated: true
55
+ } : {},
42
56
  ...step.attributes && Object.keys(step.attributes).length ? {
43
57
  attributes: step.attributes
44
58
  } : {}
@@ -57,6 +71,10 @@ function toDatadogStep(step) {
57
71
  group: j.group,
58
72
  service: j.service,
59
73
  outcome: j.outcome,
74
+ ...j.failedRequest ? {
75
+ failed_request_id: j.failedRequest.rayId,
76
+ kibana_url: requestLogsUrl(j.failedRequest)
77
+ } : {},
60
78
  ...j.reason ? {
61
79
  reason: j.reason
62
80
  } : {},
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sinks/datadog.ts"],"sourcesContent":["import { isSanitizedJourneyEvent, sanitizeJourneyEvent } from '../core/sanitize';\nimport { getJourneyRumFallback, setJourneyRumFallback } from '../global';\nimport type { JourneyEvent, JourneyStepEvent } from '../core/types';\n\nexport { normalizeTagValue } from '../core/sanitize';\n\nconst JOURNEY_ACTION = 'user_journey';\n\nconst DD_RUM_KEY = 'DD_RUM';\n\n/**\n * Not imported from @datadog/browser-rum on purpose: importing it sets globalThis.DD_RUM at\n * module load, which lets an MFE bundle replace the host's initialized RUM instance.\n */\ninterface RumLike {\n addAction(name: string, context?: object): void;\n}\n\nfunction getDdRum(): RumLike | undefined {\n return (globalThis as Record<string, unknown>)[DD_RUM_KEY] as RumLike | undefined;\n}\n\n/**\n * Publish a RUM instance for MFE bundles to share — **only needed if\n * `globalThis.DD_RUM` is not set**.\n *\n * Prefer exposing the initialized instance as `globalThis.DD_RUM` (ST host:\n * `datadogGuard`). `sendToDatadog` resolves `DD_RUM` first, then `__stJourney.rum`\n * (this fallback); with neither present the event is dropped.\n *\n * Calling `setJourneyRum` while `globalThis.DD_RUM` is already set logs a warning —\n * the call is redundant because `sendToDatadog` prefers `DD_RUM`.\n */\nexport function setJourneyRum(rum: unknown): void {\n if (getDdRum()) {\n // eslint-disable-next-line no-console -- soft-deprecate redundant bridge use\n console.warn(\n '[journey] setJourneyRum() is unnecessary when globalThis.DD_RUM is set — sendToDatadog already prefers DD_RUM'\n );\n }\n setJourneyRumFallback(rum);\n}\n\nfunction resolveRum(): RumLike | undefined {\n return getDdRum() ?? (getJourneyRumFallback() as RumLike | undefined);\n}\n\nfunction toDatadogStep(step: JourneyStepEvent) {\n /* eslint-disable @typescript-eslint/naming-convention -- Datadog RUM wire format (snake_case) */\n return {\n name: step.name,\n start_ms: step.startMs,\n duration_ms: step.durationMs,\n outcome: step.outcome,\n ...(step.reason ? { reason: step.reason } : {}),\n ...(step.httpStatus !== undefined ? { http_status: step.httpStatus } : {}),\n ...(step.attributes && Object.keys(step.attributes).length\n ? { attributes: step.attributes }\n : {}),\n };\n /* eslint-enable @typescript-eslint/naming-convention */\n}\n\n/** Ship one terminal journey as a RUM custom action (`user_journey`). */\nexport function sendToDatadog(payload: JourneyEvent): void {\n const rum = resolveRum();\n if (!rum || typeof rum.addAction !== 'function') {\n return;\n }\n\n const j = isSanitizedJourneyEvent(payload)\n ? payload.journey\n : sanitizeJourneyEvent(payload).journey;\n /* eslint-disable @typescript-eslint/naming-convention -- Datadog RUM wire format (snake_case) */\n rum.addAction(JOURNEY_ACTION, {\n journey: {\n name: j.name,\n team: j.team,\n group: j.group,\n service: j.service,\n outcome: j.outcome,\n ...(j.reason ? { reason: j.reason } : {}),\n duration_ms: j.durationMs,\n steps: j.steps.map(toDatadogStep),\n ...(j.expected ? { expected: j.expected } : {}),\n ...(j.tags ? { tags: j.tags } : {}),\n },\n });\n /* eslint-enable @typescript-eslint/naming-convention */\n}\n"],"names":["isSanitizedJourneyEvent","sanitizeJourneyEvent","getJourneyRumFallback","setJourneyRumFallback","normalizeTagValue","JOURNEY_ACTION","DD_RUM_KEY","getDdRum","globalThis","setJourneyRum","rum","console","warn","resolveRum","toDatadogStep","step","name","start_ms","startMs","duration_ms","durationMs","outcome","reason","httpStatus","undefined","http_status","attributes","Object","keys","length","sendToDatadog","payload","addAction","j","journey","team","group","service","steps","map","expected","tags"],"mappings":"AAAA,SAASA,uBAAuB,EAAEC,oBAAoB,QAAQ,mBAAmB;AACjF,SAASC,qBAAqB,EAAEC,qBAAqB,QAAQ,YAAY;AAGzE,SAASC,iBAAiB,QAAQ,mBAAmB;AAErD,MAAMC,iBAAiB;AAEvB,MAAMC,aAAa;AAUnB,SAASC;IACL,OAAO,AAACC,UAAsC,CAACF,WAAW;AAC9D;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASG,cAAcC,GAAY;IACtC,IAAIH,YAAY;QACZ,6EAA6E;QAC7EI,QAAQC,IAAI,CACR;IAER;IACAT,sBAAsBO;AAC1B;AAEA,SAASG;QACEN;IAAP,QAAOA,YAAAA,wBAAAA,uBAAAA,YAAeL;AAC1B;AAEA,SAASY,cAAcC,IAAsB;IACzC,+FAA+F,GAC/F,OAAO;QACHC,MAAMD,KAAKC,IAAI;QACfC,UAAUF,KAAKG,OAAO;QACtBC,aAAaJ,KAAKK,UAAU;QAC5BC,SAASN,KAAKM,OAAO;QACrB,GAAIN,KAAKO,MAAM,GAAG;YAAEA,QAAQP,KAAKO,MAAM;QAAC,IAAI,CAAC,CAAC;QAC9C,GAAIP,KAAKQ,UAAU,KAAKC,YAAY;YAAEC,aAAaV,KAAKQ,UAAU;QAAC,IAAI,CAAC,CAAC;QACzE,GAAIR,KAAKW,UAAU,IAAIC,OAAOC,IAAI,CAACb,KAAKW,UAAU,EAAEG,MAAM,GACpD;YAAEH,YAAYX,KAAKW,UAAU;QAAC,IAC9B,CAAC,CAAC;IACZ;AACA,sDAAsD,GAC1D;AAEA,uEAAuE,GACvE,OAAO,SAASI,cAAcC,OAAqB;IAC/C,MAAMrB,MAAMG;IACZ,IAAI,CAACH,OAAO,OAAOA,IAAIsB,SAAS,KAAK,YAAY;QAC7C;IACJ;IAEA,MAAMC,IAAIjC,wBAAwB+B,WAC5BA,QAAQG,OAAO,GACfjC,qBAAqB8B,SAASG,OAAO;IAC3C,+FAA+F,GAC/FxB,IAAIsB,SAAS,CAAC3B,gBAAgB;QAC1B6B,SAAS;YACLlB,MAAMiB,EAAEjB,IAAI;YACZmB,MAAMF,EAAEE,IAAI;YACZC,OAAOH,EAAEG,KAAK;YACdC,SAASJ,EAAEI,OAAO;YAClBhB,SAASY,EAAEZ,OAAO;YAClB,GAAIY,EAAEX,MAAM,GAAG;gBAAEA,QAAQW,EAAEX,MAAM;YAAC,IAAI,CAAC,CAAC;YACxCH,aAAac,EAAEb,UAAU;YACzBkB,OAAOL,EAAEK,KAAK,CAACC,GAAG,CAACzB;YACnB,GAAImB,EAAEO,QAAQ,GAAG;gBAAEA,UAAUP,EAAEO,QAAQ;YAAC,IAAI,CAAC,CAAC;YAC9C,GAAIP,EAAEQ,IAAI,GAAG;gBAAEA,MAAMR,EAAEQ,IAAI;YAAC,IAAI,CAAC,CAAC;QACtC;IACJ;AACA,sDAAsD,GAC1D"}
1
+ {"version":3,"sources":["../../src/sinks/datadog.ts"],"sourcesContent":["import { isSanitizedJourneyEvent, sanitizeJourneyEvent } from '../core/sanitize';\nimport { getJourneyRumFallback, setJourneyRumFallback } from '../global';\nimport type { JourneyEvent, JourneyStepEvent } from '../core/types';\nimport { requestLogsUrl } from './kibana';\n\nexport { normalizeTagValue } from '../core/sanitize';\n\nconst JOURNEY_ACTION = 'user_journey';\n\nconst DD_RUM_KEY = 'DD_RUM';\n\n/**\n * Not imported from @datadog/browser-rum on purpose: importing it sets globalThis.DD_RUM at\n * module load, which lets an MFE bundle replace the host's initialized RUM instance.\n */\ninterface RumLike {\n addAction(name: string, context?: object): void;\n}\n\nfunction getDdRum(): RumLike | undefined {\n return (globalThis as Record<string, unknown>)[DD_RUM_KEY] as RumLike | undefined;\n}\n\n/**\n * Publish a RUM instance for MFE bundles to share — **only needed if\n * `globalThis.DD_RUM` is not set**.\n *\n * Prefer exposing the initialized instance as `globalThis.DD_RUM` (ST host:\n * `datadogGuard`). `sendToDatadog` resolves `DD_RUM` first, then `__stJourney.rum`\n * (this fallback); with neither present the event is dropped.\n *\n * Calling `setJourneyRum` while `globalThis.DD_RUM` is already set logs a warning —\n * the call is redundant because `sendToDatadog` prefers `DD_RUM`.\n */\nexport function setJourneyRum(rum: unknown): void {\n if (getDdRum()) {\n // eslint-disable-next-line no-console -- soft-deprecate redundant bridge use\n console.warn(\n '[journey] setJourneyRum() is unnecessary when globalThis.DD_RUM is set — sendToDatadog already prefers DD_RUM'\n );\n }\n setJourneyRumFallback(rum);\n}\n\nfunction resolveRum(): RumLike | undefined {\n return getDdRum() ?? (getJourneyRumFallback() as RumLike | undefined);\n}\n\nfunction toDatadogStep(step: JourneyStepEvent) {\n /* eslint-disable @typescript-eslint/naming-convention -- Datadog RUM wire format (snake_case) */\n return {\n name: step.name,\n start_ms: step.startMs,\n duration_ms: step.durationMs,\n outcome: step.outcome,\n ...(step.reason ? { reason: step.reason } : {}),\n ...(step.httpStatus !== undefined ? { http_status: step.httpStatus } : {}),\n ...(step.requests?.length\n ? {\n requests: step.requests.map(request => ({\n request_id: request.rayId,\n started_at: new Date(request.startedAtEpoch).toISOString(),\n duration_ms: request.durationMs,\n http_status: request.status,\n kibana_url: requestLogsUrl(request),\n })),\n }\n : {}),\n ...(step.requestsTruncated ? { requests_truncated: true } : {}),\n ...(step.attributes && Object.keys(step.attributes).length\n ? { attributes: step.attributes }\n : {}),\n };\n /* eslint-enable @typescript-eslint/naming-convention */\n}\n\n/** Ship one terminal journey as a RUM custom action (`user_journey`). */\nexport function sendToDatadog(payload: JourneyEvent): void {\n const rum = resolveRum();\n if (!rum || typeof rum.addAction !== 'function') {\n return;\n }\n\n const j = isSanitizedJourneyEvent(payload)\n ? payload.journey\n : sanitizeJourneyEvent(payload).journey;\n /* eslint-disable @typescript-eslint/naming-convention -- Datadog RUM wire format (snake_case) */\n rum.addAction(JOURNEY_ACTION, {\n journey: {\n name: j.name,\n team: j.team,\n group: j.group,\n service: j.service,\n outcome: j.outcome,\n ...(j.failedRequest\n ? {\n failed_request_id: j.failedRequest.rayId,\n kibana_url: requestLogsUrl(j.failedRequest),\n }\n : {}),\n ...(j.reason ? { reason: j.reason } : {}),\n duration_ms: j.durationMs,\n steps: j.steps.map(toDatadogStep),\n ...(j.expected ? { expected: j.expected } : {}),\n ...(j.tags ? { tags: j.tags } : {}),\n },\n });\n /* eslint-enable @typescript-eslint/naming-convention */\n}\n"],"names":["isSanitizedJourneyEvent","sanitizeJourneyEvent","getJourneyRumFallback","setJourneyRumFallback","requestLogsUrl","normalizeTagValue","JOURNEY_ACTION","DD_RUM_KEY","getDdRum","globalThis","setJourneyRum","rum","console","warn","resolveRum","toDatadogStep","step","name","start_ms","startMs","duration_ms","durationMs","outcome","reason","httpStatus","undefined","http_status","requests","length","map","request","request_id","rayId","started_at","Date","startedAtEpoch","toISOString","status","kibana_url","requestsTruncated","requests_truncated","attributes","Object","keys","sendToDatadog","payload","addAction","j","journey","team","group","service","failedRequest","failed_request_id","steps","expected","tags"],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import type { JourneyRequestEvent } from '../core/types';
2
+ /** Fixed destination and validated Ray ID only — no request URL, query string or user data. */
3
+ export declare function requestLogsUrl(value: JourneyRequestEvent): string | undefined;
4
+ //# sourceMappingURL=kibana.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kibana.d.ts","sourceRoot":"","sources":["../../src/sinks/kibana.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAOzD,+FAA+F;AAC/F,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,GAAG,MAAM,GAAG,SAAS,CAc7E"}
@@ -0,0 +1,20 @@
1
+ import { sanitizeRequestCorrelation } from '../core/request-correlation';
2
+ const DISCOVER_URL = 'https://kibana.st.dev/app/discover#/';
3
+ // Production monolith logs; deliberately not the user's default data view.
4
+ const DATA_VIEW_ID = '894110ed-c22d-449c-9dc2-c4816f08712a';
5
+ const WINDOW_PADDING_MS = 60000;
6
+ /** Fixed destination and validated Ray ID only — no request URL, query string or user data. */ export function requestLogsUrl(value) {
7
+ const request = sanitizeRequestCorrelation(value);
8
+ if (!request) {
9
+ return undefined;
10
+ }
11
+ const from = new Date(request.startedAtEpoch - WINDOW_PADDING_MS).toISOString();
12
+ const to = new Date(request.startedAtEpoch + request.durationMs + WINDOW_PADDING_MS).toISOString();
13
+ // Origin/edge datacenter suffixes can differ. Match the hex prefix within the request window.
14
+ const rayPrefix = request.rayId.slice(0, 16);
15
+ const globalState = `(refreshInterval:(pause:!t,value:0),time:(from:'${from}',to:'${to}'))`;
16
+ const appState = `(dataSource:(type:dataView,dataViewId:'${DATA_VIEW_ID}'),query:(language:kuery,query:'Request.RequestId: ${rayPrefix}*'))`;
17
+ return `${DISCOVER_URL}?_g=${encodeURIComponent(globalState)}&_a=${encodeURIComponent(appState)}`;
18
+ }
19
+
20
+ //# sourceMappingURL=kibana.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/sinks/kibana.ts"],"sourcesContent":["import { sanitizeRequestCorrelation } from '../core/request-correlation';\nimport type { JourneyRequestEvent } from '../core/types';\n\nconst DISCOVER_URL = 'https://kibana.st.dev/app/discover#/';\n// Production monolith logs; deliberately not the user's default data view.\nconst DATA_VIEW_ID = '894110ed-c22d-449c-9dc2-c4816f08712a';\nconst WINDOW_PADDING_MS = 60_000;\n\n/** Fixed destination and validated Ray ID only — no request URL, query string or user data. */\nexport function requestLogsUrl(value: JourneyRequestEvent): string | undefined {\n const request = sanitizeRequestCorrelation(value);\n if (!request) {\n return undefined;\n }\n const from = new Date(request.startedAtEpoch - WINDOW_PADDING_MS).toISOString();\n const to = new Date(\n request.startedAtEpoch + request.durationMs + WINDOW_PADDING_MS\n ).toISOString();\n // Origin/edge datacenter suffixes can differ. Match the hex prefix within the request window.\n const rayPrefix = request.rayId.slice(0, 16);\n const globalState = `(refreshInterval:(pause:!t,value:0),time:(from:'${from}',to:'${to}'))`;\n const appState = `(dataSource:(type:dataView,dataViewId:'${DATA_VIEW_ID}'),query:(language:kuery,query:'Request.RequestId: ${rayPrefix}*'))`;\n return `${DISCOVER_URL}?_g=${encodeURIComponent(globalState)}&_a=${encodeURIComponent(appState)}`;\n}\n"],"names":["sanitizeRequestCorrelation","DISCOVER_URL","DATA_VIEW_ID","WINDOW_PADDING_MS","requestLogsUrl","value","request","undefined","from","Date","startedAtEpoch","toISOString","to","durationMs","rayPrefix","rayId","slice","globalState","appState","encodeURIComponent"],"mappings":"AAAA,SAASA,0BAA0B,QAAQ,8BAA8B;AAGzE,MAAMC,eAAe;AACrB,2EAA2E;AAC3E,MAAMC,eAAe;AACrB,MAAMC,oBAAoB;AAE1B,6FAA6F,GAC7F,OAAO,SAASC,eAAeC,KAA0B;IACrD,MAAMC,UAAUN,2BAA2BK;IAC3C,IAAI,CAACC,SAAS;QACV,OAAOC;IACX;IACA,MAAMC,OAAO,IAAIC,KAAKH,QAAQI,cAAc,GAAGP,mBAAmBQ,WAAW;IAC7E,MAAMC,KAAK,IAAIH,KACXH,QAAQI,cAAc,GAAGJ,QAAQO,UAAU,GAAGV,mBAChDQ,WAAW;IACb,8FAA8F;IAC9F,MAAMG,YAAYR,QAAQS,KAAK,CAACC,KAAK,CAAC,GAAG;IACzC,MAAMC,cAAc,CAAC,gDAAgD,EAAET,KAAK,MAAM,EAAEI,GAAG,GAAG,CAAC;IAC3F,MAAMM,WAAW,CAAC,uCAAuC,EAAEhB,aAAa,mDAAmD,EAAEY,UAAU,IAAI,CAAC;IAC5I,OAAO,GAAGb,aAAa,IAAI,EAAEkB,mBAAmBF,aAAa,IAAI,EAAEE,mBAAmBD,WAAW;AACrG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@servicetitan/journey",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Critical user journey monitoring — reports good/bad/excluded verdicts to Datadog RUM as a custom action",
5
5
  "homepage": "https://docs.st.dev/docs/frontend/journey",
6
6
  "repository": {
@@ -39,5 +39,5 @@
39
39
  "cli": {
40
40
  "webpack": false
41
41
  },
42
- "gitHead": "cd7d26dcbb74336efe088749dd930cf31715beed"
42
+ "gitHead": "1585627b4e268fe84783189b4b3d342b43bbdb17"
43
43
  }
@@ -1,5 +1,5 @@
1
1
  /** @jest-environment jsdom */
2
- import { useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
2
+ import { baseJourneyDef, useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
3
3
  import { completeJourney, journeyStep } from '../../core';
4
4
  import { moduleRuntime } from '../../core/runtime';
5
5
  import { getSharedPolicies } from '../../global';
@@ -614,3 +614,40 @@ describe('[journey] instrumentAxios', () => {
614
614
  expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
615
615
  });
616
616
  });
617
+
618
+ describe('[journey] Axios response correlation', () => {
619
+ useJourneyTestRuntime(emitMock);
620
+ test.each(['plain', 'get'])(
621
+ 'captures %s headers and preserves the rejection identity',
622
+ async shape => {
623
+ const axios = createAxiosMock();
624
+ axios.defaults.baseURL = 'https://go.servicetitan.com';
625
+ const restore = instrumentAxios(axios);
626
+ const headers =
627
+ shape === 'plain'
628
+ ? { 'cf-ray': '0123456789abcdef-IAD' }
629
+ : { get: jest.fn().mockReturnValue('0123456789abcdef-IAD') };
630
+ let error: Error;
631
+ try {
632
+ await expect(
633
+ journeyStep({ ...baseJourneyDef, name: 'axios-ray' }, 'save', async step => {
634
+ const config = axios.requestHandlers[0]({
635
+ url: '/test',
636
+ stamp: step.stamp(),
637
+ });
638
+ error = Object.assign(new Error('server error'), {
639
+ config,
640
+ response: { config, status: 500, headers },
641
+ });
642
+ await axios.responseErrHandlers[0](error);
643
+ })
644
+ ).rejects.toBe(error!);
645
+ const event = emitMock.mock.calls[0][0].journey;
646
+ expect(event.reason).toBe('request-error');
647
+ expect(event.steps[0].requests[0].rayId).toBe('0123456789abcdef-iad');
648
+ } finally {
649
+ restore();
650
+ }
651
+ }
652
+ );
653
+ });
@@ -1,5 +1,5 @@
1
1
  /** @jest-environment jsdom */
2
- import { useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
2
+ import { baseJourneyDef, useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
3
3
  import { completeJourney, journeyStep } from '../../core';
4
4
  import { moduleRuntime } from '../../core/runtime';
5
5
  import { getSharedPolicies } from '../../global';
@@ -537,3 +537,96 @@ describe('[journey] instrumentJquery', () => {
537
537
  expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
538
538
  });
539
539
  });
540
+
541
+ describe('[journey] jQuery early response correlation', () => {
542
+ useJourneyTestRuntime(emitMock);
543
+
544
+ function pendingAjax($: ReturnType<typeof createJqueryMock>, stamp: unknown) {
545
+ const callbacks: (() => void)[] = [];
546
+ const xhr = {
547
+ status: 500,
548
+ getResponseHeader: jest.fn().mockReturnValue('0123456789abcdef-IAD'),
549
+ always(callback: () => void) {
550
+ callbacks.push(callback);
551
+ },
552
+ };
553
+ const settings = { url: 'https://go.servicetitan.com/test', stamp };
554
+ for (const prefilter of $.__prefilters) {
555
+ prefilter(settings, settings, xhr);
556
+ }
557
+ return { xhr, settings, callbacks };
558
+ }
559
+
560
+ test('captures before a synchronous application failure and does not duplicate at ajaxComplete', async () => {
561
+ const $ = createJqueryMock();
562
+ const restore = instrumentJquery($);
563
+ try {
564
+ await journeyStep({ ...baseJourneyDef, name: 'jq-early' }, 'save', step => {
565
+ const { xhr, settings, callbacks } = pendingAjax($, step.stamp());
566
+ callbacks.forEach(callback => callback());
567
+ expect(emitMock).not.toHaveBeenCalled();
568
+ step.failJourney('application-error');
569
+ for (const complete of $.__completes) {
570
+ complete({}, xhr, settings);
571
+ }
572
+ });
573
+ const event = emitMock.mock.calls[0][0].journey;
574
+ expect(emitMock).toHaveBeenCalledTimes(1);
575
+ expect(event.reason).toBe('application-error');
576
+ expect(event.steps[0].requests).toHaveLength(1);
577
+ expect(event.steps[0].requests[0].rayId).toBe('0123456789abcdef-iad');
578
+ } finally {
579
+ restore();
580
+ }
581
+ });
582
+
583
+ test('keeps scoring after application callbacks, as before', async () => {
584
+ const $ = createJqueryMock();
585
+ const restore = instrumentJquery($);
586
+ try {
587
+ await journeyStep({ ...baseJourneyDef, name: 'jq-scoring' }, 'save', step => {
588
+ const { xhr, settings, callbacks } = pendingAjax($, step.stamp());
589
+ callbacks.forEach(callback => callback());
590
+ expect(emitMock).not.toHaveBeenCalled();
591
+ for (const complete of $.__completes) {
592
+ complete({}, xhr, settings);
593
+ }
594
+ });
595
+ const event = emitMock.mock.calls[0][0].journey;
596
+ expect(event.reason).toBe('request-error');
597
+ expect(event.steps[0].requests).toHaveLength(1);
598
+ } finally {
599
+ restore();
600
+ }
601
+ });
602
+
603
+ test('restore prevents early observers from modifying outstanding requests', async () => {
604
+ const $ = createJqueryMock();
605
+ const restore = instrumentJquery($);
606
+ await journeyStep({ ...baseJourneyDef, name: 'jq-restored' }, 'save', step => {
607
+ const { callbacks } = pendingAjax($, step.stamp());
608
+ restore();
609
+ callbacks.forEach(callback => callback());
610
+ step.failJourney('application-error');
611
+ });
612
+ expect(emitMock.mock.calls[0][0].journey.steps[0].requests).toBeUndefined();
613
+ });
614
+
615
+ test('a throwing header reader cannot stop the application callback', async () => {
616
+ const $ = createJqueryMock();
617
+ const restore = instrumentJquery($);
618
+ try {
619
+ await journeyStep({ ...baseJourneyDef, name: 'jq-blocked' }, 'save', step => {
620
+ const { xhr, callbacks } = pendingAjax($, step.stamp());
621
+ xhr.getResponseHeader.mockImplementation(() => {
622
+ throw new Error('blocked');
623
+ });
624
+ expect(() => callbacks.forEach(callback => callback())).not.toThrow();
625
+ step.failJourney('application-error');
626
+ });
627
+ expect(emitMock.mock.calls[0][0].journey.steps[0].requests).toBeUndefined();
628
+ } finally {
629
+ restore();
630
+ }
631
+ });
632
+ });