Skip to main content

Telemetry

The contributor portal keeps its own telemetry inside the portal Supabase project (xlsjwlmsqonllvtyplml). The OTel collector used by playroll-cpp and playroll-ui is not used here. Keeping telemetry inside Postgres means a debugging question like "did this user receive the verification email?" is a single SQL join with auth.users, auth.audit_log_entries, and the domain tables (applications, onboarding_submissions, payout_receiving_methods, contributor_app_links).

Where events come from

Two surfaces emit events:

  • Browser (surface = 'web')src/lib/telemetry.ts batches events and POSTs them every 5 seconds (or via navigator.sendBeacon on unload) to the ingest-telemetry edge function. A session_id is generated per page-load and shared across every event from that tab.
  • Edge functions (surface = 'edge')activate-contributor-app-link writes server-side observations of the same activation flow.

The browser endpoint is intentionally callable without a JWT (the contributor portal landing is public). To compensate, the edge function enforces an origin allowlist, per-IP rate limit (60/min, 300/hour), per-session quota (100 events), a strict event-name allowlist, and a PII attribute stripper.

Event catalog

EventSurfaceEmitted when
auth.otp.requestedwebBefore calling signInWithOtp
auth.otp.sentwebsignInWithOtp returned success
auth.otp.failedwebsignInWithOtp returned an error
app_link.activate.requestedwebBrowser invoked the edge function
app_link.activate.receivededgeEdge function entered the handler
app_link.activate.pendingweb/edgeReturned pending_activation
app_link.activate.verifiedweb/edgeReturned verified
app_link.activate.failedweb/edgeEdge function caught an error
payout.receiving_method.submittedwebUser clicked save on IBAN form
payout.receiving_method.rejectedwebClient-side validation failed
payout.receiving_method.persistedwebINSERT succeeded
payout.receiving_method.confirmedwebEmail confirm succeeded
payout.receiving_method.confirm_failedwebEmail confirm errored
ui.unhandled_errorwebwindow.error listener fired
ui.unhandled_rejectionwebwindow.unhandledrejection listener fired
ui.error_boundarywebA React error boundary caught a crash

Internal events emitted by the ingest endpoint itself (telemetry.rate_limited, telemetry.policy.violation, telemetry.events.dropped) sit alongside, useful to spot abuse.

PII handling

Email, IBAN, codice fiscale, date of birth, addresses, and full names must never appear in attrs in clear. The frontend passes email to the logger; the backend turns it into email_hash via deterministic SHA-256 of lower(trim(email)). The same hash is computable directly in SQL:

select public.telemetry_email_hash('philip.budgen@gmail.com');

For IBANs only iban_country (the first 2 characters) and iban_last4 are allowed in attrs.

Common queries

Did this user trigger any portal action recently?

with hash as (
select public.telemetry_email_hash('user@example.com') as email_hash
)
select occurred_at, event_name, severity, error_code, attrs
from public.telemetry_events, hash
where telemetry_events.email_hash = hash.email_hash
or telemetry_events.auth_user_id = (
select id from auth.users where email = 'user@example.com'
)
order by occurred_at desc
limit 100;

If the result is empty the user never reached any instrumented client code. If auth.otp.requested is present without auth.otp.sent, the client errored before reaching Supabase Auth. If both are present but Supabase Auth has no confirmation_sent_at, the issue is downstream (Resend SMTP or recipient inbox).

Funnel: who made it past OTP in the last 24h

select event_name, count(*) as count
from public.telemetry_events
where occurred_at > now() - interval '24 hours'
and event_name like 'auth.otp.%'
group by event_name
order by event_name;
select session_id, count(*) as calls
from public.telemetry_events
where event_name = 'app_link.activate.requested'
and occurred_at > now() - interval '24 hours'
group by session_id
having count(*) > 1
order by calls desc;

Rows with calls > 1 indicate a regression of the dedup fix.

Retention

public.purge_old_telemetry_events(retention_days integer default 90) deletes old rows. Schedule it as a daily cron from the Supabase dashboard → Database → Cron jobs.