@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/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
  }
@@ -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: input.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
  /**
@@ -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.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 };