@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.
@@ -30,3 +30,22 @@ export function sanitizeRequestCorrelation(
30
30
  status: value.status,
31
31
  };
32
32
  }
33
+
34
+ /** Production monolith origins whose Ray IDs have been verified against the log data view. */
35
+ export function isMonolithLogOrigin(value: string): boolean {
36
+ try {
37
+ const url = new URL(value);
38
+ return (
39
+ url.protocol === 'https:' &&
40
+ !url.port &&
41
+ !url.username &&
42
+ !url.password &&
43
+ (url.hostname === 'go.servicetitan.com' ||
44
+ /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:_[a-z0-9-]+)*\.eh\.go\.servicetitan\.com$/i.test(
45
+ url.hostname
46
+ ))
47
+ );
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
@@ -336,10 +336,10 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
336
336
  ...(s.reason ? { reason: s.reason } : {}),
337
337
  ...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}),
338
338
  ...(Object.keys(s.attributes).length ? { attributes: { ...s.attributes } } : {}),
339
- ...(finalOutcome === 'bad' && s.requests?.length
339
+ ...(s.requests?.length
340
340
  ? { requests: s.requests.map(request => ({ ...request })) }
341
341
  : {}),
342
- ...(finalOutcome === 'bad' && s.requestsTruncated ? { requestsTruncated: true } : {}),
342
+ ...(s.requestsTruncated ? { requestsTruncated: true } : {}),
343
343
  }));
344
344
 
