@servicetitan/journey 3.0.0 → 4.0.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/dist/__tests__/core/persist.test.d.ts +2 -0
- package/dist/__tests__/core/persist.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/runtime.d.ts +16 -1
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +56 -21
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/types.d.ts +25 -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 +1 -1
- package/dist/index.js.map +1 -1
- package/dist/sinks/datadog.d.ts +1 -1
- package/dist/sinks/datadog.d.ts.map +1 -1
- package/dist/sinks/datadog.js +4 -10
- package/dist/sinks/datadog.js.map +1 -1
- package/package.json +2 -4
- package/src/__tests__/core/persist.test.ts +496 -0
- package/src/__tests__/sinks/datadog.test.ts +2 -16
- package/src/core/api.ts +19 -0
- package/src/core/index.ts +3 -0
- package/src/core/persist.ts +131 -0
- package/src/core/runtime.ts +62 -2
- package/src/core/types.ts +26 -0
- package/src/index.ts +3 -0
- package/src/sinks/datadog.ts +14 -14
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampMaxSteps,
|
|
3
|
+
finiteSlowRequestMs,
|
|
4
|
+
finiteTimeoutMs,
|
|
5
|
+
MAX_ATTRIBUTE_KEYS,
|
|
6
|
+
MAX_TAG_KEYS,
|
|
7
|
+
resolveJourneyTimeoutMs,
|
|
8
|
+
} from './limits';
|
|
9
|
+
import { assignRecord, emptyRecord } from './sanitize';
|
|
10
|
+
import { bindJourneyEngine, freezeJourneyDef, type JourneyRuntime } from './runtime';
|
|
11
|
+
import {
|
|
12
|
+
JOURNEY_ENGINE,
|
|
13
|
+
type JourneyDef,
|
|
14
|
+
type JourneyState,
|
|
15
|
+
type SerializedJourneyState,
|
|
16
|
+
type StepRecord,
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @internal Project an open {@link JourneyState} into a plain, JSON-safe
|
|
21
|
+
* {@link SerializedJourneyState} that can ride a cookie across page navigations.
|
|
22
|
+
* The step projection mirrors `finish()`; `startedAt` (a `performance.now()`
|
|
23
|
+
* monotonic stamp) is bridged to a wall-clock epoch so it survives the page
|
|
24
|
+
* load where `performance.now()` resets.
|
|
25
|
+
*/
|
|
26
|
+
export function projectJourneyState(journey: JourneyState): SerializedJourneyState {
|
|
27
|
+
const steps = journey.steps.map(s => ({
|
|
28
|
+
name: s.name,
|
|
29
|
+
startMs: Math.round(s.startedAt - journey.startedAt),
|
|
30
|
+
durationMs: s.durationMs || Math.round(globalThis.performance.now() - s.startedAt),
|
|
31
|
+
outcome: s.outcome,
|
|
32
|
+
...(s.reason ? { reason: s.reason } : {}),
|
|
33
|
+
...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}),
|
|
34
|
+
// Attributes are raw here — final emission sanitization happens in finish().
|
|
35
|
+
...(Object.keys(s.attributes).length ? { attributes: { ...s.attributes } } : {}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const startedAtEpoch = Date.now() - (globalThis.performance.now() - journey.startedAt);
|
|
39
|
+
// Coerce any non-null st_journey_id tag to a string so a numeric/boolean id survives the round-trip.
|
|
40
|
+
const stJourneyId =
|
|
41
|
+
journey.tags.st_journey_id != null ? String(journey.tags.st_journey_id) : '';
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
stJourneyId,
|
|
45
|
+
startedAtEpoch,
|
|
46
|
+
// Open journeys are never 'excluded' — that verdict is only set inside finish().
|
|
47
|
+
verdict: journey.verdict === 'bad' ? 'bad' : 'good',
|
|
48
|
+
reason: journey.reason,
|
|
49
|
+
steps,
|
|
50
|
+
tags: { ...journey.tags },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @internal Rehydrate a {@link SerializedJourneyState} into a live
|
|
56
|
+
* {@link JourneyState} bound to the supplied runtime. `startedAt` is rebased
|
|
57
|
+
* from the wall-clock epoch back to a `performance.now()` monotonic stamp,
|
|
58
|
+
* `StepRecord[]` is rebuilt with re-linked circular references, and the
|
|
59
|
+
* `JOURNEY_ENGINE` hook is re-bound so HTTP attribution works on the new page.
|
|
60
|
+
*/
|
|
61
|
+
export function deserializeJourneyState(
|
|
62
|
+
serialized: SerializedJourneyState,
|
|
63
|
+
config: JourneyDef,
|
|
64
|
+
api: JourneyRuntime,
|
|
65
|
+
defaultJourneyIdleMs: number
|
|
66
|
+
): JourneyState {
|
|
67
|
+
const tags = emptyRecord();
|
|
68
|
+
// Caller config tags win; serialized tags fill the rest (incl. st_journey_id).
|
|
69
|
+
assignRecord(tags, serialized.tags, MAX_TAG_KEYS);
|
|
70
|
+
assignRecord(tags, config.tags, MAX_TAG_KEYS);
|
|
71
|
+
if (serialized.stJourneyId && tags.st_journey_id === undefined) {
|
|
72
|
+
tags.st_journey_id = serialized.stJourneyId;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const snapshot = freezeJourneyDef(config, tags);
|
|
76
|
+
const timeout = resolveJourneyTimeoutMs(snapshot.timeoutMs, defaultJourneyIdleMs);
|
|
77
|
+
|
|
78
|
+
const startedAt = globalThis.performance.now() - (Date.now() - serialized.startedAtEpoch);
|
|
79
|
+
|
|
80
|
+
const journey: JourneyState = {
|
|
81
|
+
config: snapshot,
|
|
82
|
+
name: snapshot.name,
|
|
83
|
+
team: snapshot.team,
|
|
84
|
+
group: snapshot.group,
|
|
85
|
+
service: snapshot.service,
|
|
86
|
+
verdict: serialized.verdict === 'bad' ? 'bad' : 'good',
|
|
87
|
+
startedAt,
|
|
88
|
+
timeoutMs: timeout.ms,
|
|
89
|
+
timeoutExplicit: timeout.explicit,
|
|
90
|
+
stepTimeoutMs:
|
|
91
|
+
snapshot.stepTimeoutMs !== undefined
|
|
92
|
+
? finiteTimeoutMs(snapshot.stepTimeoutMs)
|
|
93
|
+
: undefined,
|
|
94
|
+
reason: serialized.reason,
|
|
95
|
+
steps: [],
|
|
96
|
+
expected: snapshot.expected ? [...snapshot.expected] : null,
|
|
97
|
+
tags,
|
|
98
|
+
slowRequestMs: finiteSlowRequestMs(snapshot.slowRequestMs),
|
|
99
|
+
endpoints: snapshot.endpoints,
|
|
100
|
+
timer: null,
|
|
101
|
+
closed: false,
|
|
102
|
+
maxSteps: snapshot.maxSteps !== undefined ? clampMaxSteps(snapshot.maxSteps) : undefined,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Rebuild StepRecord[] — re-link the circular `step.journey` reference and rebase `startedAt` from the offset.
|
|
106
|
+
for (const s of serialized.steps) {
|
|
107
|
+
const step: StepRecord = {
|
|
108
|
+
name: s.name,
|
|
109
|
+
startedAt: journey.startedAt + s.startMs,
|
|
110
|
+
durationMs: s.durationMs,
|
|
111
|
+
outcome: s.outcome,
|
|
112
|
+
attributes: emptyRecord(),
|
|
113
|
+
journey,
|
|
114
|
+
};
|
|
115
|
+
if (s.reason !== undefined) {
|
|
116
|
+
step.reason = s.reason;
|
|
117
|
+
}
|
|
118
|
+
if (s.httpStatus !== undefined) {
|
|
119
|
+
step.httpStatus = s.httpStatus;
|
|
120
|
+
}
|
|
121
|
+
if (s.attributes) {
|
|
122
|
+
assignRecord(step.attributes, s.attributes, MAX_ATTRIBUTE_KEYS);
|
|
123
|
+
}
|
|
124
|
+
journey.steps.push(step);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Re-bind the engine so HTTP attribution / fail-fast works on this page.
|
|
128
|
+
journey[JOURNEY_ENGINE] = bindJourneyEngine(api, journey, api.shouldIgnoreRequest);
|
|
129
|
+
|
|
130
|
+
return journey;
|
|
131
|
+
}
|
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
|
|
@@ -685,6 +700,51 @@ export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): Journ
|
|
|
685
700
|
armCountdown(journey, remainingMs);
|
|
686
701
|
},
|
|
687
702
|
|
|
703
|
+
serializeJourneyState(name) {
|
|
704
|
+
const journey = journeys.get(name);
|
|
705
|
+
if (!journey || journey.closed) {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
return projectJourneyState(journey);
|
|
709
|
+
},
|
|
710
|
+
|
|
711
|
+
restoreJourney(serialized, config) {
|
|
712
|
+
// Refuse double-registration: leave an existing journey under this name in place.
|
|
713
|
+
if (journeys.has(config.name)) {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const journey = deserializeJourneyState(serialized, config, api, defaultJourneyIdleMs);
|
|
718
|
+
|
|
719
|
+
if (journey.timeoutMs > 0) {
|
|
720
|
+
const elapsedMs = globalThis.performance.now() - journey.startedAt;
|
|
721
|
+
if (elapsedMs >= journey.timeoutMs) {
|
|
722
|
+
// journey not yet in journeys Map — finish() handles unregistered journeys gracefully.
|
|
723
|
+
if (journey.timeoutExplicit) {
|
|
724
|
+
finish(journey, 'bad', 'journey-timeout');
|
|
725
|
+
} else {
|
|
726
|
+
finish(
|
|
727
|
+
journey,
|
|
728
|
+
journey.verdict === 'bad' ? 'bad' : 'excluded',
|
|
729
|
+
'journey-idle-timeout'
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
journeys.set(config.name, journey);
|
|
736
|
+
|
|
737
|
+
const remainingMs = journey.timeoutMs - elapsedMs;
|
|
738
|
+
if (remainingMs > 0) {
|
|
739
|
+
armCountdown(journey, remainingMs);
|
|
740
|
+
}
|
|
741
|
+
} else {
|
|
742
|
+
journeys.set(config.name, journey);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return journey;
|
|
746
|
+
},
|
|
747
|
+
|
|
688
748
|
activeJourneyStep() {
|
|
689
749
|
if (!api.getAutoAttributeRequests()) {
|
|
690
750
|
return null;
|
package/src/core/types.ts
CHANGED
|
@@ -163,6 +163,32 @@ export interface JourneyStepEvent {
|
|
|
163
163
|
attributes?: Record<string, TagValue>;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Snapshot of an open journey that can survive a page navigation. Produced by
|
|
168
|
+
* `serializeJourneyState` and rehydrated by `restoreJourney`. Mirrors the
|
|
169
|
+
* `JourneyStepEvent` projection emitted by `finish()`, plus the wall-clock
|
|
170
|
+
* start time, verdict, reason, and tags needed to re-arm the countdown.
|
|
171
|
+
*/
|
|
172
|
+
export interface SerializedJourneyState {
|
|
173
|
+
/** `st_journey_id` tag — cross-page correlation key. Empty string when unset. */
|
|
174
|
+
stJourneyId: string;
|
|
175
|
+
/** Wall-clock epoch (Date.now()) when the journey started. */
|
|
176
|
+
startedAtEpoch: number;
|
|
177
|
+
verdict: 'good' | 'bad';
|
|
178
|
+
reason: string | null;
|
|
179
|
+
steps: {
|
|
180
|
+
name: string;
|
|
181
|
+
/** Offset from the journey start, ms. */
|
|
182
|
+
startMs: number;
|
|
183
|
+
durationMs: number;
|
|
184
|
+
outcome: 'good' | 'bad';
|
|
185
|
+
reason?: string;
|
|
186
|
+
httpStatus?: number;
|
|
187
|
+
attributes?: Record<string, TagValue>;
|
|
188
|
+
}[];
|
|
189
|
+
tags: Record<string, TagValue>;
|
|
190
|
+
}
|
|
191
|
+
|
|
166
192
|
/** Neutral journey event payload (journey-specific fields only; session context is host RUM globals). */
|
|
167
193
|
export interface JourneyEvent {
|
|
168
194
|
journey: {
|
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
|
|
package/src/sinks/datadog.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { datadogRum } from '@datadog/browser-rum';
|
|
2
|
-
|
|
3
1
|
import { isSanitizedJourneyEvent, sanitizeJourneyEvent } from '../core/sanitize';
|
|
4
2
|
import { getJourneyRumFallback, setJourneyRumFallback } from '../global';
|
|
5
3
|
import type { JourneyEvent, JourneyStepEvent } from '../core/types';
|
|
@@ -10,8 +8,16 @@ const JOURNEY_ACTION = 'user_journey';
|
|
|
10
8
|
|
|
11
9
|
const DD_RUM_KEY = 'DD_RUM';
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Not imported from @datadog/browser-rum on purpose: importing it sets globalThis.DD_RUM at
|
|
13
|
+
* module load, which lets an MFE bundle replace the host's initialized RUM instance.
|
|
14
|
+
*/
|
|
15
|
+
interface RumLike {
|
|
16
|
+
addAction(name: string, context?: object): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getDdRum(): RumLike | undefined {
|
|
20
|
+
return (globalThis as Record<string, unknown>)[DD_RUM_KEY] as RumLike | undefined;
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
/**
|
|
@@ -20,7 +26,7 @@ function getDdRum(): typeof datadogRum | undefined {
|
|
|
20
26
|
*
|
|
21
27
|
* Prefer exposing the initialized instance as `globalThis.DD_RUM` (ST host:
|
|
22
28
|
* `datadogGuard`). `sendToDatadog` resolves `DD_RUM` first, then `__stJourney.rum`
|
|
23
|
-
* (this fallback)
|
|
29
|
+
* (this fallback); with neither present the event is dropped.
|
|
24
30
|
*
|
|
25
31
|
* Calling `setJourneyRum` while `globalThis.DD_RUM` is already set logs a warning —
|
|
26
32
|
* the call is redundant because `sendToDatadog` prefers `DD_RUM`.
|
|
@@ -35,13 +41,8 @@ export function setJourneyRum(rum: unknown): void {
|
|
|
35
41
|
setJourneyRumFallback(rum);
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
function resolveRum():
|
|
39
|
-
|
|
40
|
-
if (ddRum) {
|
|
41
|
-
return ddRum;
|
|
42
|
-
}
|
|
43
|
-
const shared = getJourneyRumFallback();
|
|
44
|
-
return (shared as typeof datadogRum | undefined) ?? datadogRum;
|
|
44
|
+
function resolveRum(): RumLike | undefined {
|
|
45
|
+
return getDdRum() ?? (getJourneyRumFallback() as RumLike | undefined);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
function toDatadogStep(step: JourneyStepEvent) {
|
|
@@ -63,8 +64,7 @@ function toDatadogStep(step: JourneyStepEvent) {
|
|
|
63
64
|
/** Ship one terminal journey as a RUM custom action (`user_journey`). */
|
|
64
65
|
export function sendToDatadog(payload: JourneyEvent): void {
|
|
65
66
|
const rum = resolveRum();
|
|
66
|
-
|
|
67
|
-
if (typeof rum.addAction !== 'function') {
|
|
67
|
+
if (!rum || typeof rum.addAction !== 'function') {
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
70
|
|