@edraj/sauron-node 1.0.0 → 1.3.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/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. Runs `beforeSend` on every item; a `null`
14
- * return drops it, a returned item replaces it, then it is handed to the
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. Runs `beforeSend` on every item; a `null`
122
- * return drops it, a returned item replaces it, then it is handed to the
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
- const result = beforeSend(item);
129
- if (result == null)
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
  }
@@ -177,8 +203,19 @@ export class SauronClient {
177
203
  track(event, distinctId, properties, options = {}) {
178
204
  if (typeof event !== 'string' || event.length === 0)
179
205
  return;
206
+ // An empty/absent distinct id drops a MANUAL track call: the caller should
207
+ // know who acted. The reserved workflow lifecycle events deliberately
208
+ // bypass this via emitEvent() — see workflowDistinctId().
180
209
  if (typeof distinctId !== 'string' || distinctId.length === 0)
181
210
  return;
211
+ this.emitEvent(event, distinctId, properties, options);
212
+ }
213
+ /**
214
+ * Build and dispatch an event item. Shared by the public {@link track} (which
215
+ * validates first) and the reserved workflow lifecycle emits (which must be
216
+ * able to send an intentionally empty `distinct_id`).
217
+ */
218
+ emitEvent(event, distinctId, properties, options = {}) {
182
219
  const item = {
183
220
  type: 'event',
184
221
  name: event,
@@ -260,6 +297,203 @@ export class SauronClient {
260
297
  };
261
298
  this.dispatch(item);
262
299
  }
300
+ /**
301
+ * Whether this client can still deliver telemetry — false once the
302
+ * transport has auto-disabled itself on a 401/403. Gated on this (not just
303
+ * "does a client object exist") so `startWorkflow` can't mutate local scope
304
+ * state and emit an event the transport would silently drop underneath it.
305
+ */
306
+ isEnabled() {
307
+ return this.transport.isEnabled();
308
+ }
309
+ /** Debug-gated warning log, matching the transport's own `[sauron]`-prefixed convention. */
310
+ debugLog(message, ...args) {
311
+ if (this.options.debug) {
312
+ // eslint-disable-next-line no-console
313
+ console.warn(`[sauron] ${message}`, ...args);
314
+ }
315
+ }
316
+ /**
317
+ * Emit the closing lifecycle event (`$workflow_end`/`$workflow_cancel`) for
318
+ * `active` while it is STILL the current scope's workflow (so the item-level
319
+ * `workflow_id`/`workflow_name` stamped by `dispatch()` are its own, not
320
+ * `null`/absent), through `track()` so scope tags/contexts/extra apply.
321
+ * Never mutates scope state itself — callers own clearing it, in a `finally`
322
+ * relative to this call, so a throw here can't leave state half-mutated.
323
+ *
324
+ * The `distinctId` comes from {@link SauronClient.workflowDistinctId} — read
325
+ * its note on the `'system'` fallback's effect on unique-user counts.
326
+ */
327
+ emitWorkflowClose(active, eventName, reason) {
328
+ const properties = {
329
+ workflow_id: active.workflowId,
330
+ workflow_name: active.name,
331
+ duration_ms: Math.max(0, Date.now() - Date.parse(active.startedAt)),
332
+ };
333
+ if (eventName === '$workflow_cancel') {
334
+ properties.reason = normalizeReason(reason);
335
+ }
336
+ this.emitEvent(eventName, this.workflowDistinctId(), properties);
337
+ }
338
+ /**
339
+ * The `distinct_id` to attribute a workflow lifecycle event to: the scope's
340
+ * user id, else **the empty string**.
341
+ *
342
+ * Empty is deliberate and correct — do NOT "fix" this to a sentinel like
343
+ * `'system'`, an anonymous/device id, or anything derived from the workflow
344
+ * id. The server was built for exactly this case:
345
+ *
346
+ * - `backend/crates/sauron-pipeline/src/process.rs` — both `bump_workflow`
347
+ * call sites pass `Some(distinct_id.as_str()).filter(|s| !s.is_empty())`,
348
+ * so an empty id is stored as SQL `NULL` on the `workflows` row.
349
+ * - `backend/crates/sauron-db/src/repo.rs` — the per-workflow rollup
350
+ * computes `COUNT(DISTINCT w.distinct_id) AS unique_users`, and
351
+ * `COUNT(DISTINCT ...)` skips NULLs.
352
+ *
353
+ * So an anonymous workflow run contributes *nothing* to `unique_users`,
354
+ * which is honest. Any non-empty sentinel would instead collapse every
355
+ * anonymous run of a workflow (`password_reset`, `guest_checkout`, …) into
356
+ * one fake bucket, silently reporting ~1 unique user no matter how many
357
+ * distinct invocations occurred.
358
+ *
359
+ * `EventItem.distinct_id` is a required string on the wire
360
+ * (`backend/crates/sauron-core/src/envelope.rs`), so the field is still
361
+ * always sent — it is just `""`. This is why the lifecycle emits route
362
+ * through {@link emitEvent} rather than the public {@link track}, whose
363
+ * empty-`distinctId` guard would otherwise drop them entirely.
364
+ */
365
+ workflowDistinctId() {
366
+ return getCurrentScope().data.user?.id ?? '';
367
+ }
368
+ /**
369
+ * Start a named workflow on the current scope (the `AsyncLocalStorage`
370
+ * child inside `withScope`/`runWithAsyncScope`, else the process-wide
371
+ * global scope) — request-isolated, so concurrent requests never observe
372
+ * or clobber each other's workflow. `force: true` supersedes an
373
+ * already-active workflow (emitting `$workflow_cancel` with
374
+ * `reason: 'superseded'` for it first); otherwise an active workflow makes
375
+ * this a no-op returning `already_active`.
376
+ *
377
+ * The workflow id is a fresh `randomUUID()`, minted here — never derived
378
+ * from anything deterministic. The server's rollup key is
379
+ * `(app_id, workflow_id)` app-wide, so a reused/derived id would merge
380
+ * counters from unrelated requests/environments into one row.
381
+ *
382
+ * Never throws: an unexpected failure before any side effect is reported as
383
+ * `disabled`, and `disabled` always means literally nothing happened — no
384
+ * event on the wire, no state change. A failure emitting `$workflow_start`
385
+ * AFTER the scope's workflow field was set is still reported as `ok` — the
386
+ * workflow IS live locally, and a lost start event is recoverable
387
+ * server-side (the row materializes from the next stamped item via the same
388
+ * upsert); a lost local id would not be.
389
+ */
390
+ startWorkflow(name, options) {
391
+ try {
392
+ if (!this.isEnabled())
393
+ return { status: 'disabled' };
394
+ const normalized = normalizeWorkflowName(name);
395
+ if (!normalized) {
396
+ this.debugLog('startWorkflow: invalid name', name);
397
+ return { status: 'invalid_name' };
398
+ }
399
+ const scope = getCurrentScope();
400
+ const active = scope.data.workflow;
401
+ if (active && !options?.force) {
402
+ this.debugLog(`startWorkflow("${normalized}"): "${active.name}" is already active; pass { force: true } to replace it`);
403
+ return { status: 'already_active' };
404
+ }
405
+ // Mint the replacement BEFORE superseding anything. If `randomUUID()` or
406
+ // `isoNow()` throws, the outer catch returns `disabled` — which promises
407
+ // the caller that nothing happened and their old workflow is still
408
+ // running. Minting after the supersede emit would break that promise:
409
+ // `$workflow_cancel` for the old workflow would already be on the wire
410
+ // while `scope.data.workflow` still held it, so the caller's eventual
411
+ // `endWorkflow()` would emit a SECOND terminal lifecycle event for a
412
+ // workflow row the server already recorded as cancelled.
413
+ const workflow = {
414
+ workflowId: randomUUID(),
415
+ name: normalized,
416
+ startedAt: isoNow(),
417
+ };
418
+ if (active) {
419
+ // force: supersede the old workflow. Emitted while it is still
420
+ // `scope.data.workflow`, so `dispatch()` stamps the cancel with it.
421
+ try {
422
+ this.emitWorkflowClose(active, '$workflow_cancel', 'superseded');
423
+ }
424
+ catch (emitErr) {
425
+ this.debugLog('startWorkflow: superseding $workflow_cancel emit threw', emitErr);
426
+ }
427
+ }
428
+ // Set state BEFORE emitting so $workflow_start is itself stamped with it.
429
+ scope.data.workflow = workflow;
430
+ try {
431
+ this.emitEvent('$workflow_start', this.workflowDistinctId(), {
432
+ workflow_id: workflow.workflowId,
433
+ workflow_name: workflow.name,
434
+ });
435
+ }
436
+ catch (emitErr) {
437
+ this.debugLog('startWorkflow: $workflow_start emit threw (workflow stays active)', emitErr);
438
+ }
439
+ return { status: 'ok', workflowId: workflow.workflowId };
440
+ }
441
+ catch (err) {
442
+ this.debugLog('startWorkflow threw', err);
443
+ return { status: 'disabled' };
444
+ }
445
+ }
446
+ /** Shared precondition + close logic for `endWorkflow`/`cancelWorkflow`. */
447
+ closeWorkflow(eventName, name, reason) {
448
+ try {
449
+ if (!this.isEnabled())
450
+ return { status: 'disabled' };
451
+ const scope = getCurrentScope();
452
+ const active = scope.data.workflow;
453
+ if (!active)
454
+ return { status: 'not_active' };
455
+ if (name !== undefined && normalizeWorkflowName(name) !== active.name) {
456
+ this.debugLog(`${eventName}: "${name}" does not match active workflow "${active.name}"`);
457
+ return { status: 'name_mismatch' };
458
+ }
459
+ const workflowId = active.workflowId;
460
+ try {
461
+ this.emitWorkflowClose(active, eventName, reason);
462
+ }
463
+ catch (emitErr) {
464
+ this.debugLog(`${eventName} emit threw`, emitErr);
465
+ }
466
+ finally {
467
+ // Clear AFTER emitting (so the closing event still carries the
468
+ // workflow it closes) but UNCONDITIONALLY — even if the emit above
469
+ // threw, endWorkflow/cancelWorkflow must still return `ok` below
470
+ // rather than leaving state half-mutated.
471
+ scope.data.workflow = null;
472
+ }
473
+ return { status: 'ok', workflowId };
474
+ }
475
+ catch (err) {
476
+ this.debugLog(`${eventName} threw`, err);
477
+ return { status: 'disabled' };
478
+ }
479
+ }
480
+ /**
481
+ * End the active workflow (or the one named `name`, if given). Emits
482
+ * `$workflow_end` with `duration_ms` and clears the scope's workflow field.
483
+ * A no-op returning `not_active` (nothing active) or `name_mismatch` (`name`
484
+ * given but does not match, including a `name` that fails normalization).
485
+ */
486
+ endWorkflow(name) {
487
+ return this.closeWorkflow('$workflow_end', name);
488
+ }
489
+ /**
490
+ * Cancel the active workflow (or the one named `name`, if given). Emits
491
+ * `$workflow_cancel` with `duration_ms` and `reason` (default `'user'`,
492
+ * trimmed and capped at 120 chars) and clears the scope's workflow field.
493
+ */
494
+ cancelWorkflow(name, options) {
495
+ return this.closeWorkflow('$workflow_cancel', name, options?.reason);
496
+ }
263
497
  /** Send any buffered items immediately. */
264
498
  flush() {
265
499
  return this.transport.flush();
@@ -268,6 +502,11 @@ export class SauronClient {
268
502
  close() {
269
503
  for (const uninstall of this.hookUninstallers.splice(0))
270
504
  uninstall();
505
+ // Clear (never auto-cancel — an abandoned workflow is a legitimate,
506
+ // server-derived 30-minute outcome) any workflow left on the shared
507
+ // global scope, so a later init() sharing the same process-wide scope
508
+ // doesn't inherit a stale one.
509
+ getGlobalScope().data.workflow = null;
271
510
  return this.transport.close();
272
511
  }
273
512
  }
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
  /**
@@ -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.0.0';
4
+ const SDK_VERSION = '1.3.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 };