@servicetitan/journey 4.0.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.
Files changed (66) 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/datadog-links.test.d.ts +2 -0
  5. package/dist/__tests__/sinks/datadog-links.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/index.d.ts +1 -1
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js.map +1 -1
  11. package/dist/core/request-correlation.d.ts +8 -0
  12. package/dist/core/request-correlation.d.ts.map +1 -0
  13. package/dist/core/request-correlation.js +23 -0
  14. package/dist/core/request-correlation.js.map +1 -0
  15. package/dist/core/runtime.d.ts.map +1 -1
  16. package/dist/core/runtime.js +18 -2
  17. package/dist/core/runtime.js.map +1 -1
  18. package/dist/core/sanitize.d.ts.map +1 -1
  19. package/dist/core/sanitize.js +35 -2
  20. package/dist/core/sanitize.js.map +1 -1
  21. package/dist/core/types.d.ts +17 -0
  22. package/dist/core/types.d.ts.map +1 -1
  23. package/dist/core/types.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/integrations/axios.d.ts.map +1 -1
  28. package/dist/integrations/axios.js +2 -1
  29. package/dist/integrations/axios.js.map +1 -1
  30. package/dist/integrations/http-report.d.ts +2 -0
  31. package/dist/integrations/http-report.d.ts.map +1 -1
  32. package/dist/integrations/http-report.js +17 -2
  33. package/dist/integrations/http-report.js.map +1 -1
  34. package/dist/integrations/jquery/instrument.d.ts +2 -0
  35. package/dist/integrations/jquery/instrument.d.ts.map +1 -1
  36. package/dist/integrations/jquery/instrument.js +31 -4
  37. package/dist/integrations/jquery/instrument.js.map +1 -1
  38. package/dist/integrations/request-correlation.d.ts +5 -0
  39. package/dist/integrations/request-correlation.d.ts.map +1 -0
  40. package/dist/integrations/request-correlation.js +64 -0
  41. package/dist/integrations/request-correlation.js.map +1 -0
  42. package/dist/sinks/datadog.d.ts.map +1 -1
  43. package/dist/sinks/datadog.js +26 -0
  44. package/dist/sinks/datadog.js.map +1 -1
  45. package/dist/sinks/kibana.d.ts +11 -0
  46. package/dist/sinks/kibana.d.ts.map +1 -0
  47. package/dist/sinks/kibana.js +92 -0
  48. package/dist/sinks/kibana.js.map +1 -0
  49. package/package.json +2 -2
  50. package/src/__tests__/integrations/axios.test.ts +38 -1
  51. package/src/__tests__/integrations/jquery.test.ts +94 -1
  52. package/src/__tests__/integrations/request-correlation.test.ts +383 -0
  53. package/src/__tests__/sinks/datadog-links.test.ts +58 -0
  54. package/src/__tests__/sinks/kibana.test.ts +267 -0
  55. package/src/core/index.ts +1 -0
  56. package/src/core/request-correlation.ts +51 -0
  57. package/src/core/runtime.ts +7 -0
  58. package/src/core/sanitize.ts +39 -2
  59. package/src/core/types.ts +18 -0
  60. package/src/index.ts +1 -1
  61. package/src/integrations/axios.ts +1 -0
  62. package/src/integrations/http-report.ts +33 -14
  63. package/src/integrations/jquery/instrument.ts +37 -5
  64. package/src/integrations/request-correlation.ts +93 -0
  65. package/src/sinks/datadog.ts +21 -0
  66. package/src/sinks/kibana.ts +140 -0
