@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.
- package/README.md +34 -34
- package/dist/__tests__/sinks/datadog-links.test.d.ts +2 -0
- package/dist/__tests__/sinks/datadog-links.test.d.ts.map +1 -0
- package/dist/core/request-correlation.d.ts +2 -0
- package/dist/core/request-correlation.d.ts.map +1 -1
- package/dist/core/request-correlation.js +8 -0
- package/dist/core/request-correlation.js.map +1 -1
- package/dist/core/runtime.js +2 -2
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/sanitize.d.ts.map +1 -1
- package/dist/core/sanitize.js +4 -4
- package/dist/core/sanitize.js.map +1 -1
- package/dist/integrations/request-correlation.d.ts.map +1 -1
- package/dist/integrations/request-correlation.js +3 -4
- package/dist/integrations/request-correlation.js.map +1 -1
- package/dist/sinks/datadog.d.ts.map +1 -1
- package/dist/sinks/datadog.js +11 -3
- package/dist/sinks/datadog.js.map +1 -1
- package/dist/sinks/kibana.d.ts +8 -1
- package/dist/sinks/kibana.d.ts.map +1 -1
- package/dist/sinks/kibana.js +74 -2
- package/dist/sinks/kibana.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/integrations/request-correlation.test.ts +72 -4
- package/src/__tests__/sinks/datadog-links.test.ts +58 -0
- package/src/__tests__/sinks/kibana.test.ts +161 -4
- package/src/core/request-correlation.ts +19 -0
- package/src/core/runtime.ts +2 -2
- package/src/core/sanitize.ts +9 -10
- package/src/integrations/request-correlation.ts +7 -4
- package/src/sinks/datadog.ts +4 -2
- package/src/sinks/kibana.ts +119 -3
|
@@ -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
|
+
}
|
package/src/core/runtime.ts
CHANGED
|
@@ -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
|
-
...(
|
|
339
|
+
...(s.requests?.length
|
|
340
340
|
? { requests: s.requests.map(request => ({ ...request })) }
|
|
341
341
|
: {}),
|
|
342
|
-
...(
|
|
342
|
+
...(s.requestsTruncated ? { requestsTruncated: true } : {}),
|
|
343
343
|
}));
|
|
344
344
|
|
|
345
345
|
emit({
|
package/src/core/sanitize.ts
CHANGED
|
@@ -129,20 +129,19 @@ export function setSafeAttribute(
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
function sanitizeStep(step: JourneyStepEvent
|
|
132
|
+
function sanitizeStep(step: JourneyStepEvent): JourneyStepEvent {
|
|
133
133
|
const { requests: rawRequests, requestsTruncated, ...rest } = step;
|
|
134
|
-
const requests =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
...(
|
|
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(
|
|
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 {
|
|
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.
|
|
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 &&
|
|
55
|
+
if (finalUrl && !isMonolithLogOrigin(finalUrl)) {
|
|
53
56
|
return;
|
|
54
57
|
}
|
|
55
58
|
const rayId =
|
package/src/sinks/datadog.ts
CHANGED
|
@@ -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),
|
package/src/sinks/kibana.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
|
|
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
|
+
}
|