@hanzo/event 0.3.48 → 0.3.50

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/src/core.ts CHANGED
@@ -7,21 +7,10 @@
7
7
  // Cloud resolves the tenant server-side (validated session, or the signed
8
8
  // publishable key) and stamps it; the client NEVER sends the org.
9
9
  //
10
- // 2. ERROR PLANE — every captured exception is ALSO framed as a real Sentry
11
- // envelope and POSTed to the error host named by the DSN:
12
- // POST {dsn.base}/envelope/?sentry_key=… ({base} = /v1/event/{projectId})
13
- // This is what reaches sentry.hanzo.ai (issues, grouping, stack frames).
14
- //
15
- // These are NOT the same pipe and one does NOT feed the other. The event stream
16
- // stores a `type:'error'` row in the cloud event warehouse (readable via
17
- // GET /v1/errors) — that is product signal, not error tracking. There is no
18
- // server-side fan-out from /v1/event into Sentry; without the envelope below,
19
- // nothing ever reaches sentry.hanzo.ai. An earlier revision of this file claimed
20
- // the one entry point was "lensed server-side into … error tracking (sentry)". It was
21
- // wrong, and it silently cost the fleet all of its error telemetry.
22
- //
23
- // The error plane is inert (fail-safe) when no DSN is configured: nothing is
24
- // sent, nothing throws, and the event stream is unaffected.
10
+ // Errors ride the same pipe: a captured exception is ONE event of type 'error'
11
+ // carrying {type, message, frames|stack, handled} plus release, environment,
12
+ // site, product and level. The server files it on the error plane and groups it
13
+ // into an issue. There is no second endpoint, DSN or envelope.
25
14
  //
26
15
  // Auth is orthogonal — the SAME body to the SAME endpoint, differing only in how the
27
16
  // caller proves its tenant:
@@ -34,29 +23,17 @@
34
23
  // is the carrier neither send needs a header for, which is what keeps both
35
24
  // CORS-simple and therefore sendable from a customer's own origin.
36
25
  //
37
- // The wire is the canonical `Event` (== the cloud CaptureEvent): its `type` field
38
- // is what Cloud folds to event_type='error', which is how the event WAREHOUSE
39
- // classifies the row (GET /v1/errors). That is the extent of it — the fold does
40
- // not forward anything to Sentry. The error dashboard is fed only by the envelope
41
- // in plane 2 above, and only when a DSN is set.
26
+ // The wire is the canonical `Event` (== the cloud CaptureEvent).
42
27
 
43
28
  import {
44
29
  parseAttribution,
45
30
  hasAttribution,
46
31
  deriveChannel,
47
32
  } from './attribution'
48
- import { dsnForProduct } from './dsn'
49
33
  import { keyForPage } from './org'
50
- import { EXCEPTION, PAGEVIEW } from './events'
51
- import { exceptionProperties } from './exception'
34
+ import { PAGEVIEW } from './events'
52
35
  import { scrubText, withoutFragment } from './scrub'
