mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
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:
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user