@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 +21 -0
- package/README.md +60 -0
- package/index.d.ts +88 -0
- package/index.js +327 -0
- package/package.json +46 -0
- package/react.js +61 -0
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
|
+
}
|