@hanzo/event 0.3.5 → 0.3.8

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/hz.js ADDED
@@ -0,0 +1,307 @@
1
+ /*! hz.js — the no-build distribution of @hanzo/event.
2
+ *
3
+ * A <script> tag for surfaces that have no bundler: a CMS page, a landing page,
4
+ * a docs site. It speaks the SAME wire as the npm client and posts to the SAME
5
+ * front door — one batch of WireEvents to POST {host}/v1/event:
6
+ *
7
+ * { batch: [ { messageId, type, event, timestamp, distinctId, anonymousId,
8
+ * sessionId, product, url, path, referrer, properties }, … ] }
9
+ *
10
+ * <script async src="https://unpkg.com/@hanzo/event/hz.js"
11
+ * data-product="hanzo.ai" // required: which surface this is
12
+ * data-host="https://api.hanzo.ai" // optional: API host override
13
+ * data-ga="G-XXXX" data-fb="123" // optional: also fan out to GA4 / Meta
14
+ * data-capture="1"></script> // optional: autocapture off with "0"
15
+ *
16
+ * It adds what a bundled app does not need and a plain page cannot get: DOM
17
+ * AUTOCAPTURE. Clicks on interactive elements (with a compact element locator),
18
+ * outbound links, scroll depth, form submits and core web vitals arrive as
19
+ * `$click` / `$outbound` / `$scroll` / `$form` / `$vitals` events on the one
20
+ * stream. Manual: window.hanzo.track(name, props) · identify(id, traits) · page().
21
+ * Respects DNT. No PII beyond the short element text the locator carries.
22
+ *
23
+ * It used to live in hanzoai/analytics and post a BARE JSON ARRAY of
24
+ * {site, ts, type, path, …} to analytics.hanzo.ai/v1/event — a second protocol
25
+ * behind an identical path spelling, served by a second collector with its own
26
+ * database. Both are deleted. There is one wire, one door and one client home,
27
+ * and this file is that client's script-tag form.
28
+ */
29
+ ;(function () {
30
+ var s = document.currentScript
31
+ if (!s) return
32
+ if (navigator.doNotTrack === '1' || navigator.doNotTrack === 'yes' || window.hzDNT) return
33
+
34
+ var LIB = 'hz.js'
35
+ var VERSION = '0.3.8'
36
+ var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
37
+ var product = s.getAttribute('data-product') || location.hostname
38
+ var capture = s.getAttribute('data-capture') !== '0'
39
+
40
+ function uid() {
41
+ return crypto.randomUUID
42
+ ? crypto.randomUUID()
43
+ : Date.now().toString(36) + '.' + Math.random().toString(36).slice(2)
44
+ }
45
+ function stored(store, key) {
46
+ try {
47
+ var v = store.getItem(key)
48
+ if (!v) store.setItem(key, (v = uid()))
49
+ return v
50
+ } catch (e) {
51
+ return 'anon'
52
+ }
53
+ }
54
+ var anon = stored(localStorage, 'hz_id')
55
+ var sid = stored(sessionStorage, 'hz_sid')
56
+ var person = null
57
+ try {
58
+ person = localStorage.getItem('hz_uid')
59
+ } catch (e) {}
60
+
61
+ var queue = [],
62
+ timer
63
+ function flush() {
64
+ clearTimeout(timer)
65
+ if (!queue.length) return
66
+ var body = JSON.stringify({ batch: queue.splice(0, queue.length) })
67
+ var url = host + '/v1/event'
68
+ try {
69
+ if (
70
+ navigator.sendBeacon &&
71
+ navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
72
+ )
73
+ return
74
+ } catch (e) {}
75
+ fetch(url, {
76
+ method: 'POST',
77
+ body: body,
78
+ keepalive: true,
79
+ headers: { 'content-type': 'application/json' },
80
+ }).catch(function () {})
81
+ }
82
+ // ── location redaction ────────────────────────────────────────────────────
83
+ // The same policy src/scrub.ts applies in the npm client, restated here because
84
+ // this file has no bundler and therefore cannot import it: a reset, invite or
85
+ // magic link carries a JWT in the query and an address in `?email=`, and the
86
+ // location is stamped on EVERY event — so without this, one page load ships the
87
+ // credential to the warehouse and every later click repeats it.
88
+ //
89
+ // Deliberately a SUBSET: the shapes that actually appear in a URL. Free-text
90
+ // error scrubbing (PANs, private keys, stack text) has no counterpart here
91
+ // because this distribution has no error plane. Keep the markers identical to
92
+ // scrub.ts — a warehouse row must not reveal which distribution wrote it.
93
+ var SECRETS = [
94
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, // JWT
95
+ /\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
96
+ /\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
97
+ /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
98
+ /\bhk-[A-Za-z0-9]{16,}/g,
99
+ /\bgh[posru]_[A-Za-z0-9]{20,}/g,
100
+ /\bAIza[0-9A-Za-z_-]{20,}/g,
101
+ /\bAKIA[0-9A-Z]{16}\b/g,
102
+ // Bounded like scrub.ts's: the unbounded form backtracks quadratically on
103
+ // colon-rich text that never reaches an '@'.
104
+ /[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g,
105
+ ]
106
+ var EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
107
+ function clean(u) {
108
+ if (!u) return u
109
+ if (u.length > 8192) u = u.slice(0, 8192) + '… [truncated]'
110
+ for (var i = 0; i < SECRETS.length; i++) u = u.replace(SECRETS[i], '[redacted]')
111
+ return u.replace(EMAIL, '[email]')
112
+ }
113
+
114
+ // send builds ONE WireEvent — the same shape core.ts build() produces, so the
115
+ // server cannot tell which distribution emitted it.
116
+ function send(kind, event, props) {
117
+ queue.push({
118
+ messageId: uid(),
119
+ type: kind,
120
+ event: event,
121
+ timestamp: new Date().toISOString(),
122
+ distinctId: person || anon,
123
+ anonymousId: anon,
124
+ personId: person || undefined,
125
+ sessionId: sid,
126
+ product: product,
127
+ url: clean(location.href),
128
+ path: clean(location.pathname),
129
+ referrer: clean(document.referrer) || undefined,
130
+ properties: props || undefined,
131
+ library: LIB,
132
+ libraryVersion: VERSION,
133
+ })
134
+ clearTimeout(timer)
135
+ timer = setTimeout(flush, 400)
136
+ }
137
+
138
+ // ── element locator (the autocapture detail) ──────────────────────────────
139
+ // A compact, stable, PII-light descriptor of the element interacted with, so
140
+ // movements read logically: tag, short text, id, data-*, and an ancestor path.
141
+ function locator(el) {
142
+ if (!el || el === document) return null
143
+ var o = {
144
+ tag: el.tagName ? el.tagName.toLowerCase() : '',
145
+ id: el.id || undefined,
146
+ name:
147
+ (el.getAttribute && (el.getAttribute('name') || el.getAttribute('aria-label'))) ||
148
+ undefined,
149
+ }
150
+ var txt = (el.innerText || el.value || '').trim().replace(/\s+/g, ' ').slice(0, 80)
151
+ if (txt) o.text = txt
152
+ // A link target is a URL like any other — a share/invite href carries the
153
+ // same token shapes the page URL does.
154
+ if (el.getAttribute && el.getAttribute('href')) o.href = clean(el.getAttribute('href'))
155
+ if (el.dataset) for (var k in el.dataset) if (k !== 'hz') (o.data = o.data || {})[k] = el.dataset[k]
156
+ var p = [],
157
+ n = el,
158
+ i = 0
159
+ while (n && n.tagName && i++ < 4) {
160
+ var seg = n.tagName.toLowerCase()
161
+ if (n.id) {
162
+ seg += '#' + n.id
163
+ p.unshift(seg)
164
+ break
165
+ }
166
+ if (n.className && typeof n.className === 'string')
167
+ seg += '.' + n.className.trim().split(/\s+/).slice(0, 2).join('.')
168
+ p.unshift(seg)
169
+ n = n.parentElement
170
+ }
171
+ o.sel = p.join('>')
172
+ return o
173
+ }
174
+ function interactive(el) {
175
+ return el && el.closest && el.closest('a,button,[role=button],input,select,textarea,[data-hz],[onclick]')
176
+ }
177
+
178
+ // ── auto pageviews (initial + SPA) ────────────────────────────────────────
179
+ var last = ''
180
+ function page() {
181
+ var k = location.pathname + location.search
182
+ if (k === last) return
183
+ last = k
184
+ send('pageview', '$pageview')
185
+ }
186
+ page()
187
+ ;['pushState', 'replaceState'].forEach(function (m) {
188
+ var o = history[m]
189
+ history[m] = function () {
190
+ var r = o.apply(this, arguments)
191
+ page()
192
+ return r
193
+ }
194
+ })
195
+ addEventListener('popstate', page)
196
+
197
+ // ── autocapture: clicks, outbound, scroll depth, form submits ─────────────
198
+ if (capture) {
199
+ addEventListener(
200
+ 'click',
201
+ function (e) {
202
+ var el = interactive(e.target)
203
+ if (!el) return
204
+ var loc = locator(el)
205
+ send('event', '$click', loc)
206
+ if (el.tagName === 'A' && el.host && el.host !== location.host)
207
+ send('event', '$outbound', { url: clean(el.href), el: loc })
208
+ },
209
+ true,
210
+ )
211
+ addEventListener('submit', function (e) { send('event', '$form', locator(e.target)) }, true)
212
+ var seen = {}
213
+ addEventListener(
214
+ 'scroll',
215
+ function () {
216
+ var d = document.documentElement
217
+ var pct = Math.round(((scrollY + innerHeight) / (d.scrollHeight || 1)) * 100)
218
+ ;[25, 50, 75, 100].forEach(function (m) {
219
+ if (pct >= m && !seen[m]) {
220
+ seen[m] = 1
221
+ send('event', '$scroll', { depth: m })
222
+ }
223
+ })
224
+ },
225
+ { passive: true },
226
+ )
227
+ }
228
+
229
+ // ── core web vitals (best-effort, no dep) ─────────────────────────────────
230
+ var vitals = {}
231
+ try {
232
+ new PerformanceObserver(function (l) {
233
+ l.getEntries().forEach(function (x) { vitals.lcp = Math.round(x.startTime) })
234
+ }).observe({ type: 'largest-contentful-paint', buffered: true })
235
+ new PerformanceObserver(function (l) {
236
+ l.getEntries().forEach(function (x) {
237
+ if (!x.hadRecentInput) vitals.cls = +((vitals.cls || 0) + x.value).toFixed(3)
238
+ })
239
+ }).observe({ type: 'layout-shift', buffered: true })
240
+ } catch (e) {}
241
+ addEventListener('visibilitychange', function () {
242
+ if (document.visibilityState !== 'hidden') return
243
+ if (vitals.lcp != null || vitals.cls != null) send('event', '$vitals', vitals)
244
+ flush()
245
+ })
246
+
247
+ // ── public API (manual funnel/identify) + GA/Meta fan-out ─────────────────
248
+ function assign(a, b) {
249
+ if (b) for (var k in b) a[k] = b[k]
250
+ return a
251
+ }
252
+ window.hanzo = {
253
+ track: function (name, props) { send('event', name, props || undefined) },
254
+ identify: function (id, traits) {
255
+ person = id
256
+ try { localStorage.setItem('hz_uid', id) } catch (e) {}
257
+ send('identify', undefined, traits || undefined)
258
+ },
259
+ page: function (props) {
260
+ last = ''
261
+ page()
262
+ if (props) send('event', 'page_props', props)
263
+ },
264
+ flush: flush,
265
+ }
266
+ var ga = s.getAttribute('data-ga'),
267
+ fb = s.getAttribute('data-fb')
268
+ function load(src) {
269
+ var el = document.createElement('script')
270
+ el.async = true
271
+ el.src = src
272
+ document.head.appendChild(el)
273
+ }
274
+ if (ga) {
275
+ load('https://www.googletagmanager.com/gtag/js?id=' + ga)
276
+ window.dataLayer = window.dataLayer || []
277
+ window.gtag = function () { dataLayer.push(arguments) }
278
+ gtag('js', new Date())
279
+ gtag('config', ga)
280
+ var _t = window.hanzo.track
281
+ window.hanzo.track = function (n, p) {
282
+ _t(n, p)
283
+ try { gtag('event', n, p || {}) } catch (e) {}
284
+ }
285
+ }
286
+ if (fb) {
287
+ !(function (f) {
288
+ if (f.fbq) return
289
+ var n = (f.fbq = function () {
290
+ n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments)
291
+ })
292
+ if (!f._fbq) f._fbq = n
293
+ n.push = n
294
+ n.loaded = !0
295
+ n.version = '2.0'
296
+ n.queue = []
297
+ })(window)
298
+ load('https://connect.facebook.net/en_US/fbevents.js')
299
+ fbq('init', fb)
300
+ fbq('track', 'PageView')
301
+ var _u = window.hanzo.track
302
+ window.hanzo.track = function (n, p) {
303
+ _u(n, p)
304
+ try { fbq('trackCustom', n, p || {}) } catch (e) {}
305
+ }
306
+ }
307
+ })()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.3.5",
3
+ "version": "0.3.8",
4
4
  "description": "Hanzo Event \u2014 the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes \u2014 the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/",
