@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.
- package/README.md +35 -0
- package/dist/__tests__/integrations/request-correlation.test.d.ts +2 -0
- package/dist/__tests__/integrations/request-correlation.test.d.ts.map +1 -0
- 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/__tests__/sinks/kibana.test.d.ts +2 -0
- package/dist/__tests__/sinks/kibana.test.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/request-correlation.d.ts +8 -0
- package/dist/core/request-correlation.d.ts.map +1 -0
- package/dist/core/request-correlation.js +23 -0
- package/dist/core/request-correlation.js.map +1 -0
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +18 -2
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/sanitize.d.ts.map +1 -1
- package/dist/core/sanitize.js +35 -2
- package/dist/core/sanitize.js.map +1 -1
- package/dist/core/types.d.ts +17 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/axios.d.ts.map +1 -1
- package/dist/integrations/axios.js +2 -1
- package/dist/integrations/axios.js.map +1 -1
- package/dist/integrations/http-report.d.ts +2 -0
- package/dist/integrations/http-report.d.ts.map +1 -1
- package/dist/integrations/http-report.js +17 -2
- package/dist/integrations/http-report.js.map +1 -1
- package/dist/integrations/jquery/instrument.d.ts +2 -0
- package/dist/integrations/jquery/instrument.d.ts.map +1 -1
- package/dist/integrations/jquery/instrument.js +31 -4
- package/dist/integrations/jquery/instrument.js.map +1 -1
- package/dist/integrations/request-correlation.d.ts +5 -0
- package/dist/integrations/request-correlation.d.ts.map +1 -0
- package/dist/integrations/request-correlation.js +64 -0
- package/dist/integrations/request-correlation.js.map +1 -0
- package/dist/sinks/datadog.d.ts.map +1 -1
- package/dist/sinks/datadog.js +26 -0
- package/dist/sinks/datadog.js.map +1 -1
- package/dist/sinks/kibana.d.ts +11 -0
- package/dist/sinks/kibana.d.ts.map +1 -0
- package/dist/sinks/kibana.js +92 -0
- package/dist/sinks/kibana.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/integrations/axios.test.ts +38 -1
- package/src/__tests__/integrations/jquery.test.ts +94 -1
- package/src/__tests__/integrations/request-correlation.test.ts +383 -0
- package/src/__tests__/sinks/datadog-links.test.ts +58 -0
- package/src/__tests__/sinks/kibana.test.ts +267 -0
- package/src/core/index.ts +1 -0
- package/src/core/request-correlation.ts +51 -0
- package/src/core/runtime.ts +7 -0
- package/src/core/sanitize.ts +39 -2
- package/src/core/types.ts +18 -0
- package/src/index.ts +1 -1
- package/src/integrations/axios.ts +1 -0
- package/src/integrations/http-report.ts +33 -14
- package/src/integrations/jquery/instrument.ts +37 -5
- package/src/integrations/request-correlation.ts +93 -0
- package/src/sinks/datadog.ts +21 -0
- 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
|
+
}
|
package/src/sinks/datadog.ts
CHANGED
|
@@ -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
|
+
}
|