Debugging Workflow
Status: Reference — Current Date: 2026-04-14 Code: src/lib/sentry.ts (captureCriticalError), src/adapters/supabase/client-error.supabase.ts (logClientError), src/presentation/components/ui/error-boundary.tsx, app/_layout.tsx (global handler), supabase/migrations/00104_client_errors.sql
Why this exists
Solo development on a real device + OTA-only deploys means the conventional "look at the Metro terminal" debug loop breaks the moment you're testing on a phone without an attached debugger. Screenshots of red error screens are slow, lossy, and cap at the visible portion of the stack. We need a pipeline that captures every uncaught error in a place we can query directly, regardless of whether Sentry is configured, regardless of whether the user can copy text, regardless of build mode.
This document is the single source of truth for that pipeline.
Fastest path: yarn debug:logs (errors + lag, no copy-paste)
The single command to see what's failing AND what's slow, read straight from Supabase:
yarn debug:logs # last 24h, errors + perf, summarized
node scripts/debug-logs.mjs --since=2h # 30m | 2h | 3d window
node scripts/debug-logs.mjs --kind=perf # perf | error | query | paint | navigation | interaction | render
node scripts/debug-logs.mjs --route=session # substring match on route/operation
node scripts/debug-logs.mjs --clear # prune everything (or --clear --since=7d)It signs in as the dev account (EXPO_PUBLIC_DEV_EMAIL/PASSWORD from .env) and calls the dev-gated dev_read_telemetry RPC, which returns a unified, time-sorted stream of client_errors + client_perf_events. Output is a summary (slowest operations + top errors by count/route) followed by a chronological stream. This is the primary debug entry point — prefer it over the SQL-editor steps below.
How lag gets captured: lib/perf.ts has a dependency-injected slow-op sink (perf.reportSlow / setSlowOpReporter, wired at boot by initPerfReporter). It's threshold-gated per kind (paint 1.5s, query 2s, navigation 0.8s, interaction/render 0.5s) + 5s per-op throttled, so only genuinely-slow, non-repeating ops persist to client_perf_events. perf.measure() auto-funnels into it (so screen-paint + nav-transition marks report for free), and query-client times every fetch (slow-query capture).
The three UX lag classes are captured at the shared @twomore/ui primitive level via a parallel DI sink (perf-sink.ts: setPerfSink / reportUiPerf; ui can't import lib/perf so the app bridges them in initPerfReporter):
- Interaction (tab switches):
PillNavtimes tap → 2-frame settle and reportsinteraction:tab:<value>(>250ms). Covers the discovery-tab-click latency. - Scroll jank:
FeedList/GroupedFeedListtrack the worst gap between scroll events during a gesture and reportscroll:<perfLabel>(>100ms) on scroll end. Opt in by passingperfLabel="<screen>"(set on the clubs/activity discovery lists). For RAW (non-virtualized) ScrollViews, the same monitor is exposed as theuseScrollPerf(perfLabel)hook — spread its returned handlers onto the ScrollView + setscrollEventThrottle={16}. Already wired on the profile screen and onDetailShell(opt-inperfLabelprop; set on session-detail). - Render storms: wrap a high-churn subtree in
<PerfProfiler id="<name>">(ReactProfiler) to reportrender:<id>on commits >120ms. Applied tolive-tab,match-board,profile.
Each primitive passes its own threshold through the sink so perf.reportSlow doesn't re-gate. To instrument a hot interaction/render manually anywhere: perf.reportSlow('interaction:rsvp-tap', durationMs, { kind: 'interaction' }).
PII rule: client_perf_events.context is for counts/ids/query-keys ONLY — never names/phones/coords. The adapter runs redactPII on context defensively; the query-timing path uses only queryKey[0] (the entity prefix) as the operation label, never raw key values (which can contain searched names). Retention: a prune-telemetry pg_cron job drops rows older than 7 days.
Code: lib/perf.ts, ports/repositories/perf-event.repository.port.ts, adapters/supabase/perf-event.supabase.ts, presentation/perf-reporter-init.ts, scripts/debug-logs.mjs, supabase/migrations/00198_client_telemetry.sql.
Four layers
| Layer | Destination | Always on? | Latency | Lost if |
|---|---|---|---|---|
| 1. Console | Metro / logcat / Xcode console | yes | instant | terminal not attached |
2. Supabase client_errors table | Postgres, queryable via SQL | yes (on first launch after RLS migration applies) | seconds | offline + no retry |
| 3. Sentry dashboard | sentry.io → twomore-react | only when EXPO_PUBLIC_SENTRY_DSN is set at build time | seconds | DSN missing or unreachable |
| 4. MMKV crash log | Local device storage (synchronous write) | yes | instant | only clearCrashes() called |
Layers 1–3 fire from a single call site: captureCriticalError(error, meta?) in src/lib/sentry.ts. Layer 4 is a synchronous MMKV write that survives process kill — it is surfaced in the dev panel via the 최근 크래시 card. No code in the app should call Sentry.captureException or console.error directly for unrecoverable errors — use the helper.
For EXPO_PUBLIC_SENTRY_DSN and SENTRY_AUTH_TOKEN setup (DSN visibility level, auth token scopes, rotation procedure), see docs/guides/credentials.md.
When each layer activates
- Console is the dev-loop fallback. Always works when you have a terminal attached. Worthless on a real device with the bundler in the background.
- Supabase table is the always-on layer. Ships via OTA, queryable from anywhere with the service-role key. This is what you reach for first when debugging a new error.
- Sentry is the production-grade layer. Adds breadcrumbs, screenshots, source maps, releases, alerting. Requires the DSN to be baked into the build at compile time — OTA cannot retroactively enable Sentry on an existing binary.
- MMKV crash log is the offline-first layer. Writes synchronously before the process can be killed. Survives app restarts and network outages. Visible in the dev panel 최근 크래시 card; cleared only by explicit
clearCrashes()call.
How to capture an error
Automatic — already wired
These three call sites flow through captureCriticalError automatically. Don't add new wiring; if your code path lands in any of these, your errors will be captured:
- Any error inside React render tree →
ErrorBoundary(root + per-route) catches viacomponentDidCatch - Any uncaught JS error (event handler, setTimeout, unhandled promise rejection) →
installGlobalErrorHandlerinapp/_layout.tsxviaErrorUtils.setGlobalHandler - Any explicit critical-failure call → call
captureCriticalError(err, { context })directly
Manual — when you catch in a try/catch
import { captureCriticalError } from '@/lib/sentry';
try {
await doRiskyThing();
} catch (err) {
if (err instanceof Error) {
captureCriticalError(err, {
context: {
userAction: 'submit-score',
sessionId,
},
});
}
// Show the user a recoverable error UI here — captureCriticalError is
// for the developer's record, not user feedback.
}Do not call captureCriticalError for expected errors (validation failures, network 4xx, "no data found"). Those belong in user-facing error states. The helper is for the "we have no recovery" path.
How to inspect errors
Inspect via Supabase SQL editor (fastest)
- Open the Supabase project dashboard
- SQL Editor → New Query
- Paste:
select * from public.recent_client_errors(20);That returns the latest 20 errors with truncated stacks (1500 chars each) for readability. To see the full stack of a specific error:
select error_stack, component_stack
from public.client_errors
where id = '<uuid from previous query>';Inspect via the Supabase CLI
If you have the CLI installed and a service-role key in your env:
psql "$DATABASE_URL" -c "select * from public.recent_client_errors(20)"Or the Supabase REST shortcut:
curl -s "$EXPO_PUBLIC_SUPABASE_URL/rest/v1/rpc/recent_client_errors" \
-H "apikey: $SUPABASE_SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d '{"p_limit": 20}' | jqInspect via Sentry (after rebuild)
- https://sentry.io → org →
twomore-reactproject - Issues tab — sorted by frequency, latest event, and impacted users
- Click any issue → full stack, breadcrumbs (last ~100 user actions), screenshot at crash time, device + OS + release version
Querying the client_errors table
Useful queries to keep handy:
Last 20 errors:
select * from public.recent_client_errors(20);Errors from a specific user:
select * from public.client_errors
where user_id = '<uuid>'
order by created_at desc
limit 50;Errors from the last hour, fatal only:
select created_at, error_message, error_stack
from public.client_errors
where created_at > now() - interval '1 hour'
and severity = 'fatal'
order by created_at desc;Group by error message (find duplicates):
select error_message, count(*) as occurrences, max(created_at) as last_seen
from public.client_errors
where created_at > now() - interval '7 days'
group by error_message
order by occurrences desc;Errors mentioning a specific component (search component_stack):
select created_at, error_message
from public.client_errors
where component_stack ilike '%HomeFeedScreen%'
order by created_at desc;Lifecycle & retention
The client_errors table grows unbounded by default. Periodic pruning policy:
- Manual prune (run before each release):sql
delete from public.client_errors where created_at < now() - interval '30 days'; - Future: hook a
pg_cronjob to do this nightly. Tracked but not yet implemented.
Sentry has its own retention controlled by your plan tier — check the project settings.
RLS and security
The client_errors table is insert-only for clients:
authenticatedusers can INSERT rows whereuser_id = auth.uid()(ornull)anonusers can INSERT rows withuser_id IS NULL(early-startup crashes before auth)SELECTis granted to service-role only — clients cannot read errors back, even their own
This intentional asymmetry means an error logger bug can never become an information disclosure bug. Always query via the SQL editor or service-role key, never from the client.
How to integrate this into your debugging workflow
When a user reports "the app crashed" or you hit a red error screen yourself:
- Don't try to copy the on-screen text — the new ErrorBoundary fallback shows a selectable stack, but querying the table is faster
- Open the SQL editor, run
select * from public.recent_client_errors(20) - Find the row — usually the most recent one with severity
fatal - Read the stack + component_stack — for React render errors, the component stack pinpoints which component blew up
- Read the
contextJSONB — any caller that wrapped extra metadata is visible here - Reproduce locally if needed, fix, ship via OTA, verify the row stops appearing
For ambiguous bugs (intermittent, race condition, specific user): query by user_id and inspect the timeline.
Adding a new "criticality" call site
If you find yourself writing console.error("[FooBar]", err) for an error you can't recover from:
- Replace with
captureCriticalError(err, { context: { ... } }) - Don't add anything to the
client_errorsschema unless absolutely necessary — use thecontextJSONB column for ad-hoc metadata (it's free-form) - If a new top-level error category emerges (e.g. payment failures), add a normalized column via a follow-up migration — don't pollute
contextwith structural data
Local development
For local dev with npx expo start --dev-client:
- Set
EXPO_PUBLIC_SENTRY_DSNin.env.localif you want Sentry events to flow to your local testing project (typically you don't — leave it blank) - The
client_errorstable works the same way locally — errors land in your local Supabase instance viasupabase start - The console layer (Metro) shows everything; layers 2+3 are belt-and-suspenders
When to bypass this pipeline
Almost never. Two legitimate exceptions:
- Logger debug output — use
logger.debug/logger.info/logger.warnfor non-error developer noise. These belong in console only, not inclient_errors. - Recoverable network errors — show a retry UI to the user and don't capture as critical. Use
useQueryErrorfor the standardized pattern.
If you find yourself wanting to add a new error pipeline, ask: does it answer a question that the client_errors table can't answer? If not, extend the context JSONB column instead.