@@ -0,0 +1,93 @@
1
+ import {
2
+ isMonolithLogOrigin,
3
+ MAX_CORRELATED_REQUESTS,
4
+ sanitizeRequestCorrelation,
5
+ } from '../core/request-correlation';
6
+ import { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime';
7
+ import type { HttpCompleteReport } from './http-report';
8
+ import type { JourneyRequestEvent } from '../core/types';
9
+
10
+ const capturedResponses = new WeakMap<object, JourneyRequestEvent>();
11
+
12
+ interface ResponseHeaders {
13
+ headers?: { 'cf-ray'?: unknown; 'get'?(name: string): unknown };
14
+ url?: string;
15
+ responseURL?: string;
16
+ request?: { responseURL?: string };
17
+ getResponseHeader?(name: string): unknown;
18
+ }
19
+
20
+ /** Observation only. Safe to run before jQuery invokes application callbacks. */
21
+ export function captureRequestCorrelation(
22
+ report: HttpCompleteReport
23
+ ): JourneyRequestEvent | undefined {
24
+ try {
25
+ const step = report.stamp?.step;
26
+ if (
27
+ !step?.journey ||
28
+ step.journey.closed ||
29
+ !canRouteHttpToJourney(step.journey) ||
30
+ report.stamp?.ignore ||
31
+ report.meta?.ignore ||
32
+ report.aborted ||
33
+ !report.url ||
34
+ resolveJourneyEngine(step.journey).shouldIgnoreRequest(report.requestKey)
35
+ ) {
36
+ return;
37
+ }
38
+
39
+ // Other environments/backends have not verified this log-field/data-view contract.
40
+ const pageUrl = globalThis.location?.href;
41
+ const base = report.baseURL ? new URL(report.baseURL, pageUrl).href : pageUrl;
42
+ const url = new URL(report.url, base);
43
+ if (!isMonolithLogOrigin(url.href)) {
44
+ return;
45
+ }
46
+ const response = report.response as ResponseHeaders | undefined;
47
+ if (!response || typeof response !== 'object') {
48
+ return;
49
+ }
50
+ const captured = capturedResponses.get(response);
51
+ if (captured) {
52
+ return step.requests?.includes(captured) ? captured : undefined;
53
+ }
54
+ const finalUrl = response.url || response.responseURL || response.request?.responseURL;
55
+ if (finalUrl && !isMonolithLogOrigin(finalUrl)) {
56
+ return;
57
+ }
58
+ const rayId =
59
+ typeof response.getResponseHeader === 'function'
60
+ ? response.getResponseHeader('cf-ray')
61
+ : typeof response.headers?.get === 'function'
62
+ ? response.headers.get('cf-ray')
63
+ : response.headers?.['cf-ray'];
64
+ if (typeof rayId !== 'string') {
65
+ return;
66
+ }
67
+ const request = sanitizeRequestCorrelation({
68
+ rayId,
69
+ startedAtEpoch: Date.now() - report.durationMs,
70
+ durationMs: report.durationMs,
71
+ status: report.status as number,
72
+ });
73
+ if (!request) {
74
+ return;
75
+ }
76
+ capturedResponses.set(response, request);
77
+ step.requests ??= [];
78
+ const requests = step.requests;
79
+ if (requests.length >= MAX_CORRELATED_REQUESTS) {
80
+ step.requestsTruncated = true;
81
+ // Keep errors when later successes arrive. A new error replaces a success first.
82
+ const replace = requests.findIndex(item => item.status < 400);
83
+ if (replace < 0 && request.status < 400) {
84
+ return request;
85
+ }
86
+ requests.splice(replace < 0 ? 0 : replace, 1);
87
+ }
88
+ requests.push(request);
89
+ return request;
90
+ } catch {
91
+ // Missing/blocked headers and unusual transport objects must not affect requests.
92
+ }
93
+ }
@@ -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 { journeyLogsLink, requestLogsUrl } from './kibana';
4
5
 
5
6
  export { normalizeTagValue } from '../core/sanitize';
6
7
 
@@ -14,6 +15,7 @@ const DD_RUM_KEY = 'DD_RUM';
14
15
  */
15
16
  interface RumLike {
16
17
  addAction(name: string, context?: object): void;
18
+ getUser?(): unknown;
17
19
  }
18
20
 
19
21
  function getDdRum(): RumLike | undefined {
@@ -54,6 +56,18 @@ function toDatadogStep(step: JourneyStepEvent) {
54
56
  outcome: step.outcome,
55
57
  ...(step.reason ? { reason: step.reason } : {}),
56
58
  ...(step.httpStatus !== undefined ? { http_status: step.httpStatus } : {}),
59
+ ...(step.requests?.length
60
+ ? {
61
+ requests: step.requests.map(request => ({
62
+ request_id: request.rayId,
63
+ started_at: new Date(request.startedAtEpoch).toISOString(),
64
+ duration_ms: request.durationMs,
65
+ http_status: request.status,
66
+ kibana_url: requestLogsUrl(request),
67
+ })),
68
+ }
69
+ : {}),
70
+ ...(step.requestsTruncated ? { requests_truncated: true } : {}),
57
71
  ...(step.attributes && Object.keys(step.attributes).length
58
72
  ? { attributes: step.attributes }
59
73
  : {}),
@@ -71,6 +85,7 @@ export function sendToDatadog(payload: JourneyEvent): void {
71
85
  const j = isSanitizedJourneyEvent(payload)
72
86
  ? payload.journey
73
87
  : sanitizeJourneyEvent(payload).journey;
88
+ const logs = journeyLogsLink(j, () => rum.getUser?.(), globalThis.location?.href);
74
89
  /* eslint-disable @typescript-eslint/naming-convention -- Datadog RUM wire format (snake_case) */
75
90
  rum.addAction(JOURNEY_ACTION, {
76
91
  journey: {
@@ -79,6 +94,12 @@ export function sendToDatadog(payload: JourneyEvent): void {
79
94
  group: j.group,
80
95
  service: j.service,
81
96
  outcome: j.outcome,
97
+ ...(j.failedRequest
98
+ ? {
99
+ failed_request_id: j.failedRequest.rayId,
100
+ }
101
+ : {}),
102
+ ...(logs ? { kibana_url: logs.url, kibana_scope: logs.scope } : {}),
82
103
  ...(j.reason ? { reason: j.reason } : {}),
83
104
  duration_ms: j.durationMs,
84
105
  steps: j.steps.map(toDatadogStep),
@@ -0,0 +1,140 @@
1
+ import {
2
+ isMonolithLogOrigin,
3
+ MAX_JOURNEY_CORRELATED_REQUESTS,
4
+ sanitizeRequestCorrelation,
5
+ } from '../core/request-correlation';
6
+ import type { JourneyEvent, JourneyRequestEvent } from '../core/types';
7
+
8
+ const DISCOVER_URL = 'https://kibana.st.dev/app/discover#/';
9
+ // Production monolith logs; deliberately not the user's default data view.
10
+ const DATA_VIEW_ID = '894110ed-c22d-449c-9dc2-c4816f08712a';
11
+ const WINDOW_PADDING_MS = 60_000;
12
+
13
+ /** Fixed destination and validated Ray ID only — no request URL, query string or user data. */
14
+ export function requestLogsUrl(value: JourneyRequestEvent): string | undefined {
15
+ const request = sanitizeRequestCorrelation(value);
16
+ if (!request) {
17
+ return undefined;
18
+ }
19
+ const from = new Date(request.startedAtEpoch - WINDOW_PADDING_MS).toISOString();
20
+ const to = new Date(
21
+ request.startedAtEpoch + request.durationMs + WINDOW_PADDING_MS
22
+ ).toISOString();
23
+ // Origin/edge datacenter suffixes can differ. Match the hex prefix within the request window.
24
+ const rayPrefix = request.rayId.slice(0, 16);
25
+ return discoverUrl(`Request.RequestId: ${rayPrefix}*`, from, to);
26
+ }
27
+
28
+ interface JourneyLogLink {
29
+ url: string;
30
+ scope: 'request' | 'related_requests' | 'related_logs';
31
+ }
32
+
33
+ function discoverUrl(query: string, from: string, to: string): string {
34
+ const globalState = `(refreshInterval:(pause:!t,value:0),time:(from:'${from}',to:'${to}'))`;
35
+ // The query is a quoted Rison string containing a separately escaped KQL expression.
36
+ const quotedQuery = query.replace(/!/g, '!!').replace(/'/g, "!'");
37
+ const appState = `(dataSource:(type:dataView,dataViewId:'${DATA_VIEW_ID}'),query:(language:kuery,query:'${quotedQuery}'))`;
38
+ return `${DISCOVER_URL}?_g=${encodeURIComponent(globalState)}&_a=${encodeURIComponent(appState)}`;
39
+ }
40
+
41
+ function userValue(value: unknown): string | undefined {
42
+ return typeof value === 'string' &&
43
+ value.length > 0 &&
44
+ value.length <= 254 &&
45
+ // eslint-disable-next-line no-control-regex -- Reject controls in log query values.
46
+ !/[\u0000-\u001f\u007f]/.test(value)
47
+ ? value
48
+ : undefined;
49
+ }
50
+
51
+ function kqlValue(value: string): string {
52
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
53
+ }
54
+
55
+ function relatedLogsUrl(
56
+ durationMs: number,
57
+ user: unknown,
58
+ pageUrl: string | undefined
59
+ ): string | undefined {
60
+ if (
61
+ !pageUrl ||
62
+ !isMonolithLogOrigin(pageUrl) ||
63
+ !user ||
64
+ typeof user !== 'object' ||
65
+ !Number.isFinite(durationMs) ||
66
+ durationMs < 0 ||
67
+ durationMs > 86_400_000
68
+ ) {
69
+ return;
70
+ }
71
+ const context = user as Record<string, unknown>;
72
+ const tenant = userValue(context.tenant);
73
+ const users = [...new Set([userValue(context.name), userValue(context.email)])].filter(
74
+ (value): value is string => value !== undefined
75
+ );
76
+ // Never fall back to all tenants or all users when identity is missing.
77
+ if (!tenant || !/^[a-z0-9][a-z0-9_-]*$/i.test(tenant) || users.length === 0) {
78
+ return;
79
+ }
80
+ const query = `Context.TenantName: ${kqlValue(tenant)} AND (${users
81
+ .map(value => `Request.User: ${kqlValue(value)}`)
82
+ .join(' OR ')})`;
83
+ const now = Date.now();
84
+ return discoverUrl(
85
+ query,
86
+ new Date(now - durationMs - WINDOW_PADDING_MS).toISOString(),
87
+ new Date(now + WINDOW_PADDING_MS).toISOString()
88
+ );
89
+ }
90
+
91
+ /** Keep one visible link without claiming unrelated requests caused a JS/business failure. */
92
+ export function journeyLogsLink(
93
+ journey: JourneyEvent['journey'],
94
+ getUser?: () => unknown,
95
+ pageUrl?: string
96
+ ): JourneyLogLink | undefined {
97
+ if (journey.failedRequest) {
98
+ const url = requestLogsUrl(journey.failedRequest);
99
+ if (url) {
100
+ return { url, scope: 'request' };
101
+ }
102
+ }
103
+
104
+ /*
105
+ * With no identified failure request, search this user's logs during the journey.
106
+ * RUM stubs/older hosts can lack getUser; enrichment must never prevent emission.
107
+ */
108
+ try {
109
+ const url = relatedLogsUrl(journey.durationMs, getUser?.(), pageUrl);
110
+ if (url) {
111
+ return { url, scope: 'related_logs' };
112
+ }
113
+ } catch {
114
+ // Retain request-only links if session context is unavailable or malformed.
115
+ }
116
+
117
+ const requests = journey.steps
118
+ .flatMap(step => step.requests ?? [])
119
+ .slice(0, MAX_JOURNEY_CORRELATED_REQUESTS)
120
+ .map(sanitizeRequestCorrelation)
121
+ .filter((request): request is JourneyRequestEvent => request !== undefined);
122
+ if (!requests.length) {
123
+ return;
124
+ }
125
+ const prefixes = [...new Set(requests.map(request => request.rayId.slice(0, 16)))];
126
+ const query = prefixes.map(prefix => `Request.RequestId: ${prefix}*`).join(' OR ');
127
+ return {
128
+ url: discoverUrl(
129
+ query,
130
+ new Date(
131
+ Math.min(...requests.map(request => request.startedAtEpoch)) - WINDOW_PADDING_MS
132
+ ).toISOString(),
133
+ new Date(
134
+ Math.max(...requests.map(request => request.startedAtEpoch + request.durationMs)) +
135
+ WINDOW_PADDING_MS
136
+ ).toISOString()
137
+ ),
138
+ scope: 'related_requests',
139
+ };
140
+ }