From 97a7b12d87f6352f083730c9c02eb45e1810b0e0 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Wed, 12 Aug 2026 00:32:38 -0400 Subject: [PATCH] add global error handling and enhance error tracking capabilities --- src/App.jsx | 6 ++- src/analytics/eventSchemas.js | 6 +++ src/analytics/index.js | 56 ++++++++++++++++++--- src/views/Authorization/Signup.jsx | 8 ++- src/views/Chores/MyChores.jsx | 1 + src/views/Onboarding/HeardAboutView.jsx | 9 +++- src/views/Onboarding/WorkspaceReadyView.jsx | 4 +- 7 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index dc1ada6..43ec306 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 ( diff --git a/src/analytics/eventSchemas.js b/src/analytics/eventSchemas.js index c85c671..4d164aa 100644 --- a/src/analytics/eventSchemas.js +++ b/src/analytics/eventSchemas.js @@ -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 diff --git a/src/analytics/index.js b/src/analytics/index.js index 3197bcd..b16d7e1 100644 --- a/src/analytics/index.js +++ b/src/analytics/index.js @@ -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 diff --git a/src/views/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx index 8600ce4..905516a 100644 --- a/src/views/Authorization/Signup.jsx +++ b/src/views/Authorization/Signup.jsx @@ -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 diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 7d893e8..a867de9 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -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) diff --git a/src/views/Onboarding/HeardAboutView.jsx b/src/views/Onboarding/HeardAboutView.jsx index 3211ad8..3d794db 100644 --- a/src/views/Onboarding/HeardAboutView.jsx +++ b/src/views/Onboarding/HeardAboutView.jsx @@ -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() } diff --git a/src/views/Onboarding/WorkspaceReadyView.jsx b/src/views/Onboarding/WorkspaceReadyView.jsx index 4f363cf..97fb4f0 100644 --- a/src/views/Onboarding/WorkspaceReadyView.jsx +++ b/src/views/Onboarding/WorkspaceReadyView.jsx @@ -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')