@ziplogger/browser 0.3.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ahaliav Fox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @ziplogger/browser
2
+
3
+ Browser SDK for [ZipLogger](https://ziplogger.dev) — capture uncaught errors,
4
+ unhandled promise rejections, and custom events from web apps, with a first-class React error
5
+ boundary. Zero dependencies.
6
+
7
+ ```bash
8
+ npm install @ziplogger/browser
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```js
14
+ import { ZipLoggerBrowser } from '@ziplogger/browser'
15
+
16
+ export const ziplogger = new ZipLoggerBrowser({
17
+ endpoint: 'https://app.ziplogger.dev',
18
+ apiKey: 'zk_...', // use a key dedicated to browser traffic
19
+ release: import.meta.env.VITE_APP_VERSION,
20
+ })
21
+
22
+ ziplogger.captureGlobalErrors() // window.onerror + unhandledrejection
23
+
24
+ ziplogger.log({ severity: 'info', message: 'checkout started', fields: { cartValue: 214.9 } })
25
+ try { risky() } catch (err) { ziplogger.captureError(err, { step: 'payment' }) }
26
+ ```
27
+
28
+ Every event carries `url`, `userAgent`, and `environment` fields automatically; `Error` objects
29
+ map to ZipLogger's `stackTrace`. A final `fetch(…, { keepalive: true })` flush fires on
30
+ `pagehide` so events survive navigation and tab closes.
31
+
32
+ ## React
33
+
34
+ ```jsx
35
+ import React from 'react'
36
+ import { createErrorBoundary, createUseZipLogger } from '@ziplogger/browser/react'
37
+ import { ziplogger } from './ziplogger'
38
+
39
+ const ZipLoggerErrorBoundary = createErrorBoundary(React, ziplogger)
40
+ export const useZipLogger = createUseZipLogger(React, ziplogger)
41
+
42
+ root.render(
43
+ <ZipLoggerErrorBoundary fallback={<p>Something went wrong.</p>}>
44
+ <App />
45
+ </ZipLoggerErrorBoundary>,
46
+ )
47
+
48
+ // in any component
49
+ const { captureError } = useZipLogger()
50
+ ```
51
+
52
+ Render errors ship with the component stack; the factory pattern (`createErrorBoundary(React, …)`)
53
+ keeps this package free of a React dependency, so it works with any React ≥ 16.8 — and the core
54
+ client works with Vue, Svelte, Angular, or no framework at all.
55
+
56
+ ## Notes
57
+
58
+ - Browser API keys are visible to users by design (like every client-side telemetry key). Use a
59
+ dedicated key so it can be revoked independently, and rely on your plan's rate limits.
60
+ - Batching defaults are browser-tuned: 20 events/request, 3s linger, 2 retries, 1,000-event buffer.
package/index.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ export type Severity = 'debug' | 'info' | 'warn' | 'error' | 'fatal'
2
+
3
+ export interface BrowserOptions {
4
+ /** Base URL of the ZipLogger server, e.g. "https://logs.yourcompany.com". */
5
+ endpoint: string
6
+ /** Tenant ingestion API key (zk_...). Use a key dedicated to browser traffic. */
7
+ apiKey: string
8
+ /** Source name. Default: window.location.hostname. */
9
+ source?: string
10
+ release?: string
11
+ commitSha?: string
12
+ /** Default "production". */
13
+ environment?: string
14
+ tags?: string[]
15
+ /** Attach url + userAgent fields to every event. Default true. */
16
+ includePageContext?: boolean
17
+ /** Max buffered events. Default 1000. */
18
+ queueCapacity?: number
19
+ /** Max events per request. Default 20. */
20
+ batchSize?: number
21
+ /** Linger before flushing a partial batch. Default 3000. */
22
+ flushIntervalMs?: number
23
+ /** Retry attempts per batch. Default 2 (browsers should not hammer). */
24
+ maxRetries?: number
25
+ retryBaseDelayMs?: number
26
+ }
27
+
28
+ export interface BrowserLogEntry {
29
+ message: string
30
+ severity?: Severity
31
+ timestamp?: string
32
+ source?: string
33
+ release?: string
34
+ commitSha?: string
35
+ stackTrace?: string
36
+ error?: Error
37
+ fields?: Record<string, unknown>
38
+ tags?: string[]
39
+ }
40
+
41
+ export declare class ZipLoggerBrowser {
42
+ constructor(options: BrowserOptions)
43
+ /** Events lost to backpressure or exhausted retries. */
44
+ dropped: number
45
+ /** Queue an event for background delivery. Never blocks, never throws. */
46
+ log(entry: BrowserLogEntry): void
47
+ /** Report a caught error with optional context fields. */
48
+ captureError(error: unknown, fields?: Record<string, unknown>): void
49
+ /** Capture window error / unhandledrejection events. Returns a stop function. */
50
+ captureGlobalErrors(): () => void
51
+ /**
52
+ * Wraps window.fetch: adds a W3C traceparent header to same-origin requests (plus any
53
+ * origins in propagateTo) so browser calls and backend traces share one trace id, and
54
+ * logs failed requests (HTTP >= 400 / network errors) with that trace id.
55
+ */
56
+ instrumentFetch(options?: {
57
+ propagateTo?: string[]
58
+ logFailures?: boolean
59
+ /** Export a browser-side root span per request (default true) so the waterfall starts in the browser. */
60
+ sendSpans?: boolean
61
+ /** Service name for browser spans (default "<source>-browser"). */
62
+ serviceName?: string
63
+ }): () => void
64
+ /** Send anything still buffered. keepalive=true during page unload. */
65
+ flush(keepalive?: boolean): Promise<void>
66
+ /** Flush and detach global listeners. */
67
+ close(): Promise<void>
68
+ }
69
+ export default ZipLoggerBrowser
70
+
71
+ // ./react
72
+ import type * as ReactNamespace from 'react'
73
+ export declare function createErrorBoundary(
74
+ React: typeof ReactNamespace,
75
+ client: ZipLoggerBrowser,
76
+ ): ReactNamespace.ComponentType<{
77
+ children?: ReactNamespace.ReactNode
78
+ fallback?: ReactNamespace.ReactNode
79
+ name?: string
80
+ onError?: (error: unknown, info: { componentStack?: string }) => void
81
+ }>
82
+ export declare function createUseZipLogger(
83
+ React: typeof ReactNamespace,
84
+ client: ZipLoggerBrowser,
85
+ ): () => {
86
+ captureError: (error: unknown, fields?: Record<string, unknown>) => void
87
+ log: (entry: BrowserLogEntry) => void
88
+ }
package/index.js ADDED
@@ -0,0 +1,327 @@
1
+ /**
2
+ * ZipLogger browser SDK.
3
+ *
4
+ * Same delivery semantics as every ZipLogger SDK — bounded queue, NDJSON batches,
5
+ * retry with backoff honoring 429 Retry-After, drop-on-backpressure, never throws —
6
+ * tuned for the browser: smaller defaults, `fetch(..., { keepalive: true })` so a
7
+ * final flush survives page unload, and automatic page context on every event.
8
+ *
9
+ * Zero dependencies. Works in any environment with `fetch` (tests run under Node).
10
+ */
11
+
12
+ const SEVERITIES = new Set(['debug', 'info', 'warn', 'error', 'fatal'])
13
+ const HAS_WINDOW = typeof window !== 'undefined'
14
+
15
+ /** Cryptographically-random lowercase hex, for W3C trace/span ids. */
16
+ function randomHex(bytes) {
17
+ const buf = new Uint8Array(bytes)
18
+ globalThis.crypto.getRandomValues(buf)
19
+ let out = ''
20
+ for (const b of buf) out += b.toString(16).padStart(2, '0')
21
+ return out
22
+ }
23
+
24
+ export class ZipLoggerBrowser {
25
+ /** @param {import('./index').BrowserOptions} options */
26
+ constructor(options) {
27
+ if (!options || !options.endpoint) throw new Error('ZipLogger: endpoint is required')
28
+ if (!options.apiKey) throw new Error('ZipLogger: apiKey is required')
29
+
30
+ const trimmed = String(options.endpoint).replace(/\/+$/, '')
31
+ this._url = /\/logs$/i.test(trimmed) ? trimmed : trimmed + '/ingest/v1/logs'
32
+ this._apiKey = options.apiKey
33
+ this._source = options.source || (HAS_WINDOW ? window.location.hostname : 'browser')
34
+ this._release = options.release
35
+ this._commitSha = options.commitSha
36
+ this._environment = options.environment || 'production'
37
+ this._tags = Array.isArray(options.tags) && options.tags.length ? options.tags : undefined
38
+ this._includePageContext = options.includePageContext !== false
39
+
40
+ this._queueCapacity = options.queueCapacity ?? 1_000
41
+ this._batchSize = options.batchSize ?? 20
42
+ this._flushInterval = options.flushIntervalMs ?? 3_000
43
+ this._maxRetries = options.maxRetries ?? 2
44
+ this._retryBaseDelay = options.retryBaseDelayMs ?? 500
45
+
46
+ this.dropped = 0
47
+ this._queue = []
48
+ this._timer = null
49
+ this._sending = Promise.resolve()
50
+ this._detach = []
51
+
52
+ if (HAS_WINDOW) {
53
+ const onHide = () => { void this.flush(true) }
54
+ window.addEventListener('pagehide', onHide)
55
+ document.addEventListener('visibilitychange', () => {
56
+ if (document.visibilityState === 'hidden') onHide()
57
+ })
58
+ this._detach.push(() => window.removeEventListener('pagehide', onHide))
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Queue one event for background delivery. Never blocks, never throws.
64
+ * @param {import('./index').BrowserLogEntry} entry
65
+ */
66
+ log(entry) {
67
+ if (this._queue.length >= this._queueCapacity) { this.dropped++; return }
68
+
69
+ const fields = { environment: this._environment, ...entry.fields }
70
+ if (this._includePageContext && HAS_WINDOW) {
71
+ fields.url = window.location.href
72
+ fields.userAgent = navigator.userAgent
73
+ }
74
+ const record = {
75
+ timestamp: entry.timestamp ?? new Date().toISOString(),
76
+ severity: SEVERITIES.has(entry.severity) ? entry.severity : 'info',
77
+ message: entry.message ?? '',
78
+ source: entry.source ?? this._source,
79
+ release: entry.release ?? this._release,
80
+ commitSha: entry.commitSha ?? this._commitSha,
81
+ stackTrace: entry.stackTrace,
82
+ fields,
83
+ tags: entry.tags ?? this._tags,
84
+ }
85
+ if (entry.error instanceof Error) {
86
+ record.stackTrace = record.stackTrace ?? (entry.error.stack || String(entry.error))
87
+ fields.exceptionType = entry.error.name
88
+ fields.exceptionMessage = entry.error.message
89
+ }
90
+ delete record.error
91
+
92
+ this._queue.push(record)
93
+ this._schedule(this._queue.length >= this._batchSize ? 0 : this._flushInterval)
94
+ }
95
+
96
+ /** Convenience: report a caught error with optional context. */
97
+ captureError(error, fields) {
98
+ this.log({
99
+ severity: 'error',
100
+ message: error && error.message ? error.message : String(error),
101
+ error: error instanceof Error ? error : undefined,
102
+ fields,
103
+ })
104
+ }
105
+
106
+ /**
107
+ * Start capturing window `error` and `unhandledrejection` events.
108
+ * Returns a function that stops capturing.
109
+ */
110
+ captureGlobalErrors() {
111
+ if (!HAS_WINDOW) return () => {}
112
+ const onError = (event) => {
113
+ this.log({
114
+ severity: 'error',
115
+ message: event.message || 'Uncaught error',
116
+ error: event.error instanceof Error ? event.error : undefined,
117
+ fields: { file: event.filename, line: event.lineno, column: event.colno, handler: 'window.onerror' },
118
+ })
119
+ }
120
+ const onRejection = (event) => {
121
+ const reason = event.reason
122
+ this.log({
123
+ severity: 'error',
124
+ message: reason && reason.message ? `Unhandled rejection: ${reason.message}` : 'Unhandled promise rejection',
125
+ error: reason instanceof Error ? reason : undefined,
126
+ fields: { handler: 'unhandledrejection' },
127
+ })
128
+ }
129
+ window.addEventListener('error', onError)
130
+ window.addEventListener('unhandledrejection', onRejection)
131
+ const stop = () => {
132
+ window.removeEventListener('error', onError)
133
+ window.removeEventListener('unhandledrejection', onRejection)
134
+ }
135
+ this._detach.push(stop)
136
+ return stop
137
+ }
138
+
139
+ /**
140
+ * Instrument `window.fetch` for distributed tracing: every outgoing request to your own
141
+ * backend gets a W3C `traceparent` header, so the server continues the SAME trace — a failed
142
+ * browser call and its backend waterfall share one trace id in ZipLogger. Failed requests
143
+ * (HTTP >= 400 or network errors) are logged automatically with that trace id, which becomes
144
+ * a "View trace" link on the server.
145
+ *
146
+ * Propagation targets: same-origin requests by default; pass `propagateTo` (array of origin
147
+ * prefixes, e.g. ["https://api.mycompany.com"]) for cross-origin APIs you control — those
148
+ * servers must allow the `traceparent` header in CORS. Returns a function that stops
149
+ * instrumenting.
150
+ *
151
+ * @param {{ propagateTo?: string[], logFailures?: boolean }} [options]
152
+ */
153
+ instrumentFetch(options = {}) {
154
+ if (!HAS_WINDOW || typeof window.fetch !== 'function') return () => {}
155
+ const propagateTo = options.propagateTo || []
156
+ const logFailures = options.logFailures !== false
157
+ const sendSpans = options.sendSpans !== false
158
+ const serviceName = options.serviceName || `${this._source}-browser`
159
+ const ingestOrigin = this._url.split('/').slice(0, 3).join('/')
160
+ const original = window.fetch.bind(window)
161
+ const self = this
162
+ const spanQueue = []
163
+ let spanTimer = null
164
+
165
+ const shouldPropagate = (url) => {
166
+ if (url.startsWith(ingestOrigin)) return false // never trace our own telemetry shipping
167
+ if (url.startsWith(window.location.origin) || url.startsWith('/')) return true
168
+ return propagateTo.some((origin) => url.startsWith(origin))
169
+ }
170
+
171
+ // Ship the browser-side root spans over OTLP/JSON, so the ZipLogger waterfall shows the
172
+ // request from the user's browser down through every backend service — one trace.
173
+ const flushSpans = () => {
174
+ spanTimer = null
175
+ if (spanQueue.length === 0) return
176
+ const spans = spanQueue.splice(0, spanQueue.length)
177
+ const payload = {
178
+ resourceSpans: [{
179
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
180
+ scopeSpans: [{ spans }],
181
+ }],
182
+ }
183
+ void original(`${ingestOrigin}/v1/traces`, {
184
+ method: 'POST',
185
+ headers: { 'Content-Type': 'application/json', 'X-Api-Key': self._apiKey },
186
+ body: JSON.stringify(payload),
187
+ keepalive: true,
188
+ }).catch(() => {})
189
+ }
190
+ const enqueueSpan = (span) => {
191
+ if (!sendSpans) return
192
+ spanQueue.push(span)
193
+ if (spanTimer === null) spanTimer = setTimeout(flushSpans, self._flushInterval)
194
+ }
195
+
196
+ window.fetch = async function (input, init) {
197
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
198
+ if (!shouldPropagate(String(url))) return original(input, init)
199
+
200
+ const traceId = randomHex(16)
201
+ const spanId = randomHex(8)
202
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
203
+ headers.set('traceparent', `00-${traceId}-${spanId}-01`)
204
+
205
+ const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase()
206
+ const path = String(url).replace(/^https?:\/\/[^/]+/, '') || '/'
207
+ const startNs = String(BigInt(Date.now()) * 1000000n)
208
+ const finish = (status, errorMessage) => {
209
+ enqueueSpan({
210
+ traceId, spanId,
211
+ name: `${method} ${path.split('?')[0]}`,
212
+ kind: 'SPAN_KIND_CLIENT',
213
+ startTimeUnixNano: startNs,
214
+ endTimeUnixNano: String(BigInt(Date.now()) * 1000000n),
215
+ attributes: [
216
+ { key: 'url.full', value: { stringValue: String(url) } },
217
+ { key: 'http.request.method', value: { stringValue: method } },
218
+ ...(status ? [{ key: 'http.response.status_code', value: { intValue: String(status) } }] : []),
219
+ ],
220
+ ...(errorMessage || (status && status >= 400)
221
+ ? { status: { code: 'STATUS_CODE_ERROR', message: errorMessage || `HTTP ${status}` } }
222
+ : {}),
223
+ })
224
+ }
225
+
226
+ try {
227
+ const response = await original(input, { ...init, headers })
228
+ finish(response.status)
229
+ if (logFailures && response.status >= 400) {
230
+ self.log({
231
+ severity: response.status >= 500 ? 'error' : 'warn',
232
+ message: `${method} ${url} failed with HTTP ${response.status}`,
233
+ fields: { traceId, spanId, httpStatus: response.status, requestUrl: String(url) },
234
+ })
235
+ }
236
+ return response
237
+ } catch (error) {
238
+ const message = error && error.message ? error.message : 'network error'
239
+ finish(null, message)
240
+ if (logFailures) {
241
+ self.log({
242
+ severity: 'error',
243
+ message: `${method} ${url} failed: ${message}`,
244
+ error: error instanceof Error ? error : undefined,
245
+ fields: { traceId, spanId, requestUrl: String(url) },
246
+ })
247
+ }
248
+ throw error
249
+ }
250
+ }
251
+
252
+ const stop = () => {
253
+ window.fetch = original
254
+ if (spanTimer !== null) { clearTimeout(spanTimer); flushSpans() }
255
+ }
256
+ this._detach.push(stop)
257
+ return stop
258
+ }
259
+
260
+ _schedule(delay) {
261
+ if (this._timer !== null) {
262
+ if (delay > 0) return
263
+ clearTimeout(this._timer)
264
+ }
265
+ this._timer = setTimeout(() => {
266
+ this._timer = null
267
+ this._sending = this._sending.then(() => this._drain(false)).catch(() => {})
268
+ }, delay)
269
+ if (typeof this._timer === 'object' && this._timer.unref) this._timer.unref()
270
+ }
271
+
272
+ async _drain(keepalive) {
273
+ while (this._queue.length > 0) {
274
+ const batch = this._queue.splice(0, this._batchSize)
275
+ await this._send(batch, keepalive)
276
+ }
277
+ }
278
+
279
+ async _send(batch, keepalive) {
280
+ const payload = batch.map((e) => JSON.stringify(e)).join('\n')
281
+
282
+ for (let attempt = 0; ; attempt++) {
283
+ let retryAfterMs = null
284
+ try {
285
+ const response = await fetch(this._url, {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/x-ndjson', 'X-Api-Key': this._apiKey },
288
+ body: payload,
289
+ keepalive, // survives page unload (64 KB budget — batches are small)
290
+ })
291
+ if (response.ok) return
292
+ if (response.status !== 429 && response.status !== 408 && response.status < 500) {
293
+ this.dropped += batch.length
294
+ return
295
+ }
296
+ const header = response.headers.get('retry-after')
297
+ if (header && !Number.isNaN(Number(header))) retryAfterMs = Number(header) * 1000
298
+ } catch {
299
+ // offline / network failure — transient
300
+ }
301
+
302
+ if (keepalive || attempt >= this._maxRetries) {
303
+ this.dropped += batch.length // unloading pages don't get retries
304
+ return
305
+ }
306
+ await new Promise((resolve) => {
307
+ const timer = setTimeout(resolve, Math.min(retryAfterMs ?? this._retryBaseDelay * 2 ** attempt, 10_000))
308
+ if (typeof timer === 'object' && timer.unref) timer.unref()
309
+ })
310
+ }
311
+ }
312
+
313
+ /** Send anything still buffered. Pass keepalive=true during page unload. */
314
+ async flush(keepalive = false) {
315
+ if (this._timer !== null) { clearTimeout(this._timer); this._timer = null }
316
+ this._sending = this._sending.then(() => this._drain(keepalive)).catch(() => {})
317
+ await this._sending
318
+ }
319
+
320
+ /** Flush and detach all global listeners. */
321
+ async close() {
322
+ for (const stop of this._detach.splice(0)) stop()
323
+ await this.flush()
324
+ }
325
+ }
326
+
327
+ export default ZipLoggerBrowser
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@ziplogger/browser",
3
+ "version": "0.3.3",
4
+ "description": "ZipLogger browser SDK \u2014 capture console errors, unhandled rejections, and custom events from web apps, with a React error boundary. Zero dependencies.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "types": "index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "default": "./index.js"
13
+ },
14
+ "./react": {
15
+ "types": "./index.d.ts",
16
+ "default": "./react.js"
17
+ }
18
+ },
19
+ "sideEffects": false,
20
+ "scripts": {
21
+ "test": "node --test test/client.test.mjs"
22
+ },
23
+ "keywords": [
24
+ "ziplogger",
25
+ "logging",
26
+ "browser",
27
+ "react",
28
+ "error-tracking"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/ahaliav/ZipLogger_Client.git",
33
+ "directory": "sdk_browser"
34
+ },
35
+ "homepage": "https://ziplogger.dev",
36
+ "bugs": {
37
+ "url": "https://github.com/ahaliav/ZipLogger_Client/issues"
38
+ },
39
+ "author": "Ahaliav Fox",
40
+ "files": [
41
+ "README.md",
42
+ "index.d.ts",
43
+ "index.js",
44
+ "react.js"
45
+ ]
46
+ }
package/react.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * React integration for @ziplogger/browser.
3
+ *
4
+ * The factory pattern keeps this package dependency-free — pass your React in:
5
+ *
6
+ * import React from 'react'
7
+ * import { ZipLoggerBrowser } from '@ziplogger/browser'
8
+ * import { createErrorBoundary } from '@ziplogger/browser/react'
9
+ *
10
+ * const ziplogger = new ZipLoggerBrowser({ endpoint: '...', apiKey: 'zk_...' })
11
+ * const ZipLoggerErrorBoundary = createErrorBoundary(React, ziplogger)
12
+ *
13
+ * <ZipLoggerErrorBoundary fallback={<p>Something went wrong.</p>}>
14
+ * <App />
15
+ * </ZipLoggerErrorBoundary>
16
+ */
17
+
18
+ /**
19
+ * @param {typeof import('react')} React
20
+ * @param {import('./index').ZipLoggerBrowser} client
21
+ */
22
+ export function createErrorBoundary(React, client) {
23
+ return class ZipLoggerErrorBoundary extends React.Component {
24
+ constructor(props) {
25
+ super(props)
26
+ this.state = { hasError: false }
27
+ }
28
+
29
+ static getDerivedStateFromError() {
30
+ return { hasError: true }
31
+ }
32
+
33
+ componentDidCatch(error, info) {
34
+ client.log({
35
+ severity: 'error',
36
+ message: `React error boundary: ${error && error.message ? error.message : String(error)}`,
37
+ error: error instanceof Error ? error : undefined,
38
+ fields: {
39
+ componentStack: info && info.componentStack ? String(info.componentStack).trim() : undefined,
40
+ boundary: this.props.name || 'ZipLoggerErrorBoundary',
41
+ },
42
+ })
43
+ if (this.props.onError) this.props.onError(error, info)
44
+ }
45
+
46
+ render() {
47
+ if (this.state.hasError) return this.props.fallback ?? null
48
+ return this.props.children
49
+ }
50
+ }
51
+ }
52
+
53
+ /** Convenience hook factory: returns a stable `captureError(error, fields)` callback. */
54
+ export function createUseZipLogger(React, client) {
55
+ return function useZipLogger() {
56
+ return React.useMemo(() => ({
57
+ captureError: (error, fields) => client.captureError(error, fields),
58
+ log: (entry) => client.log(entry),
59
+ }), [])
60
+ }
61
+ }