@@ -17,6 +17,7 @@
17
17
  "files": [
18
18
  "dist",
19
19
  "src",
20
+ "hz.js",
20
21
  "README.md",
21
22
  "TAXONOMY.md"
22
23
  ],
package/src/core.test.ts CHANGED
@@ -2,6 +2,20 @@ import { describe, it, expect, beforeEach } from 'vitest'
2
2
  import { Analytics, VERSION } from './core'
3
3
  import { EVENTS, PAGEVIEW } from './events'
4
4
  import type { Transport, WireEvent } from './types'
5
+ import pkg from '../package.json' with { type: 'json' }
6
+
7
+ // VERSION is stamped on every event as `libraryVersion`, and it is the only way
8
+ // to tell from the warehouse which build emitted a row. It is hand-maintained
9
+ // (it lives alone to keep sentry.ts from importing core.ts), so it silently fell
10
+ // a release behind: 0.3.4 shipped stamping "0.3.3", making its rows
11
+ // indistinguishable from the previous release's. Every other assertion compares
12
+ // VERSION to itself and so cannot catch that. This one pins it to the version
13
+ // actually published.
14
+ describe('VERSION', () => {
15
+ it('matches the published package version', () => {
16
+ expect(VERSION).toBe(pkg.version)
17
+ })
18
+ })
5
19
 
