Realtime AI tracing: zichtbaar maken wat de AI doet

Een AI-agent die een bericht verwerkt, doet van alles voordat hij antwoordt: context laden, kennisdocumenten ophalen, het LLM aanroepen, acties uitvoeren. Vanuit het perspectief van de gebruiker is dat een zwarte doos. Hij stuurt een bericht en wacht.
Dat wachten voelt anders als de gebruiker kan zien wat er gebeurt. Niet als een technische stacktrace, maar als menselijke stappen: "Kennisbank raadplegen — 3 documenten gevonden". Die transparantie heeft twee effecten: de wachttijd voelt korter, en het vertrouwen in het systeem stijgt omdat het helder is waarop het antwoord is gebaseerd.
Hier is hoe we dat hebben gebouwd.
De trace-stappen
Een agent-turn produceert een reeks stappen. We hebben ze gedefinieerd als een discriminated union:
export type AiStep =
| { step: "agent_started" }
| {
step: "context_loaded";
diensten_count?: number;
workflows_count?: number;
}
| { step: "rag_retrieval"; no_hit?: boolean; docs_count?: number }
| { step: "llm_call"; action_tags_found?: boolean }
| { step: "action_executed"; type?: string }
| { step: "agent_done" };
Elke stap heeft optionele metadata: hoeveel documenten zijn opgehaald, is er een actie gevonden in het LLM-antwoord, hoeveel entiteiten staan er in de context? Die details verschijnen in de UI naast de stap-label.
De mapping van stap naar leesbare tekst zit in een config-object, zodat de UI-component niet hoeft te weten wat elke stap betekent:
export const AI_STEP_CONFIG = {
context_loaded: {
label: "Context laden",
detail: (s) => {
const parts = [];
if (s.diensten_count) parts.push(`${s.diensten_count} diensten`);
if (s.workflows_count) parts.push(`${s.workflows_count} workflows`);
return parts.join(", ") || null;
},
},
rag_retrieval: {
label: "Kennisbank",
detail: (s) => (s.no_hit ? "geen hits" : `${s.docs_count} documenten`),
},
llm_call: { label: "Antwoord genereren" },
action_executed: { label: "Actie uitvoeren", detail: (s) => s.type },
};
Broadcasting via Supabase Realtime
De agent stuurt stappen naar de client via Supabase Realtime Broadcast. Broadcast is hiervoor de juiste keuze: het is eenrichtingsverkeer, laag-latent, en er is geen persistentie nodig — als een client de verbinding mist, hoeft hij de stap niet terug te kunnen lezen.
De broadcast-helper is een singleton die kanalen hergebruikt om het aantal open verbindingen te beperken:
let _client: ReturnType<typeof createClient> | null = null;
const _channels = new Map<string, SupabaseChannel>();
function getOrCreateChannel(channelName: string) {
if (_channels.has(channelName)) return _channels.get(channelName)!;
const channel = getClient().channel(channelName, {
config: { broadcast: { ack: false } },
});
_channels.set(channelName, channel);
return channel;
}
ack: false betekent dat we niet wachten op bevestiging van de client. Voor trace-stappen is dat de juiste trade-off: sneller broadcasten is waardevoller dan de garantie dat elke stap aankomt.
Het broadcasten zelf is fire-and-forget — fouten worden geslikt zodat een broadcast-probleem nooit de agent-turn laat mislukken:
export async function broadcast(
channelName: string,
event: string,
payload: Record<string, unknown>,
): Promise<void> {
try {
const channel = getOrCreateChannel(channelName);
await channel.httpSend(event, payload);
} catch (err) {
logger.warn(`broadcast failed on channel "${channelName}":`, err);
}
}
httpSend() is de expliciete REST-delivery van Supabase Realtime — we vermijden de deprecated impliciete send() → REST-fallback die in oudere versies van de client zat.
De UI: denken-bel en details-uitklapper
In de chat-interface verschijnt terwijl de agent werkt een "denken-bel" die de huidige stap toont:
export function AiThinkingBubble({ steps }: { steps: AiStep[] }) {
const currentStep = steps.at(-1);
const currentConfig = currentStep ? AI_STEP_CONFIG[currentStep.step] : null;
const statusText = `${currentConfig?.label ?? "Nadenken"}…`;
return (
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/10 px-4 py-2.5">
<div className="flex items-center gap-2 text-sm text-amber-600/70">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{statusText}</span>
</div>
</div>
);
}
Na afloop vervangt het uiteindelijke antwoord de denken-bel. De trace-stappen zijn dan inklapbaar beschikbaar via een kleine "Details"-knop onder het antwoord. Zo is de trace niet opdringerig in de normale flow, maar altijd beschikbaar voor wie wil weten waarop het antwoord is gebaseerd.
Persistente logging voor debugging achteraf
Naast de live broadcast loggen we elke agent-turn naar een ai_call_logs-tabel. Die log bevat de volledige input en output:
export type AiCallLogParams = {
user_message: string;
intent: string;
retrieval_query: string;
retrieval_keywords: string[];
knowledge_docs_retrieved: RetrievedDoc[]; // inclusief similarity-scores en bron
people_retrieved: Array<{ name: string; id: string }>;
tool_calls: Array<{ name: string; args: unknown; result: string }>;
response_text: string | null;
latency_ms: number | null;
prompt_tokens: number | null;
completion_tokens: number | null;
cost_usd: number | null;
agent: string | null;
fallback_agent: string | null;
};
De logging is niet-blokkerend: de aanroep naar logAiCall() wordt na het antwoord als fire-and-forget gestart. De gebruiker wacht niet op de log-write.
Er zit een retry-mechanisme in voor het geval de tabel nog niet alle kolommen heeft na een migratie:
// Eerste poging: volledige insert
const { data, error } = await sb.from("ai_call_logs").insert({ ...allParams });
if (!error) return data.id;
// Tweede poging: zonder token/cost-kolommen (migratie nog niet gerund)
const { data: fallback } = await sb
.from("ai_call_logs")
.insert({ ...coreParams });
Dat klinkt als een hack, maar het is pragmatisch: het maakt het mogelijk om een nieuwe versie te deployen zonder dat de logging breekt terwijl de database-migratie nog onderweg is.
Feedback koppelen aan de trace
Gebruikers kunnen elk AI-antwoord beoordelen met een duim omhoog of omlaag. Negatieve feedback triggert optioneel regeneratie. Die feedback wordt opgeslagen als een apart record gekoppeld aan de ai_call_log — zodat je in het analysedashboard kunt zien welke retrieval-patronen leiden tot negatieve feedback.
De combinatie van trace-data en gebruikersfeedback maakt het mogelijk om de vraag te beantwoorden die anders bijna onmogelijk te beantwoorden is: "waarom gaf de AI een slecht antwoord op deze vraag?" Was het slechte retrieval (geen hits, of de verkeerde documenten)? Was het het model dat een hallucinate antwoord gaf ondanks goede context? Was het een intent-mismatch — de verkeerde agent aangesproken? De log vertelt het je.
Observability als product-feature
Het klassieke beeld van observability is een Grafana-dashboard voor een on-call engineer. Wat we hier hebben gebouwd is anders: de trace is onderdeel van de gebruikerservaring. Wie de AI gebruikt, ziet wat de AI doet.
Dat heeft een bijwerking die we niet hadden verwacht: gebruikers beginnen de output anders te beoordelen als ze weten dat "Kennisbank — geen hits" is opgetreden. Ze begrijpen dat het antwoord dan niet op interne documenten is gebaseerd, en passen hun vertrouwen daarop aan. Transparantie maakt de AI voorspelbaarder — niet technisch, maar voor de eindgebruiker.
Als je vergelijkbare tracing wilt bouwen, is Supabase Realtime Broadcast een goede keuze voor de live component: geen extra infra, werkt op dezelfde database-verbinding als de rest van je stack, en is eenvoudig op te ruimen als kanalen niet meer nodig zijn. Vertel ons over je AI-project als je wilt sparren over hoe je observability in de gebruikerservaring kunt verwerken.