345
345
  emit({
@@ -129,20 +129,19 @@ export function setSafeAttribute(
129
129
  }
130
130
  }
131
131
 
132
- function sanitizeStep(step: JourneyStepEvent, includeRequests: boolean): JourneyStepEvent {
132
+ function sanitizeStep(step: JourneyStepEvent): JourneyStepEvent {
133
133
  const { requests: rawRequests, requestsTruncated, ...rest } = step;
134
- const requests =
135
- includeRequests && Array.isArray(rawRequests)
136
- ? rawRequests
137
- .slice(0, MAX_CORRELATED_REQUESTS)
138
- .map(sanitizeRequestCorrelation)
139
- .filter(request => request !== undefined)
140
- : [];
134
+ const requests = Array.isArray(rawRequests)
135
+ ? rawRequests
136
+ .slice(0, MAX_CORRELATED_REQUESTS)
137
+ .map(sanitizeRequestCorrelation)
138
+ .filter(request => request !== undefined)
139
+ : [];
141
140
  const name = normalizeTagValue(step.name);
142
141
  return {
143
142
  ...rest,
144
143
  ...(requests.length ? { requests } : {}),
145
- ...(includeRequests && requestsTruncated === true ? { requestsTruncated: true } : {}),
144
+ ...(requestsTruncated === true ? { requestsTruncated: true } : {}),
146
145
  name: name || step.name,
147
146
  ...(step.reason ? { reason: normalizeTagValue(step.reason) } : {}),
148
147
  ...(step.attributes
@@ -164,7 +163,7 @@ export function sanitizeJourneyEvent(event: JourneyEvent): JourneyEvent {
164
163
  ? copyRecord(j.tags, { maxKeys: MAX_TAG_KEYS, normalizeKeys: true })
165
164
  : undefined;
166
165
  const tagKeys = tags ? Object.keys(tags) : [];
167
- const steps = j.steps.map(step => sanitizeStep(step, j.outcome === 'bad'));
166
+ const steps = j.steps.map(sanitizeStep);
168
167
  // Prefer recently started steps at the cap; failedRequest preserves the scoring request separately.
169
168
  let requestBudget = MAX_JOURNEY_CORRELATED_REQUESTS;
170
169
  for (let index = steps.length - 1; index >= 0; index--) {
@@ -1,9 +1,12 @@
1
- import { MAX_CORRELATED_REQUESTS, sanitizeRequestCorrelation } from '../core/request-correlation';
1
+ import {
2
+ isMonolithLogOrigin,
3
+ MAX_CORRELATED_REQUESTS,
4
+ sanitizeRequestCorrelation,
5
+ } from '../core/request-correlation';
2
6
  import { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime';
3
7
  import type { HttpCompleteReport } from './http-report';
4
8
  import type { JourneyRequestEvent } from '../core/types';
5
9
 
6
- const MONOLITH_ORIGIN = 'https://go.servicetitan.com';
7
10
  const capturedResponses = new WeakMap<object, JourneyRequestEvent>();
8
11
 
9
12
  interface ResponseHeaders {
@@ -37,7 +40,7 @@ export function captureRequestCorrelation(
37
40
  const pageUrl = globalThis.location?.href;
38
41
  const base = report.baseURL ? new URL(report.baseURL, pageUrl).href : pageUrl;
39
42
  const url = new URL(report.url, base);
40
- if (url.origin !== MONOLITH_ORIGIN) {
43
+ if (!isMonolithLogOrigin(url.href)) {
41
44
  return;
42
45
  }
43
46
  const response = report.response as ResponseHeaders | undefined;
@@ -49,7 +52,7 @@ export function captureRequestCorrelation(
49
52
  return step.requests?.includes(captured) ? captured : undefined;
50
53
  }
51
54
  const finalUrl = response.url || response.responseURL || response.request?.responseURL;
52
- if (finalUrl && new URL(finalUrl).origin !== MONOLITH_ORIGIN) {
55
+ if (finalUrl && !isMonolithLogOrigin(finalUrl)) {
53
56
  return;
54
57
  }
55
58
  const rayId =
@@ -1,7 +1,7 @@
1
1
  import { isSanitizedJourneyEvent, sanitizeJourneyEvent } from '../core/sanitize';
2
2
  import { getJourneyRumFallback, setJourneyRumFallback } from '../global';
3
3
  import type { JourneyEvent, JourneyStepEvent } from '../core/types';
4
- import { requestLogsUrl } from './kibana';
4
+ import { journeyLogsLink, requestLogsUrl } from './kibana';
5
5
 
6
6
  export { normalizeTagValue } from '../core/sanitize';
7
7
 
@@ -15,6 +15,7 @@ const DD_RUM_KEY = 'DD_RUM';
15
15
  */
16
16
  interface RumLike {
17
17
  addAction(name: string, context?: object): void;
18
+ getUser?(): unknown;
18
19
  }
19
20
 
20
21
  function getDdRum(): RumLike | undefined {
@@ -84,6 +85,7 @@ export function sendToDatadog(payload: JourneyEvent): void {
84
85
  const j = isSanitizedJourneyEvent(payload)
85
86
  ? payload.journey
86
87
  : sanitizeJourneyEvent(payload).journey;
88
+ const logs = journeyLogsLink(j, () => rum.getUser?.(), globalThis.location?.href);
87
89
  /* eslint-disable @typescript-eslint/naming-convention -- Datadog RUM wire format (snake_case) */
88
90
  rum.addAction(JOURNEY_ACTION, {
89
91
  journey: {
@@ -95,9 +97,9 @@ export function sendToDatadog(payload: JourneyEvent): void {
95
97
  ...(j.failedRequest
96
98
  ? {
97
99
  failed_request_id: j.failedRequest.rayId,
98
- kibana_url: requestLogsUrl(j.failedRequest),
99
100
  }
100
101
  : {}),
102
+ ...(logs ? { kibana_url: logs.url, kibana_scope: logs.scope } : {}),
101
103
  ...(j.reason ? { reason: j.reason } : {}),
102
104
  duration_ms: j.durationMs,
103
105
  steps: j.steps.map(toDatadogStep),
@@ -1,5 +1,9 @@
1
- import { sanitizeRequestCorrelation } from '../core/request-correlation';
2
- import type { JourneyRequestEvent } from '../core/types';
1
+ import {
2
+ isMonolithLogOrigin,
3
+ MAX_JOURNEY_CORRELATED_REQUESTS,
4
+ sanitizeRequestCorrelation,
5
+ } from '../core/request-correlation';
6
+ import type { JourneyEvent, JourneyRequestEvent } from '../core/types';
3
7
 
4
8
  const DISCOVER_URL = 'https://kibana.st.dev/app/discover#/';
5
9
  // Production monolith logs; deliberately not the user's default data view.
@@ -18,7 +22,119 @@ export function requestLogsUrl(value: JourneyRequestEvent): string | undefined {
18
22
  ).toISOString();
19
23
  // Origin/edge datacenter suffixes can differ. Match the hex prefix within the request window.
20
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 {
21
34
  const globalState = `(refreshInterval:(pause:!t,value:0),time:(from:'${from}',to:'${to}'))`;
22
- const appState = `(dataSource:(type:dataView,dataViewId:'${DATA_VIEW_ID}'),query:(language:kuery,query:'Request.RequestId: ${rayPrefix}*'))`;
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}'))`;
23
38
  return `${DISCOVER_URL}?_g=${encodeURIComponent(globalState)}&_a=${encodeURIComponent(appState)}`;
24
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
+ }