La maggior parte delle integrazioni analytics spara chiamate di evento sparse lato client, spera nel migliore dei mondi e produce una dashboard di cui nessuno si fida. Il tracking di attivazione in produzione assomiglia ai quattro file qui sotto: un registry di eventi tipizzato da Zod, un helper track() chiamato dalle Server Action, un'aggregazione notturna che scrive su tenant_metrics e la query del funnel da cui la dashboard legge. Server-side da capo a fondo; fonte di verità su Postgres; il vendor (PostHog o Plausible) a valle.
1. Il registry di eventi tipizzato
Ogni evento ha un nome, uno schema di properties e un owner. Aggiungere un evento è una PR che aggiorna questo file; non farlo lascia l'helper non tipizzabile al call site.
// src/lib/analytics/events.ts
import { z } from 'zod'
export const EVENT_NAMES = [
'user.signed_up',
'user.verified_email',
'tenant.created',
'project.created',
'invite.sent',
'invite.accepted',
'subscription.started',
'first_value.reached',
] as const
export type EventName = (typeof EVENT_NAMES)[number]
export const EVENT_PROPS = {
'user.signed_up': z.object({
source: z.enum(['organic', 'paid', 'referral', 'direct']).optional(),
plan: z.string().optional(),
}),
'user.verified_email': z.object({
verification_age_minutes: z.number().int().nonnegative(),
}),
'tenant.created': z.object({
plan: z.string(),
seat_count: z.number().int().positive().default(1),
}),
'project.created': z.object({
project_id: z.string().uuid(),
template: z.string().optional(),
}),
'invite.sent': z.object({
invite_id: z.string().uuid(),
role: z.enum(['owner', 'admin', 'member', 'billing']),
}),
'invite.accepted': z.object({
invite_id: z.string().uuid(),
accept_age_hours: z.number().int().nonnegative(),
}),
'subscription.started': z.object({
stripe_subscription_id: z.string(),
plan: z.string(),
}),
'first_value.reached': z.object({
flow: z.string(),
elapsed_minutes: z.number().int().nonnegative(),
}),
} as const satisfies Record<EventName, z.ZodTypeAny>
export type EventProps<T extends EventName> = z.infer<(typeof EVENT_PROPS)[T]>
2. L'helper track()
Una funzione tipizzata chiamata da qualsiasi Server Action. Valida le properties a runtime, scrive su Postgres e inoltra al vendor di analytics quando configurato. Se la validazione fallisce lancia; preferiamo trovare il bug piuttosto che scrivere un evento malformato.
// src/lib/analytics/track.ts
import { adminClient } from '@/lib/supabase/admin'
import { EVENT_PROPS, type EventName, type EventProps } from './events'
interface TrackInput<T extends EventName> {
tenantId: string
userId: string | null
event: T
properties: EventProps<T>
}
export async function track<T extends EventName>(
input: TrackInput<T>,
): Promise<void> {
const schema = EVENT_PROPS[input.event]
const validated = schema.parse(input.properties)
// Scrive nella nostra tabella events come primo passo; è la fonte di verità.
const { error } = await adminClient.from('events').insert({
tenant_id: input.tenantId,
user_id: input.userId,
event_name: input.event,
properties: validated,
})
if (error) throw error
// Inoltra a PostHog se configurato. Il fallimento qui viene loggato ma non
// lanciato; la dashboard customer dipende dalla scrittura Postgres, non
// dall'arrivo al terzo.
if (process.env.POSTHOG_API_KEY) {
void fetch(`${process.env.POSTHOG_HOST}/i/v0/e/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: process.env.POSTHOG_API_KEY,
event: input.event,
properties: {
...validated,
$groups: { tenant: input.tenantId },
distinct_id: input.userId ?? input.tenantId,
},
}),
}).catch(() => {})
}
}
3. Una Server Action che fa partire un evento
L'evento parte DOPO che la scrittura sul database è andata bene. Le due operazioni sono dentro un try / catch così uno stato parziale non lascia l'evento scritto senza la riga corrispondente, o viceversa.
// app/[lang]/(app)/projects/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { createServerClient } from '@/lib/supabase/server'
import { getServerSession, getActiveTenantId } from '@/lib/auth/server'
import { track } from '@/lib/analytics/track'
const Input = z.object({
name: z.string().min(1).max(200),
template: z.string().optional(),
})
export async function createProject(
raw: unknown,
): Promise<{ ok: true; projectId: string } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const tenantId = await getActiveTenantId()
if (!tenantId) return { ok: false, error: 'no active tenant' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
const supabase = await createServerClient()
const { data: project, error } = await supabase
.from('projects')
.insert({
tenant_id: tenantId,
name: parsed.data.name,
template: parsed.data.template ?? null,
created_by: session.userId,
})
.select('id')
.single()
if (error) return { ok: false, error: error.message }
// Spara l'evento di attivazione solo dopo che la scrittura DB è andata bene.
await track({
tenantId,
userId: session.userId,
event: 'project.created',
properties: {
project_id: project.id,
template: parsed.data.template,
},
})
revalidatePath('/projects')
return { ok: true, projectId: project.id }
}
4. L'aggregazione notturna e la query del funnel
L'aggregazione calcola le metriche per tenant ogni notte e le scrive su tenant_metrics. La dashboard legge da tenant_metrics (economico, indicizzato) invece di rieseguire lo stream di eventi ad ogni caricamento pagina.
-- supabase/migrations/0020_tenant_metrics.sql
create table tenant_metrics (
tenant_id uuid primary key references tenants(id) on delete cascade,
activation_score numeric(5,2) not null default 0,
funnel_step_pct jsonb not null default '{}',
time_to_activation_hours integer,
drop_off_step text,
events_total integer not null default 0,
computed_at timestamptz not null default now()
);
create or replace function refresh_tenant_metrics()
returns void
language plpgsql
security definer
as $$
declare
rec record;
begin
for rec in
select t.id as tenant_id, t.created_at as tenant_created_at
from tenants t
loop
with funnel as (
select
event_name,
min(created_at) as first_seen
from events
where tenant_id = rec.tenant_id
group by event_name
),
steps as (
select
(select first_seen from funnel where event_name = 'user.signed_up') as signed_up,
(select first_seen from funnel where event_name = 'project.created') as project_created,
(select first_seen from funnel where event_name = 'invite.accepted') as invite_accepted,
(select first_seen from funnel where event_name = 'first_value.reached') as first_value
)
insert into tenant_metrics (tenant_id, activation_score, funnel_step_pct, time_to_activation_hours, drop_off_step, events_total, computed_at)
select
rec.tenant_id,
case
when first_value is not null then 100
when invite_accepted is not null then 75
when project_created is not null then 50
when signed_up is not null then 25
else 0
end as activation_score,
jsonb_build_object(
'signed_up', signed_up is not null,
'project_created', project_created is not null,
'invite_accepted', invite_accepted is not null,
'first_value', first_value is not null
) as funnel_step_pct,
case
when first_value is not null
then extract(epoch from (first_value - signed_up))::int / 3600
end as time_to_activation_hours,
case
when first_value is null and invite_accepted is not null then 'first_value'
when invite_accepted is null and project_created is not null then 'invite_accepted'
when project_created is null and signed_up is not null then 'project_created'
end as drop_off_step,
(select count(*) from events where tenant_id = rec.tenant_id),
now()
from steps
on conflict (tenant_id) do update set
activation_score = excluded.activation_score,
funnel_step_pct = excluded.funnel_step_pct,
time_to_activation_hours = excluded.time_to_activation_hours,
drop_off_step = excluded.drop_off_step,
events_total = excluded.events_total,
computed_at = now();
end loop;
end;
$$;
// app/admin/funnel/page.tsx
import { Suspense } from 'react'
import { adminClient } from '@/lib/supabase/admin'
async function FunnelData() {
const { data: rows } = await adminClient
.from('tenant_metrics')
.select('tenant_id, activation_score, drop_off_step, time_to_activation_hours, computed_at')
.order('computed_at', { ascending: false })
.limit(200)
if (!rows) return <p>No metrics yet.</p>
return (
<table className="ds-table">
<thead>
<tr>
<th>Tenant</th>
<th>Score</th>
<th>Drop-off</th>
<th>Time to activation</th>
<th>Computed</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.tenant_id}>
<td>{r.tenant_id}</td>
<td>{r.activation_score}</td>
<td>{r.drop_off_step ?? '—'}</td>
<td>{r.time_to_activation_hours ?? '—'}</td>
<td>{new Date(r.computed_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)
}
export default function FunnelPage() {
return (
<Suspense fallback={<p>Loading funnel…</p>}>
<FunnelData />
</Suspense>
)
}
5. Come si tiene insieme tutto questo
Le Server Action sparano eventi quando hanno successo. La tabella events è la fonte di verità; row-level security limita in automatico le letture per tenant. L'aggregazione notturna deriva metriche per tenant che la dashboard legge in una sola query. PostHog o Plausible ricevono una copia per l'esplorazione; la tabella interna è quella su cui il team ottimizza.
L'attivazione smette di essere una sensazione viscerale del founder e diventa un numero su una dashboard che il team apre il lunedì mattina. Il funnel diventa il manufatto contro cui ogni decisione di prodotto argomenta; i prossimi dieci esperimenti hanno un target invece che una semplice intuizione.