← Terug naar blog
AI Engineering
17 maart 2026

Realtime AI tracing: zichtbaar maken wat de AI doet

Observability als UX-feature — hoe je de interne stappen van een AI-agent live aan de gebruiker toont zonder de latentie te verhogen
Aron Heesakkers
Aron HeesakkersFullstack Developer & AI Engineer

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.


Project bespreken?

Heb je een use case waar je over twijfelt?

We praten graag over of een AI-aanpak past — en zeggen het eerlijk als het beter zonder kan. Vaste prijs, afgesproken scope.
Plan een gesprek
Probeer AI