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.tsbatches events and POSTs them every 5 seconds (or vianavigator.sendBeaconon unload) to theingest-telemetryedge function. Asession_idis generated per page-load and shared across every event from that tab. - Edge functions (
surface = 'edge') —activate-contributor-app-linkwrites 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
| Event | Surface | Emitted when |
|---|---|---|
auth.otp.requested | web | Before calling signInWithOtp |
auth.otp.sent | web | signInWithOtp returned success |
auth.otp.failed | web | signInWithOtp returned an error |
app_link.activate.requested | web | Browser invoked the edge function |
app_link.activate.received | edge | Edge function entered the handler |
app_link.activate.pending | web/edge | Returned pending_activation |
app_link.activate.verified | web/edge | Returned verified |
app_link.activate.failed | web/edge | Edge function caught an error |
payout.receiving_method.submitted | web | User clicked save on IBAN form |
payout.receiving_method.rejected | web | Client-side validation failed |
payout.receiving_method.persisted | web | INSERT succeeded |
payout.receiving_method.confirmed | web | Email confirm succeeded |
payout.receiving_method.confirm_failed | web | Email confirm errored |
ui.unhandled_error | web | window.error listener fired |
ui.unhandled_rejection | web | window.unhandledrejection listener fired |
ui.error_boundary | web | A 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;
App-link activations should happen once per session
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.