@errtap/browser 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.
Files changed (4) hide show
  1. package/README.md +34 -0
  2. package/index.d.ts +33 -0
  3. package/index.js +250 -0
  4. package/package.json +29 -0
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @errtap/browser
2
+
3
+ Browser error tracking for [ErrTap](https://www.errtap.com) — captures unhandled errors, promise rejections, and structured logs.
4
+
5
+ ```bash
6
+ npm i @errtap/browser
7
+ ```
8
+
9
+ ```js
10
+ import { init } from '@errtap/browser';
11
+
12
+ init({
13
+ dsn: 'https://et_…@api.errtap.com', // from your project's settings page
14
+ environment: 'production',
15
+ release: import.meta.env?.VITE_GIT_SHA,
16
+ });
17
+ ```
18
+
19
+ `init` registers `window.onerror` and `unhandledrejection` handlers. Manual capture and logs:
20
+
21
+ ```js
22
+ import { captureException, captureMessage, logger } from '@errtap/browser';
23
+
24
+ logger.info('checkout started', { cartSize: 3 });
25
+ try {
26
+ await checkout();
27
+ } catch (e) {
28
+ captureException(e, { tags: { flow: 'checkout' } });
29
+ }
30
+ ```
31
+
32
+ Or drop it in a plain `<script type="module">` — no build step required.
33
+
34
+ Docs: https://docs.errtap.com
package/index.d.ts ADDED
@@ -0,0 +1,33 @@
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
+ }
10
+
11
+ /** Extra fields merged into the event payload. `fingerprint` overrides server-side grouping. */
12
+ export interface CaptureExtra extends Record<string, unknown> {
13
+ fingerprint?: string;
14
+ tags?: Record<string, unknown>;
15
+ context?: Record<string, unknown>;
16
+ }
17
+
18
+ export function resolveDsn(
19
+ dsn: string,
20
+ endpointOverride?: string,
21
+ ): { key: string; endpoint: string; logEndpoint: string } | null;
22
+
23
+ export function init(options: ErrTapOptions): void;
24
+ export function captureException(error: Error, extra?: CaptureExtra): void;
25
+ export function captureMessage(message: string, extra?: CaptureExtra): void;
26
+
27
+ export const logger: {
28
+ debug(message: string, data?: Record<string, unknown>): void;
29
+ info(message: string, data?: Record<string, unknown>): void;
30
+ warn(message: string, data?: Record<string, unknown>): void;
31
+ warning(message: string, data?: Record<string, unknown>): void;
32
+ error(message: string, data?: Record<string, unknown>): void;
33
+ };
package/index.js ADDED
@@ -0,0 +1,250 @@
1
+ // GENERATED FILE — do not edit. Source: sdk/sdk-js (core.js + browser.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 }} options
230
+ */
231
+ export function init(options) {
232
+ configure(
233
+ options,
234
+ () => ({
235
+ url: typeof location !== 'undefined' ? location.href : undefined,
236
+ context: { userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined },
237
+ }),
238
+ // ponytail: sendBeacon can't carry the Authorization header, so keepalive fetch is the send path
239
+ { keepalive: true },
240
+ );
241
+ window.addEventListener('error', (e) => {
242
+ if (e.error) captureException(e.error);
243
+ else captureMessage(String(e.message || 'Unknown error'), { url: e.filename });
244
+ });
245
+ window.addEventListener('unhandledrejection', (e) => {
246
+ e.reason instanceof Error
247
+ ? captureException(e.reason)
248
+ : captureMessage(`Unhandled rejection: ${String(e.reason)}`);
249
+ });
250
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@errtap/browser",
3
+ "version": "0.1.0",
4
+ "description": "ErrTap browser 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
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/ErrTap/observer-app.git",
16
+ "directory": "sdk/sdk-js-browser"
17
+ },
18
+ "homepage": "https://docs.errtap.com",
19
+ "bugs": {
20
+ "url": "https://github.com/ErrTap/observer-app/issues"
21
+ },
22
+ "keywords": [
23
+ "error-tracking",
24
+ "monitoring",
25
+ "observability",
26
+ "errtap",
27
+ "browser"
28
+ ]
29
+ }