@edraj/sauron-node 1.0.0 → 1.4.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/CHANGELOG.md +57 -0
- package/README.md +1148 -88
- package/dist/client.d.ts +113 -4
- package/dist/client.js +261 -9
- package/dist/index.d.ts +23 -1
- package/dist/index.js +24 -0
- package/dist/scope.js +5 -0
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +5 -2
- package/dist/types.d.ts +52 -4
- package/dist/workflow.d.ts +26 -0
- package/dist/workflow.js +51 -0
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BreadcrumbInput, CaptureExceptionOptions, InitOptions, Level, MetadataOptions, TransactionInput } from './types.js';
|
|
1
|
+
import type { BreadcrumbInput, CaptureExceptionOptions, InitOptions, Level, MetadataOptions, TransactionInput, WorkflowResult } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* The Sauron server-side client. Buffers events/errors and dispatches them via
|
|
4
4
|
* a background transport. Constructed by {@link init}.
|
|
@@ -10,9 +10,21 @@ export declare class SauronClient {
|
|
|
10
10
|
private readonly hookUninstallers;
|
|
11
11
|
constructor(options: InitOptions);
|
|
12
12
|
/**
|
|
13
|
-
* The single enqueue chokepoint
|
|
14
|
-
*
|
|
15
|
-
* transport.
|
|
13
|
+
* The single enqueue chokepoint — every error/event/transaction (however
|
|
14
|
+
* constructed, including `captureMessage`'s inline-built item) passes
|
|
15
|
+
* through here before reaching the transport. Stamping the active
|
|
16
|
+
* workflow ONCE, right here, means a future capture path can't forget it
|
|
17
|
+
* the way a per-construction-site stamp could. `identify` items are
|
|
18
|
+
* excluded: the server has no workflow columns for them.
|
|
19
|
+
*
|
|
20
|
+
* Runs `beforeSend` on every item; a `null` return drops it, a returned
|
|
21
|
+
* item replaces it, then it is handed to the transport.
|
|
22
|
+
*
|
|
23
|
+
* `beforeSend` is user-supplied, so it is guarded: a throwing hook must
|
|
24
|
+
* never propagate into the caller's `track`/`captureException`/etc — that
|
|
25
|
+
* would break the SDK's no-throw guarantee. On throw, the item is treated
|
|
26
|
+
* as UNMODIFIED and still sent (never silently dropped); the failure is
|
|
27
|
+
* only surfaced via the debug logger.
|
|
16
28
|
*/
|
|
17
29
|
private dispatch;
|
|
18
30
|
/**
|
|
@@ -24,12 +36,109 @@ export declare class SauronClient {
|
|
|
24
36
|
trackTransaction(input: TransactionInput): void;
|
|
25
37
|
/** Capture a product-analytics event. `distinctId` is required. */
|
|
26
38
|
track(event: string, distinctId: string, properties?: Record<string, unknown>, options?: MetadataOptions): void;
|
|
39
|
+
/**
|
|
40
|
+
* Build and dispatch an event item. Shared by the public {@link track} (which
|
|
41
|
+
* validates first) and the reserved workflow lifecycle emits (which must be
|
|
42
|
+
* able to send an intentionally empty `distinct_id`).
|
|
43
|
+
*/
|
|
44
|
+
private emitEvent;
|
|
27
45
|
/** Capture a native `Error` (or error-like value) as an error item. */
|
|
28
46
|
captureException(error: unknown, options?: CaptureExceptionOptions): void;
|
|
29
47
|
/** Capture a bare message as an error item (no exception payload). */
|
|
30
48
|
captureMessage(message: string, level?: Level, options?: MetadataOptions): void;
|
|
31
49
|
/** Associate traits with a distinct id. */
|
|
32
50
|
identify(distinctId: string, traits?: Record<string, unknown>): void;
|
|
51
|
+
/**
|
|
52
|
+
* Whether this client can still deliver telemetry — false once the
|
|
53
|
+
* transport has auto-disabled itself on a 401/403. Gated on this (not just
|
|
54
|
+
* "does a client object exist") so `startWorkflow` can't mutate local scope
|
|
55
|
+
* state and emit an event the transport would silently drop underneath it.
|
|
56
|
+
*/
|
|
57
|
+
isEnabled(): boolean;
|
|
58
|
+
/** Debug-gated warning log, matching the transport's own `[sauron]`-prefixed convention. */
|
|
59
|
+
private debugLog;
|
|
60
|
+
/**
|
|
61
|
+
* Emit the closing lifecycle event (`$workflow_end`/`$workflow_cancel`) for
|
|
62
|
+
* `active` while it is STILL the current scope's workflow (so the item-level
|
|
63
|
+
* `workflow_id`/`workflow_name` stamped by `dispatch()` are its own, not
|
|
64
|
+
* `null`/absent), through `track()` so scope tags/contexts/extra apply.
|
|
65
|
+
* Never mutates scope state itself — callers own clearing it, in a `finally`
|
|
66
|
+
* relative to this call, so a throw here can't leave state half-mutated.
|
|
67
|
+
*
|
|
68
|
+
* The `distinctId` comes from {@link SauronClient.workflowDistinctId} — read
|
|
69
|
+
* its note on the `'system'` fallback's effect on unique-user counts.
|
|
70
|
+
*/
|
|
71
|
+
private emitWorkflowClose;
|
|
72
|
+
/**
|
|
73
|
+
* The `distinct_id` to attribute a workflow lifecycle event to: the scope's
|
|
74
|
+
* user id, else **the empty string**.
|
|
75
|
+
*
|
|
76
|
+
* Empty is deliberate and correct — do NOT "fix" this to a sentinel like
|
|
77
|
+
* `'system'`, an anonymous/device id, or anything derived from the workflow
|
|
78
|
+
* id. The server was built for exactly this case:
|
|
79
|
+
*
|
|
80
|
+
* - `backend/crates/sauron-pipeline/src/process.rs` — both `bump_workflow`
|
|
81
|
+
* call sites pass `Some(distinct_id.as_str()).filter(|s| !s.is_empty())`,
|
|
82
|
+
* so an empty id is stored as SQL `NULL` on the `workflows` row.
|
|
83
|
+
* - `backend/crates/sauron-db/src/repo.rs` — the per-workflow rollup
|
|
84
|
+
* computes `COUNT(DISTINCT w.distinct_id) AS unique_users`, and
|
|
85
|
+
* `COUNT(DISTINCT ...)` skips NULLs.
|
|
86
|
+
*
|
|
87
|
+
* So an anonymous workflow run contributes *nothing* to `unique_users`,
|
|
88
|
+
* which is honest. Any non-empty sentinel would instead collapse every
|
|
89
|
+
* anonymous run of a workflow (`password_reset`, `guest_checkout`, …) into
|
|
90
|
+
* one fake bucket, silently reporting ~1 unique user no matter how many
|
|
91
|
+
* distinct invocations occurred.
|
|
92
|
+
*
|
|
93
|
+
* `EventItem.distinct_id` is a required string on the wire
|
|
94
|
+
* (`backend/crates/sauron-core/src/envelope.rs`), so the field is still
|
|
95
|
+
* always sent — it is just `""`. This is why the lifecycle emits route
|
|
96
|
+
* through {@link emitEvent} rather than the public {@link track}, whose
|
|
97
|
+
* empty-`distinctId` guard would otherwise drop them entirely.
|
|
98
|
+
*/
|
|
99
|
+
private workflowDistinctId;
|
|
100
|
+
/**
|
|
101
|
+
* Start a named workflow on the current scope (the `AsyncLocalStorage`
|
|
102
|
+
* child inside `withScope`/`runWithAsyncScope`, else the process-wide
|
|
103
|
+
* global scope) — request-isolated, so concurrent requests never observe
|
|
104
|
+
* or clobber each other's workflow. `force: true` supersedes an
|
|
105
|
+
* already-active workflow (emitting `$workflow_cancel` with
|
|
106
|
+
* `reason: 'superseded'` for it first); otherwise an active workflow makes
|
|
107
|
+
* this a no-op returning `already_active`.
|
|
108
|
+
*
|
|
109
|
+
* The workflow id is a fresh `randomUUID()`, minted here — never derived
|
|
110
|
+
* from anything deterministic. The server's rollup key is
|
|
111
|
+
* `(app_id, workflow_id)` app-wide, so a reused/derived id would merge
|
|
112
|
+
* counters from unrelated requests/environments into one row.
|
|
113
|
+
*
|
|
114
|
+
* Never throws: an unexpected failure before any side effect is reported as
|
|
115
|
+
* `disabled`, and `disabled` always means literally nothing happened — no
|
|
116
|
+
* event on the wire, no state change. A failure emitting `$workflow_start`
|
|
117
|
+
* AFTER the scope's workflow field was set is still reported as `ok` — the
|
|
118
|
+
* workflow IS live locally, and a lost start event is recoverable
|
|
119
|
+
* server-side (the row materializes from the next stamped item via the same
|
|
120
|
+
* upsert); a lost local id would not be.
|
|
121
|
+
*/
|
|
122
|
+
startWorkflow(name: string, options?: {
|
|
123
|
+
force?: boolean;
|
|
124
|
+
}): WorkflowResult;
|
|
125
|
+
/** Shared precondition + close logic for `endWorkflow`/`cancelWorkflow`. */
|
|
126
|
+
private closeWorkflow;
|
|
127
|
+
/**
|
|
128
|
+
* End the active workflow (or the one named `name`, if given). Emits
|
|
129
|
+
* `$workflow_end` with `duration_ms` and clears the scope's workflow field.
|
|
130
|
+
* A no-op returning `not_active` (nothing active) or `name_mismatch` (`name`
|
|
131
|
+
* given but does not match, including a `name` that fails normalization).
|
|
132
|
+
*/
|
|
133
|
+
endWorkflow(name?: string): WorkflowResult;
|
|
134
|
+
/**
|
|
135
|
+
* Cancel the active workflow (or the one named `name`, if given). Emits
|
|
136
|
+
* `$workflow_cancel` with `duration_ms` and `reason` (default `'user'`,
|
|
137
|
+
* trimmed and capped at 120 chars) and clears the scope's workflow field.
|
|
138
|
+
*/
|
|
139
|
+
cancelWorkflow(name?: string, options?: {
|
|
140
|
+
reason?: string;
|
|
141
|
+
}): WorkflowResult;
|
|
33
142
|
/** Send any buffered items immediately. */
|
|
34
143
|
flush(): Promise<void>;
|
|
35
144
|
/** Flush then stop the background timer, and remove any opt-in process hooks. */
|
package/dist/client.js
CHANGED
|
@@ -5,8 +5,8 @@ import { Transport } from './transport.js';
|
|
|
5
5
|
import { parseError } from './stacktrace.js';
|
|
6
6
|
import { installAutoCapture, installShutdownHooks } from './autocapture.js';
|
|
7
7
|
import { getCurrentScope, getGlobalScope, normalizeBreadcrumb, } from './scope.js';
|
|
8
|
+
import { normalizeReason, normalizeWorkflowName } from './workflow.js';
|
|
8
9
|
const DEFAULTS = {
|
|
9
|
-
environment: 'production',
|
|
10
10
|
release: null,
|
|
11
11
|
sampleRate: 1,
|
|
12
12
|
flushInterval: 5000,
|
|
@@ -24,7 +24,6 @@ function resolveOptions(options) {
|
|
|
24
24
|
const sampleRate = typeof options.sampleRate === 'number' ? options.sampleRate : DEFAULTS.sampleRate;
|
|
25
25
|
return {
|
|
26
26
|
dsn: options.dsn,
|
|
27
|
-
environment: options.environment ?? DEFAULTS.environment,
|
|
28
27
|
release: options.release ?? DEFAULTS.release,
|
|
29
28
|
tags: options.tags ?? {},
|
|
30
29
|
contexts: options.contexts ?? {},
|
|
@@ -98,7 +97,6 @@ export class SauronClient {
|
|
|
98
97
|
}
|
|
99
98
|
this.transport = new Transport({
|
|
100
99
|
dsn,
|
|
101
|
-
environment: this.options.environment,
|
|
102
100
|
release: this.options.release,
|
|
103
101
|
context: buildContext(),
|
|
104
102
|
flushInterval: this.options.flushInterval,
|
|
@@ -118,16 +116,44 @@ export class SauronClient {
|
|
|
118
116
|
}
|
|
119
117
|
}
|
|
120
118
|
/**
|
|
121
|
-
* The single enqueue chokepoint
|
|
122
|
-
*
|
|
123
|
-
* transport.
|
|
119
|
+
* The single enqueue chokepoint — every error/event/transaction (however
|
|
120
|
+
* constructed, including `captureMessage`'s inline-built item) passes
|
|
121
|
+
* through here before reaching the transport. Stamping the active
|
|
122
|
+
* workflow ONCE, right here, means a future capture path can't forget it
|
|
123
|
+
* the way a per-construction-site stamp could. `identify` items are
|
|
124
|
+
* excluded: the server has no workflow columns for them.
|
|
125
|
+
*
|
|
126
|
+
* Runs `beforeSend` on every item; a `null` return drops it, a returned
|
|
127
|
+
* item replaces it, then it is handed to the transport.
|
|
128
|
+
*
|
|
129
|
+
* `beforeSend` is user-supplied, so it is guarded: a throwing hook must
|
|
130
|
+
* never propagate into the caller's `track`/`captureException`/etc — that
|
|
131
|
+
* would break the SDK's no-throw guarantee. On throw, the item is treated
|
|
132
|
+
* as UNMODIFIED and still sent (never silently dropped); the failure is
|
|
133
|
+
* only surfaced via the debug logger.
|
|
124
134
|
*/
|
|
125
135
|
dispatch(item) {
|
|
136
|
+
if (item.type !== 'identify') {
|
|
137
|
+
const workflow = getCurrentScope().data.workflow;
|
|
138
|
+
if (workflow) {
|
|
139
|
+
item.workflow_id = workflow.workflowId;
|
|
140
|
+
item.workflow_name = workflow.name;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
126
143
|
const beforeSend = this.options.beforeSend;
|
|
127
144
|
if (beforeSend) {
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
let result = item;
|
|
146
|
+
try {
|
|
147
|
+
result = beforeSend(item);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
this.debugLog('beforeSend threw', err);
|
|
151
|
+
result = item;
|
|
152
|
+
}
|
|
153
|
+
if (result == null) {
|
|
154
|
+
this.debugLog('dropped by beforeSend');
|
|
130
155
|
return;
|
|
156
|
+
}
|
|
131
157
|
this.transport.enqueue(result);
|
|
132
158
|
return;
|
|
133
159
|
}
|
|
@@ -153,12 +179,25 @@ export class SauronClient {
|
|
|
153
179
|
trackTransaction(input) {
|
|
154
180
|
if (typeof input?.name !== 'string' || input.name.length === 0)
|
|
155
181
|
return;
|
|
182
|
+
// `durationMs` accepted as an alias — see TransactionInput. Without it, a
|
|
183
|
+
// caller using the browser SDK's spelling shipped a transaction with the
|
|
184
|
+
// duration field absent and no complaint from anywhere.
|
|
185
|
+
const duration = input.duration_ms ?? input.durationMs;
|
|
186
|
+
if (typeof duration !== 'number' || !Number.isFinite(duration)) {
|
|
187
|
+
// Refused loudly rather than sent. A transaction whose whole purpose is
|
|
188
|
+
// to record a duration is not worth persisting without one, and silence
|
|
189
|
+
// here is what let the mis-spelling survive: the item looked delivered.
|
|
190
|
+
this.debugLog(`dropped transaction "${input.name}": duration_ms must be a finite number ` +
|
|
191
|
+
`(got ${typeof duration === 'number' ? duration : typeof duration}). ` +
|
|
192
|
+
`Use duration_ms, or durationMs if you are porting from the browser SDK.`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
156
195
|
const distinctId = input.distinct_id ?? getCurrentScope().data.user?.id ?? undefined;
|
|
157
196
|
const item = {
|
|
158
197
|
type: 'transaction',
|
|
159
198
|
name: input.name,
|
|
160
199
|
op: input.op ?? 'custom',
|
|
161
|
-
duration_ms:
|
|
200
|
+
duration_ms: duration,
|
|
162
201
|
timestamp: isoNow(),
|
|
163
202
|
};
|
|
164
203
|
if (input.status !== undefined)
|
|
@@ -177,8 +216,19 @@ export class SauronClient {
|
|
|
177
216
|
track(event, distinctId, properties, options = {}) {
|
|
178
217
|
if (typeof event !== 'string' || event.length === 0)
|
|
179
218
|
return;
|
|
219
|
+
// An empty/absent distinct id drops a MANUAL track call: the caller should
|
|
220
|
+
// know who acted. The reserved workflow lifecycle events deliberately
|
|
221
|
+
// bypass this via emitEvent() — see workflowDistinctId().
|
|
180
222
|
if (typeof distinctId !== 'string' || distinctId.length === 0)
|
|
181
223
|
return;
|
|
224
|
+
this.emitEvent(event, distinctId, properties, options);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Build and dispatch an event item. Shared by the public {@link track} (which
|
|
228
|
+
* validates first) and the reserved workflow lifecycle emits (which must be
|
|
229
|
+
* able to send an intentionally empty `distinct_id`).
|
|
230
|
+
*/
|
|
231
|
+
emitEvent(event, distinctId, properties, options = {}) {
|
|
182
232
|
const item = {
|
|
183
233
|
type: 'event',
|
|
184
234
|
name: event,
|
|
@@ -260,6 +310,203 @@ export class SauronClient {
|
|
|
260
310
|
};
|
|
261
311
|
this.dispatch(item);
|
|
262
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* Whether this client can still deliver telemetry — false once the
|
|
315
|
+
* transport has auto-disabled itself on a 401/403. Gated on this (not just
|
|
316
|
+
* "does a client object exist") so `startWorkflow` can't mutate local scope
|
|
317
|
+
* state and emit an event the transport would silently drop underneath it.
|
|
318
|
+
*/
|
|
319
|
+
isEnabled() {
|
|
320
|
+
return this.transport.isEnabled();
|
|
321
|
+
}
|
|
322
|
+
/** Debug-gated warning log, matching the transport's own `[sauron]`-prefixed convention. */
|
|
323
|
+
debugLog(message, ...args) {
|
|
324
|
+
if (this.options.debug) {
|
|
325
|
+
// eslint-disable-next-line no-console
|
|
326
|
+
console.warn(`[sauron] ${message}`, ...args);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Emit the closing lifecycle event (`$workflow_end`/`$workflow_cancel`) for
|
|
331
|
+
* `active` while it is STILL the current scope's workflow (so the item-level
|
|
332
|
+
* `workflow_id`/`workflow_name` stamped by `dispatch()` are its own, not
|
|
333
|
+
* `null`/absent), through `track()` so scope tags/contexts/extra apply.
|
|
334
|
+
* Never mutates scope state itself — callers own clearing it, in a `finally`
|
|
335
|
+
* relative to this call, so a throw here can't leave state half-mutated.
|
|
336
|
+
*
|
|
337
|
+
* The `distinctId` comes from {@link SauronClient.workflowDistinctId} — read
|
|
338
|
+
* its note on the `'system'` fallback's effect on unique-user counts.
|
|
339
|
+
*/
|
|
340
|
+
emitWorkflowClose(active, eventName, reason) {
|
|
341
|
+
const properties = {
|
|
342
|
+
workflow_id: active.workflowId,
|
|
343
|
+
workflow_name: active.name,
|
|
344
|
+
duration_ms: Math.max(0, Date.now() - Date.parse(active.startedAt)),
|
|
345
|
+
};
|
|
346
|
+
if (eventName === '$workflow_cancel') {
|
|
347
|
+
properties.reason = normalizeReason(reason);
|
|
348
|
+
}
|
|
349
|
+
this.emitEvent(eventName, this.workflowDistinctId(), properties);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The `distinct_id` to attribute a workflow lifecycle event to: the scope's
|
|
353
|
+
* user id, else **the empty string**.
|
|
354
|
+
*
|
|
355
|
+
* Empty is deliberate and correct — do NOT "fix" this to a sentinel like
|
|
356
|
+
* `'system'`, an anonymous/device id, or anything derived from the workflow
|
|
357
|
+
* id. The server was built for exactly this case:
|
|
358
|
+
*
|
|
359
|
+
* - `backend/crates/sauron-pipeline/src/process.rs` — both `bump_workflow`
|
|
360
|
+
* call sites pass `Some(distinct_id.as_str()).filter(|s| !s.is_empty())`,
|
|
361
|
+
* so an empty id is stored as SQL `NULL` on the `workflows` row.
|
|
362
|
+
* - `backend/crates/sauron-db/src/repo.rs` — the per-workflow rollup
|
|
363
|
+
* computes `COUNT(DISTINCT w.distinct_id) AS unique_users`, and
|
|
364
|
+
* `COUNT(DISTINCT ...)` skips NULLs.
|
|
365
|
+
*
|
|
366
|
+
* So an anonymous workflow run contributes *nothing* to `unique_users`,
|
|
367
|
+
* which is honest. Any non-empty sentinel would instead collapse every
|
|
368
|
+
* anonymous run of a workflow (`password_reset`, `guest_checkout`, …) into
|
|
369
|
+
* one fake bucket, silently reporting ~1 unique user no matter how many
|
|
370
|
+
* distinct invocations occurred.
|
|
371
|
+
*
|
|
372
|
+
* `EventItem.distinct_id` is a required string on the wire
|
|
373
|
+
* (`backend/crates/sauron-core/src/envelope.rs`), so the field is still
|
|
374
|
+
* always sent — it is just `""`. This is why the lifecycle emits route
|
|
375
|
+
* through {@link emitEvent} rather than the public {@link track}, whose
|
|
376
|
+
* empty-`distinctId` guard would otherwise drop them entirely.
|
|
377
|
+
*/
|
|
378
|
+
workflowDistinctId() {
|
|
379
|
+
return getCurrentScope().data.user?.id ?? '';
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Start a named workflow on the current scope (the `AsyncLocalStorage`
|
|
383
|
+
* child inside `withScope`/`runWithAsyncScope`, else the process-wide
|
|
384
|
+
* global scope) — request-isolated, so concurrent requests never observe
|
|
385
|
+
* or clobber each other's workflow. `force: true` supersedes an
|
|
386
|
+
* already-active workflow (emitting `$workflow_cancel` with
|
|
387
|
+
* `reason: 'superseded'` for it first); otherwise an active workflow makes
|
|
388
|
+
* this a no-op returning `already_active`.
|
|
389
|
+
*
|
|
390
|
+
* The workflow id is a fresh `randomUUID()`, minted here — never derived
|
|
391
|
+
* from anything deterministic. The server's rollup key is
|
|
392
|
+
* `(app_id, workflow_id)` app-wide, so a reused/derived id would merge
|
|
393
|
+
* counters from unrelated requests/environments into one row.
|
|
394
|
+
*
|
|
395
|
+
* Never throws: an unexpected failure before any side effect is reported as
|
|
396
|
+
* `disabled`, and `disabled` always means literally nothing happened — no
|
|
397
|
+
* event on the wire, no state change. A failure emitting `$workflow_start`
|
|
398
|
+
* AFTER the scope's workflow field was set is still reported as `ok` — the
|
|
399
|
+
* workflow IS live locally, and a lost start event is recoverable
|
|
400
|
+
* server-side (the row materializes from the next stamped item via the same
|
|
401
|
+
* upsert); a lost local id would not be.
|
|
402
|
+
*/
|
|
403
|
+
startWorkflow(name, options) {
|
|
404
|
+
try {
|
|
405
|
+
if (!this.isEnabled())
|
|
406
|
+
return { status: 'disabled' };
|
|
407
|
+
const normalized = normalizeWorkflowName(name);
|
|
408
|
+
if (!normalized) {
|
|
409
|
+
this.debugLog('startWorkflow: invalid name', name);
|
|
410
|
+
return { status: 'invalid_name' };
|
|
411
|
+
}
|
|
412
|
+
const scope = getCurrentScope();
|
|
413
|
+
const active = scope.data.workflow;
|
|
414
|
+
if (active && !options?.force) {
|
|
415
|
+
this.debugLog(`startWorkflow("${normalized}"): "${active.name}" is already active; pass { force: true } to replace it`);
|
|
416
|
+
return { status: 'already_active' };
|
|
417
|
+
}
|
|
418
|
+
// Mint the replacement BEFORE superseding anything. If `randomUUID()` or
|
|
419
|
+
// `isoNow()` throws, the outer catch returns `disabled` — which promises
|
|
420
|
+
// the caller that nothing happened and their old workflow is still
|
|
421
|
+
// running. Minting after the supersede emit would break that promise:
|
|
422
|
+
// `$workflow_cancel` for the old workflow would already be on the wire
|
|
423
|
+
// while `scope.data.workflow` still held it, so the caller's eventual
|
|
424
|
+
// `endWorkflow()` would emit a SECOND terminal lifecycle event for a
|
|
425
|
+
// workflow row the server already recorded as cancelled.
|
|
426
|
+
const workflow = {
|
|
427
|
+
workflowId: randomUUID(),
|
|
428
|
+
name: normalized,
|
|
429
|
+
startedAt: isoNow(),
|
|
430
|
+
};
|
|
431
|
+
if (active) {
|
|
432
|
+
// force: supersede the old workflow. Emitted while it is still
|
|
433
|
+
// `scope.data.workflow`, so `dispatch()` stamps the cancel with it.
|
|
434
|
+
try {
|
|
435
|
+
this.emitWorkflowClose(active, '$workflow_cancel', 'superseded');
|
|
436
|
+
}
|
|
437
|
+
catch (emitErr) {
|
|
438
|
+
this.debugLog('startWorkflow: superseding $workflow_cancel emit threw', emitErr);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Set state BEFORE emitting so $workflow_start is itself stamped with it.
|
|
442
|
+
scope.data.workflow = workflow;
|
|
443
|
+
try {
|
|
444
|
+
this.emitEvent('$workflow_start', this.workflowDistinctId(), {
|
|
445
|
+
workflow_id: workflow.workflowId,
|
|
446
|
+
workflow_name: workflow.name,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch (emitErr) {
|
|
450
|
+
this.debugLog('startWorkflow: $workflow_start emit threw (workflow stays active)', emitErr);
|
|
451
|
+
}
|
|
452
|
+
return { status: 'ok', workflowId: workflow.workflowId };
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
this.debugLog('startWorkflow threw', err);
|
|
456
|
+
return { status: 'disabled' };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** Shared precondition + close logic for `endWorkflow`/`cancelWorkflow`. */
|
|
460
|
+
closeWorkflow(eventName, name, reason) {
|
|
461
|
+
try {
|
|
462
|
+
if (!this.isEnabled())
|
|
463
|
+
return { status: 'disabled' };
|
|
464
|
+
const scope = getCurrentScope();
|
|
465
|
+
const active = scope.data.workflow;
|
|
466
|
+
if (!active)
|
|
467
|
+
return { status: 'not_active' };
|
|
468
|
+
if (name !== undefined && normalizeWorkflowName(name) !== active.name) {
|
|
469
|
+
this.debugLog(`${eventName}: "${name}" does not match active workflow "${active.name}"`);
|
|
470
|
+
return { status: 'name_mismatch' };
|
|
471
|
+
}
|
|
472
|
+
const workflowId = active.workflowId;
|
|
473
|
+
try {
|
|
474
|
+
this.emitWorkflowClose(active, eventName, reason);
|
|
475
|
+
}
|
|
476
|
+
catch (emitErr) {
|
|
477
|
+
this.debugLog(`${eventName} emit threw`, emitErr);
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
// Clear AFTER emitting (so the closing event still carries the
|
|
481
|
+
// workflow it closes) but UNCONDITIONALLY — even if the emit above
|
|
482
|
+
// threw, endWorkflow/cancelWorkflow must still return `ok` below
|
|
483
|
+
// rather than leaving state half-mutated.
|
|
484
|
+
scope.data.workflow = null;
|
|
485
|
+
}
|
|
486
|
+
return { status: 'ok', workflowId };
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
this.debugLog(`${eventName} threw`, err);
|
|
490
|
+
return { status: 'disabled' };
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* End the active workflow (or the one named `name`, if given). Emits
|
|
495
|
+
* `$workflow_end` with `duration_ms` and clears the scope's workflow field.
|
|
496
|
+
* A no-op returning `not_active` (nothing active) or `name_mismatch` (`name`
|
|
497
|
+
* given but does not match, including a `name` that fails normalization).
|
|
498
|
+
*/
|
|
499
|
+
endWorkflow(name) {
|
|
500
|
+
return this.closeWorkflow('$workflow_end', name);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Cancel the active workflow (or the one named `name`, if given). Emits
|
|
504
|
+
* `$workflow_cancel` with `duration_ms` and `reason` (default `'user'`,
|
|
505
|
+
* trimmed and capped at 120 chars) and clears the scope's workflow field.
|
|
506
|
+
*/
|
|
507
|
+
cancelWorkflow(name, options) {
|
|
508
|
+
return this.closeWorkflow('$workflow_cancel', name, options?.reason);
|
|
509
|
+
}
|
|
263
510
|
/** Send any buffered items immediately. */
|
|
264
511
|
flush() {
|
|
265
512
|
return this.transport.flush();
|
|
@@ -268,6 +515,11 @@ export class SauronClient {
|
|
|
268
515
|
close() {
|
|
269
516
|
for (const uninstall of this.hookUninstallers.splice(0))
|
|
270
517
|
uninstall();
|
|
518
|
+
// Clear (never auto-cancel — an abandoned workflow is a legitimate,
|
|
519
|
+
// server-derived 30-minute outcome) any workflow left on the shared
|
|
520
|
+
// global scope, so a later init() sharing the same process-wide scope
|
|
521
|
+
// doesn't inherit a stale one.
|
|
522
|
+
getGlobalScope().data.workflow = null;
|
|
271
523
|
return this.transport.close();
|
|
272
524
|
}
|
|
273
525
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* HTTP transport over Node's global `fetch`.
|
|
7
7
|
*/
|
|
8
8
|
import { SauronClient } from './client.js';
|
|
9
|
-
import type { BreadcrumbInput, CaptureExceptionOptions, InitOptions, Level, MetadataOptions, TransactionInput, User } from './types.js';
|
|
9
|
+
import type { BreadcrumbInput, CaptureExceptionOptions, InitOptions, Level, MetadataOptions, TransactionInput, User, WorkflowResult } from './types.js';
|
|
10
10
|
export { SauronClient, describeError } from './client.js';
|
|
11
11
|
export { parseDsn, DsnError } from './dsn.js';
|
|
12
12
|
export type { Dsn } from './dsn.js';
|
|
@@ -15,6 +15,8 @@ export { Transport } from './transport.js';
|
|
|
15
15
|
export { Scope, getGlobalScope, getCurrentScope, withScope, runWithAsyncScope, configureScope, } from './scope.js';
|
|
16
16
|
export { installAutoCapture, installShutdownHooks } from './autocapture.js';
|
|
17
17
|
export type { AutoCaptureOptions } from './autocapture.js';
|
|
18
|
+
/** Bare read of the current scope's workflow — client-agnostic, never throws. */
|
|
19
|
+
export { getWorkflow } from './workflow.js';
|
|
18
20
|
export type * from './types.js';
|
|
19
21
|
/**
|
|
20
22
|
* Initialize the global Sauron client. Returns the client for direct use.
|
|
@@ -41,6 +43,26 @@ export declare function trackTransaction(input: TransactionInput): void;
|
|
|
41
43
|
* SDK is not initialized.
|
|
42
44
|
*/
|
|
43
45
|
export declare function addBreadcrumb(crumb: BreadcrumbInput): void;
|
|
46
|
+
/**
|
|
47
|
+
* Start a named workflow on the current scope (request-isolated via
|
|
48
|
+
* `AsyncLocalStorage` — see `withScope`). No-op state-wise and returns
|
|
49
|
+
* `{ status: 'disabled' }` if the SDK is not initialized.
|
|
50
|
+
*/
|
|
51
|
+
export declare function startWorkflow(name: string, options?: {
|
|
52
|
+
force?: boolean;
|
|
53
|
+
}): WorkflowResult;
|
|
54
|
+
/**
|
|
55
|
+
* End the active workflow (or the one named `name`, if given). Returns
|
|
56
|
+
* `{ status: 'disabled' }` if the SDK is not initialized.
|
|
57
|
+
*/
|
|
58
|
+
export declare function endWorkflow(name?: string): WorkflowResult;
|
|
59
|
+
/**
|
|
60
|
+
* Cancel the active workflow (or the one named `name`, if given). Returns
|
|
61
|
+
* `{ status: 'disabled' }` if the SDK is not initialized.
|
|
62
|
+
*/
|
|
63
|
+
export declare function cancelWorkflow(name?: string, options?: {
|
|
64
|
+
reason?: string;
|
|
65
|
+
}): WorkflowResult;
|
|
44
66
|
/** Set the user on the active scope (the global scope outside a `withScope`). */
|
|
45
67
|
export declare function setUser(user: User | null): void;
|
|
46
68
|
/** Set a single tag on the active scope. */
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,8 @@ export { parseStackString, parseError, isInAppFrame } from './stacktrace.js';
|
|
|
13
13
|
export { Transport } from './transport.js';
|
|
14
14
|
export { Scope, getGlobalScope, getCurrentScope, withScope, runWithAsyncScope, configureScope, } from './scope.js';
|
|
15
15
|
export { installAutoCapture, installShutdownHooks } from './autocapture.js';
|
|
16
|
+
/** Bare read of the current scope's workflow — client-agnostic, never throws. */
|
|
17
|
+
export { getWorkflow } from './workflow.js';
|
|
16
18
|
let activeClient = null;
|
|
17
19
|
/**
|
|
18
20
|
* Initialize the global Sauron client. Returns the client for direct use.
|
|
@@ -56,6 +58,28 @@ export function trackTransaction(input) {
|
|
|
56
58
|
export function addBreadcrumb(crumb) {
|
|
57
59
|
activeClient?.addBreadcrumb(crumb);
|
|
58
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Start a named workflow on the current scope (request-isolated via
|
|
63
|
+
* `AsyncLocalStorage` — see `withScope`). No-op state-wise and returns
|
|
64
|
+
* `{ status: 'disabled' }` if the SDK is not initialized.
|
|
65
|
+
*/
|
|
66
|
+
export function startWorkflow(name, options) {
|
|
67
|
+
return activeClient?.startWorkflow(name, options) ?? { status: 'disabled' };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* End the active workflow (or the one named `name`, if given). Returns
|
|
71
|
+
* `{ status: 'disabled' }` if the SDK is not initialized.
|
|
72
|
+
*/
|
|
73
|
+
export function endWorkflow(name) {
|
|
74
|
+
return activeClient?.endWorkflow(name) ?? { status: 'disabled' };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Cancel the active workflow (or the one named `name`, if given). Returns
|
|
78
|
+
* `{ status: 'disabled' }` if the SDK is not initialized.
|
|
79
|
+
*/
|
|
80
|
+
export function cancelWorkflow(name, options) {
|
|
81
|
+
return activeClient?.cancelWorkflow(name, options) ?? { status: 'disabled' };
|
|
82
|
+
}
|
|
59
83
|
/** Set the user on the active scope (the global scope outside a `withScope`). */
|
|
60
84
|
export function setUser(user) {
|
|
61
85
|
getCurrentScope().setUser(user);
|
package/dist/scope.js
CHANGED
|
@@ -39,6 +39,7 @@ export class Scope {
|
|
|
39
39
|
contexts: {},
|
|
40
40
|
extra: {},
|
|
41
41
|
breadcrumbs: [],
|
|
42
|
+
workflow: null,
|
|
42
43
|
};
|
|
43
44
|
maxBreadcrumbs;
|
|
44
45
|
constructor(maxBreadcrumbs = DEFAULT_MAX_BREADCRUMBS) {
|
|
@@ -87,6 +88,10 @@ export class Scope {
|
|
|
87
88
|
copy.data.contexts = { ...this.data.contexts };
|
|
88
89
|
copy.data.extra = { ...this.data.extra };
|
|
89
90
|
copy.data.breadcrumbs = this.data.breadcrumbs.slice();
|
|
91
|
+
// ActiveWorkflow is never mutated in place (always replaced wholesale by
|
|
92
|
+
// startWorkflow/endWorkflow/cancelWorkflow), so sharing the reference is
|
|
93
|
+
// safe — no need to deep-copy it.
|
|
94
|
+
copy.data.workflow = this.data.workflow;
|
|
90
95
|
return copy;
|
|
91
96
|
}
|
|
92
97
|
/**
|
package/dist/transport.d.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type { Dsn } from './dsn.js';
|
|
|
2
2
|
import type { Context, EnvelopeItem, FetchLike, SleepFn } from './types.js';
|
|
3
3
|
export interface TransportConfig {
|
|
4
4
|
dsn: Dsn;
|
|
5
|
-
environment: string;
|
|
6
5
|
release: string | null;
|
|
7
6
|
context: Context;
|
|
8
7
|
flushInterval: number;
|
|
@@ -74,6 +73,8 @@ export declare class Transport {
|
|
|
74
73
|
private flushChain;
|
|
75
74
|
constructor(config: TransportConfig);
|
|
76
75
|
private startTimer;
|
|
76
|
+
/** Whether the transport still accepts items (false once auth-disabled by a 401/403). */
|
|
77
|
+
isEnabled(): boolean;
|
|
77
78
|
/** Enqueue an item; triggers an eager flush at `maxBatch`. */
|
|
78
79
|
enqueue(item: EnvelopeItem): void;
|
|
79
80
|
private buildEnvelope;
|
package/dist/transport.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { maybeGzip } from './gzip.js';
|
|
2
2
|
import { BoundedQueue } from './queue.js';
|
|
3
3
|
const SDK_NAME = 'sauron-node';
|
|
4
|
-
const SDK_VERSION = '1.
|
|
4
|
+
const SDK_VERSION = '1.4.0';
|
|
5
5
|
/** Default exponential-backoff base (ms) for the first retry. */
|
|
6
6
|
const DEFAULT_RETRY_BASE_MS = 200;
|
|
7
7
|
/** Hard cap on any single backoff delay (ms). */
|
|
@@ -99,6 +99,10 @@ export class Transport {
|
|
|
99
99
|
if (typeof this.timer.unref === 'function')
|
|
100
100
|
this.timer.unref();
|
|
101
101
|
}
|
|
102
|
+
/** Whether the transport still accepts items (false once auth-disabled by a 401/403). */
|
|
103
|
+
isEnabled() {
|
|
104
|
+
return !this.disabled;
|
|
105
|
+
}
|
|
102
106
|
/** Enqueue an item; triggers an eager flush at `maxBatch`. */
|
|
103
107
|
enqueue(item) {
|
|
104
108
|
if (this.disabled)
|
|
@@ -113,7 +117,6 @@ export class Transport {
|
|
|
113
117
|
dsn: this.config.dsn.raw,
|
|
114
118
|
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
|
115
119
|
sent_at: new Date().toISOString(),
|
|
116
|
-
environment: this.config.environment,
|
|
117
120
|
release: this.config.release,
|
|
118
121
|
};
|
|
119
122
|
return { header, context: this.config.context, items };
|