add global error handling and enhance error tracking capabilities

This commit is contained in:
Mo Tarbin
2026-08-12 00:32:38 -04:00
parent c88790abac
commit 97a7b12d87
7 changed files with 76 additions and 14 deletions

View File

@@ -7,7 +7,10 @@ import { useRegisterSW } from 'virtual:pwa-register/react'
import NavBar from '@/views/components/NavBar'
import { initialize as initializeAnalytics } from './analytics'
import {
initialize as initializeAnalytics,
installGlobalErrorHandlers,
} from './analytics'
import useAnalyticsIdentity from './analytics/useAnalyticsIdentity'
import { registerCapacitorListeners } from './CapacitorListener'
import PageTransition from './components/animations/PageTransition'
@@ -147,6 +150,7 @@ function App() {
useEffect(() => {
initializeAnalytics()
installGlobalErrorHandlers()
}, [])
return (

View File

@@ -85,6 +85,12 @@ export const ERROR_SCHEMAS = {
error_code: 'string',
operation: 'string',
},
// No message/stack field here by design — those come from the real Error
// object passed to posthog.captureException() itself, not from this
// sanitized properties bag. This schema only classifies how it was caught.
frontend_error: {
source: 'enum:window_error,unhandled_rejection',
},
}
const MAX_STRING_LENGTH = 200

View File

@@ -142,6 +142,9 @@ export const track = (eventName, properties = {}) => {
posthog.capture(eventName, sanitized)
}
/** Backend/API failures: a normal sanitized event, same as track() — not
* PostHog's Error Tracking product. There's no real Error object here (just
* an HTTP response), so there's no stack trace to gain from captureException. */
export const captureError = (errorType, properties = {}) => {
if (!canSend('crash')) return
const posthog = getClientSync()
@@ -150,15 +153,56 @@ export const captureError = (errorType, properties = {}) => {
const sanitized = sanitizeErrorProperties(errorType, properties)
if (!sanitized) return
// captureException (not capture) so this lands on PostHog's Error Tracking
// page, grouped by errorType — the message is deliberately generic, since
// any per-instance detail must go through the sanitized allowlist above,
// never straight into the exception message.
const error = new Error(errorType)
error.name = errorType
posthog.capture(errorType, sanitized)
}
/**
* Frontend crashes only. Uses captureException (not capture) so these land
* on PostHog's Error Tracking page with a genuine message + stack trace —
* that text is NOT filtered by the sanitized allowlist below, since it comes
* from the exception object itself, not from `properties`.
*/
export const captureException = (error, properties = {}) => {
if (!canSend('crash')) return
const posthog = getClientSync()
if (!posthog) return
const sanitized = sanitizeErrorProperties('frontend_error', properties)
if (!sanitized) return
posthog.captureException(error, sanitized)
}
let globalHandlersInstalled = false
/** Reports uncaught exceptions and unhandled promise rejections to
* PostHog's Error Tracking, gated by the same crash consent as api_error.
* Complements, doesn't overlap with, src/views/Error.jsx: that's a React
* Router error-boundary screen for render/loader errors, which React catches
* before they ever reach window.onerror — a different class of failure, with
* its own user-initiated "Report this problem" flow via ErrorReportService.
* These listeners only see what bypasses React's boundaries entirely (event
* handlers, timers, unhandled promise rejections). Safe to call multiple
* times; only installs once. */
export const installGlobalErrorHandlers = () => {
if (globalHandlersInstalled || typeof window === 'undefined') return
globalHandlersInstalled = true
window.addEventListener('error', event => {
const error =
event?.error instanceof Error
? event.error
: new Error(event?.message || 'Unknown window error')
captureException(error, { source: 'window_error' })
})
window.addEventListener('unhandledrejection', event => {
const reason = event?.reason
const error = reason instanceof Error ? reason : new Error(String(reason))
captureException(error, { source: 'unhandled_rejection' })
})
}
/**
* kind: 'analytics' | 'crash'. Enabling analytics (re-)initializes PostHog
* if needed and sends analytics_enabled; enabling crash-only never talks to

View File

@@ -56,11 +56,9 @@ const SignupView = () => {
return
}
// The "how did you hear about us" step (/heard-about) is
// temporarily skipped; new accounts go straight to circle setup.
// Re-enable by navigating to '/heard-about' again — that view
// already forwards to '/circle-setup' when done.
Navigate('/circle-setup', { replace: true })
// "How did you hear about us" (cloud) / privacy preferences (self-hosted)
// that view forwards to '/circle-setup' once the user answers.
Navigate('/heard-about', { replace: true })
}
const handleSignUpValidation = () => {
// Reset errors before validation

View File

@@ -353,6 +353,7 @@ const MyChores = () => {
membersData?.res &&
choresData?.res
) {
// throw new Error('FAKE ERROR') // For testing Sentry error tracking
const processEffectAsync = async () => {
// Sync local state with query data to ensure updates are reflected
setChores(processedChores)

View File

@@ -3,6 +3,7 @@ import { Box, Button, Input, Link, Switch, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { track } from '../../analytics'
import { isOfficialDonetickInstance } from '../../utils/FeatureToggle'
import {
haptic,
@@ -79,7 +80,13 @@ const AcquisitionSurvey = ({ onDone }) => {
const finish = () => {
const answer = selected === OTHER ? detail.trim() || null : selected
if (answer) recordAcquisitionSource(answer)
if (answer) {
recordAcquisitionSource(answer)
track('onboarding_option_selected', {
step: 'heard_about',
option: answer,
})
}
onDone()
}

View File

@@ -10,6 +10,7 @@ import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import { haptic } from '../../utils/Onboarding'
import { authButtonSx } from '../Authorization/authStyles'
import { isOfficialDonetickInstance } from '../../utils/FeatureToggle'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
@@ -39,7 +40,8 @@ const WorkspaceReadyView = () => {
* and a missing offering or a store hiccup must never trap a new user here.
*/
const showPaywall = async () => {
if (!Capacitor.isNativePlatform() || !userProfile?.id) return
const isDonetickDotCom = await isOfficialDonetickInstance()
if (!isDonetickDotCom || !Capacitor.isNativePlatform() || !userProfile?.id) return
const { Purchases } = await import('@revenuecat/purchases-capacitor')
const { RevenueCatUI } = await import('@revenuecat/purchases-capacitor-ui')