@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/CHANGELOG.md +66 -0
- package/LICENSE +661 -0
- package/README.md +138 -0
- package/dist/autocapture.d.ts +35 -0
- package/dist/autocapture.js +115 -0
- package/dist/client.d.ts +42 -0
- package/dist/client.js +291 -0
- package/dist/dsn.d.ts +25 -0
- package/dist/dsn.js +55 -0
- package/dist/gzip.d.ts +19 -0
- package/dist/gzip.js +20 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +90 -0
- package/dist/queue.d.ts +63 -0
- package/dist/queue.js +150 -0
- package/dist/scope.d.ts +70 -0
- package/dist/scope.js +161 -0
- package/dist/stacktrace.d.ts +23 -0
- package/dist/stacktrace.js +93 -0
- package/dist/transport.d.ts +93 -0
- package/dist/transport.js +250 -0
- package/dist/types.d.ts +309 -0
- package/dist/types.js +8 -0
- package/package.json +59 -0
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
|
+
}
|