@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/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# @edraj/sauron-node
|
|
2
|
+
|
|
3
|
+
Server-side Node/TypeScript SDK for [Sauron](https://sauron.dev) — dispatch
|
|
4
|
+
product-analytics events and captured exceptions from your Node backends.
|
|
5
|
+
|
|
6
|
+
This is the **server-side** SDK (no browser/DOM/auto-instrumentation). For the
|
|
7
|
+
browser, use `@edraj/sauron-browser` (`sdks/js`).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @edraj/sauron-node
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node >= 18 (uses the global `fetch` and `zlib`).
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import {
|
|
21
|
+
init,
|
|
22
|
+
track,
|
|
23
|
+
captureException,
|
|
24
|
+
captureMessage,
|
|
25
|
+
identify,
|
|
26
|
+
trackTransaction,
|
|
27
|
+
addBreadcrumb,
|
|
28
|
+
withScope,
|
|
29
|
+
setUser,
|
|
30
|
+
setTag,
|
|
31
|
+
flush,
|
|
32
|
+
close,
|
|
33
|
+
} from '@edraj/sauron-node';
|
|
34
|
+
|
|
35
|
+
init({
|
|
36
|
+
dsn: 'https://<public_key>@<host>/<project_id>',
|
|
37
|
+
environment: 'production',
|
|
38
|
+
release: '1.4.2',
|
|
39
|
+
// Opt-in (both default off):
|
|
40
|
+
autoCaptureUnhandled: true, // capture uncaughtException / unhandledRejection
|
|
41
|
+
autoShutdown: true, // flush on beforeExit / SIGTERM / SIGINT
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Product analytics — distinctId is required.
|
|
45
|
+
track('order_completed', 'user-123', { total: 42.5, currency: 'USD' });
|
|
46
|
+
|
|
47
|
+
// Exceptions
|
|
48
|
+
try {
|
|
49
|
+
doWork();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
captureException(err, { user: { id: 'user-123' }, tags: { area: 'checkout' } });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
captureMessage('cache warm-up finished', 'info');
|
|
55
|
+
identify('user-123', { plan: 'pro' });
|
|
56
|
+
|
|
57
|
+
// On shutdown
|
|
58
|
+
await close(); // flushes then stops the background timer
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Per-request scope
|
|
62
|
+
|
|
63
|
+
Isolate user/tags/breadcrumbs per request with `withScope` — backed by
|
|
64
|
+
`AsyncLocalStorage`, so concurrent requests never leak state into each other:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
app.use((req, res, next) => {
|
|
68
|
+
withScope(() => {
|
|
69
|
+
setUser({ id: req.userId });
|
|
70
|
+
setTag('route', req.route.path);
|
|
71
|
+
addBreadcrumb({ category: 'http', message: `${req.method} ${req.url}` });
|
|
72
|
+
next();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`captureException` automatically attaches the active scope's user, tags and
|
|
78
|
+
breadcrumb trail. An optional `fingerprint` override is honored verbatim by the
|
|
79
|
+
backend.
|
|
80
|
+
|
|
81
|
+
### Transactions
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
trackTransaction({
|
|
85
|
+
name: 'GET /api/users',
|
|
86
|
+
op: 'http',
|
|
87
|
+
duration_ms: 12.5,
|
|
88
|
+
http_method: 'GET',
|
|
89
|
+
http_status: 200,
|
|
90
|
+
}); // distinct_id falls back to the scoped user's id when omitted
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## API
|
|
94
|
+
|
|
95
|
+
| Function | Description |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `init(options)` | Create the global client. Throws `DsnError` on an invalid DSN. |
|
|
98
|
+
| `track(event, distinctId, properties?)` | Capture an analytics event. |
|
|
99
|
+
| `captureException(error, options?)` | Capture a native `Error` (attaches scope). |
|
|
100
|
+
| `captureMessage(message, level?)` | Capture a bare message. |
|
|
101
|
+
| `identify(distinctId, traits?)` | Associate traits with a user. |
|
|
102
|
+
| `trackTransaction(input)` | Emit a performance transaction. |
|
|
103
|
+
| `addBreadcrumb(crumb)` | Add a breadcrumb to the active scope (runs `beforeBreadcrumb`). |
|
|
104
|
+
| `setUser / setTag / setTags / setContext / setExtra` | Mutate the active scope. |
|
|
105
|
+
| `withScope(cb)` / `configureScope(cb)` | Run with an isolated child scope / mutate the current one. |
|
|
106
|
+
| `installShutdownHooks(client)` | Wire `beforeExit`/`SIGTERM`/`SIGINT` to `close()`. |
|
|
107
|
+
| `flush()` | Send buffered items immediately. |
|
|
108
|
+
| `close()` | Flush, stop the timer, and remove any installed process hooks. |
|
|
109
|
+
|
|
110
|
+
Every dispatch function is a no-op before `init` / when the SDK is disabled.
|
|
111
|
+
|
|
112
|
+
### `init` options
|
|
113
|
+
|
|
114
|
+
`environment`, `release`, `sampleRate`, `flushInterval`, `maxBatch`,
|
|
115
|
+
`maxBreadcrumbs` (default 100), `gzipThresholdBytes` (default 1024),
|
|
116
|
+
`maxQueueBytes` (default 1 MiB), `offlineDir` (opt-in FIFO disk persistence),
|
|
117
|
+
`maxRetries` (default 3), `autoCaptureUnhandled` (default off),
|
|
118
|
+
`autoShutdown` (default off), `beforeSend(item)`, `beforeBreadcrumb(crumb)`.
|
|
119
|
+
|
|
120
|
+
## Transport
|
|
121
|
+
|
|
122
|
+
Items buffer in a byte-bounded in-memory queue (drop-oldest past `maxQueueBytes`,
|
|
123
|
+
optionally persisted to `offlineDir` for at-least-once delivery across restarts)
|
|
124
|
+
and flush every `flushInterval` ms (default 5000) or once `maxBatch` items
|
|
125
|
+
(default 30) accumulate. The flush timer is `unref`'d so it never keeps your
|
|
126
|
+
process alive. Each flush POSTs one envelope to
|
|
127
|
+
`{proto}://{host}/api/{project_id}/envelope` with an `X-Sauron-Key` header,
|
|
128
|
+
gzipping the body once it crosses `gzipThresholdBytes` (`Content-Encoding: gzip`).
|
|
129
|
+
Transient failures (408/413/429/5xx, network) retry with exponential backoff +
|
|
130
|
+
jitter honoring `Retry-After`; 400/401/403/404 drop without retry.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm install
|
|
136
|
+
npm run build
|
|
137
|
+
npm test
|
|
138
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in process-level hooks: auto-capture of uncaught errors and graceful
|
|
3
|
+
* shutdown. Both are OFF by default (see {@link InitOptions.autoCaptureUnhandled}
|
|
4
|
+
* / {@link InitOptions.autoShutdown}) and only installed when the consumer opts
|
|
5
|
+
* in.
|
|
6
|
+
*
|
|
7
|
+
* Auto-capture never *swallows* a crash. An uncaught exception is captured with
|
|
8
|
+
* `mechanism.handled = false`, the batch is flushed, and Node's default
|
|
9
|
+
* behavior is preserved: if this SDK is the *sole* `uncaughtException` handler,
|
|
10
|
+
* the process still exits non-zero (as Node would with no handler at all);
|
|
11
|
+
* if another handler is registered, that handler decides the process's fate.
|
|
12
|
+
* Unhandled rejections are captured but never terminate the process on their
|
|
13
|
+
* own — Node's own `unhandledRejection` mode still governs that.
|
|
14
|
+
*/
|
|
15
|
+
import type { ProcessLike } from './types.js';
|
|
16
|
+
import type { SauronClient } from './client.js';
|
|
17
|
+
export interface AutoCaptureOptions {
|
|
18
|
+
/** Injected process (tests). Defaults to the real Node `process`. */
|
|
19
|
+
process?: ProcessLike;
|
|
20
|
+
}
|
|
21
|
+
type Uninstaller = () => void;
|
|
22
|
+
/**
|
|
23
|
+
* Register `uncaughtException` + `unhandledRejection` handlers that capture with
|
|
24
|
+
* `mechanism.handled = false`. Idempotent per client; returns an uninstaller
|
|
25
|
+
* that removes the listeners.
|
|
26
|
+
*/
|
|
27
|
+
export declare function installAutoCapture(client: SauronClient, options?: AutoCaptureOptions): Uninstaller;
|
|
28
|
+
/**
|
|
29
|
+
* Wire `beforeExit`/`SIGTERM`/`SIGINT` to `client.close()` for a graceful flush
|
|
30
|
+
* on shutdown. `beforeExit` (event loop drained) just closes; a terminating
|
|
31
|
+
* signal closes then exits with the conventional code so the SDK does not hang
|
|
32
|
+
* the process. Idempotent per client; returns an uninstaller.
|
|
33
|
+
*/
|
|
34
|
+
export declare function installShutdownHooks(client: SauronClient, options?: AutoCaptureOptions): Uninstaller;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in process-level hooks: auto-capture of uncaught errors and graceful
|
|
3
|
+
* shutdown. Both are OFF by default (see {@link InitOptions.autoCaptureUnhandled}
|
|
4
|
+
* / {@link InitOptions.autoShutdown}) and only installed when the consumer opts
|
|
5
|
+
* in.
|
|
6
|
+
*
|
|
7
|
+
* Auto-capture never *swallows* a crash. An uncaught exception is captured with
|
|
8
|
+
* `mechanism.handled = false`, the batch is flushed, and Node's default
|
|
9
|
+
* behavior is preserved: if this SDK is the *sole* `uncaughtException` handler,
|
|
10
|
+
* the process still exits non-zero (as Node would with no handler at all);
|
|
11
|
+
* if another handler is registered, that handler decides the process's fate.
|
|
12
|
+
* Unhandled rejections are captured but never terminate the process on their
|
|
13
|
+
* own — Node's own `unhandledRejection` mode still governs that.
|
|
14
|
+
*/
|
|
15
|
+
/** Conventional exit code for a terminating signal (`128 + signal number`). */
|
|
16
|
+
const SIGNAL_EXIT_CODE = { SIGINT: 130, SIGTERM: 143 };
|
|
17
|
+
const autoCaptureInstalled = new WeakMap();
|
|
18
|
+
const shutdownInstalled = new WeakMap();
|
|
19
|
+
function realProcess() {
|
|
20
|
+
return process;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Register `uncaughtException` + `unhandledRejection` handlers that capture with
|
|
24
|
+
* `mechanism.handled = false`. Idempotent per client; returns an uninstaller
|
|
25
|
+
* that removes the listeners.
|
|
26
|
+
*/
|
|
27
|
+
export function installAutoCapture(client, options = {}) {
|
|
28
|
+
const existing = autoCaptureInstalled.get(client);
|
|
29
|
+
if (existing)
|
|
30
|
+
return existing;
|
|
31
|
+
const proc = options.process ?? realProcess();
|
|
32
|
+
// Guard against a capture path itself throwing and re-entering the handler.
|
|
33
|
+
let capturing = false;
|
|
34
|
+
const onUncaught = (error) => {
|
|
35
|
+
if (capturing)
|
|
36
|
+
return;
|
|
37
|
+
capturing = true;
|
|
38
|
+
try {
|
|
39
|
+
client.captureException(error, { level: 'fatal', handled: false });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Never let a capture failure mask the original crash.
|
|
43
|
+
}
|
|
44
|
+
capturing = false;
|
|
45
|
+
void client.flush().then(exitIfSole, exitIfSole);
|
|
46
|
+
};
|
|
47
|
+
const exitIfSole = () => {
|
|
48
|
+
const others = proc
|
|
49
|
+
.listeners('uncaughtException')
|
|
50
|
+
.filter((listener) => listener !== onUncaught);
|
|
51
|
+
if (others.length === 0)
|
|
52
|
+
proc.exit(1);
|
|
53
|
+
};
|
|
54
|
+
const onRejection = (reason) => {
|
|
55
|
+
if (capturing)
|
|
56
|
+
return;
|
|
57
|
+
capturing = true;
|
|
58
|
+
try {
|
|
59
|
+
client.captureException(reason, { level: 'error', handled: false });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// ignore — see above.
|
|
63
|
+
}
|
|
64
|
+
capturing = false;
|
|
65
|
+
void client.flush();
|
|
66
|
+
};
|
|
67
|
+
proc.on('uncaughtException', onUncaught);
|
|
68
|
+
proc.on('unhandledRejection', onRejection);
|
|
69
|
+
const uninstall = () => {
|
|
70
|
+
proc.removeListener('uncaughtException', onUncaught);
|
|
71
|
+
proc.removeListener('unhandledRejection', onRejection);
|
|
72
|
+
autoCaptureInstalled.delete(client);
|
|
73
|
+
};
|
|
74
|
+
autoCaptureInstalled.set(client, uninstall);
|
|
75
|
+
return uninstall;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Wire `beforeExit`/`SIGTERM`/`SIGINT` to `client.close()` for a graceful flush
|
|
79
|
+
* on shutdown. `beforeExit` (event loop drained) just closes; a terminating
|
|
80
|
+
* signal closes then exits with the conventional code so the SDK does not hang
|
|
81
|
+
* the process. Idempotent per client; returns an uninstaller.
|
|
82
|
+
*/
|
|
83
|
+
export function installShutdownHooks(client, options = {}) {
|
|
84
|
+
const existing = shutdownInstalled.get(client);
|
|
85
|
+
if (existing)
|
|
86
|
+
return existing;
|
|
87
|
+
const proc = options.process ?? realProcess();
|
|
88
|
+
let closing = false;
|
|
89
|
+
const onBeforeExit = () => {
|
|
90
|
+
if (closing)
|
|
91
|
+
return;
|
|
92
|
+
closing = true;
|
|
93
|
+
void client.close();
|
|
94
|
+
};
|
|
95
|
+
const makeSignalHandler = (signal) => () => {
|
|
96
|
+
if (closing)
|
|
97
|
+
return;
|
|
98
|
+
closing = true;
|
|
99
|
+
const code = SIGNAL_EXIT_CODE[signal] ?? 0;
|
|
100
|
+
void client.close().then(() => proc.exit(code), () => proc.exit(code));
|
|
101
|
+
};
|
|
102
|
+
const onSigterm = makeSignalHandler('SIGTERM');
|
|
103
|
+
const onSigint = makeSignalHandler('SIGINT');
|
|
104
|
+
proc.on('beforeExit', onBeforeExit);
|
|
105
|
+
proc.on('SIGTERM', onSigterm);
|
|
106
|
+
proc.on('SIGINT', onSigint);
|
|
107
|
+
const uninstall = () => {
|
|
108
|
+
proc.removeListener('beforeExit', onBeforeExit);
|
|
109
|
+
proc.removeListener('SIGTERM', onSigterm);
|
|
110
|
+
proc.removeListener('SIGINT', onSigint);
|
|
111
|
+
shutdownInstalled.delete(client);
|
|
112
|
+
};
|
|
113
|
+
shutdownInstalled.set(client, uninstall);
|
|
114
|
+
return uninstall;
|
|
115
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { BreadcrumbInput, CaptureExceptionOptions, InitOptions, Level, MetadataOptions, TransactionInput } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The Sauron server-side client. Buffers events/errors and dispatches them via
|
|
4
|
+
* a background transport. Constructed by {@link init}.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SauronClient {
|
|
7
|
+
private readonly options;
|
|
8
|
+
private readonly transport;
|
|
9
|
+
/** Uninstallers for any opt-in process-level hooks, torn down on {@link close}. */
|
|
10
|
+
private readonly hookUninstallers;
|
|
11
|
+
constructor(options: InitOptions);
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
private dispatch;
|
|
18
|
+
/**
|
|
19
|
+
* Add a breadcrumb to the active scope. Runs `beforeBreadcrumb` first; a
|
|
20
|
+
* `null` return drops the crumb.
|
|
21
|
+
*/
|
|
22
|
+
addBreadcrumb(crumb: BreadcrumbInput): void;
|
|
23
|
+
/** Emit a performance transaction item. */
|
|
24
|
+
trackTransaction(input: TransactionInput): void;
|
|
25
|
+
/** Capture a product-analytics event. `distinctId` is required. */
|
|
26
|
+
track(event: string, distinctId: string, properties?: Record<string, unknown>, options?: MetadataOptions): void;
|
|
27
|
+
/** Capture a native `Error` (or error-like value) as an error item. */
|
|
28
|
+
captureException(error: unknown, options?: CaptureExceptionOptions): void;
|
|
29
|
+
/** Capture a bare message as an error item (no exception payload). */
|
|
30
|
+
captureMessage(message: string, level?: Level, options?: MetadataOptions): void;
|
|
31
|
+
/** Associate traits with a distinct id. */
|
|
32
|
+
identify(distinctId: string, traits?: Record<string, unknown>): void;
|
|
33
|
+
/** Send any buffered items immediately. */
|
|
34
|
+
flush(): Promise<void>;
|
|
35
|
+
/** Flush then stop the background timer, and remove any opt-in process hooks. */
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
/** Derive `{type, value}` from an arbitrary thrown value. */
|
|
39
|
+
export declare function describeError(error: unknown): {
|
|
40
|
+
type: string;
|
|
41
|
+
value: string | null;
|
|
42
|
+
};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { parseDsn } from './dsn.js';
|
|
4
|
+
import { Transport } from './transport.js';
|
|
5
|
+
import { parseError } from './stacktrace.js';
|
|
6
|
+
import { installAutoCapture, installShutdownHooks } from './autocapture.js';
|
|
7
|
+
import { getCurrentScope, getGlobalScope, normalizeBreadcrumb, } from './scope.js';
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
environment: 'production',
|
|
10
|
+
release: null,
|
|
11
|
+
sampleRate: 1,
|
|
12
|
+
flushInterval: 5000,
|
|
13
|
+
maxBatch: 30,
|
|
14
|
+
maxBreadcrumbs: 100,
|
|
15
|
+
gzipThresholdBytes: 1024,
|
|
16
|
+
maxQueueBytes: 1_048_576,
|
|
17
|
+
maxRetries: 3,
|
|
18
|
+
debug: false,
|
|
19
|
+
};
|
|
20
|
+
function resolveOptions(options) {
|
|
21
|
+
if (!options || typeof options.dsn !== 'string') {
|
|
22
|
+
throw new Error('[sauron] init requires a { dsn } option');
|
|
23
|
+
}
|
|
24
|
+
const sampleRate = typeof options.sampleRate === 'number' ? options.sampleRate : DEFAULTS.sampleRate;
|
|
25
|
+
return {
|
|
26
|
+
dsn: options.dsn,
|
|
27
|
+
environment: options.environment ?? DEFAULTS.environment,
|
|
28
|
+
release: options.release ?? DEFAULTS.release,
|
|
29
|
+
tags: options.tags ?? {},
|
|
30
|
+
contexts: options.contexts ?? {},
|
|
31
|
+
extra: options.extra ?? {},
|
|
32
|
+
sampleRate: Math.min(1, Math.max(0, sampleRate)),
|
|
33
|
+
flushInterval: typeof options.flushInterval === 'number'
|
|
34
|
+
? options.flushInterval
|
|
35
|
+
: DEFAULTS.flushInterval,
|
|
36
|
+
maxBatch: typeof options.maxBatch === 'number' ? options.maxBatch : DEFAULTS.maxBatch,
|
|
37
|
+
maxBreadcrumbs: typeof options.maxBreadcrumbs === 'number'
|
|
38
|
+
? options.maxBreadcrumbs
|
|
39
|
+
: DEFAULTS.maxBreadcrumbs,
|
|
40
|
+
gzipThresholdBytes: typeof options.gzipThresholdBytes === 'number'
|
|
41
|
+
? options.gzipThresholdBytes
|
|
42
|
+
: DEFAULTS.gzipThresholdBytes,
|
|
43
|
+
maxQueueBytes: typeof options.maxQueueBytes === 'number'
|
|
44
|
+
? options.maxQueueBytes
|
|
45
|
+
: DEFAULTS.maxQueueBytes,
|
|
46
|
+
offlineDir: options.offlineDir ?? null,
|
|
47
|
+
maxRetries: typeof options.maxRetries === 'number' ? options.maxRetries : DEFAULTS.maxRetries,
|
|
48
|
+
autoCaptureUnhandled: options.autoCaptureUnhandled ?? false,
|
|
49
|
+
autoShutdown: options.autoShutdown ?? false,
|
|
50
|
+
beforeSend: options.beforeSend,
|
|
51
|
+
beforeBreadcrumb: options.beforeBreadcrumb,
|
|
52
|
+
fetchImpl: options.fetchImpl,
|
|
53
|
+
debug: options.debug ?? DEFAULTS.debug,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Minimal server-side context assembled once at init. */
|
|
57
|
+
function buildContext() {
|
|
58
|
+
return {
|
|
59
|
+
device: { device_id: randomUUID() },
|
|
60
|
+
os: { name: process.platform || null, version: os.release() || null },
|
|
61
|
+
app: {},
|
|
62
|
+
runtime: { name: 'node', version: process.versions.node ?? null },
|
|
63
|
+
user: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function isoNow() {
|
|
67
|
+
return new Date().toISOString();
|
|
68
|
+
}
|
|
69
|
+
function normalizeUser(user) {
|
|
70
|
+
if (!user)
|
|
71
|
+
return null;
|
|
72
|
+
return {
|
|
73
|
+
id: user.id ?? null,
|
|
74
|
+
email: user.email ?? null,
|
|
75
|
+
username: user.username ?? null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The Sauron server-side client. Buffers events/errors and dispatches them via
|
|
80
|
+
* a background transport. Constructed by {@link init}.
|
|
81
|
+
*/
|
|
82
|
+
export class SauronClient {
|
|
83
|
+
options;
|
|
84
|
+
transport;
|
|
85
|
+
/** Uninstallers for any opt-in process-level hooks, torn down on {@link close}. */
|
|
86
|
+
hookUninstallers = [];
|
|
87
|
+
constructor(options) {
|
|
88
|
+
this.options = resolveOptions(options);
|
|
89
|
+
const dsn = parseDsn(this.options.dsn);
|
|
90
|
+
const globalScope = getGlobalScope();
|
|
91
|
+
globalScope.setMaxBreadcrumbs(this.options.maxBreadcrumbs);
|
|
92
|
+
globalScope.setTags(this.options.tags);
|
|
93
|
+
for (const [name, block] of Object.entries(this.options.contexts)) {
|
|
94
|
+
globalScope.setContext(name, block);
|
|
95
|
+
}
|
|
96
|
+
for (const [key, value] of Object.entries(this.options.extra)) {
|
|
97
|
+
globalScope.setExtra(key, value);
|
|
98
|
+
}
|
|
99
|
+
this.transport = new Transport({
|
|
100
|
+
dsn,
|
|
101
|
+
environment: this.options.environment,
|
|
102
|
+
release: this.options.release,
|
|
103
|
+
context: buildContext(),
|
|
104
|
+
flushInterval: this.options.flushInterval,
|
|
105
|
+
maxBatch: this.options.maxBatch,
|
|
106
|
+
gzipThresholdBytes: this.options.gzipThresholdBytes,
|
|
107
|
+
maxQueueBytes: this.options.maxQueueBytes,
|
|
108
|
+
offlineDir: this.options.offlineDir,
|
|
109
|
+
maxRetries: this.options.maxRetries,
|
|
110
|
+
fetchImpl: this.options.fetchImpl,
|
|
111
|
+
debug: this.options.debug,
|
|
112
|
+
});
|
|
113
|
+
if (this.options.autoCaptureUnhandled) {
|
|
114
|
+
this.hookUninstallers.push(installAutoCapture(this));
|
|
115
|
+
}
|
|
116
|
+
if (this.options.autoShutdown) {
|
|
117
|
+
this.hookUninstallers.push(installShutdownHooks(this));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
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.
|
|
124
|
+
*/
|
|
125
|
+
dispatch(item) {
|
|
126
|
+
const beforeSend = this.options.beforeSend;
|
|
127
|
+
if (beforeSend) {
|
|
128
|
+
const result = beforeSend(item);
|
|
129
|
+
if (result == null)
|
|
130
|
+
return;
|
|
131
|
+
this.transport.enqueue(result);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.transport.enqueue(item);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Add a breadcrumb to the active scope. Runs `beforeBreadcrumb` first; a
|
|
138
|
+
* `null` return drops the crumb.
|
|
139
|
+
*/
|
|
140
|
+
addBreadcrumb(crumb) {
|
|
141
|
+
const stamped = normalizeBreadcrumb(crumb);
|
|
142
|
+
const beforeBreadcrumb = this.options.beforeBreadcrumb;
|
|
143
|
+
if (beforeBreadcrumb) {
|
|
144
|
+
const result = beforeBreadcrumb(stamped);
|
|
145
|
+
if (result == null)
|
|
146
|
+
return;
|
|
147
|
+
getCurrentScope().addBreadcrumb(result);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
getCurrentScope().addBreadcrumb(stamped);
|
|
151
|
+
}
|
|
152
|
+
/** Emit a performance transaction item. */
|
|
153
|
+
trackTransaction(input) {
|
|
154
|
+
if (typeof input?.name !== 'string' || input.name.length === 0)
|
|
155
|
+
return;
|
|
156
|
+
const distinctId = input.distinct_id ?? getCurrentScope().data.user?.id ?? undefined;
|
|
157
|
+
const item = {
|
|
158
|
+
type: 'transaction',
|
|
159
|
+
name: input.name,
|
|
160
|
+
op: input.op ?? 'custom',
|
|
161
|
+
duration_ms: input.duration_ms,
|
|
162
|
+
timestamp: isoNow(),
|
|
163
|
+
};
|
|
164
|
+
if (input.status !== undefined)
|
|
165
|
+
item.status = input.status;
|
|
166
|
+
if (input.http_method !== undefined)
|
|
167
|
+
item.http_method = input.http_method;
|
|
168
|
+
if (input.http_status !== undefined)
|
|
169
|
+
item.http_status = input.http_status;
|
|
170
|
+
if (input.url !== undefined)
|
|
171
|
+
item.url = input.url;
|
|
172
|
+
if (distinctId != null)
|
|
173
|
+
item.distinct_id = distinctId;
|
|
174
|
+
this.dispatch(item);
|
|
175
|
+
}
|
|
176
|
+
/** Capture a product-analytics event. `distinctId` is required. */
|
|
177
|
+
track(event, distinctId, properties, options = {}) {
|
|
178
|
+
if (typeof event !== 'string' || event.length === 0)
|
|
179
|
+
return;
|
|
180
|
+
if (typeof distinctId !== 'string' || distinctId.length === 0)
|
|
181
|
+
return;
|
|
182
|
+
const item = {
|
|
183
|
+
type: 'event',
|
|
184
|
+
name: event,
|
|
185
|
+
distinct_id: distinctId,
|
|
186
|
+
properties: properties ?? {},
|
|
187
|
+
timestamp: isoNow(),
|
|
188
|
+
session_id: null,
|
|
189
|
+
screen: null,
|
|
190
|
+
...getCurrentScope().mergeMetadata(options),
|
|
191
|
+
};
|
|
192
|
+
this.dispatch(item);
|
|
193
|
+
}
|
|
194
|
+
/** Capture a native `Error` (or error-like value) as an error item. */
|
|
195
|
+
captureException(error, options = {}) {
|
|
196
|
+
if (this.options.sampleRate < 1 && Math.random() >= this.options.sampleRate) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const { type, value } = describeError(error);
|
|
200
|
+
const item = {
|
|
201
|
+
type: 'error',
|
|
202
|
+
event_id: randomUUID(),
|
|
203
|
+
level: options.level ?? 'error',
|
|
204
|
+
timestamp: isoNow(),
|
|
205
|
+
exception: {
|
|
206
|
+
type,
|
|
207
|
+
value,
|
|
208
|
+
mechanism: { type: 'generic', handled: options.handled ?? true },
|
|
209
|
+
stacktrace: parseError(error),
|
|
210
|
+
},
|
|
211
|
+
message: null,
|
|
212
|
+
breadcrumbs: [],
|
|
213
|
+
tags: options.tags ?? {},
|
|
214
|
+
contexts: options.contexts ?? {},
|
|
215
|
+
extra: options.extra ?? {},
|
|
216
|
+
fingerprint: options.fingerprint ?? null,
|
|
217
|
+
user: normalizeUser(options.user),
|
|
218
|
+
session_id: null,
|
|
219
|
+
screen: null,
|
|
220
|
+
};
|
|
221
|
+
getCurrentScope().applyToErrorItem(item);
|
|
222
|
+
this.dispatch(item);
|
|
223
|
+
}
|
|
224
|
+
/** Capture a bare message as an error item (no exception payload). */
|
|
225
|
+
captureMessage(message, level = 'info', options = {}) {
|
|
226
|
+
const item = {
|
|
227
|
+
type: 'error',
|
|
228
|
+
event_id: randomUUID(),
|
|
229
|
+
level,
|
|
230
|
+
timestamp: isoNow(),
|
|
231
|
+
exception: {
|
|
232
|
+
type: 'Message',
|
|
233
|
+
value: message,
|
|
234
|
+
mechanism: { type: 'generic', handled: true },
|
|
235
|
+
stacktrace: [],
|
|
236
|
+
},
|
|
237
|
+
message,
|
|
238
|
+
breadcrumbs: [],
|
|
239
|
+
tags: options.tags ?? {},
|
|
240
|
+
contexts: options.contexts ?? {},
|
|
241
|
+
extra: options.extra ?? {},
|
|
242
|
+
fingerprint: null,
|
|
243
|
+
user: null,
|
|
244
|
+
session_id: null,
|
|
245
|
+
screen: null,
|
|
246
|
+
};
|
|
247
|
+
getCurrentScope().applyToErrorItem(item);
|
|
248
|
+
this.dispatch(item);
|
|
249
|
+
}
|
|
250
|
+
/** Associate traits with a distinct id. */
|
|
251
|
+
identify(distinctId, traits) {
|
|
252
|
+
if (typeof distinctId !== 'string' || distinctId.length === 0)
|
|
253
|
+
return;
|
|
254
|
+
const item = {
|
|
255
|
+
type: 'identify',
|
|
256
|
+
distinct_id: distinctId,
|
|
257
|
+
anonymous_id: null,
|
|
258
|
+
traits: traits ?? {},
|
|
259
|
+
timestamp: isoNow(),
|
|
260
|
+
};
|
|
261
|
+
this.dispatch(item);
|
|
262
|
+
}
|
|
263
|
+
/** Send any buffered items immediately. */
|
|
264
|
+
flush() {
|
|
265
|
+
return this.transport.flush();
|
|
266
|
+
}
|
|
267
|
+
/** Flush then stop the background timer, and remove any opt-in process hooks. */
|
|
268
|
+
close() {
|
|
269
|
+
for (const uninstall of this.hookUninstallers.splice(0))
|
|
270
|
+
uninstall();
|
|
271
|
+
return this.transport.close();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Derive `{type, value}` from an arbitrary thrown value. */
|
|
275
|
+
export function describeError(error) {
|
|
276
|
+
if (error instanceof Error) {
|
|
277
|
+
return { type: error.name || 'Error', value: error.message || null };
|
|
278
|
+
}
|
|
279
|
+
if (typeof error === 'string') {
|
|
280
|
+
return { type: 'Error', value: error };
|
|
281
|
+
}
|
|
282
|
+
if (error && typeof error === 'object') {
|
|
283
|
+
const name = error.name;
|
|
284
|
+
const message = error.message;
|
|
285
|
+
return {
|
|
286
|
+
type: typeof name === 'string' && name ? name : 'Error',
|
|
287
|
+
value: typeof message === 'string' ? message : null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return { type: 'Error', value: error === undefined ? null : String(error) };
|
|
291
|
+
}
|
package/dist/dsn.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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 interface Dsn {
|
|
8
|
+
/** The raw DSN string (embedded verbatim into the envelope header). */
|
|
9
|
+
raw: string;
|
|
10
|
+
publicKey: string;
|
|
11
|
+
/** `host:port` — used to build the endpoint. */
|
|
12
|
+
host: string;
|
|
13
|
+
/** Hostname without port. */
|
|
14
|
+
hostname: string;
|
|
15
|
+
/** `https` or `http` (no trailing colon). */
|
|
16
|
+
protocol: string;
|
|
17
|
+
projectId: string;
|
|
18
|
+
/** `POST` target: `{protocol}://{host}/api/{project_id}/envelope`. */
|
|
19
|
+
envelopeUrl: string;
|
|
20
|
+
}
|
|
21
|
+
export declare class DsnError extends Error {
|
|
22
|
+
constructor(message: string);
|
|
23
|
+
}
|
|
24
|
+
/** Parse and validate a DSN, deriving the transport URL. */
|
|
25
|
+
export declare function parseDsn(dsn: string): Dsn;
|