@servicetitan/journey 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +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":"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"}
1
+ {"version":3,"sources":["../../src/integrations/request-correlation.ts"],"sourcesContent":["import {\n isMonolithLogOrigin,\n MAX_CORRELATED_REQUESTS,\n sanitizeRequestCorrelation,\n} from '../core/request-correlation';\nimport { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime';\nimport type { HttpCompleteReport } from './http-report';\nimport type { JourneyRequestEvent } from '../core/types';\n\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 (!isMonolithLogOrigin(url.href)) {\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 && !isMonolithLogOrigin(finalUrl)) {\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":["isMonolithLogOrigin","MAX_CORRELATED_REQUESTS","sanitizeRequestCorrelation","canRouteHttpToJourney","resolveJourneyEngine","capturedResponses","WeakMap","captureRequestCorrelation","report","step","globalThis","response","stamp","journey","closed","ignore","meta","aborted","url","shouldIgnoreRequest","requestKey","pageUrl","location","href","base","baseURL","URL","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":"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"}
@@ -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;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
+ {"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;AAmBrD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAQhD;AAkCD,yEAAyE;AACzE,wBAAgB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAgCzD"}
@@ -1,6 +1,6 @@
1
1
  import { isSanitizedJourneyEvent, sanitizeJourneyEvent } from '../core/sanitize';
2
2
  import { getJourneyRumFallback, setJourneyRumFallback } from '../global';
3
- import { requestLogsUrl } from './kibana';
3
+ import { journeyLogsLink, requestLogsUrl } from './kibana';
4
4
  export { normalizeTagValue } from '../core/sanitize';
5
5
  const JOURNEY_ACTION = 'user_journey';
6
6
  const DD_RUM_KEY = 'DD_RUM';
@@ -59,11 +59,16 @@ function toDatadogStep(step) {
59
59
  };
60
60
  /* eslint-enable @typescript-eslint/naming-convention */ }
61
61
  /** Ship one terminal journey as a RUM custom action (`user_journey`). */ export function sendToDatadog(payload) {
62
+ var _globalThis_location;
62
63
  const rum = resolveRum();
63
64
  if (!rum || typeof rum.addAction !== 'function') {
64
65
  return;
65
66
  }
66
67
  const j = isSanitizedJourneyEvent(payload) ? payload.journey : sanitizeJourneyEvent(payload).journey;
68
+ const logs = journeyLogsLink(j, ()=>{
69
+ var _rum_getUser;
70
+ return (_rum_getUser = rum.getUser) === null || _rum_getUser === void 0 ? void 0 : _rum_getUser.call(rum);
71
+ }, (_globalThis_location = globalThis.location) === null || _globalThis_location === void 0 ? void 0 : _globalThis_location.href);
67
72
  /* eslint-disable @typescript-eslint/naming-convention -- Datadog RUM wire format (snake_case) */ rum.addAction(JOURNEY_ACTION, {
68
73
  journey: {
69
74
  name: j.name,
@@ -72,8 +77,11 @@ function toDatadogStep(step) {
72
77
  service: j.service,
73
78
  outcome: j.outcome,
74
79
  ...j.failedRequest ? {
75
- failed_request_id: j.failedRequest.rayId,
76
- kibana_url: requestLogsUrl(j.failedRequest)
80
+ failed_request_id: j.failedRequest.rayId
81
+ } : {},
82
+ ...logs ? {
83
+ kibana_url: logs.url,
84
+ kibana_scope: logs.scope
77
85
  } : {},
78
86
  ...j.reason ? {
79
87
  reason: j.reason
@@ -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';\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":"AAAA,SAASA,uBAAuB,EAAEC,oBAAoB,QAAQ,mBAAmB;AACjF,SAASC,qBAAqB,EAAEC,qBAAqB,QAAQ,YAAY;AAEzE,SAASC,cAAc,QAAQ,WAAW;AAE1C,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;IACAV,sBAAsBQ;AAC1B;AAEA,SAASG;QACEN;IAAP,QAAOA,YAAAA,wBAAAA,uBAAAA,YAAeN;AAC1B;AAEA,SAASa,cAAcC,IAAsB;QASjCA;IARR,+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,EAAAA,iBAAAA,KAAKW,QAAQ,cAAbX,qCAAAA,eAAeY,MAAM,IACnB;YACID,UAAUX,KAAKW,QAAQ,CAACE,GAAG,CAACC,CAAAA,UAAY,CAAA;oBACpCC,YAAYD,QAAQE,KAAK;oBACzBC,YAAY,IAAIC,KAAKJ,QAAQK,cAAc,EAAEC,WAAW;oBACxDhB,aAAaU,QAAQT,UAAU;oBAC/BK,aAAaI,QAAQO,MAAM;oBAC3BC,YAAYlC,eAAe0B;gBAC/B,CAAA;QACJ,IACA,CAAC,CAAC;QACR,GAAId,KAAKuB,iBAAiB,GAAG;YAAEC,oBAAoB;QAAK,IAAI,CAAC,CAAC;QAC9D,GAAIxB,KAAKyB,UAAU,IAAIC,OAAOC,IAAI,CAAC3B,KAAKyB,UAAU,EAAEb,MAAM,GACpD;YAAEa,YAAYzB,KAAKyB,UAAU;QAAC,IAC9B,CAAC,CAAC;IACZ;AACA,sDAAsD,GAC1D;AAEA,uEAAuE,GACvE,OAAO,SAASG,cAAcC,OAAqB;IAC/C,MAAMlC,MAAMG;IACZ,IAAI,CAACH,OAAO,OAAOA,IAAImC,SAAS,KAAK,YAAY;QAC7C;IACJ;IAEA,MAAMC,IAAI/C,wBAAwB6C,WAC5BA,QAAQG,OAAO,GACf/C,qBAAqB4C,SAASG,OAAO;IAC3C,+FAA+F,GAC/FrC,IAAImC,SAAS,CAACxC,gBAAgB;QAC1B0C,SAAS;YACL/B,MAAM8B,EAAE9B,IAAI;YACZgC,MAAMF,EAAEE,IAAI;YACZC,OAAOH,EAAEG,KAAK;YACdC,SAASJ,EAAEI,OAAO;YAClB7B,SAASyB,EAAEzB,OAAO;YAClB,GAAIyB,EAAEK,aAAa,GACb;gBACIC,mBAAmBN,EAAEK,aAAa,CAACpB,KAAK;gBACxCM,YAAYlC,eAAe2C,EAAEK,aAAa;YAC9C,IACA,CAAC,CAAC;YACR,GAAIL,EAAExB,MAAM,GAAG;gBAAEA,QAAQwB,EAAExB,MAAM;YAAC,IAAI,CAAC,CAAC;YACxCH,aAAa2B,EAAE1B,UAAU;YACzBiC,OAAOP,EAAEO,KAAK,CAACzB,GAAG,CAACd;YACnB,GAAIgC,EAAEQ,QAAQ,GAAG;gBAAEA,UAAUR,EAAEQ,QAAQ;YAAC,IAAI,CAAC,CAAC;YAC9C,GAAIR,EAAES,IAAI,GAAG;gBAAEA,MAAMT,EAAES,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 { journeyLogsLink, 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 getUser?(): unknown;\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 const logs = journeyLogsLink(j, () => rum.getUser?.(), globalThis.location?.href);\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 }\n : {}),\n ...(logs ? { kibana_url: logs.url, kibana_scope: logs.scope } : {}),\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","journeyLogsLink","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","logs","getUser","location","href","team","group","service","failedRequest","failed_request_id","url","kibana_scope","scope","steps","expected","tags"],"mappings":"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"}
@@ -1,4 +1,11 @@
1
- import type { JourneyRequestEvent } from '../core/types';
1
+ import type { JourneyEvent, JourneyRequestEvent } from '../core/types';
2
2
  /** Fixed destination and validated Ray ID only — no request URL, query string or user data. */
3
3
  export declare function requestLogsUrl(value: JourneyRequestEvent): string | undefined;
4
+ interface JourneyLogLink {
5
+ url: string;
6
+ scope: 'request' | 'related_requests' | 'related_logs';
7
+ }
8
+ /** Keep one visible link without claiming unrelated requests caused a JS/business failure. */
9
+ export declare function journeyLogsLink(journey: JourneyEvent['journey'], getUser?: () => unknown, pageUrl?: string): JourneyLogLink | undefined;
10
+ export {};
4
11
  //# sourceMappingURL=kibana.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"kibana.d.ts","sourceRoot":"","sources":["../../src/sinks/kibana.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAOvE,+FAA+F;AAC/F,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,GAAG,MAAM,GAAG,SAAS,CAY7E;AAED,UAAU,cAAc;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,GAAG,kBAAkB,GAAG,cAAc,CAAC;CAC1D;AA4DD,8FAA8F;AAC9F,wBAAgB,eAAe,CAC3B,OAAO,EAAE,YAAY,CAAC,SAAS,CAAC,EAChC,OAAO,CAAC,EAAE,MAAM,OAAO,EACvB,OAAO,CAAC,EAAE,MAAM,GACjB,cAAc,GAAG,SAAS,CA4C5B"}
@@ -1,4 +1,4 @@
1
- import { sanitizeRequestCorrelation } from '../core/request-correlation';
1
+ import { isMonolithLogOrigin, MAX_JOURNEY_CORRELATED_REQUESTS, sanitizeRequestCorrelation } from '../core/request-correlation';
2
2
  const DISCOVER_URL = 'https://kibana.st.dev/app/discover#/';
3
3
  // Production monolith logs; deliberately not the user's default data view.
4
4
  const DATA_VIEW_ID = '894110ed-c22d-449c-9dc2-c4816f08712a';
@@ -12,9 +12,81 @@ const WINDOW_PADDING_MS = 60000;
12
12
  const to = new Date(request.startedAtEpoch + request.durationMs + WINDOW_PADDING_MS).toISOString();
13
13
  // Origin/edge datacenter suffixes can differ. Match the hex prefix within the request window.
14
14
  const rayPrefix = request.rayId.slice(0, 16);
15
+ return discoverUrl(`Request.RequestId: ${rayPrefix}*`, from, to);
16
+ }
17
+ function discoverUrl(query, from, to) {
15
18
  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}*'))`;
19
+ // The query is a quoted Rison string containing a separately escaped KQL expression.
20
+ const quotedQuery = query.replace(/!/g, '!!').replace(/'/g, "!'");
21
+ const appState = `(dataSource:(type:dataView,dataViewId:'${DATA_VIEW_ID}'),query:(language:kuery,query:'${quotedQuery}'))`;
17
22
  return `${DISCOVER_URL}?_g=${encodeURIComponent(globalState)}&_a=${encodeURIComponent(appState)}`;
18
23
  }
24
+ function userValue(value) {
25
+ return typeof value === 'string' && value.length > 0 && value.length <= 254 && // eslint-disable-next-line no-control-regex -- Reject controls in log query values.
26
+ !/[\u0000-\u001f\u007f]/.test(value) ? value : undefined;
27
+ }
28
+ function kqlValue(value) {
29
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
30
+ }
31
+ function relatedLogsUrl(durationMs, user, pageUrl) {
32
+ if (!pageUrl || !isMonolithLogOrigin(pageUrl) || !user || typeof user !== 'object' || !Number.isFinite(durationMs) || durationMs < 0 || durationMs > 86400000) {
33
+ return;
34
+ }
35
+ const context = user;
36
+ const tenant = userValue(context.tenant);
37
+ const users = [
38
+ ...new Set([
39
+ userValue(context.name),
40
+ userValue(context.email)
41
+ ])
42
+ ].filter((value)=>value !== undefined);
43
+ // Never fall back to all tenants or all users when identity is missing.
44
+ if (!tenant || !/^[a-z0-9][a-z0-9_-]*$/i.test(tenant) || users.length === 0) {
45
+ return;
46
+ }
47
+ const query = `Context.TenantName: ${kqlValue(tenant)} AND (${users.map((value)=>`Request.User: ${kqlValue(value)}`).join(' OR ')})`;
48
+ const now = Date.now();
49
+ return discoverUrl(query, new Date(now - durationMs - WINDOW_PADDING_MS).toISOString(), new Date(now + WINDOW_PADDING_MS).toISOString());
50
+ }
51
+ /** Keep one visible link without claiming unrelated requests caused a JS/business failure. */ export function journeyLogsLink(journey, getUser, pageUrl) {
52
+ if (journey.failedRequest) {
53
+ const url = requestLogsUrl(journey.failedRequest);
54
+ if (url) {
55
+ return {
56
+ url,
57
+ scope: 'request'
58
+ };
59
+ }
60
+ }
61
+ /*
62
+ * With no identified failure request, search this user's logs during the journey.
63
+ * RUM stubs/older hosts can lack getUser; enrichment must never prevent emission.
64
+ */ try {
65
+ const url = relatedLogsUrl(journey.durationMs, getUser === null || getUser === void 0 ? void 0 : getUser(), pageUrl);
66
+ if (url) {
67
+ return {
68
+ url,
69
+ scope: 'related_logs'
70
+ };
71
+ }
72
+ } catch (unused) {
73
+ // Retain request-only links if session context is unavailable or malformed.
74
+ }
75
+ const requests = journey.steps.flatMap((step)=>{
76
+ var _step_requests;
77
+ return (_step_requests = step.requests) !== null && _step_requests !== void 0 ? _step_requests : [];
78
+ }).slice(0, MAX_JOURNEY_CORRELATED_REQUESTS).map(sanitizeRequestCorrelation).filter((request)=>request !== undefined);
79
+ if (!requests.length) {
80
+ return;
81
+ }
82
+ const prefixes = [
83
+ ...new Set(requests.map((request)=>request.rayId.slice(0, 16)))
84
+ ];
85
+ const query = prefixes.map((prefix)=>`Request.RequestId: ${prefix}*`).join(' OR ');
86
+ return {
87
+ url: discoverUrl(query, new Date(Math.min(...requests.map((request)=>request.startedAtEpoch)) - WINDOW_PADDING_MS).toISOString(), new Date(Math.max(...requests.map((request)=>request.startedAtEpoch + request.durationMs)) + WINDOW_PADDING_MS).toISOString()),
88
+ scope: 'related_requests'
89
+ };
90
+ }
19
91
 
20
92
  //# sourceMappingURL=kibana.js.map
@@ -1 +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"}
1
+ {"version":3,"sources":["../../src/sinks/kibana.ts"],"sourcesContent":["import {\n isMonolithLogOrigin,\n MAX_JOURNEY_CORRELATED_REQUESTS,\n sanitizeRequestCorrelation,\n} from '../core/request-correlation';\nimport type { JourneyEvent, 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 return discoverUrl(`Request.RequestId: ${rayPrefix}*`, from, to);\n}\n\ninterface JourneyLogLink {\n url: string;\n scope: 'request' | 'related_requests' | 'related_logs';\n}\n\nfunction discoverUrl(query: string, from: string, to: string): string {\n const globalState = `(refreshInterval:(pause:!t,value:0),time:(from:'${from}',to:'${to}'))`;\n // The query is a quoted Rison string containing a separately escaped KQL expression.\n const quotedQuery = query.replace(/!/g, '!!').replace(/'/g, \"!'\");\n const appState = `(dataSource:(type:dataView,dataViewId:'${DATA_VIEW_ID}'),query:(language:kuery,query:'${quotedQuery}'))`;\n return `${DISCOVER_URL}?_g=${encodeURIComponent(globalState)}&_a=${encodeURIComponent(appState)}`;\n}\n\nfunction userValue(value: unknown): string | undefined {\n return typeof value === 'string' &&\n value.length > 0 &&\n value.length <= 254 &&\n // eslint-disable-next-line no-control-regex -- Reject controls in log query values.\n !/[\\u0000-\\u001f\\u007f]/.test(value)\n ? value\n : undefined;\n}\n\nfunction kqlValue(value: string): string {\n return `\"${value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\"`;\n}\n\nfunction relatedLogsUrl(\n durationMs: number,\n user: unknown,\n pageUrl: string | undefined\n): string | undefined {\n if (\n !pageUrl ||\n !isMonolithLogOrigin(pageUrl) ||\n !user ||\n typeof user !== 'object' ||\n !Number.isFinite(durationMs) ||\n durationMs < 0 ||\n durationMs > 86_400_000\n ) {\n return;\n }\n const context = user as Record<string, unknown>;\n const tenant = userValue(context.tenant);\n const users = [...new Set([userValue(context.name), userValue(context.email)])].filter(\n (value): value is string => value !== undefined\n );\n // Never fall back to all tenants or all users when identity is missing.\n if (!tenant || !/^[a-z0-9][a-z0-9_-]*$/i.test(tenant) || users.length === 0) {\n return;\n }\n const query = `Context.TenantName: ${kqlValue(tenant)} AND (${users\n .map(value => `Request.User: ${kqlValue(value)}`)\n .join(' OR ')})`;\n const now = Date.now();\n return discoverUrl(\n query,\n new Date(now - durationMs - WINDOW_PADDING_MS).toISOString(),\n new Date(now + WINDOW_PADDING_MS).toISOString()\n );\n}\n\n/** Keep one visible link without claiming unrelated requests caused a JS/business failure. */\nexport function journeyLogsLink(\n journey: JourneyEvent['journey'],\n getUser?: () => unknown,\n pageUrl?: string\n): JourneyLogLink | undefined {\n if (journey.failedRequest) {\n const url = requestLogsUrl(journey.failedRequest);\n if (url) {\n return { url, scope: 'request' };\n }\n }\n\n /*\n * With no identified failure request, search this user's logs during the journey.\n * RUM stubs/older hosts can lack getUser; enrichment must never prevent emission.\n */\n try {\n const url = relatedLogsUrl(journey.durationMs, getUser?.(), pageUrl);\n if (url) {\n return { url, scope: 'related_logs' };\n }\n } catch {\n // Retain request-only links if session context is unavailable or malformed.\n }\n\n const requests = journey.steps\n .flatMap(step => step.requests ?? [])\n .slice(0, MAX_JOURNEY_CORRELATED_REQUESTS)\n .map(sanitizeRequestCorrelation)\n .filter((request): request is JourneyRequestEvent => request !== undefined);\n if (!requests.length) {\n return;\n }\n const prefixes = [...new Set(requests.map(request => request.rayId.slice(0, 16)))];\n const query = prefixes.map(prefix => `Request.RequestId: ${prefix}*`).join(' OR ');\n return {\n url: discoverUrl(\n query,\n new Date(\n Math.min(...requests.map(request => request.startedAtEpoch)) - WINDOW_PADDING_MS\n ).toISOString(),\n new Date(\n Math.max(...requests.map(request => request.startedAtEpoch + request.durationMs)) +\n WINDOW_PADDING_MS\n ).toISOString()\n ),\n scope: 'related_requests',\n };\n}\n"],"names":["isMonolithLogOrigin","MAX_JOURNEY_CORRELATED_REQUESTS","sanitizeRequestCorrelation","DISCOVER_URL","DATA_VIEW_ID","WINDOW_PADDING_MS","requestLogsUrl","value","request","undefined","from","Date","startedAtEpoch","toISOString","to","durationMs","rayPrefix","rayId","slice","discoverUrl","query","globalState","quotedQuery","replace","appState","encodeURIComponent","userValue","length","test","kqlValue","relatedLogsUrl","user","pageUrl","Number","isFinite","context","tenant","users","Set","name","email","filter","map","join","now","journeyLogsLink","journey","getUser","failedRequest","url","scope","requests","steps","flatMap","step","prefixes","prefix","Math","min","max"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@servicetitan/journey",
3
- "version": "4.1.0",
3
+ "version": "4.2.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": "1585627b4e268fe84783189b4b3d342b43bbdb17"
42
+ "gitHead": "dcfd73b9e16731d66271c95b6fa37ef638ae8641"
43
43
  }
@@ -1,6 +1,6 @@
1
1
  /** @jest-environment jsdom */
2
2
  import { baseJourneyDef, useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
3
- import { journeyStep } from '../../core';
3
+ import { excludeJourney, journeyStep } from '../../core';
4
4
  import { createJourneyRuntime, moduleRuntime } from '../../core/runtime';
5
5
  import type { StepHandle } from '../../core/types';
6
6
  import { instrumentFetch } from '../../integrations/fetch';
@@ -90,7 +90,7 @@ describe('[journey] request correlation', () => {
90
90
  step.completeJourney();
91
91
  });
92
92
  expect(event().outcome).toBe('good');
93
- expect(event().steps[0].requests).toBeUndefined();
93
+ expect(event().steps[0].requests).toHaveLength(1);
94
94
  expect(event().failedRequest).toBeUndefined();
95
95
  });
96
96
 
@@ -252,7 +252,8 @@ describe('[journey] request correlation', () => {
252
252
  expect(event().steps[0].requests).toHaveLength(1);
253
253
  expect(event().failedRequest).toBeUndefined();
254
254
  sendToDatadog(emit.mock.calls[0][0]);
255
- expect(addAction.mock.calls[0][1].journey.kibana_url).toBeUndefined();
255
+ expect(addAction.mock.calls[0][1].journey.kibana_scope).toBe('related_requests');
256
+ expect(addAction.mock.calls[0][1].journey.failed_request_id).toBeUndefined();
256
257
  });
257
258
 
258
259
  test('a successful response never replaces an earlier captured failure', async () => {
@@ -309,7 +310,74 @@ describe('[journey] request correlation', () => {
309
310
  });
310
311
  expect(event().outcome).toBe(policy === 'continue' ? 'good' : 'excluded');
311
312
  expect(event().failedRequest).toBeUndefined();
312
- expect(event().steps[0].requests).toBeUndefined();
313
+ expect(event().steps[0].requests).toHaveLength(1);
313
314
  }
314
315
  );
315
316
  });
317
+
318
+ describe('[journey] request links independent of verdict', () => {
319
+ useJourneyTestRuntime(emit);
320
+ beforeEach(() => {
321
+ jest.spyOn(Date, 'now').mockReturnValue(epoch);
322
+ addAction.mockClear();
323
+ (globalThis as Record<string, unknown>).DD_RUM = { addAction };
324
+ });
325
+ afterEach(() => {
326
+ delete (globalThis as Record<string, unknown>).DD_RUM;
327
+ jest.restoreAllMocks();
328
+ });
329
+
330
+ test.each(['good', 'excluded'] as const)('keeps successful request details on %s', outcome => {
331
+ return journeyStep(baseJourneyDef, 'load', step => {
332
+ onHttpComplete(report(step, { status: 200 }));
333
+ if (outcome === 'good') {
334
+ step.completeJourney();
335
+ } else {
336
+ excludeJourney(baseJourneyDef.name);
337
+ }
338
+ }).then(() => {
339
+ expect(event().outcome).toBe(outcome);
340
+ expect(event().steps[0].requests).toHaveLength(1);
341
+ sendToDatadog(emit.mock.calls[0][0]);
342
+ const j = addAction.mock.calls[0][1].journey;
343
+ expect(j.kibana_scope).toBe('related_requests');
344
+ expect(j.steps[0].requests[0].http_status).toBe(200);
345
+ expect(j.steps[0].requests[0].kibana_url).toContain('https://kibana.st.dev/');
346
+ expect(j.failed_request_id).toBeUndefined();
347
+ });
348
+ });
349
+
350
+ test('step-error keeps its request link without claiming HTTP scoring caused it', async () => {
351
+ const failure = new Error('application error');
352
+ await expect(
353
+ journeyStep(baseJourneyDef, 'save', step => {
354
+ captureRequestCorrelation(report(step));
355
+ throw failure;
356
+ })
357
+ ).rejects.toBe(failure);
358
+ expect(event().reason).toBe('step-error');
359
+ sendToDatadog(emit.mock.calls[0][0]);
360
+ const j = addAction.mock.calls[0][1].journey;
361
+ expect(j.kibana_scope).toBe('related_requests');
362
+ expect(j.failed_request_id).toBeUndefined();
363
+ expect(j.steps[0].requests[0].http_status).toBe(500);
364
+ });
365
+
366
+ test('captures request IDs from verified Enterprise Hub monolith endpoints', async () => {
367
+ await journeyStep(baseJourneyDef, 'save', step =>
368
+ onHttpComplete(
369
+ report(step, {
370
+ url: 'https://test_tenant.eh.go.servicetitan.com/test',
371
+ response: {
372
+ headers: { 'cf-ray': ray },
373
+ responseURL: 'https://test_tenant.eh.go.servicetitan.com/test',
374
+ },
375
+ })
376
+ )
377
+ );
378
+ expect(event().outcome).toBe('bad');
379
+ expect(event().failedRequest.rayId).toBe(ray.toLowerCase());
380
+ sendToDatadog(emit.mock.calls[0][0]);
381
+ expect(addAction.mock.calls[0][1].journey.kibana_scope).toBe('request');
382
+ });
383
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ * @jest-environment-options {"url": "https://go.servicetitan.com/"}
4
+ */
5
+ import type { JourneyEvent } from '../../core/types';
6
+ import { sendToDatadog } from '../../sinks/datadog';
7
+
8
+ function sampleEvent(overrides: Partial<JourneyEvent['journey']> = {}): JourneyEvent {
9
+ return {
10
+ journey: {
11
+ name: 'test',
12
+ team: 'test',
13
+ group: 'test',
14
+ service: 'test',
15
+ outcome: 'good',
16
+ durationMs: 120,
17
+ steps: [],
18
+ ...overrides,
19
+ },
20
+ };
21
+ }
22
+
23
+ describe('[journey] link visibility independent of failure reason', () => {
24
+ const addAction = jest.fn();
25
+ const getUser = jest.fn();
26
+ beforeEach(() => {
27
+ addAction.mockClear();
28
+ getUser.mockReset().mockReturnValue({ tenant: 'test_tenant', name: 'test-agent' });
29
+ (globalThis as Record<string, unknown>).DD_RUM = { addAction, getUser };
30
+ });
31
+ afterEach(() => {
32
+ delete (globalThis as Record<string, unknown>).DD_RUM;
33
+ });
34
+
35
+ test.each(['step-error', 'request-error', 'step-timeout', 'journey-timeout'])(
36
+ 'always uses the same visible field for %s with host user context',
37
+ reason => {
38
+ sendToDatadog(sampleEvent({ outcome: 'bad', reason }));
39
+ const j = addAction.mock.calls[0][1].journey;
40
+ expect(j.kibana_url).toContain('https://kibana.st.dev/app/discover');
41
+ expect(j.kibana_scope).toBe('related_logs');
42
+ expect(j.failed_request_id).toBeUndefined();
43
+ expect(j.reason).toBe(reason);
44
+ expect(j.outcome).toBe('bad');
45
+ }
46
+ );
47
+
48
+ test('getUser failure does not lose an event without requests', () => {
49
+ getUser.mockImplementation(() => {
50
+ throw new Error('getUser unavailable');
51
+ });
52
+ expect(() =>
53
+ sendToDatadog(sampleEvent({ reason: 'step-error', outcome: 'bad' }))
54
+ ).not.toThrow();
55
+ expect(addAction).toHaveBeenCalledTimes(1);
56
+ expect(addAction.mock.calls[0][1].journey.outcome).toBe('bad');
57
+ });
58
+ });
@@ -1,8 +1,11 @@
1
1
  /** @jest-environment jsdom */
2
2
  import { sanitizeJourneyEvent } from '../../core/sanitize';
3
- import { MAX_JOURNEY_CORRELATED_REQUESTS } from '../../core/request-correlation';
3
+ import {
4
+ isMonolithLogOrigin,
5
+ MAX_JOURNEY_CORRELATED_REQUESTS,
6
+ } from '../../core/request-correlation';
4
7
  import type { JourneyEvent, JourneyRequestEvent } from '../../core/types';
5
- import { requestLogsUrl } from '../../sinks/kibana';
8
+ import { journeyLogsLink, requestLogsUrl } from '../../sinks/kibana';
6
9
 
7
10
  const request: JourneyRequestEvent = {
8
11
  rayId: '0123456789abcdef-IAD',
@@ -78,8 +81,10 @@ describe('[journey] request logs link', () => {
78
81
  ]);
79
82
  });
80
83
 
81
- test.each(['good', 'excluded'] as const)('omits correlation on %s events', outcome => {
82
- expect(sanitizeJourneyEvent(event(outcome)).journey.steps[0].requests).toBeUndefined();
84
+ test.each(['good', 'excluded'] as const)('keeps request links on %s events', outcome => {
85
+ expect(sanitizeJourneyEvent(event(outcome)).journey.steps[0].requests).toEqual([
86
+ { ...request, rayId: request.rayId.toLowerCase() },
87
+ ]);
83
88
  });
84
89
 
85
90
  test('caps the event payload and preserves links on the last failing step', () => {
@@ -108,3 +113,155 @@ describe('[journey] request logs link', () => {
108
113
  expect(sanitizeJourneyEvent(payload).journey.failedRequest).toBeUndefined();
109
114
  });
110
115
  });
116
+
117
+ describe('[journey] visible Kibana link', () => {
118
+ const now = Date.parse('2026-09-17T18:00:02.000Z');
119
+ const user = { tenant: 'test_tenant', name: 'test-agent', email: 'agent@example.test' };
120
+ const page = 'https://go.servicetitan.com/';
121
+
122
+ beforeEach(() => {
123
+ jest.spyOn(Date, 'now').mockReturnValue(now);
124
+ });
125
+ afterEach(() => jest.restoreAllMocks());
126
+
127
+ function queryState(url: string): string {
128
+ return new URLSearchParams(new URL(url).hash.slice(3)).get('_a')!;
129
+ }
130
+
131
+ test.each(['step-error', 'request-error', 'step-timeout', 'journey-timeout', 'custom-failure'])(
132
+ 'links %s without an HTTP response to scoped related logs',
133
+ reason => {
134
+ const journey = { ...event().journey, reason, steps: [] };
135
+ const link = journeyLogsLink(journey, () => user, page)!;
136
+ expect(link.scope).toBe('related_logs');
137
+ expect(queryState(link.url)).toContain('Context.TenantName: "test_tenant"');
138
+ expect(queryState(link.url)).toContain('Request.User: "test-agent"');
139
+ expect(queryState(link.url)).toContain('Request.User: "agent@example.test"');
140
+ expect(queryState(link.url)).not.toContain('Request.RequestId');
141
+ const params = new URLSearchParams(new URL(link.url).hash.slice(3));
142
+ expect(params.get('_g')).toContain("from:'2026-09-17T17:59:00.000Z'");
143
+ expect(params.get('_g')).toContain("to:'2026-09-17T18:01:02.000Z'");
144
+ }
145
+ );
146
+
147
+ test.each(['good', 'excluded'] as const)('includes a visible link for %s journeys', outcome => {
148
+ const link = journeyLogsLink(event(outcome).journey, () => user, page)!;
149
+ expect(link.scope).toBe('related_logs');
150
+ });
151
+
152
+ test('keeps the existing precise failure link ahead of the fallback', () => {
153
+ const getUser = jest.fn(() => user);
154
+ const journey = { ...event().journey, failedRequest: request };
155
+ expect(journeyLogsLink(journey, getUser, page)).toEqual({
156
+ url: requestLogsUrl(request),
157
+ scope: 'request',
158
+ });
159
+ expect(getUser).not.toHaveBeenCalled();
160
+ });
161
+
162
+ test('does not blame an earlier successful request for a later network error', () => {
163
+ const journey = event().journey;
164
+ journey.reason = 'request-error';
165
+ journey.steps[0].requests![0] = { ...request, status: 200 };
166
+ const link = journeyLogsLink(journey, () => user, page)!;
167
+ expect(link.scope).toBe('related_logs');
168
+ expect(queryState(link.url)).not.toContain('Request.RequestId');
169
+ });
170
+
171
+ test('makes nested captured requests visible when host user context is unavailable', () => {
172
+ const journey = event().journey;
173
+ const other = { ...request, rayId: 'fedcba9876543210', status: 200 };
174
+ journey.steps[0].requests = [request, other, request];
175
+ const link = journeyLogsLink(journey)!;
176
+ expect(link.scope).toBe('related_requests');
177
+ expect(queryState(link.url)).toContain(
178
+ 'Request.RequestId: 0123456789abcdef* OR Request.RequestId: fedcba9876543210*'
179
+ );
180
+ expect(queryState(link.url).match(/0123456789abcdef/g)).toHaveLength(1);
181
+ });
182
+
183
+ test.each([
184
+ {},
185
+ { tenant: 'test_tenant' },
186
+ { name: 'test-agent' },
187
+ { tenant: 'test_tenant OR *', name: 'test-agent' },
188
+ { tenant: 'test_tenant', name: 'agent\nOR *' },
189
+ { tenant: 'test_tenant', name: 'x'.repeat(255) },
190
+ ])('does not create a broad cross-tenant search for %j', invalid => {
191
+ const journey = { ...event().journey, steps: [] };
192
+ expect(journeyLogsLink(journey, () => invalid, page)).toBeUndefined();
193
+ });
194
+
195
+ test.each([-1, Infinity, NaN, 86_400_001])('rejects invalid duration %s', durationMs => {
196
+ const journey = { ...event().journey, durationMs, steps: [] };
197
+ expect(journeyLogsLink(journey, () => user, page)).toBeUndefined();
198
+ });
199
+
200
+ test('deduplicates identical user and email values', () => {
201
+ const link = journeyLogsLink(event().journey, () => ({ ...user, email: user.name }), page)!;
202
+ expect(queryState(link.url).match(/Request.User/g)).toHaveLength(1);
203
+ });
204
+
205
+ test('escapes quotes and exclamation marks at both KQL and Rison layers', () => {
206
+ const name = `O'Neil!" OR *`;
207
+ const link = journeyLogsLink(
208
+ event().journey,
209
+ () => ({ ...user, name, email: undefined }),
210
+ page
211
+ )!;
212
+ expect(queryState(link.url)).toContain(`Request.User: "O!'Neil!!\\" OR *"`);
213
+ expect(queryState(link.url)).toContain('Context.TenantName: "test_tenant" AND (');
214
+ });
215
+
216
+ test('request caps still apply on good journeys', () => {
217
+ const payload = event('good');
218
+ payload.journey.steps = Array.from({ length: 50 }, (_, index) => ({
219
+ ...payload.journey.steps[0],
220
+ name: `step-${index}`,
221
+ }));
222
+ const safe = sanitizeJourneyEvent(payload);
223
+ expect(
224
+ safe.journey.steps.reduce((count, step) => count + (step.requests?.length ?? 0), 0)
225
+ ).toBe(MAX_JOURNEY_CORRELATED_REQUESTS);
226
+ expect(safe.journey.outcome).toBe('good');
227
+ expect(safe.journey.steps[0].requestsTruncated).toBe(true);
228
+ });
229
+
230
+ test('does not turn non-production user context into a production log link', () => {
231
+ const journey = { ...event().journey, steps: [] };
232
+ expect(
233
+ journeyLogsLink(journey, () => user, 'https://qa.servicetitan.com/')
234
+ ).toBeUndefined();
235
+ });
236
+
237
+ test('a throwing getUser cannot prevent captured request links', () => {
238
+ expect(
239
+ journeyLogsLink(
240
+ event().journey,
241
+ () => {
242
+ throw new Error('unavailable');
243
+ },
244
+ page
245
+ )?.scope
246
+ ).toBe('related_requests');
247
+ });
248
+ });
249
+
250
+ describe('[journey] verified monolith origins', () => {
251
+ test.each([
252
+ 'https://go.servicetitan.com/path',
253
+ 'https://testtenant.eh.go.servicetitan.com/path',
254
+ 'https://test_tenant.eh.go.servicetitan.com/path',
255
+ ])('accepts %s', url => expect(isMonolithLogOrigin(url)).toBe(true));
256
+
257
+ test.each([
258
+ 'https://go.servicetitan.com.attacker.test',
259
+ 'https://test.eh.go.servicetitan.com.attacker.test',
260
+ 'https://qa.servicetitan.com',
261
+ 'https://foo.go.servicetitan.com',
262
+ 'https://go.servicetitan.com:8443',
263
+ 'https://user:secret@go.servicetitan.com',
264
+ 'http://go.servicetitan.com',
265
+ 'not-a-url',
266
+ ])('rejects %s', url => expect(isMonolithLogOrigin(url)).toBe(false));
267
+ });