53
- import {
54
- buildEnvelope,
55
- buildSentryEvent,
56
- normalizeError as normalizeThrowable,
57
- parseDsn,
58
- type ErrorIdentity,
59
- } from './sentry'
36
+ import { framesFromStack, normalizeError as normalizeThrowable } from './throwable'
60
37
  import {
61
38
  adoptAnonId,
62
39
  adoptSession,
@@ -74,7 +51,6 @@ import type {
74
51
  Attribution,
75
52
  CaptureErrorOptions,
76
53
  Cohort,
77
- Dsn,
78
54
  EventKind,
79
55
  Exception,
80
56
  Transport,
@@ -86,7 +62,6 @@ export { VERSION }
86
62
 
87
63
  const EVENT_PATH = '/v1/event' // the ONE canonical ingestion entry point
88
64
  const DEFAULT_HOST = 'https://api.hanzo.ai' // the one edge; cookie apps pass host:''
89
- const ENVELOPE_CONTENT_TYPE = 'application/x-sentry-envelope'
90
65
  // The beacon body's type. text/plain is CORS-SAFELISTED, which is the whole
91
66
  // property: a safelisted type makes the POST a SIMPLE request, and a simple
92
67
  // request needs no preflight. An unloading document does not get a second round
@@ -95,22 +70,7 @@ const ENVELOPE_CONTENT_TYPE = 'application/x-sentry-envelope'
95
70
  // CORS class and nothing else.
96
71
  const BEACON_CONTENT_TYPE = 'text/plain'
97
72
 
98
- /** readEnvDsn resolves a DSN from the public env when config omits one, so an app
99
- * gets the error plane by setting ONE build-time variable and nothing else.
100
- * Next/Vite inline these at build; the access is guarded so it is safe in a bare
101
- * browser and during SSR/prerender where `process` may not exist. */
102
- function readEnvDsn(): string | undefined {
103
- try {
104
- if (typeof process !== 'undefined' && process.env) {
105
- return process.env.NEXT_PUBLIC_HANZO_EVENT_DSN || process.env.HANZO_EVENT_DSN || undefined
106
- }
107
- } catch {
108
- /* no process — browser without inlined env */
109
- }
110
- return undefined
111
- }
112
-
113
- /** readEnv reads an inlined build-time variable, guarded like readEnvDsn. */
73
+ /** readEnv reads an inlined build-time variable, guarded for SSR. */
114
74
  function readEnv(name: string): string | undefined {
115
75
  try {
116
76
  if (typeof process !== 'undefined' && process.env) return process.env[name] || undefined
@@ -127,14 +87,15 @@ function appendQuery(url: string, key: string, value: string): string {
127
87
  }
128
88
 
129
89
  /** Normalize anything thrown (Error | string | unknown) into an Exception. */
130
- /** normalizeError adapts the shared, hostile-input-safe normalizer (sentry.ts) to
131
- * the event stream's Exception shape. ONE normalizer serves both planes: a thrown
132
- * object may define `name`/`message`/`stack` as throwing getters, and when each
133
- * plane rolled its own reader the stream still lost the report that the error
134
- * plane had already survived. */
90
+ /** normalizeError adapts the hostile-input-safe normalizer (sentry.ts) to the
91
+ * stream's Exception shape: a thrown object may define `name`/`message`/`stack`
92
+ * as throwing getters. */
135
93
  function normalizeError(err: unknown): Exception {
136
94
  const n = normalizeThrowable(err)
137
- return { type: n.name, message: n.message, stack: n.stack }
95
+ const frames = framesFromStack(n.stack)
96
+ .slice(-50)
97
+ .map((f) => ({ function: f.function, file: f.filename, line: f.lineno, column: f.colno }))
98
+ return { type: n.name, message: n.message, stack: n.stack, frames }
138
99
  }
139
100
 
140
101
  // React Native defines a `window` but no `document` (and no `window.location`),
@@ -144,6 +105,12 @@ function normalizeError(err: unknown): Exception {
144
105
  // unload listeners RN doesn't have), while `capture()`/`flush()` — which are NOT
145
106
  // gated on this and post through plain `fetch` — still emit. So the client is
146
107
  // inert-safe under SSR/Node and live on RN, without a second transport.
108
+ /** The screen and language a page view carries, for the readers that derive device and locale. */
109
+ const viewport = (): Record<string, string> =>
110
+ isBrowser()
111
+ ? { screen: `${window.screen?.width ?? 0}x${window.screen?.height ?? 0}`, language: navigator.language || '' }
112
+ : {}
113
+
147
114
  const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined'
148
115
 
149
116
  /** serializeBatch stringifies a batch, salvaging what it can. `properties` is
@@ -225,13 +192,15 @@ class DefaultTransport implements Transport {
225
192
  // for an origin that cannot pass one. A JWT is first-party by construction, so
226
193
  // it keeps the header and the session it travels with, and so does the error
227
194
  // plane, which names its own envelope type.
228
- const simple = opts.ingestKey !== undefined && opts.contentType === undefined
195
+ const simple = opts.ingestKey !== undefined && opts.token === undefined && opts.contentType === undefined
229
196
  const headers: Record<string, string> = {
230
197
  'Content-Type': simple ? BEACON_CONTENT_TYPE : (opts.contentType ?? 'application/json'),
231
198
  }
232
- const bearer = simple ? undefined : (opts.ingestKey ?? opts.token)
199
+ // A signed-in page sends the key (which project) in the query AND the person's bearer
200
+ // (who) in the header; the bearer never replaces the key.
201
+ const bearer = simple ? undefined : (opts.token ?? opts.ingestKey)
233
202
  if (bearer) headers.Authorization = `Bearer ${bearer}`
234
- void fetch(simple ? appendQuery(url, 'ingest_key', opts.ingestKey!) : url, {
203
+ void fetch(opts.ingestKey ? appendQuery(url, 'ingest_key', opts.ingestKey) : url, {
235
204
  method: 'POST',
236
205
  headers,
237
206
  body,
@@ -267,8 +236,6 @@ export class Analytics {
267
236
  private started = false
268
237
  /** The view pageview() last counted — path + location. */
269
238
  private counted?: string
270
- /** Parsed error-plane DSN, or null when the plane is inert. */
271
- private dsn: Dsn | null
272
239
  /** Guards against an error thrown *inside* the error path re-entering it. */
273
240
  private reentrant = false
274
241
 
@@ -304,43 +271,14 @@ export class Analytics {
304
271
  config.ingestKey ?? readEnv('NEXT_PUBLIC_PUBLISHABLE_KEY') ?? keyForPage(),
305
272
  }
306
273
  this.transport = config.transport ?? new DefaultTransport()
307
- // Error plane, most specific source first: an explicit DSN wins, then the
308
- // inlined build-time env (a per-deploy override), then the product registry —
309
- // whose DSN carries the SAME resolved key (not a baked one), so declaring
310
- // `product` + providing the key is enough to report errors. Malformed or
311
- // absent => null => inert, never throwing into the host app.
312
- this.dsn = parseDsn(
313
- config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product, this.cfg.ingestKey)
314
- )
315
274
  }
316
275
 
317
276
  /** adopt gives this client a credential it does not have. The key belongs to
318
277
  * the stream, not to whichever caller happened to ask for the handle first,
319
- * so a later caller carrying one hands it over. The error plane derives from
320
- * the key, so it comes up here too when it was inert for want of one.
321
- * Present fields are never overwritten: the first caller's key stays. */
278
+ * so a later caller carrying one hands it over. Present fields are never overwritten: the first caller's key stays. */
322
279
  adopt(config: AnalyticsConfig): void {
323
280
  if (config.ingestKey && !this.cfg.ingestKey) this.cfg.ingestKey = config.ingestKey
324
281
  if (config.getToken && !this.cfg.getToken) this.cfg.getToken = config.getToken
325
- if (!this.dsn) {
326
- this.dsn = parseDsn(
327
- config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product, this.cfg.ingestKey),
328
- )
329
- }
330
- }
331
-
332
- /** errorPlaneEnabled reports whether captured exceptions can actually reach the
333
- * error host. False means a DSN was never configured — the documented
334
- * fail-safe. Exposed so an app (or a test) can assert its wiring instead of
335
- * discovering months later that nothing was ever reported. */
336
- get errorPlaneEnabled(): boolean {
337
- return this.dsn !== null
338
- }
339
-
340
- /** errorIngestUrl is the fully-derived envelope endpoint, or undefined when the
341
- * plane is inert. Diagnostics only. */
342
- get errorIngestUrl(): string | undefined {
343
- return this.dsn?.ingestUrl
344
282
  }
345
283
 
346
284
  /** init is idempotent and browser-only for its side effects: capture first-touch
@@ -383,10 +321,7 @@ export class Analytics {
383
321
  window.addEventListener('visibilitychange', flushHidden)
384
322
  window.addEventListener('pagehide', () => this.flush(true))
385
323
 
386
- // Auto error capture — the drop-in @sentry replacement. Unhandled errors and
387
- // rejected promises are reported on BOTH planes: a Sentry envelope to the DSN
388
- // host (what reaches the error dashboard — requires a DSN) and a type:'error'
389
- // event on the stream (product signal in the warehouse).
324
+ // Unhandled errors and rejected promises are reported as type:'error' events.
390
325
  if (this.cfg.captureErrors) {
391
326
  window.addEventListener('error', (e: ErrorEvent) => {
392
327
  this.captureError(e.error ?? e.message, { handled: false })
@@ -448,7 +383,7 @@ export class Analytics {
448
383
  const view = (p ?? '') + '\0' + (isBrowser() ? window.location.href : '')
449
384
  if (view === this.counted) return
450
385
  this.counted = view
451
- this.enqueue('pageview', PAGEVIEW, { path: p, properties })
386
+ this.enqueue('pageview', PAGEVIEW, { path: p, properties: { ...viewport(), ...properties } })
452
387
  }
453
388
 
454
389
  /** capture records a named product event with optional properties. Commerce
@@ -462,71 +397,30 @@ export class Analytics {
462
397
  }
463
398
 
464
399
  /** captureError reports a caught error, an unhandled rejection, a React render
465
- * error, or a manual report to BOTH planes, from one call:
466
- *
467
- * - the ERROR PLANE — a real Sentry envelope to the DSN host. This is the one
468
- * that produces an issue in sentry.hanzo.ai (grouping, stack frames, AST).
469
- * Inert when no DSN is configured.
470
- * - the EVENT STREAM — a `type:'error'` row in the cloud event warehouse, so
471
- * an error stays correlated with the session's pageviews for product
472
- * analysis (readable via GET /v1/errors).
473
- *
474
- * Both carry the SAME session and subject id, so an error and the pageview
475
- * before it join up. Never throws back into the app; errors are higher-signal
476
- * than pageviews, so both planes flush promptly (a crash may unload the page
477
- * moments later). */
400
+ * error, or a manual report as ONE event of type 'error' on the one stream.
401
+ * It carries the exception (type, message, frames, stack, handled) and the
402
+ * release, environment, site, product and level that group and scope it, and
403
+ * flushes promptly (a crash may unload the page moments later). Never throws
404
+ * back into the app. */
478
405
  captureError(err: unknown, context?: CaptureErrorOptions): void {
479
406
  // A failure inside the error path must not recurse through the global handlers.
480
407
  if (this.reentrant) return
481
408
  this.reentrant = true
482
409
  try {
483
- // ERROR PLANE FIRST, in its own try. The planes are independent, so neither
484
- // may be able to starve the other: `properties` is arbitrary caller data
485
- // (a DOM node, a React synthetic event, an axios error — all circular and
486
- // all common), and serializing it on the event stream can throw. When the
487
- // stream ran first, that throw escaped to the outer catch and the crash
488
- // report was never sent — silently losing exactly the signal this client
489
- // exists to deliver. Order and isolation are the fix.
490
- try {
491
- this.sendError(err, context)
492
- } catch {
493
- /* the error plane must never take the event stream down with it */
494
- }
495
-
496
- try {
497
- const handled = context?.handled ?? true
498
- const ex = normalizeError(err)
499
- ex.handled = handled
500
- // NAME: the reserved '$exception', never the message. The message was the
501
- // name until 0.3.20, which put every distinct error string — one per failed
502
- // chunk id, per ResizeObserver notification — permanently into the event
503
- // taxonomy, and left Error Tracking (which reads this exact name) at zero.
504
- //
505
- // TYPE 'event', not 'error'. `type` alone picks the storage plane: 'error'
506
- // routes to the error plane, which the product-event projection does not
507
- // read, so an exception filed there is invisible to Error Tracking however
508
- // well-formed it is. The full error record still reaches the error plane as
509
- // a Sentry envelope above — this row is the product-analytics breadcrumb,
510
- // which is what keeps a crash correlated with the session's pageviews.
511
- //
512
- // `error` is still carried: the server folds it into properties.$exception
513
- // (scrubbing message and stack on the way), which is the shape existing
514
- // readers bind to.
515
- this.enqueue('event', EXCEPTION, {
516
- error: ex,
517
- properties: {
518
- ...context?.properties,
519
- ...exceptionProperties(err, {
520
- handled,
521
- id: uuidv7(),
522
- level: context?.level,
523
- }),
524
- },
525
- })
526
- this.flush()
527
- } catch {
528
- /* nor the reverse */
529
- }
410
+ const handled = context?.handled ?? true
411
+ const ex = normalizeError(err)
412
+ ex.handled = handled
413
+ this.enqueue('error', undefined, {
414
+ error: ex,
415
+ level: context?.level ?? (handled ? 'error' : 'fatal'),
416
+ release: this.cfg.release ?? readEnv('NEXT_PUBLIC_HANZO_RELEASE'),
417
+ environment: this.cfg.environment ?? readEnv('NODE_ENV'),
418
+ site: isBrowser() ? window.location.hostname : undefined,
419
+ properties: context?.properties,
420
+ })
421
+ this.flush()
422
+ } catch {
423
+ /* the error path never takes the app down */
530
424
  } finally {
531
425
  this.reentrant = false
532
426
  }
@@ -555,20 +449,10 @@ export class Analytics {
555
449
  this.queue = []
556
450
  this.clearTimer()
557
451
 
558
- // A publishable key and a bearer JWT are mutually exclusive credentials, and the
559
- // BEARER WINS. It names a real principal and resolves to THAT person's org;
560
- // a pk- names one org for everybody holding it. So the key is what attributes
561
- // a visitor nobody has vouched for, and it must never displace someone who
562
- // has been.
563
- //
564
- // The precedence used to run the other way, which was survivable only while
565
- // the key had to be passed in code. Once it also resolves from the build env,
566
- // key-wins means setting one variable silently blanks every signed-in user's
567
- // token and re-files their events under whichever org minted the key — on a
568
- // console served to several brands from one bundle, that is a cross-tenant
569
- // leak introduced by an env var.
452
+ // Two kinds of credential, and both ride when both are held: the publishable key
453
+ // names the project, the bearer names the person. The bearer never replaces the key.
570
454
  const token = this.cfg.getToken?.() ?? undefined
571
- const key = token ? undefined : this.cfg.ingestKey?.trim() || undefined
455
+ const key = this.cfg.ingestKey?.trim() || undefined
572
456
  // Only a bearer JWT blocks the beacon: sendBeacon cannot set an Authorization
573
457
  // header. A pk- rides ?ingest_key; a cookie rides credentials.
574
458
  const useBeacon = beacon && !token
@@ -588,41 +472,6 @@ export class Analytics {
588
472
 
589
473
  // ── internals ────────────────────────────────────────────────────────────
590
474
 
591
- /** sendError frames one exception as a Sentry envelope and posts it to the DSN's
592
- * ingest URL. The DSN's own key rides ?sentry_key= (the credential channel the
593
- * server trusts, and the only one a headerless beacon can carry), so NO bearer
594
- * or publishable key is attached here — the two planes authenticate
595
- * independently. Errors are sent one envelope per event, immediately: batching
596
- * a crash report is how you lose it. */
597
- private sendError(err: unknown, options?: CaptureErrorOptions): void {
598
- if (!this.cfg.enabled || !this.dsn) return
599
- const event = buildSentryEvent({
600
- error: err,
601
- options,
602
- identity: this.errorIdentity(),
603
- capturePII: this.cfg.capturePII ?? false,
604
- })
605
- const body = buildEnvelope(event, this.dsn)
606
- if (this.cfg.debug) console.debug('[event] error →', this.dsn.ingestUrl, event.event_id)
607
- this.transport.send(this.dsn.ingestUrl, body, {
608
- beacon: false,
609
- contentType: ENVELOPE_CONTENT_TYPE,
610
- debug: this.cfg.debug,
611
- })
612
- }
613
-
614
- /** errorIdentity is the SAME identity the event stream stamps — the OIDC subject
615
- * once identify() has run, else the anon id. Never email/PII. */
616
- private errorIdentity(): ErrorIdentity {
617
- return {
618
- userId: this.personId ?? anonId(),
619
- sessionId: sessionId(),
620
- product: this.cfg.product,
621
- release: this.cfg.release ?? readEnv('NEXT_PUBLIC_HANZO_RELEASE'),
622
- environment: this.cfg.environment ?? readEnv('NODE_ENV'),
623
- }
624
- }
625
-
626
475
  private enqueue(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): void {
627
476
  if (!this.cfg.enabled) return
628
477
  if (!this.started) this.init()
package/src/exception.ts CHANGED
@@ -29,7 +29,7 @@
29
29
  // throw site. framesFromStack (sentry.ts) already returns that order, which is why
30
30
  // this module reuses it rather than re-parsing.
31
31
 
32
- import { framesFromStack, normalizeError } from './sentry'
32
+ import { framesFromStack, normalizeError } from './throwable'
33
33
  import type {
34
34
  ExceptionEntry,
35
35
  ExceptionFrame,
package/src/index.ts CHANGED
@@ -15,12 +15,10 @@ export {
15
15
  getCohort,
16
16
  getFirstTouch,
17
17
  } from './core'
18
- export { parseDsn, buildSentryEvent, buildEnvelope, framesFromStack } from './sentry'
18
+ export { framesFromStack } from './throwable'
19
19
  export { uuidv7, uuidv7Time } from './uid'
20
20
  export { linkUrl, readLink, stripLink, LINK_PARAMS } from './link'
21
21
  export type { LinkState } from './link'
22
- export { PRODUCT_PROJECT, dsnForProduct } from './dsn'
23
- export type { ErrorIdentity } from './sentry'
24
22
  export { scrubText, redactSecrets, scrubPII } from './scrub'
25
23
  export { EVENTS, EXCEPTION, PAGEVIEW } from './events'
26
24
  export { exceptionEntry, exceptionProperties, fingerprint, digest } from './exception'
@@ -31,7 +29,7 @@ export {
31
29
  } from './consent'
32
30
  export type { Choice, Region } from './consent'
33
31
  export { CLICK_IDS, capture as captureClick, touch } from './touch'
34
- export { start as startTags, track, mirror, reach, visit, SITE_KEY } from './tags'
32
+ export { start as startTags, ready as tagsReady, track, mirror, reach, visit, SITE_KEY } from './tags'
35
33
  export type { BrowserTag, TagOptions } from './tags'
36
34
  export { commerceItem, worth } from './items'
37
35
  export type { CommerceItem, PlanLine } from './items'
@@ -51,13 +49,11 @@ export type {
51
49
  Attribution,
52
50
  CaptureErrorOptions,
53
51
  Cohort,
54
- Dsn,
55
52
  EventKind,
56
53
  Exception,
57
54
  ExceptionEntry,
58
55
  ExceptionFrame,
59
56
  ExceptionProperties,
60
- SentryEvent,
61
57
  SentryFrame,
62
58
  SentryLevel,
63
59
  Transport,
package/src/org.test.ts CHANGED
@@ -127,3 +127,12 @@ describe('a surface that configures nothing', () => {
127
127
  expect((none as unknown as { cfg: { ingestKey?: string } }).cfg.ingestKey).toBeUndefined()
128
128
  })
129
129
  })
130
+
131
+ describe('the hanzo org files under the hanzo.ai project', () => {
132
+ it('names hanzo.network, hanzo.market, hanzo.industries and hanzo.agency', () => {
133
+ for (const host of ['hanzo.network', 'hanzo.market', 'hanzo.industries', 'hanzo.agency']) {
134
+ expect(orgOf(host)).toBe('hanzo')
135
+ expect(keyFor(host)).toBe('pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc')
136
+ }
137
+ })
138
+ })
package/src/org.ts CHANGED
@@ -51,11 +51,15 @@ export const ORG_DOMAIN: Readonly<Record<string, readonly string[]>> = Object.fr
51
51
  // separate fact, which is the whole reason this is a table.
52
52
  hanzo: Object.freeze([
53
53
  'hanzo.ai',
54
+ 'hanzo.agency',
54
55
  'hanzo.app',
55
56
  'hanzo.bot',
56
57
  'hanzo.chat',
57
58
  'hanzo.codes',
58
59
  'hanzo.id',
60
+ 'hanzo.industries',
61
+ 'hanzo.market',
62
+ 'hanzo.network',
59
63
  'hanzo.sh',
60
64
  'hanzo.team',
61
65
  'hanzo.ventures',
@@ -89,7 +93,7 @@ export const ORG_DOMAIN: Readonly<Record<string, readonly string[]>> = Object.fr
89
93
  * recreate a project, move BOTH.
90
94
  */
91
95
  export const ORG_KEY: Keyring = Object.freeze({
92
- hanzo: 'pk-rM_CdaF2MQckGCrla113SrR1oH4zvqN8xh2I95Z9tY8',
96
+ hanzo: 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
93
97
  lux: 'pk-gUZp6ZVfhJzSwK-rb4oLbVkpCnMBx5uSCpxf_5yEhQk',
94
98
  zoo: 'pk-3TKpKnERV9AQSsBUERWkZejC1O1mUxc1jRzsP3MPbs4',
95
99
  })
@@ -81,13 +81,6 @@ describe('one stream, one client', () => {
81
81
  expect(tx.sent[0].token).toBe(KEY)
82
82
  })
83
83
 
84
- it('brings up the error plane on the key it adopts', () => {
85
- const a = createAnalytics({ product: 'console', host: HOST, transport: tx })
86
- expect(a.errorPlaneEnabled).toBe(false)
87
- createAnalytics({ product: 'console', host: HOST, ingestKey: KEY })
88
- expect(a.errorPlaneEnabled).toBe(true)
89
- })
90
-
91
84
  it('keeps the first credential rather than overwriting it', () => {
92
85
  createAnalytics({ product: 'site', host: HOST, ingestKey: KEY, transport: tx })
93
86
  createAnalytics({ product: 'site', host: HOST, ingestKey: 'pk-second' }).capture('x')
package/src/tags.ts CHANGED
@@ -69,6 +69,11 @@ export const SITE_KEY: Readonly<Record<string, string>> = Object.freeze({
69
69
  'platform.hanzo.ai': 'pk-My1RpZLEUnTj8vAdPbWKYdDUuhxZJ4dVjHcwjZN4rZ8',
70
70
  'docs.hanzo.ai': 'pk-jukhtjMT2ymoeBDAeFjINQWlBv-v9sNn1TPztiCrrwk',
71
71
  'hanzo.bot': 'pk-W5d7Mn7ZukT7igyscIy6Pqe8JpA0Ge604Yn4xNR4JCU',
72
+ 'hanzo.chat': 'pk-ATxWeB2jNyRSxDgqnkGoDj3CCbzrmTxSktUh9zlrO-E',
73
+ 'hanzo.id': 'pk-_GVF3KlaqprlPHDumBMRFeFU1lIGOaarQfgimNVDIo8',
74
+ 'hanzo.industries': 'pk-Vh1wvL2HqIAv9qpVvmrznTI9uMHrTD-jyIC-vnlBsgY',
75
+ 'hanzo.market': 'pk-DN3xQFa0H61epUVg9OkiOYLzCdteNt9hE6f-S-ednjQ',
76
+ 'hanzo.network': 'pk-YJ2eiA_7ckLStMPte8Ar8FsmRp_ycmeIeRbpGprZYmM',
72
77
  'cloud.hanzo.ai': 'pk-RAfEGHPoNdCEU9fnA_cPd_Xo9Tci44rlYQV9xuJ1Ob0',
73
78
  })
74
79
 
@@ -107,6 +112,29 @@ function flush(): void {
107
112
  if (settled() || leaving) held.splice(0).forEach((send) => send())
108
113
  }
109
114
 
115
+ /**
116
+ * Resolves once every tag this visitor's choice loads has taken the page view, or after
117
+ * `limit` ms, whichever is first: the tag set has answered, gtag.js has loaded or failed,
118
+ * and Meta's library has replaced the stub that queued the call. A page that is about to
119
+ * leave waits on this so the visit is counted where it arrived. A tag the choice does not
120
+ * load is not waited for.
121
+ */
122
+ export function ready(limit = 3000): Promise<void> {
123
+ if (typeof window === 'undefined') return Promise.resolve()
124
+ const start = Date.now()
125
+ const meta = () => {
126
+ const fbq = page().fbq
127
+ return ![...loaded].some((k) => k.startsWith('meta:')) || typeof fbq?.callMethod === 'function'
128
+ }
129
+ return new Promise((resolve) => {
130
+ const tick = () => {
131
+ if ((configured && settled() && meta()) || Date.now() - start >= limit) return resolve()
132
+ setTimeout(tick, 100)
133
+ }
134
+ tick()
135
+ })
136
+ }
137
+
110
138
  function script(src: string, onload?: () => void, onerror?: () => void): void {
111
139
  const s = document.createElement('script')
112
140
  s.async = true
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { framesFromStack, normalizeError } from './throwable'
3
+
4
+ describe('framesFromStack', () => {
5
+ it('parses a V8 stack oldest-first with in_app marking', () => {
6
+ const stack = [
7
+ 'TypeError: x is not a function',
8
+ ' at inner (https://app.hanzo.ai/app.js:10:5)',
9
+ ' at outer (https://app.hanzo.ai/app.js:20:1)',
10
+ ' at eval (webpack-internal:///./node_modules/lib/index.js:3:2)',
11
+ ].join('\n')
12
+ const frames = framesFromStack(stack)
13
+ expect(frames).toHaveLength(3)
14
+ // oldest-first: the node_modules frame is the outermost caller (first),
15
+ // the crash site (inner) is last.
16
+ expect(frames[frames.length - 1].function).toBe('inner')
17
+ expect(frames[frames.length - 1].lineno).toBe(10)
18
+ expect(frames[frames.length - 1].in_app).toBe(true)
19
+ expect(frames[0].function).toBe('eval')
20
+ expect(frames[0].in_app).toBe(false) // node_modules
21
+ })
22
+ it('parses a Firefox/Safari stack (fn@file:li:co and bare @file)', () => {
23
+ const stack = ['boom@https://app.hanzo.ai/a.js:1:2', '@https://app.hanzo.ai/b.js:3:4'].join('\n')
24
+ const frames = framesFromStack(stack)
25
+ expect(frames).toHaveLength(2)
26
+ expect(frames[frames.length - 1].function).toBe('boom')
27
+ expect(frames[frames.length - 1].filename).toBe('https://app.hanzo.ai/a.js')
28
+ })
29
+ it('skips the header line and tolerates junk', () => {
30
+ expect(framesFromStack('Error: nope\n total garbage line')).toHaveLength(0)
31
+ expect(framesFromStack(undefined)).toHaveLength(0)
32
+ })
33
+ })
34
+
35
+ describe('normalizeError', () => {
36
+ it('coerces Error, string, and objects', () => {
37
+ expect(normalizeError(new RangeError('r')).name).toBe('RangeError')
38
+ expect(normalizeError('boom')).toEqual({ name: 'Error', message: 'boom' })
39
+ expect(normalizeError({ a: 1 }).message).toBe('{"a":1}')
40
+ })
41
+ })
42
+
43
+ describe('normalizeError survives hostile input', () => {
44
+ const bomb = (prop: string) => {
45
+ const e = new Error('real message')
46
+ Object.defineProperty(e, prop, {
47
+ get() {
48
+ throw new Error(prop + ' bomb')
49
+ },
50
+ configurable: true,
51
+ })
52
+ return e
53
+ }
54
+
55
+ for (const prop of ['stack', 'message', 'name']) {
56
+ it(`a throwing ${prop} getter does not throw`, () => {
57
+ expect(() => normalizeError(bomb(prop))).not.toThrow()
58
+ const n = normalizeError(bomb(prop))
59
+ expect(typeof n.name).toBe('string')
60
+ expect(typeof n.message).toBe('string')
61
+ })
62
+ }
63
+
64
+ it('an object with a throwing toString is still reportable', () => {
65
+ const evil = {
66
+ toString() {
67
+ throw new Error('toString bomb')
68
+ },
69
+ }
70
+ expect(() => normalizeError(evil)).not.toThrow()
71
+ })
72
+ })