@ziplogger/browser 0.3.3 → 0.4.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @ziplogger/browser
2
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.
3
+ Browser SDK for [ZipLogger](https://ziplogger.dev) — product-analytics events,
4
+ uncaught errors, unhandled promise rejections and distributed tracing from web apps, with a
5
+ first-class React error boundary. Zero dependencies.
6
6
 
7
7
  ```bash
8
8
  npm install @ziplogger/browser
@@ -25,6 +25,32 @@ ziplogger.log({ severity: 'info', message: 'checkout started', fields: { cartVal
25
25
  try { risky() } catch (err) { ziplogger.captureError(err, { step: 'payment' }) }
26
26
  ```
27
27
 
28
+ ## Events
29
+
30
+ Events are what people did; logs are what you read when something breaks. They are separate calls
31
+ because ZipLogger answers different questions with each.
32
+
33
+ ```js
34
+ ziplogger.track('checkout_started', { cartValue: 214.9, currency: 'USD' })
35
+
36
+ // After sign-in. Links everything this browser did anonymously to the account, so the visitor
37
+ // stops being counted as two people.
38
+ ziplogger.identify('user_42')
39
+
40
+ ziplogger.reset() // on sign-out: a fresh anonymous identity from here
41
+ ```
42
+
43
+ An un-identified visitor still needs an id, or the server has nobody to attribute the event to. The
44
+ SDK mints one on first use and keeps it in `localStorage` (`zl_anon`), with a per-tab session id in
45
+ `sessionStorage` (`zl_sess`) — that stored anonymous id is exactly what `identify()` later links.
46
+ Both fall back to memory when storage is unavailable, so private mode degrades to per-page
47
+ attribution rather than an error.
48
+
49
+ Every event carries an `insertId`, so a retry after a timeout cannot count it twice.
50
+
51
+ **Do not put credentials in properties.** Values that look like tokens, keys or card numbers are
52
+ redacted server-side, but the safe habit is not to send them.
53
+
28
54
  Every event carries `url`, `userAgent`, and `environment` fields automatically; `Error` objects
29
55
  map to ZipLogger's `stackTrace`. A final `fetch(…, { keepalive: true })` flush fires on
30
56
  `pagehide` so events survive navigation and tab closes.
package/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export type Severity = 'debug' | 'info' | 'warn' | 'error' | 'fatal'
2
2
 
3
3
  export interface BrowserOptions {
4
- /** Base URL of the ZipLogger server, e.g. "https://logs.yourcompany.com". */
4
+ /** Your ZipLogger origin, e.g. "https://app.ziplogger.dev" (or your own host if
5
+ * you self-host). Paths are appended for you. */
5
6
  endpoint: string
6
7
  /** Tenant ingestion API key (zk_...). Use a key dedicated to browser traffic. */
7
8
  apiKey: string
@@ -12,8 +13,15 @@ export interface BrowserOptions {
12
13
  /** Default "production". */
13
14
  environment?: string
14
15
  tags?: string[]
15
- /** Attach url + userAgent fields to every event. Default true. */
16
+ /** Attach url + userAgent to every log line, and url + page to every event. Default true. */
16
17
  includePageContext?: boolean
18
+ /** Your id for the signed-in user, when the page already knows it. Otherwise call identify(). */
19
+ userId?: string
20
+ /** Override the generated anonymous id. Normally left alone: it is minted once and kept in
21
+ * localStorage so a visitor's pre-login events can be linked to their account later. */
22
+ anonymousId?: string
23
+ /** Override the generated session id (per tab, kept in sessionStorage). */
24
+ sessionId?: string
17
25
  /** Max buffered events. Default 1000. */
18
26
  queueCapacity?: number
19
27
  /** Max events per request. Default 20. */
@@ -38,16 +46,42 @@ export interface BrowserLogEntry {
38
46
  tags?: string[]
39
47
  }
40
48
 
49
+ export interface Identity {
50
+ userId: string | null
51
+ anonymousId: string | null
52
+ sessionId: string | null
53
+ }
54
+
41
55
  export declare class ZipLoggerBrowser {
42
56
  constructor(options: BrowserOptions)
43
- /** Events lost to backpressure or exhausted retries. */
57
+ /** Records lost to backpressure or exhausted retries, logs and events together. */
44
58
  dropped: number
59
+ /** The ids events are currently attributed to. */
60
+ readonly identity: Identity
45
61
  /** Queue an event for background delivery. Never blocks, never throws. */
46
62
  log(entry: BrowserLogEntry): void
47
63
  /** Report a caught error with optional context fields. */
48
64
  captureError(error: unknown, fields?: Record<string, unknown>): void
49
65
  /** Capture window error / unhandledrejection events. Returns a stop function. */
50
66
  captureGlobalErrors(): () => void
67
+ /**
68
+ * Record a product-analytics event -- a signup, a checkout, a plan change.
69
+ *
70
+ * Distinct from log(): logs are lines you read when something breaks, events are things people
71
+ * did, and ZipLogger answers different questions with each. Never blocks, never throws.
72
+ *
73
+ * Values that look like credentials are redacted server-side; do not send passwords, tokens or
74
+ * card numbers as properties.
75
+ */
76
+ track(name: string, properties?: Record<string, unknown>): void
77
+ /**
78
+ * Attach this browser's anonymous history to a real account and use that id from now on.
79
+ * Call once after sign-in; the server links the ids so pre-login events stop being a separate
80
+ * person.
81
+ */
82
+ identify(userId: string, properties?: Record<string, unknown>): void
83
+ /** Forget the signed-in user and start a fresh anonymous identity, e.g. on sign-out. */
84
+ reset(): void
51
85
  /**
52
86
  * Wraps window.fetch: adds a W3C traceparent header to same-origin requests (plus any
53
87
  * origins in propagateTo) so browser calls and backend traces share one trace id, and
@@ -61,7 +95,7 @@ export declare class ZipLoggerBrowser {
61
95
  /** Service name for browser spans (default "<source>-browser"). */
62
96
  serviceName?: string
63
97
  }): () => void
64
- /** Send anything still buffered. keepalive=true during page unload. */
98
+ /** Send anything still buffered, logs and events both. keepalive=true during page unload. */
65
99
  flush(keepalive?: boolean): Promise<void>
66
100
  /** Flush and detach global listeners. */
67
101
  close(): Promise<void>
@@ -85,4 +119,6 @@ export declare function createUseZipLogger(
85
119
  ): () => {
86
120
  captureError: (error: unknown, fields?: Record<string, unknown>) => void
87
121
  log: (entry: BrowserLogEntry) => void
122
+ track: (name: string, properties?: Record<string, unknown>) => void
123
+ identify: (userId: string, properties?: Record<string, unknown>) => void
88
124
  }
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ZipLogger browser SDK.
2
+ * ZipLogger browser SDK — logs, errors, traces and product-analytics events.
3
3
  *
4
4
  * Same delivery semantics as every ZipLogger SDK — bounded queue, NDJSON batches,
5
5
  * retry with backoff honoring 429 Retry-After, drop-on-backpressure, never throws —
@@ -29,6 +29,9 @@ export class ZipLoggerBrowser {
29
29
 
30
30
  const trimmed = String(options.endpoint).replace(/\/+$/, '')
31
31
  this._url = /\/logs$/i.test(trimmed) ? trimmed : trimmed + '/ingest/v1/logs'
32
+ // Events are a different endpoint with a different payload, so they get their own URL and
33
+ // queue rather than being squeezed through the log pipeline.
34
+ this._eventsUrl = trimmed.replace(/\/ingest\/v1\/logs$/i, '') + '/ingest/v1/events'
32
35
  this._apiKey = options.apiKey
33
36
  this._source = options.source || (HAS_WINDOW ? window.location.hostname : 'browser')
34
37
  this._release = options.release
@@ -49,6 +52,18 @@ export class ZipLoggerBrowser {
49
52
  this._sending = Promise.resolve()
50
53
  this._detach = []
51
54
 
55
+ this._events = []
56
+ this._eventTimer = null
57
+ this._eventSending = Promise.resolve()
58
+
59
+ // An event with neither a user id nor an anonymous id is rejected by the server, so a
60
+ // visitor who has not signed in needs a stable id of their own. It lives in localStorage so
61
+ // it survives reloads -- that is what lets identify() later attach their pre-login events to
62
+ // the account. Session id is per-tab and per-visit, so it belongs in sessionStorage.
63
+ this._userId = options.userId ?? null
64
+ this._anonymousId = options.anonymousId ?? this._persistedId('local', 'zl_anon', 'anon')
65
+ this._sessionId = options.sessionId ?? this._persistedId('session', 'zl_sess', 'sess')
66
+
52
67
  if (HAS_WINDOW) {
53
68
  const onHide = () => { void this.flush(true) }
54
69
  window.addEventListener('pagehide', onHide)
@@ -103,6 +118,91 @@ export class ZipLoggerBrowser {
103
118
  })
104
119
  }
105
120
 
121
+ /**
122
+ * Record a product-analytics event: a signup, a checkout, a plan change.
123
+ *
124
+ * Separate from `log()` on purpose. Logs are lines you read when something breaks; events are
125
+ * things people did, and they answer different questions in different places in ZipLogger.
126
+ * Never blocks, never throws.
127
+ *
128
+ * @param {string} name Event name, e.g. "checkout_started". Lower-cased server-side.
129
+ * @param {Record<string, unknown>} [properties] Your own properties. Values that look like
130
+ * credentials are redacted server-side; do not send passwords, tokens or card numbers.
131
+ */
132
+ track(name, properties) {
133
+ if (!name || typeof name !== 'string') return
134
+ if (this._events.length >= this._queueCapacity) { this.dropped++; return }
135
+
136
+ const event = {
137
+ type: 'track',
138
+ name,
139
+ timestamp: new Date().toISOString(),
140
+ userId: this._userId ?? undefined,
141
+ anonymousId: this._anonymousId ?? undefined,
142
+ sessionId: this._sessionId ?? undefined,
143
+ environment: this._environment,
144
+ release: this._release,
145
+ commitSha: this._commitSha,
146
+ // An idempotency key, so a retry after a timeout cannot count the same event twice.
147
+ insertId: randomHex(12),
148
+ properties: properties && typeof properties === 'object' ? properties : undefined,
149
+ }
150
+ if (this._includePageContext && HAS_WINDOW) {
151
+ event.url = window.location.href
152
+ event.page = window.location.pathname
153
+ }
154
+
155
+ this._events.push(event)
156
+ this._scheduleEvents(this._events.length >= this._batchSize ? 0 : this._flushInterval)
157
+ }
158
+
159
+ /**
160
+ * Attach everything this browser has done anonymously to a real account, and use the account
161
+ * id from now on.
162
+ *
163
+ * Call it once after sign-in. The server links the two ids, so the events this visitor sent
164
+ * before signing in stop being a separate person -- which is the whole point of holding an
165
+ * anonymous id in the first place.
166
+ *
167
+ * @param {string} userId Your own id for the user.
168
+ * @param {Record<string, unknown>} [properties] Optional properties for the identify event.
169
+ */
170
+ identify(userId, properties) {
171
+ if (!userId || typeof userId !== 'string') return
172
+ const anonymousId = this._anonymousId
173
+
174
+ this._userId = userId
175
+ if (!anonymousId) return // nothing to link; later events simply carry the user id
176
+
177
+ if (this._events.length >= this._queueCapacity) { this.dropped++; return }
178
+ this._events.push({
179
+ type: 'identify',
180
+ timestamp: new Date().toISOString(),
181
+ userId,
182
+ anonymousId,
183
+ sessionId: this._sessionId ?? undefined,
184
+ environment: this._environment,
185
+ insertId: randomHex(12),
186
+ properties: properties && typeof properties === 'object' ? properties : undefined,
187
+ })
188
+ // Linking is what every later event depends on, so it does not wait for a full batch.
189
+ this._scheduleEvents(0)
190
+ }
191
+
192
+ /** Forget the signed-in user, e.g. on sign-out. Later events are anonymous again. */
193
+ reset() {
194
+ this._userId = null
195
+ this._anonymousId = this._newId('anon')
196
+ this._sessionId = this._newId('sess')
197
+ this._store('local', 'zl_anon', this._anonymousId)
198
+ this._store('session', 'zl_sess', this._sessionId)
199
+ }
200
+
201
+ /** The ids this client is currently attributing events to. Useful in tests and debugging. */
202
+ get identity() {
203
+ return { userId: this._userId, anonymousId: this._anonymousId, sessionId: this._sessionId }
204
+ }
205
+
106
206
  /**
107
207
  * Start capturing window `error` and `unhandledrejection` events.
108
208
  * Returns a function that stops capturing.
@@ -257,6 +357,51 @@ export class ZipLoggerBrowser {
257
357
  return stop
258
358
  }
259
359
 
360
+ /** A stable id from web storage, minted on first use. Falls back to memory when storage is
361
+ * unavailable (private mode, blocked cookies) -- attribution is then per-page, never an error. */
362
+ _persistedId(kind, key, prefix) {
363
+ const existing = this._read(kind, key)
364
+ if (existing) return existing
365
+ const id = this._newId(prefix)
366
+ this._store(kind, key, id)
367
+ return id
368
+ }
369
+
370
+ _newId(prefix) { return `${prefix}_${randomHex(10)}` }
371
+
372
+ _read(kind, key) {
373
+ try {
374
+ const store = kind === 'local' ? globalThis.localStorage : globalThis.sessionStorage
375
+ return store ? store.getItem(key) : null
376
+ } catch { return null } // storage disabled: not an error worth surfacing
377
+ }
378
+
379
+ _store(kind, key, value) {
380
+ try {
381
+ const store = kind === 'local' ? globalThis.localStorage : globalThis.sessionStorage
382
+ if (store) store.setItem(key, value)
383
+ } catch { /* storage disabled; the id stays in memory for this page */ }
384
+ }
385
+
386
+ _scheduleEvents(delay) {
387
+ if (this._eventTimer !== null) {
388
+ if (delay > 0) return
389
+ clearTimeout(this._eventTimer)
390
+ }
391
+ this._eventTimer = setTimeout(() => {
392
+ this._eventTimer = null
393
+ this._eventSending = this._eventSending.then(() => this._drainEvents(false)).catch(() => {})
394
+ }, delay)
395
+ if (typeof this._eventTimer === 'object' && this._eventTimer.unref) this._eventTimer.unref()
396
+ }
397
+
398
+ async _drainEvents(keepalive) {
399
+ while (this._events.length > 0) {
400
+ const batch = this._events.splice(0, this._batchSize)
401
+ await this._post(this._eventsUrl, batch, batch.length, keepalive)
402
+ }
403
+ }
404
+
260
405
  _schedule(delay) {
261
406
  if (this._timer !== null) {
262
407
  if (delay > 0) return
@@ -277,12 +422,21 @@ export class ZipLoggerBrowser {
277
422
  }
278
423
 
279
424
  async _send(batch, keepalive) {
425
+ await this._post(this._url, batch, batch.length, keepalive)
426
+ }
427
+
428
+ /**
429
+ * One NDJSON POST with the retry policy both queues share: retry only what retrying can fix
430
+ * (429, 408, 5xx), honour Retry-After, and never retry during page unload -- an unloading page
431
+ * has no time, and a duplicate is worse than a loss when the event already carries an insertId.
432
+ */
433
+ async _post(url, batch, count, keepalive) {
280
434
  const payload = batch.map((e) => JSON.stringify(e)).join('\n')
281
435
 
282
436
  for (let attempt = 0; ; attempt++) {
283
437
  let retryAfterMs = null
284
438
  try {
285
- const response = await fetch(this._url, {
439
+ const response = await fetch(url, {
286
440
  method: 'POST',
287
441
  headers: { 'Content-Type': 'application/x-ndjson', 'X-Api-Key': this._apiKey },
288
442
  body: payload,
@@ -290,7 +444,7 @@ export class ZipLoggerBrowser {
290
444
  })
291
445
  if (response.ok) return
292
446
  if (response.status !== 429 && response.status !== 408 && response.status < 500) {
293
- this.dropped += batch.length
447
+ this.dropped += count
294
448
  return
295
449
  }
296
450
  const header = response.headers.get('retry-after')
@@ -300,7 +454,7 @@ export class ZipLoggerBrowser {
300
454
  }
301
455
 
302
456
  if (keepalive || attempt >= this._maxRetries) {
303
- this.dropped += batch.length // unloading pages don't get retries
457
+ this.dropped += count // unloading pages don't get retries
304
458
  return
305
459
  }
306
460
  await new Promise((resolve) => {
@@ -310,11 +464,13 @@ export class ZipLoggerBrowser {
310
464
  }
311
465
  }
312
466
 
313
- /** Send anything still buffered. Pass keepalive=true during page unload. */
467
+ /** Send anything still buffered, logs and events both. Pass keepalive=true during page unload. */
314
468
  async flush(keepalive = false) {
315
469
  if (this._timer !== null) { clearTimeout(this._timer); this._timer = null }
470
+ if (this._eventTimer !== null) { clearTimeout(this._eventTimer); this._eventTimer = null }
316
471
  this._sending = this._sending.then(() => this._drain(keepalive)).catch(() => {})
317
- await this._sending
472
+ this._eventSending = this._eventSending.then(() => this._drainEvents(keepalive)).catch(() => {})
473
+ await Promise.all([this._sending, this._eventSending])
318
474
  }
319
475
 
320
476
  /** Flush and detach all global listeners. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "0.4.0",
4
+ "description": "ZipLogger browser SDK \u2014 product-analytics events, console errors, unhandled rejections and distributed tracing from web apps, with a React error boundary. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "index.js",
@@ -22,19 +22,22 @@
22
22
  },
23
23
  "keywords": [
24
24
  "ziplogger",
25
+ "analytics",
26
+ "events",
25
27
  "logging",
26
28
  "browser",
27
29
  "react",
28
- "error-tracking"
30
+ "error-tracking",
31
+ "opentelemetry"
29
32
  ],
30
33
  "repository": {
31
34
  "type": "git",
32
- "url": "git+https://github.com/ahaliav/ZipLogger_Client.git",
35
+ "url": "git+https://github.com/ziploggerhq/ZipLogger_Client.git",
33
36
  "directory": "sdk_browser"
34
37
  },
35
38
  "homepage": "https://ziplogger.dev",
36
39
  "bugs": {
37
- "url": "https://github.com/ahaliav/ZipLogger_Client/issues"
40
+ "url": "https://github.com/ziploggerhq/ZipLogger_Client/issues"
38
41
  },
39
42
  "author": "Ahaliav Fox",
40
43
  "files": [
package/react.js CHANGED
@@ -50,12 +50,14 @@ export function createErrorBoundary(React, client) {
50
50
  }
51
51
  }
52
52
 
53
- /** Convenience hook factory: returns a stable `captureError(error, fields)` callback. */
53
+ /** Convenience hook factory: stable callbacks for logging, error capture and events. */
54
54
  export function createUseZipLogger(React, client) {
55
55
  return function useZipLogger() {
56
56
  return React.useMemo(() => ({
57
57
  captureError: (error, fields) => client.captureError(error, fields),
58
58
  log: (entry) => client.log(entry),
59
+ track: (name, properties) => client.track(name, properties),
60
+ identify: (userId, properties) => client.identify(userId, properties),
59
61
  }), [])
60
62
  }
61
63
  }