syntheogenesis / supabase /migrations /0011_analytics.sql
Tengo Gzirishvili
Analytics (events/dwell/run-completion) + sidebar sign-in row
9856abf
Raw
History Blame Contribute Delete
13.7 kB
-- 0011_analytics.sql
-- Product analytics: engagement events, dwell sessions, and reporting views.
--
-- Why: public.runs only logs /api/run (the DE pipeline) and β€” until the engine
-- change that ships with this migration β€” only ever wrote status='started'
-- (finished_at / elapsed_seconds / n_variants stayed NULL, and successful_runs
-- in user_summary was always 0). profiles.last_seen_at was also never written.
-- This migration adds the missing pieces so activation, retention, engagement
-- (across ALL tools), and real dwell time become queryable.
--
-- Privacy: mirrors public.runs β€” no sequence content; anonymous visitors are
-- keyed only by a salted /24-IP+UA fingerprint, never a raw IP. Retention is
-- bounded by prune_old_analytics() (companion to 0010's prune_old_runs).
--
-- Run once in the Supabase SQL editor. Re-runnable: tables/functions/views use
-- IF NOT EXISTS / OR REPLACE, and policies are dropped before (re)create.
create extension if not exists "pgcrypto";
-- ═══════════════════════════════════════════════════════════════════════
-- EVENTS β€” one row per meaningful tool action (engagement)
-- ───────────────────────────────────────────────────────────────────────
-- Written by the Flask backend via the service role (auth.log_event), fired
-- from the after_request hook for a curated set of endpoints (plasmid map,
-- CRISPR design, saves, alignment, DE round-2, …). /api/run is NOT logged here
-- β€” public.runs is authoritative for it, so logging it twice would double-count.
create table if not exists public.events (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade,
anon_fingerprint text,
kind text not null,
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
comment on table public.events is
'Engagement events β€” one row per meaningful tool action. No sequence content.';
create index if not exists events_user_idx on public.events(user_id, created_at desc);
create index if not exists events_kind_idx on public.events(kind, created_at desc);
create index if not exists events_created_idx on public.events(created_at desc);
-- ═══════════════════════════════════════════════════════════════════════
-- APP_SESSIONS β€” per-session dwell time
-- ───────────────────────────────────────────────────────────────────────
-- One row per browser session, upserted by the /api/ping heartbeat via the
-- record_ping() RPC below. visible_ms is CUMULATIVE foreground time; the RPC
-- keeps the max so duplicate / out-of-order beats are harmless.
create table if not exists public.app_sessions (
session_id text primary key,
user_id uuid references auth.users(id) on delete set null,
anon_fingerprint text,
first_seen timestamptz not null default now(),
last_seen timestamptz not null default now(),
visible_ms bigint not null default 0,
ping_count integer not null default 0
);
comment on table public.app_sessions is
'Per-session dwell tracking. Upserted by record_ping() from /api/ping.';
create index if not exists app_sessions_user_idx on public.app_sessions(user_id, first_seen desc);
create index if not exists app_sessions_first_idx on public.app_sessions(first_seen desc);
-- ═══════════════════════════════════════════════════════════════════════
-- record_ping() β€” atomic dwell upsert
-- ───────────────────────────────────────────────────────────────────────
-- Insert a new session, or bump last_seen / ping_count and keep visible_ms
-- monotonic. Crucially, it PRESERVES an already-known user_id when a later beat
-- arrives without one β€” the on-hide sendBeacon is unauthenticated, so a naive
-- upsert would null out the user_id captured by the earlier authenticated beats.
create or replace function public.record_ping(
p_session_id text,
p_user_id uuid,
p_anon text,
p_visible_ms bigint
) returns void
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.app_sessions
(session_id, user_id, anon_fingerprint, first_seen, last_seen, visible_ms, ping_count)
values
(p_session_id, p_user_id, p_anon, now(), now(), greatest(coalesce(p_visible_ms, 0), 0), 1)
on conflict (session_id) do update set
last_seen = now(),
visible_ms = greatest(public.app_sessions.visible_ms, excluded.visible_ms),
ping_count = public.app_sessions.ping_count + 1,
user_id = coalesce(public.app_sessions.user_id, excluded.user_id),
anon_fingerprint = coalesce(public.app_sessions.anon_fingerprint, excluded.anon_fingerprint);
end;
$$;
-- Only the service role (Flask backend) may call it. Revoke the default public
-- EXECUTE, then grant it back to service_role explicitly β€” the engine invokes
-- this RPC with the service key at runtime, so without this grant every ping
-- would fail "permission denied for function record_ping".
revoke all on function public.record_ping(text, uuid, text, bigint) from public;
revoke all on function public.record_ping(text, uuid, text, bigint) from anon;
revoke all on function public.record_ping(text, uuid, text, bigint) from authenticated;
grant execute on function public.record_ping(text, uuid, text, bigint) to service_role;
comment on function public.record_ping(text, uuid, text, bigint) is
'Atomic upsert of a dwell session from /api/ping. Service-role only.';
-- ═══════════════════════════════════════════════════════════════════════
-- ROW-LEVEL SECURITY β€” deny by default; reads of own rows; writes via service
-- role (which bypasses RLS, like public.runs).
-- ═══════════════════════════════════════════════════════════════════════
alter table public.events enable row level security;
alter table public.app_sessions enable row level security;
drop policy if exists "events_select_own" on public.events;
drop policy if exists "app_sessions_select_own" on public.app_sessions;
create policy "events_select_own"
on public.events for select using (auth.uid() = user_id);
create policy "app_sessions_select_own"
on public.app_sessions for select using (auth.uid() = user_id);
-- ═══════════════════════════════════════════════════════════════════════
-- RETENTION SWEEP β€” companion to 0010's prune_old_runs. Free tier has no
-- pg_cron, so this is a callable maintenance routine; safe to run repeatedly.
-- ═══════════════════════════════════════════════════════════════════════
create or replace function public.prune_old_analytics(retain_days integer default 90)
returns integer
language plpgsql
security definer
set search_path = public
as $$
declare
d_events integer;
d_sess integer;
begin
delete from public.events
where created_at < now() - make_interval(days => greatest(retain_days, 1));
get diagnostics d_events = row_count;
delete from public.app_sessions
where last_seen < now() - make_interval(days => greatest(retain_days, 1));
get diagnostics d_sess = row_count;
return d_events + d_sess;
end;
$$;
revoke all on function public.prune_old_analytics(integer) from public;
revoke all on function public.prune_old_analytics(integer) from anon;
revoke all on function public.prune_old_analytics(integer) from authenticated;
comment on function public.prune_old_analytics(integer) is
'Maintenance: delete events + app_sessions older than retain_days (default 90).';
-- ═══════════════════════════════════════════════════════════════════════
-- REPORTING VIEWS (service-role only β€” RLS on the base tables blocks anon).
-- Build dashboards against these instead of re-writing the joins.
-- ═══════════════════════════════════════════════════════════════════════
-- Per-user activation: first activity = earliest run OR event.
create or replace view public.user_activation as
with first_act as (
select user_id, min(ts) as first_activity_at from (
select user_id, started_at as ts from public.runs where user_id is not null
union all
select user_id, created_at as ts from public.events where user_id is not null
) a group by user_id
)
select
p.id,
p.email,
p.created_at as signed_up_at,
p.signup_source,
fa.first_activity_at,
(fa.first_activity_at is not null) as ever_active,
(fa.first_activity_at <= p.created_at + interval '24 hours') as activated_24h,
(fa.first_activity_at <= p.created_at + interval '7 days') as activated_7d
from public.profiles p
left join first_act fa on fa.user_id = p.id;
comment on view public.user_activation is
'Per-user activation: did they act, and how soon after signup. Service-role only.';
-- Weekly signup cohorts Γ— still-active in later windows.
create or replace view public.retention_cohorts as
with act as (
select user_id, started_at::date as d from public.runs where user_id is not null
union all
select user_id, created_at::date as d from public.events where user_id is not null
)
select
date_trunc('week', p.created_at)::date as cohort_week,
count(distinct p.id) as cohort_size,
count(distinct a.user_id) filter (
where a.d >= (p.created_at + interval '1 day')::date
and a.d < (p.created_at + interval '8 days')::date) as wk1,
count(distinct a.user_id) filter (
where a.d >= (p.created_at + interval '8 days')::date
and a.d < (p.created_at + interval '29 days')::date) as wk2_4,
count(distinct a.user_id) filter (
where a.d >= (p.created_at + interval '29 days')::date) as wk4plus
from public.profiles p
left join act a on a.user_id = p.id
group by 1 order by 1 desc;
comment on view public.retention_cohorts is
'Weekly signup cohorts Γ— later activity (runs+events). Service-role only.';
-- Daily action volume + active users (runs + events unioned).
create or replace view public.engagement_daily as
with act as (
select user_id, anon_fingerprint, started_at as ts, 'run'::text as kind from public.runs
union all
select user_id, anon_fingerprint, created_at as ts, kind from public.events
)
select
date_trunc('day', ts)::date as day,
count(*) as actions,
count(distinct user_id) filter (where user_id is not null) as signed_in_users,
count(distinct coalesce(user_id::text, anon_fingerprint)) as total_actors
from act
group by 1 order by 1 desc;
comment on view public.engagement_daily is
'Daily action volume + active users (runs+events). Service-role only.';
-- Per-session dwell.
create or replace view public.dwell_sessions as
select
s.session_id,
s.user_id,
p.email,
s.first_seen,
s.last_seen,
s.ping_count,
round(s.visible_ms / 1000.0) as visible_seconds
from public.app_sessions s
left join public.profiles p on p.id = s.user_id
order by s.first_seen desc;
comment on view public.dwell_sessions is
'Per-session foreground dwell time. Service-role only.';
-- Daily dwell summary.
create or replace view public.dwell_daily as
select
date_trunc('day', first_seen)::date as day,
count(*) as sessions,
count(distinct coalesce(user_id::text, anon_fingerprint)) as visitors,
round(avg(visible_ms) / 1000.0) as avg_session_seconds,
round((percentile_cont(0.5) within group (order by visible_ms)) / 1000.0)
as median_session_seconds
from public.app_sessions
group by 1 order by 1 desc;
comment on view public.dwell_daily is
'Daily dwell: sessions, visitors, avg/median foreground seconds. Service-role only.';