@servicetitan/journey 1.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/README.md +7 -0
- package/dist/__mocks__/test-runtime.d.ts +16 -0
- package/dist/__mocks__/test-runtime.d.ts.map +1 -0
- package/dist/__tests__/ambient.test.d.ts +2 -0
- package/dist/__tests__/ambient.test.d.ts.map +1 -0
- package/dist/__tests__/axios.test.d.ts +2 -0
- package/dist/__tests__/axios.test.d.ts.map +1 -0
- package/dist/__tests__/config.test.d.ts +2 -0
- package/dist/__tests__/config.test.d.ts.map +1 -0
- package/dist/__tests__/context.test.d.ts +2 -0
- package/dist/__tests__/context.test.d.ts.map +1 -0
- package/dist/__tests__/datadog.test.d.ts +2 -0
- package/dist/__tests__/datadog.test.d.ts.map +1 -0
- package/dist/__tests__/define-journey.test.d.ts +2 -0
- package/dist/__tests__/define-journey.test.d.ts.map +1 -0
- package/dist/__tests__/expose-app-journey.test.d.ts +2 -0
- package/dist/__tests__/expose-app-journey.test.d.ts.map +1 -0
- package/dist/__tests__/fetch.test.d.ts +2 -0
- package/dist/__tests__/fetch.test.d.ts.map +1 -0
- package/dist/__tests__/jquery.test.d.ts +2 -0
- package/dist/__tests__/jquery.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/request-key.test.d.ts +2 -0
- package/dist/__tests__/request-key.test.d.ts.map +1 -0
- package/dist/__tests__/requests.test.d.ts +2 -0
- package/dist/__tests__/requests.test.d.ts.map +1 -0
- package/dist/__tests__/runtime.test.d.ts +2 -0
- package/dist/__tests__/runtime.test.d.ts.map +1 -0
- package/dist/__tests__/scope.test.d.ts +2 -0
- package/dist/__tests__/scope.test.d.ts.map +1 -0
- package/dist/__tests__/sinks.test.d.ts +2 -0
- package/dist/__tests__/sinks.test.d.ts.map +1 -0
- package/dist/__tests__/step.test.d.ts +2 -0
- package/dist/__tests__/step.test.d.ts.map +1 -0
- package/dist/__tests__/timeouts.test.d.ts +2 -0
- package/dist/__tests__/timeouts.test.d.ts.map +1 -0
- package/dist/config.d.ts +42 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +46 -0
- package/dist/config.js.map +1 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +5 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/registry.d.ts +50 -0
- package/dist/core/registry.d.ts.map +1 -0
- package/dist/core/registry.js +102 -0
- package/dist/core/registry.js.map +1 -0
- package/dist/core/runtime.d.ts +51 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +401 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/step-tag.d.ts +16 -0
- package/dist/core/step-tag.d.ts.map +1 -0
- package/dist/core/step-tag.js +35 -0
- package/dist/core/step-tag.js.map +1 -0
- package/dist/core/types.d.ts +143 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +3 -0
- package/dist/core/types.js.map +1 -0
- package/dist/endpoint-policy.d.ts +29 -0
- package/dist/endpoint-policy.d.ts.map +1 -0
- package/dist/endpoint-policy.js +39 -0
- package/dist/endpoint-policy.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/axios.d.ts +23 -0
- package/dist/integrations/axios.d.ts.map +1 -0
- package/dist/integrations/axios.js +51 -0
- package/dist/integrations/axios.js.map +1 -0
- package/dist/integrations/fetch.d.ts +21 -0
- package/dist/integrations/fetch.d.ts.map +1 -0
- package/dist/integrations/fetch.js +63 -0
- package/dist/integrations/fetch.js.map +1 -0
- package/dist/integrations/jquery/index.d.ts +22 -0
- package/dist/integrations/jquery/index.d.ts.map +1 -0
- package/dist/integrations/jquery/index.js +27 -0
- package/dist/integrations/jquery/index.js.map +1 -0
- package/dist/integrations/jquery/instrument.d.ts +18 -0
- package/dist/integrations/jquery/instrument.d.ts.map +1 -0
- package/dist/integrations/jquery/instrument.js +44 -0
- package/dist/integrations/jquery/instrument.js.map +1 -0
- package/dist/integrations/react/context.d.ts +6 -0
- package/dist/integrations/react/context.d.ts.map +1 -0
- package/dist/integrations/react/context.js +14 -0
- package/dist/integrations/react/context.js.map +1 -0
- package/dist/integrations/react/scope.d.ts +13 -0
- package/dist/integrations/react/scope.d.ts.map +1 -0
- package/dist/integrations/react/scope.js +27 -0
- package/dist/integrations/react/scope.js.map +1 -0
- package/dist/integrations/request-key.d.ts +17 -0
- package/dist/integrations/request-key.d.ts.map +1 -0
- package/dist/integrations/request-key.js +39 -0
- package/dist/integrations/request-key.js.map +1 -0
- package/dist/sinks/datadog.d.ts +6 -0
- package/dist/sinks/datadog.d.ts.map +1 -0
- package/dist/sinks/datadog.js +64 -0
- package/dist/sinks/datadog.js.map +1 -0
- package/package.json +44 -0
- package/src/__mocks__/test-runtime.ts +37 -0
- package/src/__tests__/ambient.test.ts +101 -0
- package/src/__tests__/axios.test.ts +365 -0
- package/src/__tests__/config.test.ts +261 -0
- package/src/__tests__/context.test.tsx +84 -0
- package/src/__tests__/datadog.test.ts +160 -0
- package/src/__tests__/define-journey.test.ts +176 -0
- package/src/__tests__/expose-app-journey.test.ts +149 -0
- package/src/__tests__/fetch.test.ts +264 -0
- package/src/__tests__/jquery.test.ts +327 -0
- package/src/__tests__/registry.test.ts +265 -0
- package/src/__tests__/request-key.test.ts +81 -0
- package/src/__tests__/requests.test.ts +337 -0
- package/src/__tests__/runtime.test.ts +273 -0
- package/src/__tests__/scope.test.tsx +128 -0
- package/src/__tests__/sinks.test.ts +99 -0
- package/src/__tests__/step.test.ts +305 -0
- package/src/__tests__/timeouts.test.ts +226 -0
- package/src/config.ts +87 -0
- package/src/core/index.ts +39 -0
- package/src/core/registry.ts +162 -0
- package/src/core/runtime.ts +489 -0
- package/src/core/step-tag.ts +24 -0
- package/src/core/types.ts +160 -0
- package/src/endpoint-policy.ts +66 -0
- package/src/index.ts +51 -0
- package/src/integrations/axios.ts +85 -0
- package/src/integrations/fetch.ts +97 -0
- package/src/integrations/jquery/index.ts +62 -0
- package/src/integrations/jquery/instrument.ts +84 -0
- package/src/integrations/react/context.ts +17 -0
- package/src/integrations/react/scope.tsx +32 -0
- package/src/integrations/request-key.ts +46 -0
- package/src/sinks/datadog.ts +71 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { defaultRuntime } from './runtime';
|
|
2
|
+
import type {
|
|
3
|
+
JourneyDef,
|
|
4
|
+
JourneyEvent,
|
|
5
|
+
JourneyHandle,
|
|
6
|
+
JourneySink,
|
|
7
|
+
JourneyState,
|
|
8
|
+
JourneyStepOptions,
|
|
9
|
+
JourneyStepRef,
|
|
10
|
+
JourneyStepTarget,
|
|
11
|
+
StepHandle,
|
|
12
|
+
TagValue,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
export type { JourneyHandle, JourneySink };
|
|
16
|
+
|
|
17
|
+
/** Look up an open journey by name. */
|
|
18
|
+
export function getOpenJourney(name: string): JourneyState | undefined {
|
|
19
|
+
return defaultRuntime.getOpenJourney(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** First open journey among `names` (order preserved), or null. */
|
|
23
|
+
export function firstOpenJourney(names: readonly string[]): string | null {
|
|
24
|
+
return defaultRuntime.firstOpenJourney(names);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Active step for soft-ambient HTTP attribution, or null when 0 or 2+ steps are in flight
|
|
29
|
+
* app-wide. Untagged traffic is attributed to that lone step even if unrelated — prefer
|
|
30
|
+
* `step.tag()` when the page has background instrumented requests. Adapters call this when
|
|
31
|
+
* a request has no explicit stamp.
|
|
32
|
+
*/
|
|
33
|
+
export function activeJourneyStep(): JourneyStepRef | null {
|
|
34
|
+
return defaultRuntime.activeJourneyStep();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Record a finished backend request against a captured step.
|
|
39
|
+
* 5xx / network / slow (over threshold) mark Bad; 4xx is not counted. Fail-fast on breach.
|
|
40
|
+
*/
|
|
41
|
+
export function reportBackendRequest(
|
|
42
|
+
step: JourneyStepRef | undefined,
|
|
43
|
+
status: number | undefined,
|
|
44
|
+
durationMs: number,
|
|
45
|
+
requestKey: string
|
|
46
|
+
): void {
|
|
47
|
+
defaultRuntime.reportBackendRequest(step, status, durationMs, requestKey);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run one step of a named journey. Runs untraced (noop handle) when no matching journey is open.
|
|
52
|
+
* `journey` may be a name, null, or an ordered candidate list.
|
|
53
|
+
* Prefer `defineJourney(...).step` when you own the journey config.
|
|
54
|
+
*/
|
|
55
|
+
export async function journeyStep<T>(
|
|
56
|
+
journey: JourneyStepTarget,
|
|
57
|
+
stepName: string,
|
|
58
|
+
fn: (step: StepHandle) => T | Promise<T>,
|
|
59
|
+
opts?: JourneyStepOptions
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
return defaultRuntime.journeyStep(journey, stepName, fn, opts);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* journeyStep deferred by one microtask so a parent `<JourneyScope>` opens first
|
|
66
|
+
* (React runs child mount effects before parent). Use for mount-effect steps only.
|
|
67
|
+
*/
|
|
68
|
+
export async function journeyMountStep<T>(
|
|
69
|
+
journey: JourneyStepTarget,
|
|
70
|
+
stepName: string,
|
|
71
|
+
fn: (step: StepHandle) => T | Promise<T>,
|
|
72
|
+
opts?: JourneyStepOptions
|
|
73
|
+
): Promise<T> {
|
|
74
|
+
return defaultRuntime.journeyMountStep(journey, stepName, fn, opts);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Replace the sink list (e.g. host bootstrap or tests). Pass `[]` to disable emission. */
|
|
78
|
+
export function setJourneySinks(next: readonly JourneySink[]): void {
|
|
79
|
+
defaultRuntime.setSinks(next);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Append a sink alongside the current ones. */
|
|
83
|
+
export function addJourneySink(sink: JourneySink): void {
|
|
84
|
+
defaultRuntime.addSink(sink);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Emit a terminal journey event to every configured sink. */
|
|
88
|
+
export function emitJourneyEvent(payload: JourneyEvent): void {
|
|
89
|
+
defaultRuntime.emit(payload);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Merge custom tags onto an open journey. No-op if not open. Prefer `handle.setTags`. */
|
|
93
|
+
export function setJourneyTags(name: string | null, tags: Record<string, TagValue>): void {
|
|
94
|
+
defaultRuntime.setJourneyTags(name, tags);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Begin a journey. Restarts (closes) an already-open journey of the same name. */
|
|
98
|
+
export function startJourney(def: JourneyDef): void {
|
|
99
|
+
defaultRuntime.startJourney(def);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Fail-fast: record reason and emit immediately. Idempotent via `closed`. */
|
|
103
|
+
export function failJourneyNow(journey: JourneyState, reason: string): void {
|
|
104
|
+
defaultRuntime.failJourneyNow(journey, reason);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Mark a named journey Bad for a custom reason (fail-fast). Prefer `handle.fail`. */
|
|
108
|
+
export function failJourney(name: string, reason: string): void {
|
|
109
|
+
defaultRuntime.failJourney(name, reason);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Successful end. Outcome stays Bad if a breach already occurred. No-op when name is null. Prefer `handle.complete`. */
|
|
113
|
+
export function completeJourney(name: string | null, tags?: Record<string, TagValue>): void {
|
|
114
|
+
defaultRuntime.completeJourney(name, tags);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** User left before finishing. Stays Bad if a breach already occurred (breach-anchored). Prefer `handle.exclude`. */
|
|
118
|
+
export function excludeJourney(name: string): void {
|
|
119
|
+
defaultRuntime.excludeJourney(name);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Turn a journey config into a handle with bound lifecycle methods. Preferred public API. */
|
|
123
|
+
export function defineJourney(def: JourneyDef): JourneyHandle {
|
|
124
|
+
const ensureStarted = () => {
|
|
125
|
+
if (!getOpenJourney(def.name)) {
|
|
126
|
+
startJourney(def);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handle: JourneyHandle = {
|
|
131
|
+
...def,
|
|
132
|
+
start(tags) {
|
|
133
|
+
startJourney(def);
|
|
134
|
+
if (tags) {
|
|
135
|
+
setJourneyTags(def.name, tags);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
step(stepName, fn, opts) {
|
|
139
|
+
ensureStarted();
|
|
140
|
+
return journeyStep(def.name, stepName, fn, opts);
|
|
141
|
+
},
|
|
142
|
+
async mountStep(stepName, fn, opts) {
|
|
143
|
+
// Defer so a parent <JourneyScope> opens first (child effects run before parent).
|
|
144
|
+
await Promise.resolve();
|
|
145
|
+
ensureStarted();
|
|
146
|
+
return journeyStep(def.name, stepName, fn, opts);
|
|
147
|
+
},
|
|
148
|
+
complete(tags) {
|
|
149
|
+
completeJourney(def.name, tags);
|
|
150
|
+
},
|
|
151
|
+
fail(reason) {
|
|
152
|
+
failJourney(def.name, reason);
|
|
153
|
+
},
|
|
154
|
+
exclude() {
|
|
155
|
+
excludeJourney(def.name);
|
|
156
|
+
},
|
|
157
|
+
setTags(tags) {
|
|
158
|
+
setJourneyTags(def.name, tags);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return handle;
|
|
162
|
+
}
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import {
|
|
2
|
+
longestEndpointMatch,
|
|
3
|
+
sortEndpointsByLongestMatch,
|
|
4
|
+
type AppRequestTimeouts,
|
|
5
|
+
} from '../endpoint-policy';
|
|
6
|
+
import { JourneyStepTag } from './step-tag';
|
|
7
|
+
import type {
|
|
8
|
+
JourneyDef,
|
|
9
|
+
JourneyEvent,
|
|
10
|
+
JourneySink,
|
|
11
|
+
JourneyState,
|
|
12
|
+
JourneyStepEvent,
|
|
13
|
+
JourneyStepOptions,
|
|
14
|
+
JourneyStepRef,
|
|
15
|
+
JourneyStepTarget,
|
|
16
|
+
Outcome,
|
|
17
|
+
StepHandle,
|
|
18
|
+
StepRecord,
|
|
19
|
+
TagValue,
|
|
20
|
+
} from './types';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_REQUEST_TIMEOUTS: AppRequestTimeouts = { defaultMs: 4_000, endpoints: [] };
|
|
23
|
+
|
|
24
|
+
const noopStep: StepHandle = {
|
|
25
|
+
name: null,
|
|
26
|
+
setAttribute() {},
|
|
27
|
+
setTags() {},
|
|
28
|
+
complete() {},
|
|
29
|
+
fail() {},
|
|
30
|
+
tag: () => ({}),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function toName(target: string | { readonly name: string }): string {
|
|
34
|
+
return typeof target === 'string' ? target : target.name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @internal Private — not part of the public package API. */
|
|
38
|
+
export interface JourneyRuntimeOptions {
|
|
39
|
+
/** Initial sinks. Defaults to `[]` — package bootstrap installs Datadog on the default runtime. */
|
|
40
|
+
sinks?: readonly JourneySink[];
|
|
41
|
+
requestTimeouts?: AppRequestTimeouts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @internal Private — not part of the public package API.
|
|
46
|
+
* Isolated journey engine (open journeys, ambient steps, sinks, request thresholds).
|
|
47
|
+
* App code should keep using free helpers (`defineJourney`, `journeyStep`, …) — they
|
|
48
|
+
* bind to the process default runtime. Prefer `resetJourney()` in tests.
|
|
49
|
+
*/
|
|
50
|
+
export interface JourneyRuntime {
|
|
51
|
+
getRequestTimeouts(): AppRequestTimeouts;
|
|
52
|
+
setRequestTimeouts(next: AppRequestTimeouts): void;
|
|
53
|
+
|
|
54
|
+
setSinks(next: readonly JourneySink[]): void;
|
|
55
|
+
addSink(sink: JourneySink): void;
|
|
56
|
+
emit(payload: JourneyEvent): void;
|
|
57
|
+
|
|
58
|
+
getOpenJourney(name: string): JourneyState | undefined;
|
|
59
|
+
firstOpenJourney(names: readonly string[]): string | null;
|
|
60
|
+
setJourneyTags(name: string | null, tags: Record<string, TagValue>): void;
|
|
61
|
+
startJourney(def: JourneyDef): void;
|
|
62
|
+
failJourneyNow(journey: JourneyState, reason: string): void;
|
|
63
|
+
failJourney(name: string, reason: string): void;
|
|
64
|
+
completeJourney(name: string | null, tags?: Record<string, TagValue>): void;
|
|
65
|
+
excludeJourney(name: string): void;
|
|
66
|
+
|
|
67
|
+
activeJourneyStep(): JourneyStepRef | null;
|
|
68
|
+
journeyStep<T>(
|
|
69
|
+
journey: JourneyStepTarget,
|
|
70
|
+
stepName: string,
|
|
71
|
+
fn: (step: StepHandle) => T | Promise<T>,
|
|
72
|
+
opts?: JourneyStepOptions
|
|
73
|
+
): Promise<T>;
|
|
74
|
+
journeyMountStep<T>(
|
|
75
|
+
journey: JourneyStepTarget,
|
|
76
|
+
stepName: string,
|
|
77
|
+
fn: (step: StepHandle) => T | Promise<T>,
|
|
78
|
+
opts?: JourneyStepOptions
|
|
79
|
+
): Promise<T>;
|
|
80
|
+
|
|
81
|
+
reportBackendRequest(
|
|
82
|
+
step: JourneyStepRef | undefined,
|
|
83
|
+
status: number | undefined,
|
|
84
|
+
durationMs: number,
|
|
85
|
+
requestKey: string
|
|
86
|
+
): void;
|
|
87
|
+
|
|
88
|
+
/** Close open journeys, clear ambient steps, restore default timeouts/sinks. */
|
|
89
|
+
reset(options?: JourneyRuntimeOptions): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @internal Private — not part of the public package API.
|
|
94
|
+
* Build an isolated engine. Rarely needed — apps use the default via free helpers.
|
|
95
|
+
*/
|
|
96
|
+
export function createJourneyRuntime(options: JourneyRuntimeOptions = {}): JourneyRuntime {
|
|
97
|
+
const journeys = new Map<string, JourneyState>();
|
|
98
|
+
const inFlight = new Set<StepRecord>();
|
|
99
|
+
let sinks: JourneySink[] = [...(options.sinks ?? [])];
|
|
100
|
+
let requestTimeouts: AppRequestTimeouts = {
|
|
101
|
+
defaultMs: options.requestTimeouts?.defaultMs ?? DEFAULT_REQUEST_TIMEOUTS.defaultMs,
|
|
102
|
+
endpoints: sortEndpointsByLongestMatch(
|
|
103
|
+
options.requestTimeouts?.endpoints ?? DEFAULT_REQUEST_TIMEOUTS.endpoints
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function getInFlightSteps(journey: JourneyState): StepRecord[] {
|
|
108
|
+
const out: StepRecord[] = [];
|
|
109
|
+
for (const step of inFlight) {
|
|
110
|
+
if (step.journey === journey) {
|
|
111
|
+
out.push(step);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function enterStep(step: StepRecord): void {
|
|
118
|
+
inFlight.add(step);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function leaveStep(step: StepRecord): void {
|
|
122
|
+
inFlight.delete(step);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clearCountdown(journey: JourneyState): void {
|
|
126
|
+
if (journey.timer != null) {
|
|
127
|
+
clearTimeout(journey.timer);
|
|
128
|
+
journey.timer = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function emit(payload: JourneyEvent): void {
|
|
133
|
+
for (const sink of sinks) {
|
|
134
|
+
try {
|
|
135
|
+
sink(payload);
|
|
136
|
+
} catch {
|
|
137
|
+
// One sinking throw must not block the remaining sinks.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function finish(journey: JourneyState, outcome: Outcome, reason?: string): void {
|
|
143
|
+
if (journey.closed) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
journey.closed = true;
|
|
147
|
+
journeys.delete(journey.name);
|
|
148
|
+
clearCountdown(journey);
|
|
149
|
+
|
|
150
|
+
for (const step of getInFlightSteps(journey)) {
|
|
151
|
+
if (step.durationMs === 0) {
|
|
152
|
+
step.durationMs = Math.round(globalThis.performance.now() - step.startedAt);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let finalReason = journey.reason ?? reason;
|
|
157
|
+
let finalOutcome = outcome;
|
|
158
|
+
const durationMs = Math.round(globalThis.performance.now() - journey.startedAt);
|
|
159
|
+
if (journey.timeoutMs > 0 && durationMs > journey.timeoutMs) {
|
|
160
|
+
finalOutcome = 'bad';
|
|
161
|
+
finalReason = finalReason ?? 'journey-timeout';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const steps: JourneyStepEvent[] = journey.steps.map(s => ({
|
|
165
|
+
name: s.name,
|
|
166
|
+
startMs: Math.round(s.startedAt - journey.startedAt),
|
|
167
|
+
durationMs: s.durationMs,
|
|
168
|
+
outcome: s.outcome,
|
|
169
|
+
...(s.reason ? { reason: s.reason } : {}),
|
|
170
|
+
...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}),
|
|
171
|
+
...(Object.keys(s.attributes).length ? { attributes: s.attributes } : {}),
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
emit({
|
|
175
|
+
journey: {
|
|
176
|
+
name: journey.name,
|
|
177
|
+
team: journey.team,
|
|
178
|
+
group: journey.group,
|
|
179
|
+
service: journey.service,
|
|
180
|
+
outcome: finalOutcome,
|
|
181
|
+
...(finalOutcome === 'bad' && finalReason ? { reason: finalReason } : {}),
|
|
182
|
+
durationMs,
|
|
183
|
+
steps,
|
|
184
|
+
...(journey.expected ? { expected: journey.expected } : {}),
|
|
185
|
+
...(Object.keys(journey.tags).length ? { tags: journey.tags } : {}),
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function armCountdown(journey: JourneyState): void {
|
|
191
|
+
if (journey.timeoutMs <= 0) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
journey.timer = setTimeout(() => {
|
|
195
|
+
finish(journey, 'bad', 'journey-timeout');
|
|
196
|
+
}, journey.timeoutMs);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function resolveRequestTimeoutMs(journey: JourneyState, key: string): number {
|
|
200
|
+
const own = longestEndpointMatch(journey.endpoints, key);
|
|
201
|
+
if (own?.timeoutMs) {
|
|
202
|
+
return own.timeoutMs;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const app = longestEndpointMatch(requestTimeouts.endpoints, key);
|
|
206
|
+
if (app?.timeoutMs) {
|
|
207
|
+
return app.timeoutMs;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (journey.requestTimeoutMs != null) {
|
|
211
|
+
return journey.requestTimeoutMs;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return requestTimeouts.defaultMs;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function shouldIgnoreRequest(journey: JourneyState, key: string): boolean {
|
|
218
|
+
const own = longestEndpointMatch(journey.endpoints, key);
|
|
219
|
+
if (own) {
|
|
220
|
+
return own.ignore === true;
|
|
221
|
+
}
|
|
222
|
+
return longestEndpointMatch(requestTimeouts.endpoints, key)?.ignore === true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function resolveJourneyName(journey: JourneyStepTarget): string | null {
|
|
226
|
+
if (journey == null) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (typeof journey === 'string' || !Array.isArray(journey)) {
|
|
230
|
+
return toName(journey as string | { readonly name: string });
|
|
231
|
+
}
|
|
232
|
+
return api.firstOpenJourney(journey.map(toName));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function createStepHandle(journeyName: string, step: StepRecord): StepHandle {
|
|
236
|
+
return {
|
|
237
|
+
name: journeyName,
|
|
238
|
+
setAttribute(key, value) {
|
|
239
|
+
step.attributes[key] = value;
|
|
240
|
+
},
|
|
241
|
+
setTags(tags) {
|
|
242
|
+
api.setJourneyTags(journeyName, tags);
|
|
243
|
+
},
|
|
244
|
+
complete(tags) {
|
|
245
|
+
api.completeJourney(journeyName, tags);
|
|
246
|
+
},
|
|
247
|
+
fail(reason) {
|
|
248
|
+
api.failJourney(journeyName, reason);
|
|
249
|
+
},
|
|
250
|
+
// Box in JourneyStepTag — raw StepRecord is circular and blows jQuery/axios deep-merge.
|
|
251
|
+
tag: () => ({ journeyStep: new JourneyStepTag(step) }),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const api: JourneyRuntime = {
|
|
256
|
+
getRequestTimeouts() {
|
|
257
|
+
return requestTimeouts;
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
setRequestTimeouts(next) {
|
|
261
|
+
requestTimeouts = {
|
|
262
|
+
...next,
|
|
263
|
+
endpoints: sortEndpointsByLongestMatch(next.endpoints),
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
setSinks(next) {
|
|
268
|
+
sinks = [...next];
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
addSink(sink) {
|
|
272
|
+
sinks.push(sink);
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
emit,
|
|
276
|
+
|
|
277
|
+
getOpenJourney(name) {
|
|
278
|
+
return journeys.get(name);
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
firstOpenJourney(names) {
|
|
282
|
+
for (const name of names) {
|
|
283
|
+
if (journeys.has(name)) {
|
|
284
|
+
return name;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
setJourneyTags(name, tags) {
|
|
291
|
+
const journey = name ? journeys.get(name) : undefined;
|
|
292
|
+
if (journey) {
|
|
293
|
+
Object.assign(journey.tags, tags);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
startJourney(def) {
|
|
298
|
+
const existing = journeys.get(def.name);
|
|
299
|
+
if (existing) {
|
|
300
|
+
finish(existing, existing.verdict === 'bad' ? 'bad' : 'excluded');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const journey: JourneyState = {
|
|
304
|
+
name: def.name,
|
|
305
|
+
team: def.team,
|
|
306
|
+
group: def.group,
|
|
307
|
+
service: def.service,
|
|
308
|
+
verdict: 'good',
|
|
309
|
+
startedAt: globalThis.performance.now(),
|
|
310
|
+
timeoutMs: def.timeoutMs ?? 0,
|
|
311
|
+
stepTimeoutMs: def.stepTimeoutMs,
|
|
312
|
+
reason: null,
|
|
313
|
+
steps: [],
|
|
314
|
+
expected: def.steps ? [...def.steps] : null,
|
|
315
|
+
tags: { ...(def.tags ?? {}) },
|
|
316
|
+
requestTimeoutMs: def.requestTimeoutMs,
|
|
317
|
+
endpoints: sortEndpointsByLongestMatch(def.endpoints),
|
|
318
|
+
timer: null,
|
|
319
|
+
closed: false,
|
|
320
|
+
};
|
|
321
|
+
journeys.set(def.name, journey);
|
|
322
|
+
armCountdown(journey);
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
failJourneyNow(journey, reason) {
|
|
326
|
+
if (journey.closed) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
journey.verdict = 'bad';
|
|
330
|
+
journey.reason ??= reason;
|
|
331
|
+
finish(journey, 'bad');
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
failJourney(name, reason) {
|
|
335
|
+
const journey = journeys.get(name);
|
|
336
|
+
if (journey) {
|
|
337
|
+
api.failJourneyNow(journey, reason);
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
completeJourney(name, tags) {
|
|
342
|
+
if (!name) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (tags) {
|
|
346
|
+
api.setJourneyTags(name, tags);
|
|
347
|
+
}
|
|
348
|
+
const journey = journeys.get(name);
|
|
349
|
+
if (journey) {
|
|
350
|
+
finish(journey, journey.verdict === 'bad' ? 'bad' : 'good');
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
excludeJourney(name) {
|
|
355
|
+
const journey = journeys.get(name);
|
|
356
|
+
if (journey) {
|
|
357
|
+
finish(journey, journey.verdict === 'bad' ? 'bad' : 'excluded');
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
activeJourneyStep() {
|
|
362
|
+
if (inFlight.size !== 1) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return inFlight.values().next().value ?? null;
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
async journeyStep(journey, stepName, fn, opts) {
|
|
369
|
+
const journeyName = resolveJourneyName(journey);
|
|
370
|
+
const open = journeyName ? journeys.get(journeyName) : undefined;
|
|
371
|
+
if (!open || !journeyName) {
|
|
372
|
+
return fn(noopStep);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const step: StepRecord = {
|
|
376
|
+
name: stepName,
|
|
377
|
+
startedAt: globalThis.performance.now(),
|
|
378
|
+
durationMs: 0,
|
|
379
|
+
outcome: 'good',
|
|
380
|
+
attributes: {},
|
|
381
|
+
journey: open,
|
|
382
|
+
};
|
|
383
|
+
open.steps.push(step);
|
|
384
|
+
|
|
385
|
+
const handle = createStepHandle(journeyName, step);
|
|
386
|
+
|
|
387
|
+
const stepTimeoutMs = opts?.timeoutMs ?? open.stepTimeoutMs ?? 0;
|
|
388
|
+
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
389
|
+
if (stepTimeoutMs > 0) {
|
|
390
|
+
timeoutTimer = setTimeout(() => {
|
|
391
|
+
if (open.closed) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
step.outcome = 'bad';
|
|
395
|
+
step.reason ??= 'step-timeout';
|
|
396
|
+
step.attributes.timeout_ms ??= stepTimeoutMs;
|
|
397
|
+
step.durationMs = Math.round(globalThis.performance.now() - step.startedAt);
|
|
398
|
+
api.failJourneyNow(open, 'step-timeout');
|
|
399
|
+
}, stepTimeoutMs);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
enterStep(step);
|
|
403
|
+
try {
|
|
404
|
+
return await fn(handle);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
407
|
+
step.outcome = 'bad';
|
|
408
|
+
step.reason ??= 'step-error';
|
|
409
|
+
step.attributes.error ??= message;
|
|
410
|
+
api.failJourneyNow(open, 'step-error');
|
|
411
|
+
throw err;
|
|
412
|
+
} finally {
|
|
413
|
+
if (timeoutTimer != null) {
|
|
414
|
+
clearTimeout(timeoutTimer);
|
|
415
|
+
}
|
|
416
|
+
if (step.durationMs === 0) {
|
|
417
|
+
step.durationMs = Math.round(globalThis.performance.now() - step.startedAt);
|
|
418
|
+
}
|
|
419
|
+
leaveStep(step);
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
async journeyMountStep(journey, stepName, fn, opts) {
|
|
424
|
+
await Promise.resolve();
|
|
425
|
+
return api.journeyStep(journey, stepName, fn, opts);
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
reportBackendRequest(step, status, durationMs, requestKey) {
|
|
429
|
+
if (!step) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const journey = step.journey;
|
|
433
|
+
if (journey.closed) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (shouldIgnoreRequest(journey, requestKey)) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const failed = status === undefined || status >= 500;
|
|
441
|
+
const slow = durationMs > resolveRequestTimeoutMs(journey, requestKey);
|
|
442
|
+
if (!failed && !slow) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const category = failed ? 'request-error' : 'request-latency';
|
|
447
|
+
|
|
448
|
+
step.outcome = 'bad';
|
|
449
|
+
step.reason ??= category;
|
|
450
|
+
if (failed && status !== undefined) {
|
|
451
|
+
step.httpStatus = status;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
api.failJourneyNow(journey, category);
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
reset(next = {}) {
|
|
458
|
+
for (const journey of [...journeys.values()]) {
|
|
459
|
+
clearCountdown(journey);
|
|
460
|
+
journey.closed = true;
|
|
461
|
+
}
|
|
462
|
+
journeys.clear();
|
|
463
|
+
inFlight.clear();
|
|
464
|
+
sinks = [...(next.sinks ?? [])];
|
|
465
|
+
requestTimeouts = {
|
|
466
|
+
defaultMs: next.requestTimeouts?.defaultMs ?? DEFAULT_REQUEST_TIMEOUTS.defaultMs,
|
|
467
|
+
endpoints: sortEndpointsByLongestMatch(
|
|
468
|
+
next.requestTimeouts?.endpoints ?? DEFAULT_REQUEST_TIMEOUTS.endpoints
|
|
469
|
+
),
|
|
470
|
+
};
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
return api;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* @internal Private — not part of the public package API.
|
|
479
|
+
* Process-wide engine used by free helpers. App code does not need to touch this.
|
|
480
|
+
*/
|
|
481
|
+
export const defaultRuntime: JourneyRuntime = createJourneyRuntime();
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Reset the default engine (open journeys, ambient steps, sinks, request thresholds).
|
|
485
|
+
* Intended for tests — call in `beforeEach` / `afterEach` instead of manual cleanup.
|
|
486
|
+
*/
|
|
487
|
+
export function resetJourney(options?: JourneyRuntimeOptions): void {
|
|
488
|
+
defaultRuntime.reset(options);
|
|
489
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { JourneyStepRef, StepRecord } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-plain wrapper for {@link StepHandle.tag}.
|
|
5
|
+
*
|
|
6
|
+
* jQuery `ajaxSetup` / axios `mergeConfig` deep-merge plain objects. A raw
|
|
7
|
+
* {@link StepRecord} is circular (`step.journey.steps → step`), so putting it
|
|
8
|
+
* on ajax/axios options causes "Maximum call stack size exceeded". Class
|
|
9
|
+
* instances are assigned by reference instead of recursed.
|
|
10
|
+
*/
|
|
11
|
+
export class JourneyStepTag {
|
|
12
|
+
constructor(readonly step: StepRecord) {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Unwrap `step.tag().journeyStep` or a JourneyStepTag / StepRecord stamp. */
|
|
16
|
+
export function resolveJourneyStep(value: unknown): JourneyStepRef | undefined {
|
|
17
|
+
if (value instanceof JourneyStepTag) {
|
|
18
|
+
return value.step;
|
|
19
|
+
}
|
|
20
|
+
if (value && typeof value === 'object') {
|
|
21
|
+
return value as JourneyStepRef;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|