Skip to content

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:

bash
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): PillNav times tap → 2-frame settle and reports interaction:tab:<value> (>250ms). Covers the discovery-tab-click latency.
  • Scroll jank: FeedList / GroupedFeedList track the worst gap between scroll events during a gesture and report scroll:<perfLabel> (>100ms) on scroll end. Opt in by passing perfLabel="<screen>" (set on the clubs/activity discovery lists). For RAW (non-virtualized) ScrollViews, the same monitor is exposed as the useScrollPerf(perfLabel) hook — spread its returned handlers onto the ScrollView + set scrollEventThrottle={16}. Already wired on the profile screen and on DetailShell (opt-in perfLabel prop; set on session-detail).
  • Render storms: wrap a high-churn subtree in <PerfProfiler id="<name>"> (React Profiler) to report render:<id> on commits >120ms. Applied to live-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

LayerDestinationAlways on?LatencyLost if
1. ConsoleMetro / logcat / Xcode consoleyesinstantterminal not attached
2. Supabase client_errors tablePostgres, queryable via SQLyes (on first launch after RLS migration applies)secondsoffline + no retry
3. Sentry dashboardsentry.io → twomore-reactonly when EXPO_PUBLIC_SENTRY_DSN is set at build timesecondsDSN missing or unreachable
4. MMKV crash logLocal device storage (synchronous write)yesinstantonly 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:

  1. Any error inside React render treeErrorBoundary (root + per-route) catches via componentDidCatch
  2. Any uncaught JS error (event handler, setTimeout, unhandled promise rejection) → installGlobalErrorHandler in app/_layout.tsx via ErrorUtils.setGlobalHandler
  3. Any explicit critical-failure call → call captureCriticalError(err, { context }) directly

Manual — when you catch in a try/catch

ts
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)

  1. Open the Supabase project dashboard
  2. SQL Editor → New Query
  3. Paste:
sql
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:

sql
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:

bash
psql "$DATABASE_URL" -c "select * from public.recent_client_errors(20)"

Or the Supabase REST shortcut:

bash
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}' | jq

Inspect via Sentry (after rebuild)

  1. https://sentry.io → org → twomore-react project
  2. Issues tab — sorted by frequency, latest event, and impacted users
  3. 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:

sql
select * from public.recent_client_errors(20);

Errors from a specific user:

sql
select * from public.client_errors
where user_id = '<uuid>'
order by created_at desc
limit 50;

Errors from the last hour, fatal only:

sql
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):

sql
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):

sql
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_cron job 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:

  • authenticated users can INSERT rows where user_id = auth.uid() (or null)
  • anon users can INSERT rows with user_id IS NULL (early-startup crashes before auth)
  • SELECT is 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:

  1. Don't try to copy the on-screen text — the new ErrorBoundary fallback shows a selectable stack, but querying the table is faster
  2. Open the SQL editor, run select * from public.recent_client_errors(20)
  3. Find the row — usually the most recent one with severity fatal
  4. Read the stack + component_stack — for React render errors, the component stack pinpoints which component blew up
  5. Read the context JSONB — any caller that wrapped extra metadata is visible here
  6. 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:

  1. Replace with captureCriticalError(err, { context: { ... } })
  2. Don't add anything to the client_errors schema unless absolutely necessary — use the context JSONB column for ad-hoc metadata (it's free-form)
  3. If a new top-level error category emerges (e.g. payment failures), add a normalized column via a follow-up migration — don't pollute context with structural data

Local development

For local dev with npx expo start --dev-client:

  • Set EXPO_PUBLIC_SENTRY_DSN in .env.local if you want Sentry events to flow to your local testing project (typically you don't — leave it blank)
  • The client_errors table works the same way locally — errors land in your local Supabase instance via supabase start
  • The console layer (Metro) shows everything; layers 2+3 are belt-and-suspenders

When to bypass this pipeline

Almost never. Two legitimate exceptions:

  1. Logger debug output — use logger.debug / logger.info / logger.warn for non-error developer noise. These belong in console only, not in client_errors.
  2. Recoverable network errors — show a retry UI to the user and don't capture as critical. Use useQueryError for 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.

Markdown remains the source of truth. Run yarn docs:check before handoff.