@servicetitan/journey 3.0.1 → 4.1.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__/core/persist.test.d.ts +2 -0
- package/dist/__tests__/core/persist.test.d.ts.map +1 -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/kibana.test.d.ts +2 -0
- package/dist/__tests__/sinks/kibana.test.d.ts.map +1 -0
- package/dist/core/api.d.ts +13 -1
- package/dist/core/api.d.ts.map +1 -1
- package/dist/core/api.js +14 -0
- package/dist/core/api.js.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/persist.d.ts +19 -0
- package/dist/core/persist.d.ts.map +1 -0
- package/dist/core/persist.js +111 -0
- package/dist/core/persist.js.map +1 -0
- package/dist/core/request-correlation.d.ts +6 -0
- package/dist/core/request-correlation.d.ts.map +1 -0
- package/dist/core/request-correlation.js +15 -0
- package/dist/core/request-correlation.js.map +1 -0
- package/dist/core/runtime.d.ts +16 -1
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +74 -23
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/sanitize.d.ts.map +1 -1
- package/dist/core/sanitize.js +36 -3
- package/dist/core/sanitize.js.map +1 -1
- package/dist/core/types.d.ts +42 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +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 +65 -0
- package/dist/integrations/request-correlation.js.map +1 -0
- package/dist/sinks/datadog.d.ts.map +1 -1
- package/dist/sinks/datadog.js +18 -0
- package/dist/sinks/datadog.js.map +1 -1
- package/dist/sinks/kibana.d.ts +4 -0
- package/dist/sinks/kibana.d.ts.map +1 -0
- package/dist/sinks/kibana.js +20 -0
- package/dist/sinks/kibana.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/core/persist.test.ts +496 -0
- 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 +315 -0
- package/src/__tests__/sinks/kibana.test.ts +110 -0
- package/src/core/api.ts +19 -0
- package/src/core/index.ts +4 -0
- package/src/core/persist.ts +131 -0
- package/src/core/request-correlation.ts +32 -0
- package/src/core/runtime.ts +69 -2
- package/src/core/sanitize.ts +41 -3
- package/src/core/types.ts +44 -0
- package/src/index.ts +4 -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 +90 -0
- package/src/sinks/datadog.ts +19 -0
- package/src/sinks/kibana.ts +24 -0
package/src/core/runtime.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
sortEndpointsByLongestMatch,
|
|
12
12
|
type SlowRequestConfig,
|
|
13
13
|
} from './endpoint-policy';
|
|
14
|
+
import { deserializeJourneyState, projectJourneyState } from './persist';
|
|
14
15
|
import {
|
|
15
16
|
clampMaxSteps,
|
|
16
17
|
DEFAULT_MAX_STEPS,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
type JourneyStepOptions,
|
|
37
38
|
type JourneyStepTarget,
|
|
38
39
|
type Outcome,
|
|
40
|
+
type SerializedJourneyState,
|
|
39
41
|
type StepHandle,
|
|
40
42
|
type StepRecord,
|
|
41
43
|
type TagValue,
|
|
@@ -106,7 +108,8 @@ export interface JourneyRuntimeDebugSnapshot {
|
|
|
106
108
|
};
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
|
|
111
|
+
/** @internal Freeze a caller-supplied journey def + tags snapshot. */
|
|
112
|
+
export function freezeJourneyDef(config: JourneyDef, tags: Record<string, TagValue>): JourneyDef {
|
|
110
113
|
const endpoints = sortEndpointsByLongestMatch(config.endpoints);
|
|
111
114
|
return Object.freeze({
|
|
112
115
|
...config,
|
|
@@ -164,6 +167,17 @@ export interface JourneyRuntime {
|
|
|
164
167
|
completeJourneyState(journey: JourneyState, tags?: Record<string, TagValue>): void;
|
|
165
168
|
excludeJourney(journey: JourneyState): void;
|
|
166
169
|
updateOpenJourney(config: JourneyDef): void;
|
|
170
|
+
/**
|
|
171
|
+
* @internal Snapshot an open journey for cross-page transfer. Does NOT
|
|
172
|
+
* close the journey. Returns `null` when no journey is open under `name`.
|
|
173
|
+
*/
|
|
174
|
+
serializeJourneyState(name: string): SerializedJourneyState | null;
|
|
175
|
+
/**
|
|
176
|
+
* @internal Rehydrate a serialized journey onto this runtime. Returns
|
|
177
|
+
* `null` (after emitting a `bad` `journey-timeout` event) when the journey
|
|
178
|
+
* already exhausted its budget in transit.
|
|
179
|
+
*/
|
|
180
|
+
restoreJourney(serialized: SerializedJourneyState, config: JourneyDef): JourneyState | null;
|
|
167
181
|
|
|
168
182
|
activeJourneyStep(): StepRecord | null;
|
|
169
183
|
journeyStep<T>(
|
|
@@ -192,7 +206,8 @@ export interface JourneyRuntime {
|
|
|
192
206
|
reset(options?: JourneyRuntimeOptions): void;
|
|
193
207
|
}
|
|
194
208
|
|
|
195
|
-
|
|
209
|
+
/** @internal Bind the fail/exclude/report hooks for a journey's creating engine. */
|
|
210
|
+
export function bindJourneyEngine(
|
|
196
211
|
api: JourneyRuntime,
|
|
197
212
|
journey: JourneyState,
|
|
198
213
|
shouldIgnoreRequest: (target: JourneyState, key: string) => boolean
|
|
@@ -321,6 +336,10 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
|
|
|
321
336
|
...(s.reason ? { reason: s.reason } : {}),
|
|
322
337
|
...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}),
|
|
323
338
|
...(Object.keys(s.attributes).length ? { attributes: { ...s.attributes } } : {}),
|
|
339
|
+
...(finalOutcome === 'bad' && s.requests?.length
|
|
340
|
+
? { requests: s.requests.map(request => ({ ...request })) }
|
|
341
|
+
: {}),
|
|
342
|
+
...(finalOutcome === 'bad' && s.requestsTruncated ? { requestsTruncated: true } : {}),
|
|
324
343
|
}));
|
|
325
344
|
|
|
326
345
|
emit({
|
|
@@ -330,6 +349,9 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
|
|
|
330
349
|
group: journey.group,
|
|
331
350
|
service: journey.service,
|
|
332
351
|
outcome: finalOutcome,
|
|
352
|
+
...(finalOutcome === 'bad' && journey.scoringRequest
|
|
353
|
+
? { failedRequest: { ...journey.scoringRequest } }
|
|
354
|
+
: {}),
|
|
333
355
|
...(finalReason ? { reason: finalReason } : {}),
|
|
334
356
|
durationMs,
|
|
335
357
|
steps,
|
|
@@ -685,6 +707,51 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
|
|
|
685
707
|
armCountdown(journey, remainingMs);
|
|
686
708
|
},
|
|
687
709
|
|
|
710
|
+
serializeJourneyState(name) {
|
|
711
|
+
const journey = journeys.get(name);
|
|
712
|
+
if (!journey || journey.closed) {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
return projectJourneyState(journey);
|
|
716
|
+
},
|
|
717
|
+
|
|
718
|
+
restoreJourney(serialized, config) {
|
|
719
|
+
// Refuse double-registration: leave an existing journey under this name in place.
|
|
720
|
+
if (journeys.has(config.name)) {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const journey = deserializeJourneyState(serialized, config, api, defaultJourneyIdleMs);
|
|
725
|
+
|
|
726
|
+
if (journey.timeoutMs > 0) {
|
|
727
|
+
const elapsedMs = globalThis.performance.now() - journey.startedAt;
|
|
728
|
+
if (elapsedMs >= journey.timeoutMs) {
|
|
729
|
+
// journey not yet in journeys Map — finish() handles unregistered journeys gracefully.
|
|
730
|
+
if (journey.timeoutExplicit) {
|
|
731
|
+
finish(journey, 'bad', 'journey-timeout');
|
|
732
|
+
} else {
|
|
733
|
+
finish(
|
|
734
|
+
journey,
|
|
735
|
+
journey.verdict === 'bad' ? 'bad' : 'excluded',
|
|
736
|
+
'journey-idle-timeout'
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
journeys.set(config.name, journey);
|
|
743
|
+
|
|
744
|
+
const remainingMs = journey.timeoutMs - elapsedMs;
|
|
745
|
+
if (remainingMs > 0) {
|
|
746
|
+
armCountdown(journey, remainingMs);
|
|
747
|
+
}
|
|
748
|
+
} else {
|
|
749
|
+
journeys.set(config.name, journey);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return journey;
|
|
753
|
+
},
|
|
754
|
+
|
|
688
755
|
activeJourneyStep() {
|
|
689
756
|
if (!api.getAutoAttributeRequests()) {
|
|
690
757
|
return null;
|
package/src/core/sanitize.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { isUnsafeKey, MAX_ATTRIBUTE_KEYS, MAX_TAG_KEYS, MAX_TAG_VALUE_LEN } from './limits';
|
|
2
2
|
import type { JourneyEvent, JourneyStepEvent, TagValue } from './types';
|
|
3
|
+
import {
|
|
4
|
+
MAX_CORRELATED_REQUESTS,
|
|
5
|
+
MAX_JOURNEY_CORRELATED_REQUESTS,
|
|
6
|
+
sanitizeRequestCorrelation,
|
|
7
|
+
} from './request-correlation';
|
|
3
8
|
|
|
4
9
|
const SANITIZED_EVENT = Symbol.for('st.journey.sanitized');
|
|
5
10
|
|
|
@@ -124,10 +129,20 @@ export function setSafeAttribute(
|
|
|
124
129
|
}
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
function sanitizeStep(step: JourneyStepEvent): JourneyStepEvent {
|
|
132
|
+
function sanitizeStep(step: JourneyStepEvent, includeRequests: boolean): JourneyStepEvent {
|
|
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
|
+
: [];
|
|
128
141
|
const name = normalizeTagValue(step.name);
|
|
129
142
|
return {
|
|
130
|
-
...
|
|
143
|
+
...rest,
|
|
144
|
+
...(requests.length ? { requests } : {}),
|
|
145
|
+
...(includeRequests && requestsTruncated === true ? { requestsTruncated: true } : {}),
|
|
131
146
|
name: name || step.name,
|
|
132
147
|
...(step.reason ? { reason: normalizeTagValue(step.reason) } : {}),
|
|
133
148
|
...(step.attributes
|
|
@@ -149,6 +164,28 @@ export function sanitizeJourneyEvent(event: JourneyEvent): JourneyEvent {
|
|
|
149
164
|
? copyRecord(j.tags, { maxKeys: MAX_TAG_KEYS, normalizeKeys: true })
|
|
150
165
|
: undefined;
|
|
151
166
|
const tagKeys = tags ? Object.keys(tags) : [];
|
|
167
|
+
const steps = j.steps.map(step => sanitizeStep(step, j.outcome === 'bad'));
|
|
168
|
+
// Prefer recently started steps at the cap; failedRequest preserves the scoring request separately.
|
|
169
|
+
let requestBudget = MAX_JOURNEY_CORRELATED_REQUESTS;
|
|
170
|
+
for (let index = steps.length - 1; index >= 0; index--) {
|
|
171
|
+
const step = steps[index];
|
|
172
|
+
if (!step.requests) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (step.requests.length > requestBudget) {
|
|
176
|
+
step.requestsTruncated = true;
|
|
177
|
+
if (requestBudget === 0) {
|
|
178
|
+
delete step.requests;
|
|
179
|
+
} else {
|
|
180
|
+
step.requests = step.requests.slice(-requestBudget);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
requestBudget -= step.requests?.length ?? 0;
|
|
184
|
+
}
|
|
185
|
+
const failedRequest =
|
|
186
|
+
j.outcome === 'bad' && j.failedRequest
|
|
187
|
+
? sanitizeRequestCorrelation(j.failedRequest)
|
|
188
|
+
: undefined;
|
|
152
189
|
const sanitized: JourneyEvent = {
|
|
153
190
|
journey: {
|
|
154
191
|
name: normalizeTagValue(j.name),
|
|
@@ -156,9 +193,10 @@ export function sanitizeJourneyEvent(event: JourneyEvent): JourneyEvent {
|
|
|
156
193
|
group: normalizeTagValue(j.group),
|
|
157
194
|
service: normalizeTagValue(j.service),
|
|
158
195
|
outcome: j.outcome,
|
|
196
|
+
...(failedRequest ? { failedRequest } : {}),
|
|
159
197
|
...(j.reason ? { reason: normalizeTagValue(j.reason) } : {}),
|
|
160
198
|
durationMs: j.durationMs,
|
|
161
|
-
steps
|
|
199
|
+
steps,
|
|
162
200
|
...(expected?.length ? { expected } : {}),
|
|
163
201
|
...(tagKeys.length ? { tags } : {}),
|
|
164
202
|
},
|
package/src/core/types.ts
CHANGED
|
@@ -134,6 +134,16 @@ export interface JourneyState {
|
|
|
134
134
|
/** Frozen snapshot taken at start — not the caller's original object. */
|
|
135
135
|
config: JourneyDef;
|
|
136
136
|
[JOURNEY_ENGINE]?: JourneyEngine;
|
|
137
|
+
/** Present only during synchronous HTTP scoring, so finish can identify the causing request. */
|
|
138
|
+
scoringRequest?: JourneyRequestEvent;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Response correlation for production monolith logs. Never a metric tag. */
|
|
142
|
+
export interface JourneyRequestEvent {
|
|
143
|
+
rayId: string;
|
|
144
|
+
startedAtEpoch: number;
|
|
145
|
+
durationMs: number;
|
|
146
|
+
status: number;
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
/** @internal Engine in-flight / completed step record — not part of the supported public API. */
|
|
@@ -150,6 +160,9 @@ export interface StepRecord {
|
|
|
150
160
|
onHttpAborted?: boolean;
|
|
151
161
|
/** @internal Request keys attributed while this step was in flight (debug). */
|
|
152
162
|
attributedRequestKeys?: string[];
|
|
163
|
+
/** Bounded response metadata; no URLs, bodies, or other headers. */
|
|
164
|
+
requests?: JourneyRequestEvent[];
|
|
165
|
+
requestsTruncated?: boolean;
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
export interface JourneyStepEvent {
|
|
@@ -161,6 +174,35 @@ export interface JourneyStepEvent {
|
|
|
161
174
|
reason?: string;
|
|
162
175
|
httpStatus?: number;
|
|
163
176
|
attributes?: Record<string, TagValue>;
|
|
177
|
+
/** Correlated requests on bad journeys, not necessarily the cause of a business error. */
|
|
178
|
+
requests?: JourneyRequestEvent[];
|
|
179
|
+
requestsTruncated?: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Snapshot of an open journey that can survive a page navigation. Produced by
|
|
184
|
+
* `serializeJourneyState` and rehydrated by `restoreJourney`. Mirrors the
|
|
185
|
+
* `JourneyStepEvent` projection emitted by `finish()`, plus the wall-clock
|
|
186
|
+
* start time, verdict, reason, and tags needed to re-arm the countdown.
|
|
187
|
+
*/
|
|
188
|
+
export interface SerializedJourneyState {
|
|
189
|
+
/** `st_journey_id` tag — cross-page correlation key. Empty string when unset. */
|
|
190
|
+
stJourneyId: string;
|
|
191
|
+
/** Wall-clock epoch (Date.now()) when the journey started. */
|
|
192
|
+
startedAtEpoch: number;
|
|
193
|
+
verdict: 'good' | 'bad';
|
|
194
|
+
reason: string | null;
|
|
195
|
+
steps: {
|
|
196
|
+
name: string;
|
|
197
|
+
/** Offset from the journey start, ms. */
|
|
198
|
+
startMs: number;
|
|
199
|
+
durationMs: number;
|
|
200
|
+
outcome: 'good' | 'bad';
|
|
201
|
+
reason?: string;
|
|
202
|
+
httpStatus?: number;
|
|
203
|
+
attributes?: Record<string, TagValue>;
|
|
204
|
+
}[];
|
|
205
|
+
tags: Record<string, TagValue>;
|
|
164
206
|
}
|
|
165
207
|
|
|
166
208
|
/** Neutral journey event payload (journey-specific fields only; session context is host RUM globals). */
|
|
@@ -174,6 +216,8 @@ export interface JourneyEvent {
|
|
|
174
216
|
reason?: string;
|
|
175
217
|
durationMs: number;
|
|
176
218
|
steps: JourneyStepEvent[];
|
|
219
|
+
/** Present only when a correlated HTTP report synchronously closed the journey as bad. */
|
|
220
|
+
failedRequest?: JourneyRequestEvent;
|
|
177
221
|
expected?: string[];
|
|
178
222
|
tags?: Record<string, TagValue>;
|
|
179
223
|
};
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ export {
|
|
|
13
13
|
excludeJourney,
|
|
14
14
|
failJourney,
|
|
15
15
|
journeyStep,
|
|
16
|
+
restoreJourney,
|
|
17
|
+
serializeJourneyState,
|
|
16
18
|
setJourneyTags,
|
|
17
19
|
// Custom transport adapters / request attribution.
|
|
18
20
|
singleInFlightJourneyStep,
|
|
@@ -23,6 +25,7 @@ export {
|
|
|
23
25
|
type JourneyNameLike,
|
|
24
26
|
type JourneyStepOptions,
|
|
25
27
|
type JourneyStepTarget,
|
|
28
|
+
type SerializedJourneyState,
|
|
26
29
|
type StepHandle,
|
|
27
30
|
} from './core';
|
|
28
31
|
|
|
@@ -57,7 +60,7 @@ export {
|
|
|
57
60
|
export { sendToConsole } from './sinks/console';
|
|
58
61
|
export { normalizeTagValue, sendToDatadog, setJourneyRum } from './sinks/datadog';
|
|
59
62
|
|
|
60
|
-
export type { JourneyEvent, JourneyStepEvent, TagValue } from './core';
|
|
63
|
+
export type { JourneyEvent, JourneyRequestEvent, JourneyStepEvent, TagValue } from './core';
|
|
61
64
|
|
|
62
65
|
export { useJourneyName, useOptionalJourneyName } from './integrations/react/context';
|
|
63
66
|
export { JourneyScope } from './integrations/react/scope';
|
|
@@ -10,6 +10,7 @@ import { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime';
|
|
|
10
10
|
import type { JourneyStepStamp } from '../core/step-tag';
|
|
11
11
|
import type { StepRecord } from '../core/types';
|
|
12
12
|
import type { JourneyTimedMeta } from './request-key';
|
|
13
|
+
import { captureRequestCorrelation } from './request-correlation';
|
|
13
14
|
|
|
14
15
|
export interface HttpCompleteReport {
|
|
15
16
|
stamp: JourneyStepStamp | undefined;
|
|
@@ -18,6 +19,8 @@ export interface HttpCompleteReport {
|
|
|
18
19
|
requestKey: string;
|
|
19
20
|
aborted?: boolean;
|
|
20
21
|
url?: string;
|
|
22
|
+
/** Actual request base, used only to validate the log-correlation origin. */
|
|
23
|
+
baseURL?: string;
|
|
21
24
|
method?: string;
|
|
22
25
|
/** Per-request literal overrides from JourneyTimedMeta. */
|
|
23
26
|
meta?: JourneyTimedMeta;
|
|
@@ -73,19 +76,7 @@ function httpScoreReason(report: HttpCompleteReport): string {
|
|
|
73
76
|
* 4xx with policy `'continue'` (default) still scores latency.
|
|
74
77
|
*/
|
|
75
78
|
export function onHttpComplete(report: HttpCompleteReport): void {
|
|
76
|
-
const {
|
|
77
|
-
stamp,
|
|
78
|
-
status,
|
|
79
|
-
durationMs,
|
|
80
|
-
requestKey,
|
|
81
|
-
aborted,
|
|
82
|
-
url,
|
|
83
|
-
method,
|
|
84
|
-
meta,
|
|
85
|
-
request,
|
|
86
|
-
response,
|
|
87
|
-
error,
|
|
88
|
-
} = report;
|
|
79
|
+
const { stamp, requestKey, aborted, meta } = report;
|
|
89
80
|
if (!stamp) {
|
|
90
81
|
return;
|
|
91
82
|
}
|
|
@@ -124,6 +115,34 @@ export function onHttpComplete(report: HttpCompleteReport): void {
|
|
|
124
115
|
return;
|
|
125
116
|
}
|
|
126
117
|
|
|
118
|
+
// Capture before a policy/latency breach synchronously emits the terminal event.
|
|
119
|
+
const correlation = captureRequestCorrelation(report);
|
|
120
|
+
const previous = step.journey.scoringRequest;
|
|
121
|
+
// Scope this pointer to scoring. Later JS/business failures must not blame the last request.
|
|
122
|
+
step.journey.scoringRequest = correlation;
|
|
123
|
+
try {
|
|
124
|
+
scoreHttpComplete(report, step);
|
|
125
|
+
} finally {
|
|
126
|
+
step.journey.scoringRequest = previous;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function scoreHttpComplete(report: HttpCompleteReport, step: StepRecord): void {
|
|
131
|
+
const {
|
|
132
|
+
stamp,
|
|
133
|
+
status,
|
|
134
|
+
durationMs,
|
|
135
|
+
requestKey,
|
|
136
|
+
aborted,
|
|
137
|
+
url,
|
|
138
|
+
method,
|
|
139
|
+
meta,
|
|
140
|
+
request,
|
|
141
|
+
response,
|
|
142
|
+
error,
|
|
143
|
+
} = report;
|
|
144
|
+
const engine = resolveJourneyEngine(step.journey);
|
|
145
|
+
|
|
127
146
|
const ctx: JourneyHttpPolicyContext = {
|
|
128
147
|
requestKey,
|
|
129
148
|
status,
|
|
@@ -134,7 +153,7 @@ export function onHttpComplete(report: HttpCompleteReport): void {
|
|
|
134
153
|
stepName: step.name,
|
|
135
154
|
};
|
|
136
155
|
|
|
137
|
-
const score = stamp
|
|
156
|
+
const score = stamp?.score;
|
|
138
157
|
if (score) {
|
|
139
158
|
let policy: JourneyRequestPolicy | undefined;
|
|
140
159
|
try {
|
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
registerInstrumentRecord,
|
|
6
6
|
type InstrumentRecord,
|
|
7
7
|
} from '../../global';
|
|
8
|
-
import { onHttpComplete } from '../http-report';
|
|
8
|
+
import { onHttpComplete, type HttpCompleteReport } from '../http-report';
|
|
9
|
+
import { captureRequestCorrelation } from '../request-correlation';
|
|
9
10
|
import { requestKey, type JourneyTimedMeta } from '../request-key';
|
|
10
11
|
import { acceptInstrumentTarget } from '../soft-skip';
|
|
11
12
|
|
|
@@ -14,6 +15,8 @@ export interface JqueryXHR {
|
|
|
14
15
|
statusText?: string;
|
|
15
16
|
stamp?: unknown;
|
|
16
17
|
journeyStart?: number;
|
|
18
|
+
getResponseHeader?(name: string): string | null;
|
|
19
|
+
always?(callback: () => void): unknown;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export interface JqueryAjaxSettings extends JourneyTimedMeta {
|
|
@@ -39,7 +42,10 @@ export interface JqueryStatic {
|
|
|
39
42
|
};
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
function
|
|
45
|
+
function timingReport(
|
|
46
|
+
jqXHR: JqueryXHR,
|
|
47
|
+
settings: JqueryAjaxSettings
|
|
48
|
+
): HttpCompleteReport | undefined {
|
|
43
49
|
const stamp = jqueryStamps.get(jqXHR) ?? adoptJourneyStepStamp(jqXHR.stamp);
|
|
44
50
|
if (!stamp || stamp.ignore || settings.ignore === true) {
|
|
45
51
|
return;
|
|
@@ -48,7 +54,7 @@ function reportTiming(jqXHR: JqueryXHR, settings: JqueryAjaxSettings): void {
|
|
|
48
54
|
const aborted = jqXHR.statusText === 'abort';
|
|
49
55
|
// jQuery uses status 0 for network / abort failures — treat like a missing status.
|
|
50
56
|
const status = aborted || jqXHR.status === 0 ? undefined : jqXHR.status;
|
|
51
|
-
|
|
57
|
+
return {
|
|
52
58
|
stamp,
|
|
53
59
|
status,
|
|
54
60
|
durationMs: globalThis.performance.now() - start,
|
|
@@ -59,7 +65,7 @@ function reportTiming(jqXHR: JqueryXHR, settings: JqueryAjaxSettings): void {
|
|
|
59
65
|
meta: settings,
|
|
60
66
|
request: settings,
|
|
61
67
|
response: jqXHR,
|
|
62
|
-
}
|
|
68
|
+
};
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
function isJqueryStatic(value: unknown): value is JqueryStatic {
|
|
@@ -118,6 +124,29 @@ export function instrumentJquery($: JqueryStatic | null | undefined): () => void
|
|
|
118
124
|
return;
|
|
119
125
|
}
|
|
120
126
|
stampAjax(jqXHR, options);
|
|
127
|
+
/*
|
|
128
|
+
* Prefilters run before application success/error callbacks; ajaxComplete runs after.
|
|
129
|
+
* Capture headers here, but keep verdict scoring in ajaxComplete as before.
|
|
130
|
+
*/
|
|
131
|
+
try {
|
|
132
|
+
if (jqueryStamps.has(jqXHR)) {
|
|
133
|
+
jqXHR.always?.(() => {
|
|
134
|
+
if (current.disposed) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const report = timingReport(jqXHR, options);
|
|
139
|
+
if (report) {
|
|
140
|
+
captureRequestCorrelation(report);
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// This observer must never interrupt application callbacks.
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Partial jQuery-compatible clients may not support response observers.
|
|
149
|
+
}
|
|
121
150
|
});
|
|
122
151
|
jqueryPrefiltersInstalled.add(key);
|
|
123
152
|
}
|
|
@@ -127,7 +156,10 @@ export function instrumentJquery($: JqueryStatic | null | undefined): () => void
|
|
|
127
156
|
if (!current || current.disposed) {
|
|
128
157
|
return;
|
|
129
158
|
}
|
|
130
|
-
|
|
159
|
+
const report = timingReport(jqXHR, settings);
|
|
160
|
+
if (report) {
|
|
161
|
+
onHttpComplete(report);
|
|
162
|
+
}
|
|
131
163
|
});
|
|
132
164
|
jqueryCompletesInstalled.add(key);
|
|
133
165
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { MAX_CORRELATED_REQUESTS, sanitizeRequestCorrelation } from '../core/request-correlation';
|
|
2
|
+
import { canRouteHttpToJourney, resolveJourneyEngine } from '../core/runtime';
|
|
3
|
+
import type { HttpCompleteReport } from './http-report';
|
|
4
|
+
import type { JourneyRequestEvent } from '../core/types';
|
|
5
|
+
|
|
6
|
+
const MONOLITH_ORIGIN = 'https://go.servicetitan.com';
|
|
7
|
+
const capturedResponses = new WeakMap<object, JourneyRequestEvent>();
|
|
8
|
+
|
|
9
|
+
interface ResponseHeaders {
|
|
10
|
+
headers?: { 'cf-ray'?: unknown; 'get'?(name: string): unknown };
|
|
11
|
+
url?: string;
|
|
12
|
+
responseURL?: string;
|
|
13
|
+
request?: { responseURL?: string };
|
|
14
|
+
getResponseHeader?(name: string): unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Observation only. Safe to run before jQuery invokes application callbacks. */
|
|
18
|
+
export function captureRequestCorrelation(
|
|
19
|
+
report: HttpCompleteReport
|
|
20
|
+
): JourneyRequestEvent | undefined {
|
|
21
|
+
try {
|
|
22
|
+
const step = report.stamp?.step;
|
|
23
|
+
if (
|
|
24
|
+
!step?.journey ||
|
|
25
|
+
step.journey.closed ||
|
|
26
|
+
!canRouteHttpToJourney(step.journey) ||
|
|
27
|
+
report.stamp?.ignore ||
|
|
28
|
+
report.meta?.ignore ||
|
|
29
|
+
report.aborted ||
|
|
30
|
+
!report.url ||
|
|
31
|
+
resolveJourneyEngine(step.journey).shouldIgnoreRequest(report.requestKey)
|
|
32
|
+
) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Other environments/backends have not verified this log-field/data-view contract.
|
|
37
|
+
const pageUrl = globalThis.location?.href;
|
|
38
|
+
const base = report.baseURL ? new URL(report.baseURL, pageUrl).href : pageUrl;
|
|
39
|
+
const url = new URL(report.url, base);
|
|
40
|
+
if (url.origin !== MONOLITH_ORIGIN) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const response = report.response as ResponseHeaders | undefined;
|
|
44
|
+
if (!response || typeof response !== 'object') {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const captured = capturedResponses.get(response);
|
|
48
|
+
if (captured) {
|
|
49
|
+
return step.requests?.includes(captured) ? captured : undefined;
|
|
50
|
+
}
|
|
51
|
+
const finalUrl = response.url || response.responseURL || response.request?.responseURL;
|
|
52
|
+
if (finalUrl && new URL(finalUrl).origin !== MONOLITH_ORIGIN) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const rayId =
|
|
56
|
+
typeof response.getResponseHeader === 'function'
|
|
57
|
+
? response.getResponseHeader('cf-ray')
|
|
58
|
+
: typeof response.headers?.get === 'function'
|
|
59
|
+
? response.headers.get('cf-ray')
|
|
60
|
+
: response.headers?.['cf-ray'];
|
|
61
|
+
if (typeof rayId !== 'string') {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const request = sanitizeRequestCorrelation({
|
|
65
|
+
rayId,
|
|
66
|
+
startedAtEpoch: Date.now() - report.durationMs,
|
|
67
|
+
durationMs: report.durationMs,
|
|
68
|
+
status: report.status as number,
|
|
69
|
+
});
|
|
70
|
+
if (!request) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
capturedResponses.set(response, request);
|
|
74
|
+
step.requests ??= [];
|
|
75
|
+
const requests = step.requests;
|
|
76
|
+
if (requests.length >= MAX_CORRELATED_REQUESTS) {
|
|
77
|
+
step.requestsTruncated = true;
|
|
78
|
+
// Keep errors when later successes arrive. A new error replaces a success first.
|
|
79
|
+
const replace = requests.findIndex(item => item.status < 400);
|
|
80
|
+
if (replace < 0 && request.status < 400) {
|
|
81
|
+
return request;
|
|
82
|
+
}
|
|
83
|
+
requests.splice(replace < 0 ? 0 : replace, 1);
|
|
84
|
+
}
|
|
85
|
+
requests.push(request);
|
|
86
|
+
return request;
|
|
87
|
+
} catch {
|
|
88
|
+
// Missing/blocked headers and unusual transport objects must not affect requests.
|
|
89
|
+
}
|
|
90
|
+
}
|
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 { requestLogsUrl } from './kibana';
|
|
4
5
|
|
|
5
6
|
export { normalizeTagValue } from '../core/sanitize';
|
|
6
7
|
|
|
@@ -54,6 +55,18 @@ function toDatadogStep(step: JourneyStepEvent) {
|
|
|
54
55
|
outcome: step.outcome,
|
|
55
56
|
...(step.reason ? { reason: step.reason } : {}),
|
|
56
57
|
...(step.httpStatus !== undefined ? { http_status: step.httpStatus } : {}),
|
|
58
|
+
...(step.requests?.length
|
|
59
|
+
? {
|
|
60
|
+
requests: step.requests.map(request => ({
|
|
61
|
+
request_id: request.rayId,
|
|
62
|
+
started_at: new Date(request.startedAtEpoch).toISOString(),
|
|
63
|
+
duration_ms: request.durationMs,
|
|
64
|
+
http_status: request.status,
|
|
65
|
+
kibana_url: requestLogsUrl(request),
|
|
66
|
+
})),
|
|
67
|
+
}
|
|
68
|
+
: {}),
|
|
69
|
+
...(step.requestsTruncated ? { requests_truncated: true } : {}),
|
|
57
70
|
...(step.attributes && Object.keys(step.attributes).length
|
|
58
71
|
? { attributes: step.attributes }
|
|
59
72
|
: {}),
|
|
@@ -79,6 +92,12 @@ export function sendToDatadog(payload: JourneyEvent): void {
|
|
|
79
92
|
group: j.group,
|
|
80
93
|
service: j.service,
|
|
81
94
|
outcome: j.outcome,
|
|
95
|
+
...(j.failedRequest
|
|
96
|
+
? {
|
|
97
|
+
failed_request_id: j.failedRequest.rayId,
|
|
98
|
+
kibana_url: requestLogsUrl(j.failedRequest),
|
|
99
|
+
}
|
|
100
|
+
: {}),
|
|
82
101
|
...(j.reason ? { reason: j.reason } : {}),
|
|
83
102
|
duration_ms: j.durationMs,
|
|
84
103
|
steps: j.steps.map(toDatadogStep),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { sanitizeRequestCorrelation } from '../core/request-correlation';
|
|
2
|
+
import type { JourneyRequestEvent } from '../core/types';
|
|
3
|
+
|
|
4
|
+
const DISCOVER_URL = 'https://kibana.st.dev/app/discover#/';
|
|
5
|
+
// Production monolith logs; deliberately not the user's default data view.
|
|
6
|
+
const DATA_VIEW_ID = '894110ed-c22d-449c-9dc2-c4816f08712a';
|
|
7
|
+
const WINDOW_PADDING_MS = 60_000;
|
|
8
|
+
|
|
9
|
+
/** Fixed destination and validated Ray ID only — no request URL, query string or user data. */
|
|
10
|
+
export function requestLogsUrl(value: JourneyRequestEvent): string | undefined {
|
|
11
|
+
const request = sanitizeRequestCorrelation(value);
|
|
12
|
+
if (!request) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const from = new Date(request.startedAtEpoch - WINDOW_PADDING_MS).toISOString();
|
|
16
|
+
const to = new Date(
|
|
17
|
+
request.startedAtEpoch + request.durationMs + WINDOW_PADDING_MS
|
|
18
|
+
).toISOString();
|
|
19
|
+
// Origin/edge datacenter suffixes can differ. Match the hex prefix within the request window.
|
|
20
|
+
const rayPrefix = request.rayId.slice(0, 16);
|
|
21
|
+
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}*'))`;
|
|
23
|
+
return `${DISCOVER_URL}?_g=${encodeURIComponent(globalState)}&_a=${encodeURIComponent(appState)}`;
|
|
24
|
+
}
|