@errtap/node 0.1.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/README.md +35 -0
- package/index.d.ts +35 -0
- package/index.js +243 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @errtap/node
|
|
2
|
+
|
|
3
|
+
Node.js error tracking for [ErrTap](https://www.errtap.com) — captures uncaught exceptions, unhandled rejections, and structured logs.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i @errtap/node
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { init } from '@errtap/node';
|
|
11
|
+
|
|
12
|
+
init({
|
|
13
|
+
dsn: process.env.ERRTAP_DSN, // https://et_…@host, from your project's settings page
|
|
14
|
+
environment: process.env.NODE_ENV,
|
|
15
|
+
release: process.env.GIT_SHA,
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`init` registers `uncaughtException` / `unhandledRejection` handlers. After reporting an uncaught exception the process exits (pass `exitOnFatal: false` to keep it alive). Manual capture and logs:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import { captureException, captureMessage, logger } from '@errtap/node';
|
|
23
|
+
|
|
24
|
+
await logger.info('job started', { jobId });
|
|
25
|
+
try {
|
|
26
|
+
await work();
|
|
27
|
+
} catch (e) {
|
|
28
|
+
await captureException(e, { tags: { job: 'imports' } });
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Using NestJS or Next.js? Prefer `@errtap/nestjs` / `@errtap/next`, which wrap this package.
|
|
34
|
+
|
|
35
|
+
Docs: https://docs.errtap.com
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface ErrTapOptions {
|
|
2
|
+
/** URL DSN (`https://et_…@host`) or bare key (requires `endpoint`) */
|
|
3
|
+
dsn: string;
|
|
4
|
+
/** Override ingest URL; optional when `dsn` is a URL DSN */
|
|
5
|
+
endpoint?: string;
|
|
6
|
+
environment?: string;
|
|
7
|
+
release?: string;
|
|
8
|
+
tags?: Record<string, unknown>;
|
|
9
|
+
/** exit the process after reporting an uncaughtException (default true) */
|
|
10
|
+
exitOnFatal?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Extra fields merged into the event payload. `fingerprint` overrides server-side grouping. */
|
|
14
|
+
export interface CaptureExtra extends Record<string, unknown> {
|
|
15
|
+
fingerprint?: string;
|
|
16
|
+
tags?: Record<string, unknown>;
|
|
17
|
+
context?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveDsn(
|
|
21
|
+
dsn: string,
|
|
22
|
+
endpointOverride?: string,
|
|
23
|
+
): { key: string; endpoint: string; logEndpoint: string } | null;
|
|
24
|
+
|
|
25
|
+
export function init(options: ErrTapOptions): void;
|
|
26
|
+
export function captureException(error: Error, extra?: CaptureExtra): Promise<void>;
|
|
27
|
+
export function captureMessage(message: string, extra?: CaptureExtra): Promise<void>;
|
|
28
|
+
|
|
29
|
+
export const logger: {
|
|
30
|
+
debug(message: string, data?: Record<string, unknown>): Promise<void>;
|
|
31
|
+
info(message: string, data?: Record<string, unknown>): Promise<void>;
|
|
32
|
+
warn(message: string, data?: Record<string, unknown>): Promise<void>;
|
|
33
|
+
warning(message: string, data?: Record<string, unknown>): Promise<void>;
|
|
34
|
+
error(message: string, data?: Record<string, unknown>): Promise<void>;
|
|
35
|
+
};
|
package/index.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit. Source: sdk/sdk-js (core.js + node.js).
|
|
2
|
+
// Regenerate with: node sdk/sync.mjs
|
|
3
|
+
|
|
4
|
+
// Shared core for the browser and Node entrypoints. Each entry calls configure()
|
|
5
|
+
// with an environment-specific context provider and fetch options, then registers
|
|
6
|
+
// its own global error handlers.
|
|
7
|
+
let cfg = null;
|
|
8
|
+
let context = () => ({});
|
|
9
|
+
let fetchOpts = {};
|
|
10
|
+
/** @type {{ level: string, message: string, data?: object, ts: number }[]} */
|
|
11
|
+
const breadcrumbs = [];
|
|
12
|
+
const BREADCRUMB_MAX = 20;
|
|
13
|
+
const PAYLOAD_MAX_BYTES = 256 * 1024;
|
|
14
|
+
const TRANSPORT_TIMEOUT_MS = 10_000;
|
|
15
|
+
const TRANSPORT_RETRIES = 2;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a Sentry-style URL DSN (`https://et_key@host[:port]`) or a bare key.
|
|
19
|
+
* @param {string} dsn
|
|
20
|
+
* @param {string} [endpointOverride]
|
|
21
|
+
* @returns {{ key: string, endpoint: string, logEndpoint: string } | null}
|
|
22
|
+
*/
|
|
23
|
+
export function resolveDsn(dsn, endpointOverride) {
|
|
24
|
+
if (!dsn) return null;
|
|
25
|
+
if (dsn.includes('@')) {
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(dsn);
|
|
28
|
+
const key = decodeURIComponent(u.username);
|
|
29
|
+
if (!key) return null;
|
|
30
|
+
const origin = u.origin;
|
|
31
|
+
let logOrigin = origin;
|
|
32
|
+
if (endpointOverride) {
|
|
33
|
+
try {
|
|
34
|
+
logOrigin = new URL(endpointOverride).origin;
|
|
35
|
+
} catch {
|
|
36
|
+
/* keep DSN origin */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
key,
|
|
41
|
+
endpoint: endpointOverride || `${origin}/ingest/error`,
|
|
42
|
+
logEndpoint: `${logOrigin}/ingest/log`,
|
|
43
|
+
};
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!endpointOverride) return null;
|
|
49
|
+
let origin;
|
|
50
|
+
try {
|
|
51
|
+
origin = new URL(endpointOverride).origin;
|
|
52
|
+
} catch {
|
|
53
|
+
origin = null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
key: dsn,
|
|
57
|
+
endpoint: endpointOverride,
|
|
58
|
+
logEndpoint: origin ? `${origin}/ingest/log` : endpointOverride.replace(/\/ingest\/error\/?$/, '/ingest/log'),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function configure(options, contextFn = () => ({}), extraFetchOpts = {}) {
|
|
63
|
+
const resolved = resolveDsn(options.dsn, options.endpoint);
|
|
64
|
+
if (!resolved) {
|
|
65
|
+
cfg = null;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
cfg = {
|
|
69
|
+
environment: 'production',
|
|
70
|
+
...options,
|
|
71
|
+
dsn: resolved.key,
|
|
72
|
+
endpoint: resolved.endpoint,
|
|
73
|
+
logEndpoint: resolved.logEndpoint,
|
|
74
|
+
};
|
|
75
|
+
context = contextFn;
|
|
76
|
+
fetchOpts = extraFetchOpts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pushBreadcrumb(level, message, data) {
|
|
80
|
+
breadcrumbs.push({ level, message, data, ts: Date.now() });
|
|
81
|
+
if (breadcrumbs.length > BREADCRUMB_MAX) breadcrumbs.shift();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mergeTags(...sources) {
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const src of sources) {
|
|
87
|
+
if (!src || typeof src !== 'object' || Array.isArray(src)) continue;
|
|
88
|
+
Object.assign(out, src);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mergeContext(...sources) {
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const src of sources) {
|
|
96
|
+
if (!src || typeof src !== 'object' || Array.isArray(src)) continue;
|
|
97
|
+
for (const [key, value] of Object.entries(src)) {
|
|
98
|
+
const existing = out[key];
|
|
99
|
+
if (
|
|
100
|
+
value &&
|
|
101
|
+
typeof value === 'object' &&
|
|
102
|
+
!Array.isArray(value) &&
|
|
103
|
+
existing &&
|
|
104
|
+
typeof existing === 'object' &&
|
|
105
|
+
!Array.isArray(existing)
|
|
106
|
+
) {
|
|
107
|
+
out[key] = { ...existing, ...value };
|
|
108
|
+
} else {
|
|
109
|
+
out[key] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function withBreadcrumbs(payload) {
|
|
117
|
+
if (!breadcrumbs.length) return payload;
|
|
118
|
+
const existing = payload.context && typeof payload.context === 'object' ? payload.context : {};
|
|
119
|
+
return {
|
|
120
|
+
...payload,
|
|
121
|
+
context: { ...existing, breadcrumbs: breadcrumbs.slice() },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildEnvelope(payload) {
|
|
126
|
+
const runtime = context();
|
|
127
|
+
const body = withBreadcrumbs({
|
|
128
|
+
environment: cfg.environment,
|
|
129
|
+
release: cfg.release,
|
|
130
|
+
...runtime,
|
|
131
|
+
...payload,
|
|
132
|
+
tags: mergeTags(cfg.tags, runtime.tags, payload.tags),
|
|
133
|
+
context: mergeContext(runtime.context, payload.context),
|
|
134
|
+
});
|
|
135
|
+
if (typeof body.message === 'string' && body.message.length > 8192) {
|
|
136
|
+
body.message = body.message.slice(0, 8192);
|
|
137
|
+
}
|
|
138
|
+
if (typeof body.stacktrace === 'string' && body.stacktrace.length > 32_768) {
|
|
139
|
+
body.stacktrace = body.stacktrace.slice(0, 32_768);
|
|
140
|
+
}
|
|
141
|
+
let json = JSON.stringify(body);
|
|
142
|
+
if (json.length > PAYLOAD_MAX_BYTES) {
|
|
143
|
+
json = JSON.stringify({
|
|
144
|
+
...body,
|
|
145
|
+
message: String(body.message ?? '').slice(0, 4096),
|
|
146
|
+
stacktrace: typeof body.stacktrace === 'string' ? body.stacktrace.slice(0, 8192) : undefined,
|
|
147
|
+
truncated: true,
|
|
148
|
+
originalBytes: json.length,
|
|
149
|
+
}).slice(0, PAYLOAD_MAX_BYTES);
|
|
150
|
+
}
|
|
151
|
+
return json;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function sendWithRetry(url, init) {
|
|
155
|
+
for (let attempt = 0; attempt <= TRANSPORT_RETRIES; attempt++) {
|
|
156
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
157
|
+
const timer = controller ? setTimeout(() => controller.abort(), TRANSPORT_TIMEOUT_MS) : null;
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetch(url, { ...init, signal: controller?.signal });
|
|
160
|
+
if (timer) clearTimeout(timer);
|
|
161
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
162
|
+
return;
|
|
163
|
+
} catch {
|
|
164
|
+
if (timer) clearTimeout(timer);
|
|
165
|
+
if (attempt < TRANSPORT_RETRIES) {
|
|
166
|
+
await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {Error} error */
|
|
173
|
+
export function captureException(error, extra = {}) {
|
|
174
|
+
return sendError(
|
|
175
|
+
withBreadcrumbs({
|
|
176
|
+
message: error.message || String(error),
|
|
177
|
+
type: error.name || 'Error',
|
|
178
|
+
stacktrace: error.stack,
|
|
179
|
+
...extra,
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function captureMessage(message, extra = {}) {
|
|
185
|
+
return sendError(withBreadcrumbs({ message, type: 'Message', ...extra }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sendError(payload) {
|
|
189
|
+
if (!cfg) return;
|
|
190
|
+
return sendWithRetry(cfg.endpoint, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
...fetchOpts,
|
|
193
|
+
headers: { 'Content-Type': 'application/json', Authorization: `DSN ${cfg.dsn}` },
|
|
194
|
+
body: buildEnvelope(payload),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sendLog(level, message, data) {
|
|
199
|
+
if (!cfg?.logEndpoint) return;
|
|
200
|
+
pushBreadcrumb(level, message, data);
|
|
201
|
+
return sendWithRetry(cfg.logEndpoint, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
...fetchOpts,
|
|
204
|
+
headers: { 'Content-Type': 'application/json', Authorization: `DSN ${cfg.dsn}` },
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
level,
|
|
207
|
+
message: String(message).slice(0, 8192),
|
|
208
|
+
environment: cfg.environment,
|
|
209
|
+
release: cfg.release,
|
|
210
|
+
tags: mergeTags(cfg.tags, context().tags),
|
|
211
|
+
context: mergeContext(context().context, data),
|
|
212
|
+
...context(),
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const logger = {
|
|
218
|
+
debug: (message, data) => sendLog('debug', message, data),
|
|
219
|
+
info: (message, data) => sendLog('info', message, data),
|
|
220
|
+
warn: (message, data) => sendLog('warning', message, data),
|
|
221
|
+
warning: (message, data) => sendLog('warning', message, data),
|
|
222
|
+
error: (message, data) => sendLog('error', message, data),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// test helpers (not part of the public API surface for apps)
|
|
226
|
+
export const __test = { mergeContext, mergeTags, buildEnvelope };
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {{ dsn: string, endpoint?: string, environment?: string, release?: string, tags?: object, exitOnFatal?: boolean }} options
|
|
230
|
+
*/
|
|
231
|
+
export function init(options = {}) {
|
|
232
|
+
const exitOnFatal = options.exitOnFatal !== false;
|
|
233
|
+
configure(options, () => ({ context: { node: process.version, platform: process.platform } }));
|
|
234
|
+
process.on('uncaughtException', async (err) => {
|
|
235
|
+
await captureException(err);
|
|
236
|
+
if (exitOnFatal) process.exit(1);
|
|
237
|
+
});
|
|
238
|
+
process.on('unhandledRejection', (reason) => {
|
|
239
|
+
reason instanceof Error
|
|
240
|
+
? captureException(reason)
|
|
241
|
+
: captureMessage(`Unhandled rejection: ${String(reason)}`);
|
|
242
|
+
});
|
|
243
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@errtap/node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ErrTap Node.js error tracking SDK",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"index.js",
|
|
10
|
+
"index.d.ts"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/ErrTap/observer-app.git",
|
|
19
|
+
"directory": "sdk/sdk-node"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://docs.errtap.com",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/ErrTap/observer-app/issues"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"error-tracking",
|
|
27
|
+
"monitoring",
|
|
28
|
+
"observability",
|
|
29
|
+
"errtap",
|
|
30
|
+
"node"
|
|
31
|
+
]
|
|
32
|
+
}
|