6
20
  interface Sent {
7
21
  url: string
@@ -167,6 +181,108 @@ describe('Analytics capture', () => {
167
181
  }
168
182
  })
169
183
 
184
+ // Stamping the location on EVERY event multiplied an exposure that used to
185
+ // cost one row per page load: a reset/invite/magic link carries a JWT in the
186
+ // query and an address in `?email=`, so without scrubbing, every click on that
187
+ // page ships both to the warehouse in cleartext. The error plane has always
188
+ // scrubbed its free text; the location field is free text too.
189
+ describe('location scrubbing', () => {
190
+ const withLocation = (href: string, referrer: string, fn: () => void) => {
191
+ const g = globalThis as Record<string, unknown>
192
+ const hadWindow = 'window' in g
193
+ const hadDocument = 'document' in g
194
+ const u = new URL(href)
195
+ g.window = {
196
+ location: { href, pathname: u.pathname, search: u.search },
197
+ addEventListener: () => {},
198
+ }
199
+ g.document = { referrer, visibilityState: 'visible' }
200
+ try {
201
+ fn()
202
+ } finally {
203
+ if (!hadWindow) delete g.window
204
+ if (!hadDocument) delete g.document
205
+ }
206
+ }
207
+
208
+ const SECRET_URL =
209
+ 'https://hanzo.ai/invite/accept?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.QWxnSWdub3JlZA&email=cfo@acme.com'
210
+
211
+ it('redacts a secret and PII from the url of every event kind', () => {
212
+ withLocation(SECRET_URL, '', () => {
213
+ const a = mk()
214
+ a.capture('$click')
215
+ a.pageview()
216
+ a.flush()
217
+
218
+ // Both kinds, because pageview() reaches the wire through a different
219
+ // branch than autocapture does.
220
+ for (const e of tx.all) {
221
+ expect(e.url).not.toContain('eyJhbGciOiJIUzI1NiJ9')
222
+ expect(e.url).not.toContain('cfo@acme.com')
223
+ expect(e.url).toContain('[redacted]')
224
+ expect(e.url).toContain('[email]')
225
+ // Scrubbed, not dropped — the page is still attributable, which is the
226
+ // whole reason the field is stamped.
227
+ expect(e.url).toContain('https://hanzo.ai/invite/accept')
228
+ }
229
+ })
230
+ })
231
+
232
+ // pageview() used to pass its own `url` through `...extra`, which merges
233
+ // AFTER the field build() reads — so scrubbing only the read would have left
234
+ // the highest-volume event emitting the raw location. The scrub runs on the
235
+ // assembled record precisely so no call site can route around it.
236
+ it('cannot be bypassed by a call site that supplies its own location', () => {
237
+ withLocation(SECRET_URL, '', () => {
238
+ const a = mk()
239
+ a.pageview('/invite/accept?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.QWxnSWdub3JlZA')
240
+ a.flush()
241
+ const view = tx.all.find((e) => e.type === 'pageview')!
242
+ expect(view.path).not.toContain('eyJhbGciOiJIUzI1NiJ9')
243
+ expect(view.path).toContain('[redacted]')
244
+ })
245
+ })
246
+
247
+ // document.referrer is the previous page's full URL and is stamped on every
248
+ // event, so it leaks the same way the current location does.
249
+ it('redacts the referrer', () => {
250
+ withLocation('https://hanzo.ai/dashboard', SECRET_URL, () => {
251
+ const a = mk()
252
+ a.capture('$click')
253
+ a.flush()
254
+ expect(tx.all[0].referrer).not.toContain('eyJhbGciOiJIUzI1NiJ9')
255
+ expect(tx.all[0].referrer).not.toContain('cfo@acme.com')
256
+ })
257
+ })
258
+
259
+ // capturePII is an explicit opt-in for END-USER identifiers. It is NOT a
260
+ // mode that ships credentials: there is no configuration under which a
261
+ // secret leaves the browser.
262
+ it('still redacts secrets when capturePII is enabled', () => {
263
+ withLocation(SECRET_URL, '', () => {
264
+ const a = mk({ capturePII: true })
265
+ a.capture('$click')
266
+ a.flush()
267
+ expect(tx.all[0].url).not.toContain('eyJhbGciOiJIUzI1NiJ9')
268
+ expect(tx.all[0].url).toContain('[redacted]')
269
+ expect(tx.all[0].url).toContain('cfo@acme.com')
270
+ })
271
+ })
272
+
273
+ // A redactor that mangles ordinary URLs would destroy the analytics it
274
+ // exists to protect, so the common case must pass through byte-for-byte.
275
+ it('leaves an ordinary url untouched', () => {
276
+ withLocation('https://hanzo.ai/pricing?plan=pro&utm_source=x', '', () => {
277
+ const a = mk()
278
+ a.capture('$click')
279
+ a.flush()
280
+ expect(tx.all[0].url).toBe('https://hanzo.ai/pricing?plan=pro&utm_source=x')
281
+ expect(tx.all[0].path).toBe('/pricing')
282
+ })
283
+ })
284
+ })
285
+
170
286
  it('auto-flushes when the batch size is reached', () => {
171
287
  const a = mk({ batchSize: 3 })
172
288
  a.capture('a')
package/src/core.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  } from './attribution'
46
46
  import { dsnForProduct } from './dsn'
47
47
  import { PAGEVIEW } from './events'
48
+ import { scrubText } from './scrub'
48
49
  import {
49
50
  buildEnvelope,
50
51
  buildSentryEvent,
@@ -311,9 +312,12 @@ export class Analytics {
311
312
 
312
313
  /** pageview records a $pageview for the current (or given) location. */
313
314
  pageview(path?: string, properties?: Record<string, unknown>): void {
314
- const url = isBrowser() ? window.location.href : undefined
315
+ // No `url` here: build() reads window.location.href for every event, in the
316
+ // same tick, so this recomputed it to the identical value. Only `path` is
317
+ // passed, because a route change fires before window.location has caught up
318
+ // and the caller's value has to win.
315
319
  const p = path ?? (isBrowser() ? window.location.pathname : undefined)
316
- this.enqueue('pageview', PAGEVIEW, { url, path: p, properties })
320
+ this.enqueue('pageview', PAGEVIEW, { path: p, properties })
317
321
  }
318
322
 
319
323
  /** capture records a named product event with optional properties. Commerce
@@ -467,7 +471,7 @@ export class Analytics {
467
471
 
468
472
  private build(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): WireEvent {
469
473
  const anon = anonId()
470
- return {
474
+ const wire: WireEvent = {
471
475
  messageId: uid(),
472
476
  type: kind,
473
477
  event,
@@ -500,6 +504,32 @@ export class Analytics {
500
504
  libraryVersion: VERSION,
501
505
  ...extra,
502
506
  }
507
+
508
+ // A location is free text an attacker (or an ordinary product flow) controls,
509
+ // and it is now stamped on EVERY event rather than only pageviews — so the
510
+ // one field that is always a URL gets the same policy the error plane has
511
+ // always applied to error text. A password-reset, invite or magic link puts
512
+ // a JWT in the query and an address in `?email=`; without this, one click on
513
+ // that page ships both to the warehouse in cleartext, and every later click
514
+ // repeats it.
515
+ //
516
+ // AFTER `...extra`, deliberately. A call site can pass its own location —
517
+ // pageview() passes `path`, and until this commit it passed `url` too — and
518
+ // `extra` merges over the fields read above, so scrubbing at the read would
519
+ // have left the highest-volume event emitting a raw location while looking
520
+ // scrubbed. Applied to the ASSEMBLED record, the guarantee holds for every
521
+ // call site, including ones not written yet.
522
+ //
523
+ // `scrubText` is the SAME policy as the error plane (secrets always,
524
+ // PII unless capturePII) rather than a second URL-specific redactor: one
525
+ // definition of "must not leave the browser", already tested, mirroring the
526
+ // server's. Guarded on presence so an absent field stays absent instead of
527
+ // becoming the empty string that `host` derivation reads as a page.
528
+ const capturePII = this.cfg.capturePII ?? false
529
+ if (wire.url) wire.url = scrubText(wire.url, capturePII)
530
+ if (wire.path) wire.path = scrubText(wire.path, capturePII)
531
+ if (wire.referrer) wire.referrer = scrubText(wire.referrer, capturePII)
532
+ return wire
503
533
  }
504
534
 
505
535
  private schedule(): void {
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  // The library version, stamped on every event (`libraryVersion`) and on the
2
2
  // Sentry `sdk` block. It lives alone so `sentry.ts` can read it without importing
3
3
  // `core.ts` — core imports sentry, so the reverse would be an import cycle.
4
- export const VERSION = '0.3.3'
4
+ export const VERSION = '0.3.8'