@edraj/sauron-node 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scope.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Scope — global process-wide defaults plus per-request/per-task isolation.
3
+ *
4
+ * A single global {@link Scope} holds process-wide user/tags/context/breadcrumbs.
5
+ * {@link withScope} (or {@link runWithAsyncScope}) layers an isolated child scope
6
+ * over the current one for the duration of a callback, backed by
7
+ * {@link AsyncLocalStorage} so concurrent requests never leak state into each
8
+ * other. Reads merge child-over-parent because a child is a *snapshot* of its
9
+ * parent taken at entry.
10
+ */
11
+ import { AsyncLocalStorage } from 'node:async_hooks';
12
+ const DEFAULT_MAX_BREADCRUMBS = 100;
13
+ /** Stamp a breadcrumb, defaulting missing fields; preserves an existing timestamp. */
14
+ export function normalizeBreadcrumb(input) {
15
+ const existing = input.timestamp;
16
+ return {
17
+ type: input.type ?? 'default',
18
+ category: input.category ?? null,
19
+ message: input.message ?? null,
20
+ level: input.level ?? null,
21
+ timestamp: typeof existing === 'string' ? existing : new Date().toISOString(),
22
+ data: input.data ?? {},
23
+ };
24
+ }
25
+ function toErrorUser(user) {
26
+ if (!user)
27
+ return null;
28
+ return {
29
+ id: user.id ?? null,
30
+ email: user.email ?? null,
31
+ username: user.username ?? null,
32
+ };
33
+ }
34
+ /** The mutable per-scope state and the operations over it. */
35
+ export class Scope {
36
+ data = {
37
+ user: null,
38
+ tags: {},
39
+ contexts: {},
40
+ extra: {},
41
+ breadcrumbs: [],
42
+ };
43
+ maxBreadcrumbs;
44
+ constructor(maxBreadcrumbs = DEFAULT_MAX_BREADCRUMBS) {
45
+ this.maxBreadcrumbs = Math.max(0, maxBreadcrumbs);
46
+ }
47
+ setUser(user) {
48
+ this.data.user = user;
49
+ return this;
50
+ }
51
+ setTag(key, value) {
52
+ this.data.tags[key] = value;
53
+ return this;
54
+ }
55
+ setTags(tags) {
56
+ Object.assign(this.data.tags, tags);
57
+ return this;
58
+ }
59
+ setContext(key, context) {
60
+ this.data.contexts[key] = context;
61
+ return this;
62
+ }
63
+ setExtra(key, value) {
64
+ this.data.extra[key] = value;
65
+ return this;
66
+ }
67
+ addBreadcrumb(crumb) {
68
+ if (this.maxBreadcrumbs <= 0)
69
+ return this;
70
+ this.data.breadcrumbs.push(normalizeBreadcrumb(crumb));
71
+ const overflow = this.data.breadcrumbs.length - this.maxBreadcrumbs;
72
+ if (overflow > 0)
73
+ this.data.breadcrumbs.splice(0, overflow);
74
+ return this;
75
+ }
76
+ setMaxBreadcrumbs(max) {
77
+ this.maxBreadcrumbs = Math.max(0, max);
78
+ const overflow = this.data.breadcrumbs.length - this.maxBreadcrumbs;
79
+ if (overflow > 0)
80
+ this.data.breadcrumbs.splice(0, overflow);
81
+ }
82
+ /** A deep-enough snapshot; children never share mutable containers with parents. */
83
+ clone() {
84
+ const copy = new Scope(this.maxBreadcrumbs);
85
+ copy.data.user = this.data.user ? { ...this.data.user } : null;
86
+ copy.data.tags = { ...this.data.tags };
87
+ copy.data.contexts = { ...this.data.contexts };
88
+ copy.data.extra = { ...this.data.extra };
89
+ copy.data.breadcrumbs = this.data.breadcrumbs.slice();
90
+ return copy;
91
+ }
92
+ /**
93
+ * Layer this scope onto an error item: fill breadcrumbs from the trail, merge
94
+ * scope tags *under* any per-call tags, and set the user only when the item
95
+ * has none (per-call values win).
96
+ */
97
+ applyToErrorItem(item) {
98
+ item.tags = { ...this.data.tags, ...(item.tags ?? {}) };
99
+ const contexts = { ...this.data.contexts, ...(item.contexts ?? {}) };
100
+ if (Object.keys(contexts).length > 0)
101
+ item.contexts = contexts;
102
+ else
103
+ delete item.contexts;
104
+ const extra = { ...this.data.extra, ...(item.extra ?? {}) };
105
+ if (Object.keys(extra).length > 0)
106
+ item.extra = extra;
107
+ else
108
+ delete item.extra;
109
+ if (item.user == null)
110
+ item.user = toErrorUser(this.data.user);
111
+ item.breadcrumbs = this.data.breadcrumbs.slice();
112
+ }
113
+ /**
114
+ * Merge this scope's metadata *under* per-call overrides for non-error items
115
+ * (analytics `track`). tags & extra merge by shallow key; contexts merge by
116
+ * block name (a per-call block replaces the same-named scope block). Empty
117
+ * maps are omitted from the result per the emit convention.
118
+ */
119
+ mergeMetadata(overrides = {}) {
120
+ const out = {};
121
+ const tags = { ...this.data.tags, ...(overrides.tags ?? {}) };
122
+ if (Object.keys(tags).length > 0)
123
+ out.tags = tags;
124
+ const contexts = { ...this.data.contexts, ...(overrides.contexts ?? {}) };
125
+ if (Object.keys(contexts).length > 0)
126
+ out.contexts = contexts;
127
+ const extra = { ...this.data.extra, ...(overrides.extra ?? {}) };
128
+ if (Object.keys(extra).length > 0)
129
+ out.extra = extra;
130
+ return out;
131
+ }
132
+ }
133
+ const globalScope = new Scope();
134
+ const als = new AsyncLocalStorage();
135
+ /** The process-wide scope holding defaults set at/after init. */
136
+ export function getGlobalScope() {
137
+ return globalScope;
138
+ }
139
+ /** The scope in effect right now: the async-local child if inside a scoped block, else global. */
140
+ export function getCurrentScope() {
141
+ return als.getStore() ?? globalScope;
142
+ }
143
+ /**
144
+ * Run `cb` with an isolated child scope (a snapshot of the current one). The
145
+ * child is passed to `cb` and is what {@link getCurrentScope} returns for the
146
+ * duration — including across `await`s inside `cb`. Returns whatever `cb`
147
+ * returns (awaitable if `cb` is async).
148
+ */
149
+ export function withScope(cb) {
150
+ const child = getCurrentScope().clone();
151
+ return als.run(child, () => cb(child));
152
+ }
153
+ /** Like {@link withScope}, but the callback takes no scope argument. */
154
+ export function runWithAsyncScope(cb) {
155
+ const child = getCurrentScope().clone();
156
+ return als.run(child, cb);
157
+ }
158
+ /** Mutate the active scope (the global scope outside any {@link withScope}). */
159
+ export function configureScope(cb) {
160
+ cb(getCurrentScope());
161
+ }
@@ -0,0 +1,23 @@
1
+ import type { Frame } from './types.js';
2
+ /**
3
+ * V8 (`Error.stack`) parser for Node.
4
+ *
5
+ * Produces neutral frames with the CRASHING FRAME LAST (raw V8 stacks list the
6
+ * crash site first, so the parsed list is reversed). No symbolication is
7
+ * performed — line/column/filename pass through verbatim.
8
+ */
9
+ export declare const MAX_FRAMES = 50;
10
+ /**
11
+ * Heuristic for whether a frame belongs to first-party ("in app") code:
12
+ * Node internals (`node:` / `internal/`) and `node_modules` dependencies are
13
+ * not in-app; everything else (the app's own files) is.
14
+ */
15
+ export declare function isInAppFrame(filename: string | null): boolean;
16
+ /**
17
+ * Parse a raw `Error.stack` string into normalized frames (crash frame last).
18
+ * Non-frame lines (the `Error: message` header, `[native code]`, etc.) are
19
+ * skipped.
20
+ */
21
+ export declare function parseStackString(stack: string | undefined | null): Frame[];
22
+ /** Parse an `Error`-like object's `.stack`. */
23
+ export declare function parseError(err: unknown): Frame[];
@@ -0,0 +1,93 @@
1
+ /**
2
+ * V8 (`Error.stack`) parser for Node.
3
+ *
4
+ * Produces neutral frames with the CRASHING FRAME LAST (raw V8 stacks list the
5
+ * crash site first, so the parsed list is reversed). No symbolication is
6
+ * performed — line/column/filename pass through verbatim.
7
+ */
8
+ export const MAX_FRAMES = 50;
9
+ /** V8 / Node: ` at fn (file:line:col)` or ` at file:line:col`. */
10
+ const V8_RE = /^\s*at (?:(.+?) )?\(?((?:[a-z][\w.+-]*:\/\/)?[^\s()]+?):(\d+):(\d+)\)?\s*$/i;
11
+ function cleanFunction(fn) {
12
+ if (!fn)
13
+ return null;
14
+ let name = fn.trim();
15
+ name = name.replace(/^async\s+/, '').replace(/^new\s+/, 'new ');
16
+ name = name.replace(/\s+\[as .+\]$/, '');
17
+ if (name === '<anonymous>' || name === '')
18
+ return null;
19
+ return name;
20
+ }
21
+ /**
22
+ * Heuristic for whether a frame belongs to first-party ("in app") code:
23
+ * Node internals (`node:` / `internal/`) and `node_modules` dependencies are
24
+ * not in-app; everything else (the app's own files) is.
25
+ */
26
+ export function isInAppFrame(filename) {
27
+ if (!filename)
28
+ return false;
29
+ if (filename === '<anonymous>' ||
30
+ filename.startsWith('node:') ||
31
+ filename.startsWith('internal/') ||
32
+ filename.startsWith('node internal')) {
33
+ return false;
34
+ }
35
+ if (filename.includes('node_modules'))
36
+ return false;
37
+ return true;
38
+ }
39
+ function stripFileProtocol(filename) {
40
+ return filename.startsWith('file://') ? filename.slice('file://'.length) : filename;
41
+ }
42
+ function parseLine(line) {
43
+ const m = V8_RE.exec(line);
44
+ if (!m)
45
+ return null;
46
+ const rawFile = m[2] || null;
47
+ const filename = rawFile ? stripFileProtocol(rawFile) : null;
48
+ const lineno = m[3] ? parseInt(m[3], 10) : null;
49
+ const colno = m[4] ? parseInt(m[4], 10) : null;
50
+ return {
51
+ function: cleanFunction(m[1]),
52
+ module: null,
53
+ filename,
54
+ abs_path: filename,
55
+ lineno: lineno !== null && Number.isNaN(lineno) ? null : lineno,
56
+ colno: colno !== null && Number.isNaN(colno) ? null : colno,
57
+ in_app: isInAppFrame(filename),
58
+ };
59
+ }
60
+ /**
61
+ * Parse a raw `Error.stack` string into normalized frames (crash frame last).
62
+ * Non-frame lines (the `Error: message` header, `[native code]`, etc.) are
63
+ * skipped.
64
+ */
65
+ export function parseStackString(stack) {
66
+ if (!stack)
67
+ return [];
68
+ const frames = [];
69
+ const lines = stack.split('\n');
70
+ for (const raw of lines) {
71
+ const line = raw.replace(/\r$/, '');
72
+ if (!line.trim())
73
+ continue;
74
+ const frame = parseLine(line);
75
+ if (frame) {
76
+ frames.push(frame);
77
+ if (frames.length >= MAX_FRAMES)
78
+ break; // keep frames nearest the crash
79
+ }
80
+ }
81
+ // Raw V8 stacks are crash-first; the wire contract wants crash-last.
82
+ frames.reverse();
83
+ return frames;
84
+ }
85
+ /** Parse an `Error`-like object's `.stack`. */
86
+ export function parseError(err) {
87
+ if (err && typeof err === 'object' && 'stack' in err) {
88
+ const stack = err.stack;
89
+ if (typeof stack === 'string')
90
+ return parseStackString(stack);
91
+ }
92
+ return [];
93
+ }
@@ -0,0 +1,93 @@
1
+ import type { Dsn } from './dsn.js';
2
+ import type { Context, EnvelopeItem, FetchLike, SleepFn } from './types.js';
3
+ export interface TransportConfig {
4
+ dsn: Dsn;
5
+ environment: string;
6
+ release: string | null;
7
+ context: Context;
8
+ flushInterval: number;
9
+ maxBatch: number;
10
+ fetchImpl?: FetchLike;
11
+ debug: boolean;
12
+ /** Gzip the body once it exceeds this many bytes. Default 1024. */
13
+ gzipThresholdBytes?: number;
14
+ /** Drop-oldest byte cap for the in-memory buffer. Default 1 MiB. */
15
+ maxQueueBytes?: number;
16
+ /**
17
+ * Hard cap on items per envelope. Default 1000, matching the server's limit —
18
+ * exceeding it is a non-retryable 400, which would discard the batch.
19
+ */
20
+ maxItemsPerEnvelope?: number;
21
+ /** Opt-in directory for FIFO disk persistence. Default off. */
22
+ offlineDir?: string | null;
23
+ /** Max retries after the first attempt. Default 3. */
24
+ maxRetries?: number;
25
+ /** Backoff base (ms). Default 200. */
26
+ retryBaseMs?: number;
27
+ /** Deterministic sleep seam (tests). Defaults to a real timer. */
28
+ sleep?: SleepFn;
29
+ /** Jitter source (tests). Defaults to `Math.random`. */
30
+ random?: () => number;
31
+ }
32
+ /**
33
+ * Parse a `Retry-After` header (delta-seconds or an HTTP-date) into a delay in
34
+ * ms, clamped to non-negative. Returns `null` when absent/unparseable.
35
+ */
36
+ export declare function parseRetryAfter(value: string | null | undefined, nowMs: number): number | null;
37
+ /**
38
+ * Buffered background transport.
39
+ *
40
+ * Items accumulate in a byte-bounded {@link BoundedQueue} (optionally persisted
41
+ * to disk). A `setInterval` timer (unref'd, so it never keeps the event loop
42
+ * alive) flushes every `flushInterval` ms, and an eager flush fires once the
43
+ * queue reaches `maxBatch`. Each flush drains a batch into one envelope, gzips
44
+ * it when large, and POSTs it with an exponential-backoff retry policy:
45
+ *
46
+ * - retry 408/429/5xx and network errors (honoring `Retry-After` on 429),
47
+ * - shrink and re-buffer on 413 rather than retrying an identically-sized body,
48
+ * - drop (no retry) on 400/401/403/404 — 401/403 also disable the SDK,
49
+ * - after `maxRetries` transient failures, the batch is re-buffered (kept for a
50
+ * later flush / next process start) rather than lost.
51
+ *
52
+ * Flushes are serialized through a promise chain so a batch is never drained by
53
+ * two overlapping flushes.
54
+ */
55
+ export declare class Transport {
56
+ private readonly config;
57
+ private readonly buffer;
58
+ private timer;
59
+ private readonly fetchImpl;
60
+ private disabled;
61
+ private readonly gzipThreshold;
62
+ private readonly maxRetries;
63
+ private readonly retryBaseMs;
64
+ private readonly sleep;
65
+ private readonly random;
66
+ /** Configured ceiling on items per envelope. */
67
+ private readonly maxItemsPerEnvelope;
68
+ /**
69
+ * Working chunk size, halved whenever the server rejects an envelope as too
70
+ * large. Without this a 413 was retried with the identical body forever.
71
+ */
72
+ private chunkItems;
73
+ /** Serializes flushes so a batch is drained/committed atomically. */
74
+ private flushChain;
75
+ constructor(config: TransportConfig);
76
+ private startTimer;
77
+ /** Enqueue an item; triggers an eager flush at `maxBatch`. */
78
+ enqueue(item: EnvelopeItem): void;
79
+ private buildEnvelope;
80
+ /**
81
+ * Drain a batch and send it. Serialized through {@link flushChain} so
82
+ * overlapping calls (eager + timer + manual) never race the queue.
83
+ */
84
+ flush(): Promise<void>;
85
+ private drainAndSend;
86
+ private send;
87
+ /** Exponential backoff with equal-jitter, capped at {@link RETRY_CAP_MS}. */
88
+ private backoffDelay;
89
+ /** Flush then stop the timer. After close the transport still accepts a
90
+ * final manual `flush`, but the background timer no longer fires. */
91
+ close(): Promise<void>;
92
+ private log;
93
+ }
@@ -0,0 +1,250 @@
1
+ import { maybeGzip } from './gzip.js';
2
+ import { BoundedQueue } from './queue.js';
3
+ const SDK_NAME = 'sauron-node';
4
+ const SDK_VERSION = '1.0.0';
5
+ /** Default exponential-backoff base (ms) for the first retry. */
6
+ const DEFAULT_RETRY_BASE_MS = 200;
7
+ /** Hard cap on any single backoff delay (ms). */
8
+ const RETRY_CAP_MS = 30_000;
9
+ /** Statuses that are worth retrying (plus any 5xx and network errors). */
10
+ function isRetryableStatus(status) {
11
+ return status === 408 || status === 413 || status === 429 || status >= 500;
12
+ }
13
+ /**
14
+ * Parse a `Retry-After` header (delta-seconds or an HTTP-date) into a delay in
15
+ * ms, clamped to non-negative. Returns `null` when absent/unparseable.
16
+ */
17
+ export function parseRetryAfter(value, nowMs) {
18
+ if (value == null)
19
+ return null;
20
+ const trimmed = value.trim();
21
+ if (trimmed === '')
22
+ return null;
23
+ const secs = Number(trimmed);
24
+ if (Number.isFinite(secs))
25
+ return Math.max(0, secs * 1000);
26
+ const dateMs = Date.parse(trimmed);
27
+ if (!Number.isNaN(dateMs))
28
+ return Math.max(0, dateMs - nowMs);
29
+ return null;
30
+ }
31
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
32
+ /**
33
+ * Buffered background transport.
34
+ *
35
+ * Items accumulate in a byte-bounded {@link BoundedQueue} (optionally persisted
36
+ * to disk). A `setInterval` timer (unref'd, so it never keeps the event loop
37
+ * alive) flushes every `flushInterval` ms, and an eager flush fires once the
38
+ * queue reaches `maxBatch`. Each flush drains a batch into one envelope, gzips
39
+ * it when large, and POSTs it with an exponential-backoff retry policy:
40
+ *
41
+ * - retry 408/429/5xx and network errors (honoring `Retry-After` on 429),
42
+ * - shrink and re-buffer on 413 rather than retrying an identically-sized body,
43
+ * - drop (no retry) on 400/401/403/404 — 401/403 also disable the SDK,
44
+ * - after `maxRetries` transient failures, the batch is re-buffered (kept for a
45
+ * later flush / next process start) rather than lost.
46
+ *
47
+ * Flushes are serialized through a promise chain so a batch is never drained by
48
+ * two overlapping flushes.
49
+ */
50
+ export class Transport {
51
+ config;
52
+ buffer;
53
+ timer = null;
54
+ fetchImpl;
55
+ disabled = false;
56
+ gzipThreshold;
57
+ maxRetries;
58
+ retryBaseMs;
59
+ sleep;
60
+ random;
61
+ /** Configured ceiling on items per envelope. */
62
+ maxItemsPerEnvelope;
63
+ /**
64
+ * Working chunk size, halved whenever the server rejects an envelope as too
65
+ * large. Without this a 413 was retried with the identical body forever.
66
+ */
67
+ chunkItems;
68
+ /** Serializes flushes so a batch is drained/committed atomically. */
69
+ flushChain = Promise.resolve();
70
+ constructor(config) {
71
+ this.config = config;
72
+ const injected = config.fetchImpl;
73
+ const globalFetch = globalThis.fetch;
74
+ const chosen = injected ?? globalFetch;
75
+ if (!chosen) {
76
+ throw new Error('[sauron] global fetch is unavailable (Node >= 18 required) and no fetchImpl was provided');
77
+ }
78
+ this.fetchImpl = chosen;
79
+ this.gzipThreshold = config.gzipThresholdBytes ?? 1024;
80
+ this.maxRetries = Math.max(0, config.maxRetries ?? 3);
81
+ this.retryBaseMs = config.retryBaseMs ?? DEFAULT_RETRY_BASE_MS;
82
+ this.maxItemsPerEnvelope = Math.max(1, config.maxItemsPerEnvelope ?? 1000);
83
+ this.chunkItems = this.maxItemsPerEnvelope;
84
+ this.sleep = config.sleep ?? realSleep;
85
+ this.random = config.random ?? Math.random;
86
+ this.buffer = new BoundedQueue({
87
+ maxBytes: config.maxQueueBytes ?? 1_048_576,
88
+ offlineDir: config.offlineDir ?? null,
89
+ });
90
+ this.startTimer();
91
+ }
92
+ startTimer() {
93
+ if (this.timer || this.config.flushInterval <= 0)
94
+ return;
95
+ this.timer = setInterval(() => {
96
+ void this.flush();
97
+ }, this.config.flushInterval);
98
+ // Do not hold the event loop open for the flush timer.
99
+ if (typeof this.timer.unref === 'function')
100
+ this.timer.unref();
101
+ }
102
+ /** Enqueue an item; triggers an eager flush at `maxBatch`. */
103
+ enqueue(item) {
104
+ if (this.disabled)
105
+ return;
106
+ this.buffer.push(item);
107
+ if (this.buffer.size >= this.config.maxBatch) {
108
+ void this.flush();
109
+ }
110
+ }
111
+ buildEnvelope(items) {
112
+ const header = {
113
+ dsn: this.config.dsn.raw,
114
+ sdk: { name: SDK_NAME, version: SDK_VERSION },
115
+ sent_at: new Date().toISOString(),
116
+ environment: this.config.environment,
117
+ release: this.config.release,
118
+ };
119
+ return { header, context: this.config.context, items };
120
+ }
121
+ /**
122
+ * Drain a batch and send it. Serialized through {@link flushChain} so
123
+ * overlapping calls (eager + timer + manual) never race the queue.
124
+ */
125
+ flush() {
126
+ const next = this.flushChain.then(() => this.drainAndSend());
127
+ // Swallow rejections on the chain so one failure can't poison later flushes.
128
+ this.flushChain = next.catch(() => undefined);
129
+ return this.flushChain;
130
+ }
131
+ async drainAndSend() {
132
+ if (this.disabled)
133
+ return;
134
+ // Send in bounded chunks rather than one envelope per flush. A queue that
135
+ // filled during an outage would otherwise go out as a single oversized
136
+ // envelope and be rejected outright.
137
+ for (;;) {
138
+ const items = this.buffer.drain(this.chunkItems);
139
+ if (items.length === 0) {
140
+ this.buffer.commit();
141
+ return;
142
+ }
143
+ const before = this.buffer.pending;
144
+ await this.send(this.buildEnvelope(items));
145
+ if (this.disabled)
146
+ return;
147
+ // Nothing left, or the send failed and re-buffered: stop rather than spin.
148
+ if (this.buffer.pending === 0 || this.buffer.pending >= before + items.length) {
149
+ return;
150
+ }
151
+ }
152
+ }
153
+ async send(envelope) {
154
+ const { dsn } = this.config;
155
+ const raw = JSON.stringify(envelope);
156
+ const { body, headers: encodingHeaders } = maybeGzip(raw, this.gzipThreshold);
157
+ const headers = {
158
+ 'Content-Type': 'application/json',
159
+ 'X-Sauron-Key': dsn.publicKey,
160
+ ...encodingHeaders,
161
+ };
162
+ let attempt = 0;
163
+ for (;;) {
164
+ let retryAfterMs = null;
165
+ try {
166
+ const res = await this.fetchImpl(dsn.envelopeUrl, {
167
+ method: 'POST',
168
+ headers,
169
+ body,
170
+ });
171
+ const status = res.status;
172
+ if (status >= 200 && status < 300) {
173
+ this.buffer.commit();
174
+ // A successful send means the shrunken chunk size did its job; go
175
+ // back to full-size envelopes rather than staying degraded forever.
176
+ this.chunkItems = this.maxItemsPerEnvelope;
177
+ return;
178
+ }
179
+ if (status === 401 || status === 403) {
180
+ this.disabled = true;
181
+ this.log(`auth failed (${status}); disabling SDK`);
182
+ this.buffer.commit();
183
+ return;
184
+ }
185
+ if (status === 413) {
186
+ // Retrying the identical body can only fail identically. Shrink the
187
+ // envelope and re-buffer so the next flush makes progress; retrying
188
+ // as-is wedged the transport permanently once the queue was full.
189
+ if (envelope.items.length > 1) {
190
+ this.chunkItems = Math.max(1, Math.floor(envelope.items.length / 2));
191
+ this.buffer.restore();
192
+ this.log(`ingest returned 413; halving envelope to ${this.chunkItems} item(s)`);
193
+ return;
194
+ }
195
+ // A single item that still doesn't fit never will.
196
+ this.log('ingest returned 413 for a single item; dropping it');
197
+ this.buffer.commit();
198
+ this.chunkItems = this.maxItemsPerEnvelope;
199
+ return;
200
+ }
201
+ if (!isRetryableStatus(status)) {
202
+ this.log(`ingest returned ${status}; dropping ${envelope.items.length} item(s)`);
203
+ this.buffer.commit();
204
+ return;
205
+ }
206
+ // Retryable HTTP status.
207
+ if (status === 429) {
208
+ retryAfterMs = parseRetryAfter(res.headers?.get('retry-after'), Date.now());
209
+ }
210
+ this.log(`ingest returned ${status}; will retry (attempt ${attempt + 1})`);
211
+ }
212
+ catch (err) {
213
+ // Network-level failure — retryable.
214
+ this.log(`transport error: ${String(err)} (attempt ${attempt + 1})`);
215
+ }
216
+ if (attempt >= this.maxRetries) {
217
+ // Out of retries: keep the batch buffered for a later flush / restart
218
+ // rather than dropping it on the floor.
219
+ this.buffer.restore();
220
+ this.log(`giving up after ${attempt + 1} attempt(s); re-buffering batch`);
221
+ return;
222
+ }
223
+ const delay = retryAfterMs ?? this.backoffDelay(attempt);
224
+ await this.sleep(Math.min(delay, RETRY_CAP_MS));
225
+ attempt += 1;
226
+ }
227
+ }
228
+ /** Exponential backoff with equal-jitter, capped at {@link RETRY_CAP_MS}. */
229
+ backoffDelay(attempt) {
230
+ const exp = Math.min(RETRY_CAP_MS, this.retryBaseMs * 2 ** attempt);
231
+ const half = exp / 2;
232
+ return Math.min(RETRY_CAP_MS, half + this.random() * half);
233
+ }
234
+ /** Flush then stop the timer. After close the transport still accepts a
235
+ * final manual `flush`, but the background timer no longer fires. */
236
+ async close() {
237
+ await this.flush();
238
+ if (this.timer) {
239
+ clearInterval(this.timer);
240
+ this.timer = null;
241
+ }
242
+ await this.flushChain;
243
+ }
244
+ log(message) {
245
+ if (this.config.debug) {
246
+ // eslint-disable-next-line no-console
247
+ console.warn(`[sauron] ${message}`);
248
+ }
249
+ }
250
+ }