@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/dsn.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSN parsing.
|
|
3
|
+
*
|
|
4
|
+
* A DSN looks like `https://<public_key>@<host>/<project_id>`. The public key
|
|
5
|
+
* is a non-secret, write-only credential.
|
|
6
|
+
*/
|
|
7
|
+
export class DsnError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(`[sauron] invalid DSN: ${message}`);
|
|
10
|
+
this.name = 'DsnError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Parse and validate a DSN, deriving the transport URL. */
|
|
14
|
+
export function parseDsn(dsn) {
|
|
15
|
+
if (typeof dsn !== 'string' || dsn.length === 0) {
|
|
16
|
+
throw new DsnError('DSN must be a non-empty string');
|
|
17
|
+
}
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(dsn);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new DsnError(`could not parse "${dsn}"`);
|
|
24
|
+
}
|
|
25
|
+
const protocol = url.protocol.replace(/:$/, '');
|
|
26
|
+
if (protocol !== 'http' && protocol !== 'https') {
|
|
27
|
+
throw new DsnError(`unsupported protocol "${protocol}"`);
|
|
28
|
+
}
|
|
29
|
+
const publicKey = url.username;
|
|
30
|
+
if (!publicKey) {
|
|
31
|
+
throw new DsnError('missing public key (the "user" part of the URL)');
|
|
32
|
+
}
|
|
33
|
+
if (url.password) {
|
|
34
|
+
throw new DsnError('DSN must not contain a secret (password component)');
|
|
35
|
+
}
|
|
36
|
+
const host = url.host; // includes port when present
|
|
37
|
+
const hostname = url.hostname;
|
|
38
|
+
if (!host) {
|
|
39
|
+
throw new DsnError('missing host');
|
|
40
|
+
}
|
|
41
|
+
const projectId = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
42
|
+
if (!projectId) {
|
|
43
|
+
throw new DsnError('missing project id (the path segment)');
|
|
44
|
+
}
|
|
45
|
+
const envelopeUrl = `${protocol}://${host}/api/${projectId}/envelope`;
|
|
46
|
+
return {
|
|
47
|
+
raw: dsn,
|
|
48
|
+
publicKey,
|
|
49
|
+
host,
|
|
50
|
+
hostname,
|
|
51
|
+
protocol,
|
|
52
|
+
projectId,
|
|
53
|
+
envelopeUrl,
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/gzip.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional gzip of the request body.
|
|
3
|
+
*
|
|
4
|
+
* The ingest accepts `Content-Encoding: gzip` (see the module docs in
|
|
5
|
+
* `sauron-core/src/envelope.rs`). We only pay the CPU cost when the payload is
|
|
6
|
+
* large enough to matter — bodies at or below `threshold` bytes go out
|
|
7
|
+
* uncompressed. Node >= 18 ships `zlib`, so this adds no runtime dependency.
|
|
8
|
+
*/
|
|
9
|
+
/** The (possibly compressed) body plus the headers to merge into the request. */
|
|
10
|
+
export interface MaybeGzipResult {
|
|
11
|
+
body: Buffer | string;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Gzip `body` when its byte length exceeds `threshold`; otherwise pass it
|
|
16
|
+
* through untouched. Returns the encoding header only when compression happened.
|
|
17
|
+
* A negative threshold disables compression entirely.
|
|
18
|
+
*/
|
|
19
|
+
export declare function maybeGzip(body: string, threshold: number): MaybeGzipResult;
|
package/dist/gzip.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional gzip of the request body.
|
|
3
|
+
*
|
|
4
|
+
* The ingest accepts `Content-Encoding: gzip` (see the module docs in
|
|
5
|
+
* `sauron-core/src/envelope.rs`). We only pay the CPU cost when the payload is
|
|
6
|
+
* large enough to matter — bodies at or below `threshold` bytes go out
|
|
7
|
+
* uncompressed. Node >= 18 ships `zlib`, so this adds no runtime dependency.
|
|
8
|
+
*/
|
|
9
|
+
import { gzipSync } from 'node:zlib';
|
|
10
|
+
/**
|
|
11
|
+
* Gzip `body` when its byte length exceeds `threshold`; otherwise pass it
|
|
12
|
+
* through untouched. Returns the encoding header only when compression happened.
|
|
13
|
+
* A negative threshold disables compression entirely.
|
|
14
|
+
*/
|
|
15
|
+
export function maybeGzip(body, threshold) {
|
|
16
|
+
if (threshold < 0 || Buffer.byteLength(body) <= threshold) {
|
|
17
|
+
return { body, headers: {} };
|
|
18
|
+
}
|
|
19
|
+
return { body: gzipSync(body), headers: { 'Content-Encoding': 'gzip' } };
|
|
20
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @edraj/sauron-node — server-side Node/TypeScript SDK.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches product-analytics events and captured exceptions to the Sauron
|
|
5
|
+
* ingest gateway. No browser/DOM/auto-instrumentation — a buffered background
|
|
6
|
+
* HTTP transport over Node's global `fetch`.
|
|
7
|
+
*/
|
|
8
|
+
import { SauronClient } from './client.js';
|
|
9
|
+
import type { BreadcrumbInput, CaptureExceptionOptions, InitOptions, Level, MetadataOptions, TransactionInput, User } from './types.js';
|
|
10
|
+
export { SauronClient, describeError } from './client.js';
|
|
11
|
+
export { parseDsn, DsnError } from './dsn.js';
|
|
12
|
+
export type { Dsn } from './dsn.js';
|
|
13
|
+
export { parseStackString, parseError, isInAppFrame } from './stacktrace.js';
|
|
14
|
+
export { Transport } from './transport.js';
|
|
15
|
+
export { Scope, getGlobalScope, getCurrentScope, withScope, runWithAsyncScope, configureScope, } from './scope.js';
|
|
16
|
+
export { installAutoCapture, installShutdownHooks } from './autocapture.js';
|
|
17
|
+
export type { AutoCaptureOptions } from './autocapture.js';
|
|
18
|
+
export type * from './types.js';
|
|
19
|
+
/**
|
|
20
|
+
* Initialize the global Sauron client. Returns the client for direct use.
|
|
21
|
+
* A clearly-invalid DSN throws a typed `DsnError`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function init(options: InitOptions): SauronClient;
|
|
24
|
+
/** The client created by the most recent {@link init}, if any. */
|
|
25
|
+
export declare function getClient(): SauronClient | null;
|
|
26
|
+
/** Capture a product-analytics event. No-op if the SDK is not initialized. */
|
|
27
|
+
export declare function track(event: string, distinctId: string, properties?: Record<string, unknown>, options?: MetadataOptions): void;
|
|
28
|
+
/** Capture a native `Error`. No-op if the SDK is not initialized. */
|
|
29
|
+
export declare function captureException(error: unknown, options?: CaptureExceptionOptions): void;
|
|
30
|
+
/** Capture a bare message. No-op if the SDK is not initialized. */
|
|
31
|
+
export declare function captureMessage(message: string, level?: Level, options?: MetadataOptions): void;
|
|
32
|
+
/** Associate traits with a distinct id. No-op if the SDK is not initialized. */
|
|
33
|
+
export declare function identify(distinctId: string, traits?: Record<string, unknown>): void;
|
|
34
|
+
/**
|
|
35
|
+
* Emit a performance transaction. No-op if the SDK is not initialized.
|
|
36
|
+
* `distinct_id` falls back to the scoped user's id when omitted.
|
|
37
|
+
*/
|
|
38
|
+
export declare function trackTransaction(input: TransactionInput): void;
|
|
39
|
+
/**
|
|
40
|
+
* Add a breadcrumb to the active scope (runs `beforeBreadcrumb`). No-op if the
|
|
41
|
+
* SDK is not initialized.
|
|
42
|
+
*/
|
|
43
|
+
export declare function addBreadcrumb(crumb: BreadcrumbInput): void;
|
|
44
|
+
/** Set the user on the active scope (the global scope outside a `withScope`). */
|
|
45
|
+
export declare function setUser(user: User | null): void;
|
|
46
|
+
/** Set a single tag on the active scope. */
|
|
47
|
+
export declare function setTag(key: string, value: string): void;
|
|
48
|
+
/** Merge several tags onto the active scope. */
|
|
49
|
+
export declare function setTags(tags: Record<string, string>): void;
|
|
50
|
+
/** Set a named free-form context block on the active scope. */
|
|
51
|
+
export declare function setContext(key: string, context: Record<string, unknown> | unknown): void;
|
|
52
|
+
/** Set a single free-form extra value on the active scope. */
|
|
53
|
+
export declare function setExtra(key: string, value: unknown): void;
|
|
54
|
+
/** Flush buffered items immediately. Resolves once sent (or if not init). */
|
|
55
|
+
export declare function flush(): Promise<void>;
|
|
56
|
+
/** Flush and stop the background timer, then clear the active client. */
|
|
57
|
+
export declare function close(): Promise<void>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @edraj/sauron-node — server-side Node/TypeScript SDK.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches product-analytics events and captured exceptions to the Sauron
|
|
5
|
+
* ingest gateway. No browser/DOM/auto-instrumentation — a buffered background
|
|
6
|
+
* HTTP transport over Node's global `fetch`.
|
|
7
|
+
*/
|
|
8
|
+
import { SauronClient } from './client.js';
|
|
9
|
+
import { getCurrentScope } from './scope.js';
|
|
10
|
+
export { SauronClient, describeError } from './client.js';
|
|
11
|
+
export { parseDsn, DsnError } from './dsn.js';
|
|
12
|
+
export { parseStackString, parseError, isInAppFrame } from './stacktrace.js';
|
|
13
|
+
export { Transport } from './transport.js';
|
|
14
|
+
export { Scope, getGlobalScope, getCurrentScope, withScope, runWithAsyncScope, configureScope, } from './scope.js';
|
|
15
|
+
export { installAutoCapture, installShutdownHooks } from './autocapture.js';
|
|
16
|
+
let activeClient = null;
|
|
17
|
+
/**
|
|
18
|
+
* Initialize the global Sauron client. Returns the client for direct use.
|
|
19
|
+
* A clearly-invalid DSN throws a typed `DsnError`.
|
|
20
|
+
*/
|
|
21
|
+
export function init(options) {
|
|
22
|
+
activeClient = new SauronClient(options);
|
|
23
|
+
return activeClient;
|
|
24
|
+
}
|
|
25
|
+
/** The client created by the most recent {@link init}, if any. */
|
|
26
|
+
export function getClient() {
|
|
27
|
+
return activeClient;
|
|
28
|
+
}
|
|
29
|
+
/** Capture a product-analytics event. No-op if the SDK is not initialized. */
|
|
30
|
+
export function track(event, distinctId, properties, options) {
|
|
31
|
+
activeClient?.track(event, distinctId, properties, options);
|
|
32
|
+
}
|
|
33
|
+
/** Capture a native `Error`. No-op if the SDK is not initialized. */
|
|
34
|
+
export function captureException(error, options) {
|
|
35
|
+
activeClient?.captureException(error, options);
|
|
36
|
+
}
|
|
37
|
+
/** Capture a bare message. No-op if the SDK is not initialized. */
|
|
38
|
+
export function captureMessage(message, level, options) {
|
|
39
|
+
activeClient?.captureMessage(message, level, options);
|
|
40
|
+
}
|
|
41
|
+
/** Associate traits with a distinct id. No-op if the SDK is not initialized. */
|
|
42
|
+
export function identify(distinctId, traits) {
|
|
43
|
+
activeClient?.identify(distinctId, traits);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Emit a performance transaction. No-op if the SDK is not initialized.
|
|
47
|
+
* `distinct_id` falls back to the scoped user's id when omitted.
|
|
48
|
+
*/
|
|
49
|
+
export function trackTransaction(input) {
|
|
50
|
+
activeClient?.trackTransaction(input);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Add a breadcrumb to the active scope (runs `beforeBreadcrumb`). No-op if the
|
|
54
|
+
* SDK is not initialized.
|
|
55
|
+
*/
|
|
56
|
+
export function addBreadcrumb(crumb) {
|
|
57
|
+
activeClient?.addBreadcrumb(crumb);
|
|
58
|
+
}
|
|
59
|
+
/** Set the user on the active scope (the global scope outside a `withScope`). */
|
|
60
|
+
export function setUser(user) {
|
|
61
|
+
getCurrentScope().setUser(user);
|
|
62
|
+
}
|
|
63
|
+
/** Set a single tag on the active scope. */
|
|
64
|
+
export function setTag(key, value) {
|
|
65
|
+
getCurrentScope().setTag(key, value);
|
|
66
|
+
}
|
|
67
|
+
/** Merge several tags onto the active scope. */
|
|
68
|
+
export function setTags(tags) {
|
|
69
|
+
getCurrentScope().setTags(tags);
|
|
70
|
+
}
|
|
71
|
+
/** Set a named free-form context block on the active scope. */
|
|
72
|
+
export function setContext(key, context) {
|
|
73
|
+
getCurrentScope().setContext(key, context);
|
|
74
|
+
}
|
|
75
|
+
/** Set a single free-form extra value on the active scope. */
|
|
76
|
+
export function setExtra(key, value) {
|
|
77
|
+
getCurrentScope().setExtra(key, value);
|
|
78
|
+
}
|
|
79
|
+
/** Flush buffered items immediately. Resolves once sent (or if not init). */
|
|
80
|
+
export function flush() {
|
|
81
|
+
return activeClient ? activeClient.flush() : Promise.resolve();
|
|
82
|
+
}
|
|
83
|
+
/** Flush and stop the background timer, then clear the active client. */
|
|
84
|
+
export async function close() {
|
|
85
|
+
if (!activeClient)
|
|
86
|
+
return;
|
|
87
|
+
const client = activeClient;
|
|
88
|
+
activeClient = null;
|
|
89
|
+
await client.close();
|
|
90
|
+
}
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded in-memory send buffer with opt-in disk persistence.
|
|
3
|
+
*
|
|
4
|
+
* Items accumulate here between flushes. The buffer is byte-capped: when a push
|
|
5
|
+
* would exceed `maxBytes`, the oldest items are dropped first (drop-oldest ring)
|
|
6
|
+
* so a stalled ingest can never grow memory without bound.
|
|
7
|
+
*
|
|
8
|
+
* When `offlineDir` is set, every queued item is also written to a FIFO file so
|
|
9
|
+
* that a crash mid-outage doesn't lose it: a fresh instance reloads the pending
|
|
10
|
+
* files on construction (at-least-once across restarts). Files are deleted only
|
|
11
|
+
* once their batch is committed (delivered or intentionally dropped). Disk
|
|
12
|
+
* persistence is off by default — servers shouldn't be forced into filesystem
|
|
13
|
+
* assumptions (ephemeral containers).
|
|
14
|
+
*
|
|
15
|
+
* The flush cycle is: `push` (many) → `drain` (take a batch, moving it "in
|
|
16
|
+
* flight") → `commit` (delivered/dropped: forget it, delete its files) or
|
|
17
|
+
* `restore` (send failed: put it back at the head, keep its files).
|
|
18
|
+
*/
|
|
19
|
+
import type { EnvelopeItem } from './types.js';
|
|
20
|
+
export interface BoundedQueueOptions {
|
|
21
|
+
/** Drop-oldest byte cap for the in-memory buffer. */
|
|
22
|
+
maxBytes: number;
|
|
23
|
+
/** When set, persist pending items here (FIFO) for at-least-once across restarts. */
|
|
24
|
+
offlineDir?: string | null;
|
|
25
|
+
}
|
|
26
|
+
export declare class BoundedQueue {
|
|
27
|
+
private entries;
|
|
28
|
+
/** The batch currently handed to the transport, awaiting commit/restore. */
|
|
29
|
+
private inflight;
|
|
30
|
+
private byteCount;
|
|
31
|
+
private seq;
|
|
32
|
+
private readonly maxBytes;
|
|
33
|
+
private readonly offlineDir;
|
|
34
|
+
constructor(options: BoundedQueueOptions);
|
|
35
|
+
/** Reload persisted items (FIFO by filename) from a previous run. */
|
|
36
|
+
private reload;
|
|
37
|
+
/** Enqueue an item; persists it when offline, then enforces the byte cap. */
|
|
38
|
+
push(item: EnvelopeItem): void;
|
|
39
|
+
/** Drop oldest entries until the byte cap is satisfied. */
|
|
40
|
+
private evict;
|
|
41
|
+
/**
|
|
42
|
+
* Take up to `maxItems` queued entries, moving them "in flight". The
|
|
43
|
+
* transport must later {@link commit} them (delivered/dropped) or
|
|
44
|
+
* {@link restore} them (failed).
|
|
45
|
+
*
|
|
46
|
+
* The cap matters. Draining everything put the whole queue in one envelope,
|
|
47
|
+
* so a backlog accumulated during an outage could exceed the server's
|
|
48
|
+
* per-envelope item limit and be rejected outright — and a non-retryable
|
|
49
|
+
* rejection commits, deleting every persisted file the backlog was made of.
|
|
50
|
+
*/
|
|
51
|
+
drain(maxItems?: number): EnvelopeItem[];
|
|
52
|
+
/** Entries still queued, excluding anything currently in flight. */
|
|
53
|
+
get pending(): number;
|
|
54
|
+
/** Delivered (or intentionally dropped): forget the in-flight batch and delete its files. */
|
|
55
|
+
commit(): void;
|
|
56
|
+
/** Send failed: return the in-flight batch to the head of the queue, keeping its files. */
|
|
57
|
+
restore(): void;
|
|
58
|
+
private tryUnlink;
|
|
59
|
+
/** Bytes currently buffered (excludes the in-flight batch). */
|
|
60
|
+
get bytes(): number;
|
|
61
|
+
/** Number of queued items (excludes the in-flight batch). */
|
|
62
|
+
get size(): number;
|
|
63
|
+
}
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded in-memory send buffer with opt-in disk persistence.
|
|
3
|
+
*
|
|
4
|
+
* Items accumulate here between flushes. The buffer is byte-capped: when a push
|
|
5
|
+
* would exceed `maxBytes`, the oldest items are dropped first (drop-oldest ring)
|
|
6
|
+
* so a stalled ingest can never grow memory without bound.
|
|
7
|
+
*
|
|
8
|
+
* When `offlineDir` is set, every queued item is also written to a FIFO file so
|
|
9
|
+
* that a crash mid-outage doesn't lose it: a fresh instance reloads the pending
|
|
10
|
+
* files on construction (at-least-once across restarts). Files are deleted only
|
|
11
|
+
* once their batch is committed (delivered or intentionally dropped). Disk
|
|
12
|
+
* persistence is off by default — servers shouldn't be forced into filesystem
|
|
13
|
+
* assumptions (ephemeral containers).
|
|
14
|
+
*
|
|
15
|
+
* The flush cycle is: `push` (many) → `drain` (take a batch, moving it "in
|
|
16
|
+
* flight") → `commit` (delivered/dropped: forget it, delete its files) or
|
|
17
|
+
* `restore` (send failed: put it back at the head, keep its files).
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
const FILE_SUFFIX = '.env.json';
|
|
22
|
+
function pad(seq) {
|
|
23
|
+
return String(seq).padStart(16, '0');
|
|
24
|
+
}
|
|
25
|
+
export class BoundedQueue {
|
|
26
|
+
entries = [];
|
|
27
|
+
/** The batch currently handed to the transport, awaiting commit/restore. */
|
|
28
|
+
inflight = [];
|
|
29
|
+
byteCount = 0;
|
|
30
|
+
seq = 0;
|
|
31
|
+
maxBytes;
|
|
32
|
+
offlineDir;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.maxBytes = Math.max(0, options.maxBytes);
|
|
35
|
+
this.offlineDir = options.offlineDir ?? null;
|
|
36
|
+
if (this.offlineDir) {
|
|
37
|
+
mkdirSync(this.offlineDir, { recursive: true });
|
|
38
|
+
this.reload();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Reload persisted items (FIFO by filename) from a previous run. */
|
|
42
|
+
reload() {
|
|
43
|
+
const dir = this.offlineDir;
|
|
44
|
+
if (!dir || !existsSync(dir))
|
|
45
|
+
return;
|
|
46
|
+
const files = readdirSync(dir)
|
|
47
|
+
.filter((f) => f.endsWith(FILE_SUFFIX))
|
|
48
|
+
.sort();
|
|
49
|
+
for (const name of files) {
|
|
50
|
+
const full = join(dir, name);
|
|
51
|
+
try {
|
|
52
|
+
const raw = readFileSync(full, 'utf8');
|
|
53
|
+
const item = JSON.parse(raw);
|
|
54
|
+
this.entries.push({ item, size: Buffer.byteLength(raw), file: full });
|
|
55
|
+
this.byteCount += Buffer.byteLength(raw);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Corrupt/partial file — drop it so it can't wedge the queue.
|
|
59
|
+
this.tryUnlink(full);
|
|
60
|
+
}
|
|
61
|
+
const seqPart = Number.parseInt(name, 10);
|
|
62
|
+
if (Number.isFinite(seqPart) && seqPart >= this.seq)
|
|
63
|
+
this.seq = seqPart + 1;
|
|
64
|
+
}
|
|
65
|
+
this.evict();
|
|
66
|
+
}
|
|
67
|
+
/** Enqueue an item; persists it when offline, then enforces the byte cap. */
|
|
68
|
+
push(item) {
|
|
69
|
+
const raw = JSON.stringify(item);
|
|
70
|
+
const size = Buffer.byteLength(raw);
|
|
71
|
+
let file = null;
|
|
72
|
+
if (this.offlineDir) {
|
|
73
|
+
file = join(this.offlineDir, `${pad(this.seq++)}${FILE_SUFFIX}`);
|
|
74
|
+
try {
|
|
75
|
+
writeFileSync(file, raw);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
file = null; // best-effort persistence; never block the send path
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
this.entries.push({ item, size, file });
|
|
82
|
+
this.byteCount += size;
|
|
83
|
+
this.evict();
|
|
84
|
+
}
|
|
85
|
+
/** Drop oldest entries until the byte cap is satisfied. */
|
|
86
|
+
evict() {
|
|
87
|
+
while (this.byteCount > this.maxBytes && this.entries.length > 0) {
|
|
88
|
+
const dropped = this.entries.shift();
|
|
89
|
+
this.byteCount -= dropped.size;
|
|
90
|
+
if (dropped.file)
|
|
91
|
+
this.tryUnlink(dropped.file);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Take up to `maxItems` queued entries, moving them "in flight". The
|
|
96
|
+
* transport must later {@link commit} them (delivered/dropped) or
|
|
97
|
+
* {@link restore} them (failed).
|
|
98
|
+
*
|
|
99
|
+
* The cap matters. Draining everything put the whole queue in one envelope,
|
|
100
|
+
* so a backlog accumulated during an outage could exceed the server's
|
|
101
|
+
* per-envelope item limit and be rejected outright — and a non-retryable
|
|
102
|
+
* rejection commits, deleting every persisted file the backlog was made of.
|
|
103
|
+
*/
|
|
104
|
+
drain(maxItems) {
|
|
105
|
+
const limit = maxItems && maxItems > 0 ? maxItems : this.entries.length;
|
|
106
|
+
const batch = this.entries.splice(0, limit);
|
|
107
|
+
this.byteCount = this.entries.reduce((n, e) => n + e.size, 0);
|
|
108
|
+
if (batch.length > 0)
|
|
109
|
+
this.inflight.push(...batch);
|
|
110
|
+
return batch.map((e) => e.item);
|
|
111
|
+
}
|
|
112
|
+
/** Entries still queued, excluding anything currently in flight. */
|
|
113
|
+
get pending() {
|
|
114
|
+
return this.entries.length;
|
|
115
|
+
}
|
|
116
|
+
/** Delivered (or intentionally dropped): forget the in-flight batch and delete its files. */
|
|
117
|
+
commit() {
|
|
118
|
+
for (const e of this.inflight) {
|
|
119
|
+
if (e.file)
|
|
120
|
+
this.tryUnlink(e.file);
|
|
121
|
+
}
|
|
122
|
+
this.inflight = [];
|
|
123
|
+
}
|
|
124
|
+
/** Send failed: return the in-flight batch to the head of the queue, keeping its files. */
|
|
125
|
+
restore() {
|
|
126
|
+
if (this.inflight.length === 0)
|
|
127
|
+
return;
|
|
128
|
+
this.entries = [...this.inflight, ...this.entries];
|
|
129
|
+
for (const e of this.inflight)
|
|
130
|
+
this.byteCount += e.size;
|
|
131
|
+
this.inflight = [];
|
|
132
|
+
this.evict();
|
|
133
|
+
}
|
|
134
|
+
tryUnlink(file) {
|
|
135
|
+
try {
|
|
136
|
+
unlinkSync(file);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Already gone / never written — ignore.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Bytes currently buffered (excludes the in-flight batch). */
|
|
143
|
+
get bytes() {
|
|
144
|
+
return this.byteCount;
|
|
145
|
+
}
|
|
146
|
+
/** Number of queued items (excludes the in-flight batch). */
|
|
147
|
+
get size() {
|
|
148
|
+
return this.entries.length;
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/scope.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
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 type { Breadcrumb, BreadcrumbInput, ErrorUser, ScopeData, User } from './types.js';
|
|
12
|
+
/** Stamp a breadcrumb, defaulting missing fields; preserves an existing timestamp. */
|
|
13
|
+
export declare function normalizeBreadcrumb(input: BreadcrumbInput | Breadcrumb): Breadcrumb;
|
|
14
|
+
/** The mutable per-scope state and the operations over it. */
|
|
15
|
+
export declare class Scope {
|
|
16
|
+
readonly data: ScopeData;
|
|
17
|
+
private maxBreadcrumbs;
|
|
18
|
+
constructor(maxBreadcrumbs?: number);
|
|
19
|
+
setUser(user: User | null): this;
|
|
20
|
+
setTag(key: string, value: string): this;
|
|
21
|
+
setTags(tags: Record<string, string>): this;
|
|
22
|
+
setContext(key: string, context: Record<string, unknown> | unknown): this;
|
|
23
|
+
setExtra(key: string, value: unknown): this;
|
|
24
|
+
addBreadcrumb(crumb: BreadcrumbInput | Breadcrumb): this;
|
|
25
|
+
setMaxBreadcrumbs(max: number): void;
|
|
26
|
+
/** A deep-enough snapshot; children never share mutable containers with parents. */
|
|
27
|
+
clone(): Scope;
|
|
28
|
+
/**
|
|
29
|
+
* Layer this scope onto an error item: fill breadcrumbs from the trail, merge
|
|
30
|
+
* scope tags *under* any per-call tags, and set the user only when the item
|
|
31
|
+
* has none (per-call values win).
|
|
32
|
+
*/
|
|
33
|
+
applyToErrorItem(item: {
|
|
34
|
+
tags?: Record<string, string>;
|
|
35
|
+
contexts?: Record<string, unknown>;
|
|
36
|
+
extra?: Record<string, unknown>;
|
|
37
|
+
user?: ErrorUser | null;
|
|
38
|
+
breadcrumbs?: Breadcrumb[];
|
|
39
|
+
}): void;
|
|
40
|
+
/**
|
|
41
|
+
* Merge this scope's metadata *under* per-call overrides for non-error items
|
|
42
|
+
* (analytics `track`). tags & extra merge by shallow key; contexts merge by
|
|
43
|
+
* block name (a per-call block replaces the same-named scope block). Empty
|
|
44
|
+
* maps are omitted from the result per the emit convention.
|
|
45
|
+
*/
|
|
46
|
+
mergeMetadata(overrides?: {
|
|
47
|
+
tags?: Record<string, string>;
|
|
48
|
+
contexts?: Record<string, unknown>;
|
|
49
|
+
extra?: Record<string, unknown>;
|
|
50
|
+
}): {
|
|
51
|
+
tags?: Record<string, string>;
|
|
52
|
+
contexts?: Record<string, unknown>;
|
|
53
|
+
extra?: Record<string, unknown>;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** The process-wide scope holding defaults set at/after init. */
|
|
57
|
+
export declare function getGlobalScope(): Scope;
|
|
58
|
+
/** The scope in effect right now: the async-local child if inside a scoped block, else global. */
|
|
59
|
+
export declare function getCurrentScope(): Scope;
|
|
60
|
+
/**
|
|
61
|
+
* Run `cb` with an isolated child scope (a snapshot of the current one). The
|
|
62
|
+
* child is passed to `cb` and is what {@link getCurrentScope} returns for the
|
|
63
|
+
* duration — including across `await`s inside `cb`. Returns whatever `cb`
|
|
64
|
+
* returns (awaitable if `cb` is async).
|
|
65
|
+
*/
|
|
66
|
+
export declare function withScope<T>(cb: (scope: Scope) => T): T;
|
|
67
|
+
/** Like {@link withScope}, but the callback takes no scope argument. */
|
|
68
|
+
export declare function runWithAsyncScope<T>(cb: () => T): T;
|
|
69
|
+
/** Mutate the active scope (the global scope outside any {@link withScope}). */
|
|
70
|
+
export declare function configureScope(cb: (scope: Scope) => void): void;
|