fix(telemetry): report render errors with no fallback, count SPA routes, and load under CJS

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
hanzo-dev
2026-07-30 09:58:12 -07:00
parent a93d68a05c
commit 7223b27e85
5 changed files with 99 additions and 31 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzogui/telemetry",
"version": "0.1.0",
"version": "0.1.3",
"description": "Zero-config Hanzo telemetry — one <TelemetryProvider/> wires errors + session capture, pageviews, and product events to the ONE front door (POST api.hanzo.ai/v1/event), which lenses them into sentry.hanzo.ai, analytics.hanzo.ai and insights.hanzo.ai. Honors DNT/GPC, consent-aware, SSR-safe, fail-soft.",
"source": "src/index.ts",
"files": [
+18 -6
View File
@@ -194,11 +194,21 @@ export class TelemetryBoundary extends Component<BoundaryProps, BoundaryState> {
}
componentDidCatch(error: Error, info: ErrorInfo): void {
if (!this.props.enabled) return
this.props.telemetry.captureError(error, {
handled: false,
properties: { componentStack: info.componentStack, react: true },
})
if (this.props.enabled) {
this.props.telemetry.captureError(error, {
handled: false,
properties: { componentStack: info.componentStack, react: true },
})
}
// Re-throw HERE, not from render(), when the app supplied no fallback.
//
// React's order is: getDerivedStateFromError → re-render → commit →
// componentDidCatch. Throwing from render() aborts that sequence before the
// commit, so componentDidCatch never runs and the error is never reported —
// silently, and only for the apps that (correctly) let their own boundary
// own the UI. Throwing from componentDidCatch propagates to the next
// boundary up exactly the same way, but AFTER the error has been observed.
if (this.props.fallback === undefined) throw error
}
private reset = (): void => this.setState({ error: null })
@@ -207,7 +217,9 @@ export class TelemetryBoundary extends Component<BoundaryProps, BoundaryState> {
const { error } = this.state
if (!error) return this.props.children
const { fallback } = this.props
if (fallback === undefined) throw error
// Nothing, for the one commit it takes componentDidCatch to re-throw; the
// app's own boundary decides what the user actually sees.
if (fallback === undefined) return null
return typeof fallback === 'function' ? fallback(error, this.reset) : fallback
}
}
+6 -2
View File
@@ -73,8 +73,12 @@ function processEnv(): RawEnv {
function metaEnv(): RawEnv {
try {
const m = import.meta as unknown as { env?: Record<string, string | undefined> }
const e = m?.env
// MEMBER form, deliberately. Aliasing a bare `import.meta` first
// (`const m = import.meta`) is a parse-time SyntaxError in a CommonJS
// consumer: every babel plugin that rewrites `import.meta` for a CJS target
// matches only the member expression, so a bare one survives the transform
// and takes the whole module down. Jest is the one that finds this.
const e = (import.meta as unknown as { env?: Record<string, string | undefined> })?.env
if (!e) return {}
return {
apiUrl: first(e.VITE_HANZO_API_URL, e.EXPO_PUBLIC_HANZO_API_URL, e.PUBLIC_HANZO_API_URL),
+47 -1
View File
@@ -5,7 +5,7 @@
// error is reported, and the ambient `track()` shares the provider's stream.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act } from 'react'
import { Component, act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { TelemetryProvider } from './TelemetryProvider.js'
import { getTelemetry, setTelemetry, track } from './telemetry.js'
@@ -137,6 +137,52 @@ describe('<TelemetryProvider/> with no props', () => {
expect(err?.properties).toMatchObject({ react: true })
})
// The regression that mattered: with no `fallback` the provider must still
// REPORT before it gets out of the way. Re-throwing from render() aborted the
// commit, so componentDidCatch never ran and the error vanished — silently,
// and only for apps that correctly let their own boundary own the UI.
it('reports a render error even with NO fallback, then re-throws', () => {
const Boom = (): never => {
throw new Error('no fallback exploded')
}
vi.spyOn(console, 'error').mockImplementation(() => {})
let escaped: unknown
class Outer extends Component<{ children: ReactNode }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
componentDidCatch(e: Error) {
escaped = e
}
render() {
return this.state.failed ? 'outer caught' : this.props.children
}
}
act(() => {
root.render(
<Outer>
<TelemetryProvider replay={false}>
<Boom />
</TelemetryProvider>
</Outer>,
)
})
// Observing must not change behavior: the app's own boundary still decides.
expect(container.textContent).toBe('outer caught')
expect((escaped as Error)?.message).toBe('no fallback exploded')
act(() => getTelemetry().flush())
const err = events().find((e) => e.type === 'error') as
| { error: Record<string, unknown>; properties: Record<string, unknown> }
| undefined
expect(err?.error).toMatchObject({ message: 'no fallback exploded', handled: false })
expect(err?.properties).toMatchObject({ react: true })
})
it('collects nothing when the browser asks not to be tracked', () => {
vi.stubGlobal('navigator', { globalPrivacyControl: true })
act(() => {
+27 -21
View File
@@ -14,19 +14,24 @@
import { useEffect, useRef } from 'react'
import type { Telemetry } from './types.js'
const location = (): string => {
/** Where we are, as `{key, path}` — `key` is the full location (so a query-only
* change still counts as a view) and `path` is what gets reported.
*
* `url` is the third argument the app just handed `pushState`/`replaceState`.
* Preferring it over reading `window.location` back is not a workaround: it is
* the navigation the app actually requested, resolved against the current
* document. Reading `location` instead assumes the host reflects a pushState
* into `location` synchronously — every real browser does, but that makes the
* behavior untestable in a DOM shim, and an assumption nothing verifies is one
* that silently rots. With no `url` argument (popstate, hashchange, the initial
* load) `location` is the only source, and it is read exactly as before. */
const where = (url?: string | URL | null): { key: string; path: string | undefined } => {
try {
return window.location.pathname + window.location.search + window.location.hash
const loc = window.location
const u = url == null || url === '' ? loc : new URL(String(url), loc.href)
return { key: u.pathname + u.search + u.hash, path: u.pathname }
} catch {
return ''
}
}
const pathname = (): string | undefined => {
try {
return window.location.pathname
} catch {
return undefined
return { key: '', path: undefined }
}
}
@@ -43,7 +48,7 @@ export function useRouteTracking(
// Controlled: the app's router is the clock.
useEffect(() => {
if (!active || !controlled || typeof window === 'undefined') return
const next = path ?? pathname() ?? ''
const next = path ?? where().path ?? ''
if (last.current === next) return
last.current = next
telemetry.pageview(next)
@@ -53,11 +58,11 @@ export function useRouteTracking(
useEffect(() => {
if (!active || controlled || typeof window === 'undefined') return
const fire = (): void => {
const key = location()
const fire = (url?: string | URL | null): void => {
const { key, path: next } = where(url)
if (last.current === key) return
last.current = key
telemetry.pageview(pathname())
telemetry.pageview(next)
}
fire() // the initial load counts
@@ -67,22 +72,23 @@ export function useRouteTracking(
const replace = history.replaceState
history.pushState = function (this: History, ...args: Parameters<History['pushState']>) {
const result = push.apply(this, args)
fire()
fire(args[2])
return result
}
history.replaceState = function (this: History, ...args: Parameters<History['replaceState']>) {
const result = replace.apply(this, args)
fire()
fire(args[2])
return result
}
window.addEventListener('popstate', fire)
window.addEventListener('hashchange', fire)
const onPop = (): void => fire()
window.addEventListener('popstate', onPop)
window.addEventListener('hashchange', onPop)
return () => {
history.pushState = push
history.replaceState = replace
window.removeEventListener('popstate', fire)
window.removeEventListener('hashchange', fire)
window.removeEventListener('popstate', onPop)
window.removeEventListener('hashchange', onPop)
}
}, [telemetry, active, controlled])
}