CodeGym /Kurse /ChatGPT Apps /Strukturierte Logs und Korrelation von Anfragen

Strukturierte Logs und Korrelation von Anfragen

ChatGPT Apps
Level 17 , Lektion 0
Verfügbar

1. Warum Sie in der ChatGPT-App überhaupt strukturierte Logs brauchen

Stellen Sie sich vor, Ihr Product Owner schreibt: „Nutzer beschweren sich, dass bei der Geschenkeauswahl manchmal eine leere Liste angezeigt wird und manchmal der Checkout abstürzt. Können wir das bis zur Demo morgen reparieren?“ Sie haben:

  • ChatGPT, das manchmal Ihre App aufruft – und manchmal nicht.
  • Ein Widget in der Sandbox.
  • Einen MCP‑Server, der eine externe Produktdatenbank und ACP anfragt.
  • Webhooks vom Zahlungsanbieter.

Und nur verstreute Textlogs wie irgendwo beim MCP „something went wrong“ und irgendwo im Backend „order failed“. Bei parallelen Anfragen wird das zum Chaos: Es ist unmöglich zu verstehen, welcher Log zu welchem Nutzer und zu welcher Anfrage gehört.

Strukturierte JSON‑Logs und eine einheitliche trace_id sind genau dafür da, um:

  • mit einer ID die gesamte Kette zu sehen: von der ChatGPT‑Anfrage bis zum Webhook "order.created";
  • Logs nach Service, Tool, Nutzer, Szenario zu filtern;
  • schnell Fragen zu beantworten wie „warum ist der Checkout abgestürzt“ und „was hat der Agent getan, bevor er zu halluzinieren begann“.

Das Ziel ist also einfach: dafür sorgen, dass man den Production‑GiftGenius debuggen und überwachen kann – nicht schlechter als eine gewöhnliche Microservice‑Anwendung.

2. Stringbasierte vs. strukturierte Logs: warum console.log("oops") nicht mehr funktioniert

In der üblichen Next.js‑Entwicklung begnügen sich viele mit stringbasierten Logs: Sie drucken eine menschenlesbare Phrase und manchmal ein paar Werte. In einem einzelnen Service ist das noch erträglich. Aber im Stack einer ChatGPT‑App verwandeln sich solche Logs sehr schnell in Brei.

Ein Textlog ist einfach eine einzelne Zeile in einer Datei oder Konsole. Zum Beispiel:


console.error(`Error in suggestGifts for user ${userId}: ${error.message}`);

Wenn es hunderttausend solcher Meldungen gibt, ist es schon nicht leicht, „alle MCP‑Fehler im Checkout mit userId=… von gestern“ zu finden. Und automatisch ein Dashboard über Tool‑Fehler zu bauen – fast unmöglich.

Ein strukturiertes Log ist ein JSON‑Objekt, in dem es neben dem Nachrichtentext eine Reihe von Feldern gibt: Level, Zeit, Service, IDs, technischer und geschäftlicher Kontext. Analog zum vorherigen:

logger.error({
    message: "suggest_gifts failed",
    user_id: userId,
    trace_id,
    service: "mcp",
    tool_name: "suggest_gifts",
    error_message: error.message,
});

Jedes Feld wird vom Log‑System (ELK, Loki, Better Stack, Datadog usw.) indiziert, und danach kann man Abfragen schreiben wie service="mcp" AND level="error" AND tool_name="suggest_gifts" oder einfach suchen nach trace_id="...".

Zur Veranschaulichung – eine kleine Tabelle.

Was wird verglichen Stringbasierte Logs Strukturierte (JSON) Logs
Parsing Manuell, per Regex Automatisch nach Feldern
Suche nach Feldern Komplexe Regexp‑Abfragen Einfache Ausdrücke field=value
Aggregationen und Dashboards Schwierig, viele Krücken Trivial: count() , group by field
Anreicherung mit Kontext Per Text in der Nachricht Durch neue Felder ohne Schemaänderung
Korrelations von Anfragen Bei parallelen Anfragen nahezu unmöglich Normale Suche nach trace_id/request_id

In der Welt der LLM‑Anwendungen, in der die Hälfte der Probleme nicht „HTTP 500“ ist, sondern „das Modell hat das falsche Tool aufgerufen“, sind Sie ohne strukturierte Logs buchstäblich blind.

3. Anatomie eines JSON‑Logs für die ChatGPT-App

Als Nächstes einigen wir uns auf einen „Minimalstandard“ für Log‑Einträge, den Sie in allen Schichten von GiftGenius verwenden. Er ist nicht perfekt, deckt aber 80% der Aufgaben ab.

Wir teilen die Log‑Felder in mehrere Gruppen.

Technische Felder

Technische Felder werden benötigt, damit Observability‑Tools verstehen, wo der Eintrag überhaupt herkommt.

Man kann sie als TypeScript‑Typ beschreiben:

type LogLevel = "debug" | "info" | "warn" | "error";

interface BaseLogFields {
    timestamp: string;    // ISO 8601 UTC
    level: LogLevel;      // "info", "error"...
    service: string;      // "app-widget", "mcp", "agent", "commerce", "webhook"
    env: "dev" | "staging" | "prod";
    message: string;      // Kurze Ereignisbeschreibung
}

timestamp schreibt man am besten im ISO‑Format UTC ("2025-11-21T10:15:30.123Z"), dann können verschiedene Services ohne Zeitzonen‑Turnübungen nach Zeit sortiert werden. service und env helfen, z. B. die Logs des Production‑MCP von den Logs des Widgets in dev zu trennen. Das ist besonders relevant, wenn Sie später OpenTelemetry nutzen und gemeinsame Konventionen wie service.name, service.version usw. einsetzen möchten.

Korrelationsfelder

Das ist das Wichtigste in dieser Vorlesung. Ohne diese Felder können Sie Ereignisse nicht miteinander verknüpfen.

Wir ergänzen unser Interface:

interface CorrelationFields {
    trace_id: string;        // End-to-End-ID des gesamten Szenarios
    span_id?: string;        // (optional) ID der konkreten Operation
    parent_span_id?: string; // (optional) Übergeordnete Operation
    request_id?: string;     // Lokale ID des HTTP-Requests oder Tool-Calls
    agent_run_id?: string;   // ID des Agent-Laufs (falls vorhanden)
    tool_call_id?: string;   // ID des Aufrufs eines konkreten Tools
    checkout_session_id?: string; // ID der ACP-/Zahlungssession
}

trace_id ist der Hauptdarsteller. Sie muss in allen Logs dieselbe sein, die zum Szenario „Der Nutzer bat um Geschenkempfehlung, wir haben empfohlen, eine Bestellung erstellt und einen Webhook erhalten“ gehören. span_id und parent_span_id erlauben später den Aufbau eines „Operationsbaums“ im Stil von Distributed Tracing, aber für den Anfang kann man sogar nur mit trace_id und request_id auskommen.

Geschäftskontext

Ein technisches Log ohne Geschäftskontext wird zu „irgendetwas ist irgendwo irgendwann passiert“. Wir müssen verstehen, welcher Nutzer und in welchem Schritt des Szenarios betroffen war.

Wir erweitern das Interface:

interface BusinessFields {
    user_id?: string;     // Anonyme ID, KEINE E-Mail
    tenant_id?: string;   // Organisation/Account, falls B2B
    flow?: string;        // Z. B. "gift_recommendation" oder "checkout"
    step?: string;        // Z. B. "collect_requirements" oder "create_checkout"
}

Das Prinzip ist sehr einfach: IDs können intern sein (UUID aus Ihrer DB), dürfen aber keine PII enthalten (E‑Mail, Telefonnummer, Name). Darauf gehen wir im Sicherheitsabschnitt noch ein.

Fehlerfelder

Fehler sind ein eigenes Thema. Einen typischen Fehler‑Log will man zumindest in Typ, Code und Text aufteilen:

interface ErrorFields {
    error_type?: "validation" | "upstream" | "timeout" | "system";
    error_code?: string;       // HTTP-Status, DB-Code oder eigener Enum
    error_message?: string;    // Kurz und sicher
    stack?: string;            // Stacktrace; vorsichtig bzgl. Größe und PII
}

Wichtig ist, dass error_message keine sensitiven Daten enthält (wie „failed for card 4111 1111 1111 1111“). Besser "payment provider declined card" und irgendein sicherer Code.

Vollständiges Log‑Interface

Wir setzen alles zusammen:

export interface LogEvent
    extends BaseLogFields,
        CorrelationFields,
        BusinessFields,
        ErrorFields {
    // Reserve für zusätzliche Felder
    [key: string]: unknown;
}

Ein solches Interface können Sie sowohl im MCP‑Server als auch im Commerce‑Backend und im Agent verwenden. Dann schreiben alle Services Logs im gleichen Format, und Korrelation wird zum Spaziergang statt zur Schatzsuche.

4. Ein einfachster JSON‑Logger für GiftGenius (MCP‑Server)

Beginnen wir mit etwas sehr Minimalistischem. Angenommen, Ihr MCP‑Server ist eine Node.js/TypeScript‑Anwendung. Wir erstellen die Utility logger:

// mcp/logging.ts
import { LogEvent, LogLevel } from "./types";

function log(level: LogLevel, event: Omit<LogEvent, "level" | "timestamp">) {
    const enriched: LogEvent = {
        timestamp: new Date().toISOString(),
        level,
        env: process.env.NODE_ENV === "production" ? "prod" : "dev",
        ...event,
    };

    // JSON auf stdout ausgeben — die Log-Plattform sammelt es ein
    console.log(JSON.stringify(enriched));
}

export const logger = {
    debug: (event: Omit<LogEvent, "level" | "timestamp">) =>
        log("debug", event),
    info: (event: Omit<LogEvent, "level" | "timestamp">) =>
        log("info", event),
    warn: (event: Omit<LogEvent, "level" | "timestamp">) =>
        log("warn", event),
    error: (event: Omit<LogEvent, "level" | "timestamp">) =>
        log("error", event),
};

Das ist weder Pino noch Winston, aber für den Kurs ist die Idee wichtig: Alles wird als JSON mit sinnvollen Feldern geschrieben.

Jetzt verwenden wir ihn im Handler des MCP‑Tools suggest_gifts.

5. Logging des MCP‑Tools: vom Eingang bis zum Ausgang

Angenommen, Sie haben bereits einen Handler für das Tool suggest_gifts, der Nutzerpräferenzen annimmt und eine Liste von SKUs zurückgibt. Fügen wir dort Logs hinzu.

Nehmen wir an, wir haben die trace_id vorab aus dem HTTP‑Header x-trace-id geholt (wie sie dort hineinkommt – klären wir im nächsten Block zur Korrelation).

// mcp/tools/suggestGifts.ts
import { logger } from "../logging";

export async function suggestGiftsTool(args: SuggestGiftsArgs, ctx: {
  traceId: string;
  userId?: string;
}) {
  logger.info({
    message: "suggest_gifts called",
    service: "mcp",
    trace_id: ctx.traceId,
    user_id: ctx.userId,
    tool_name: "suggest_gifts",
    flow: "gift_recommendation",
    step: "fetch_candidates",
  });

  try {
    const gifts = await fetchGiftsFromCatalog(args);

    logger.info({
      message: "suggest_gifts succeeded",
      service: "mcp",
      trace_id: ctx.traceId,
      user_id: ctx.userId,
      tool_name: "suggest_gifts",
      flow: "gift_recommendation",
      step: "rank_candidates",
      result_count: gifts.length,
    });

    return gifts;
  } catch (error: any) {
    logger.error({
      message: "suggest_gifts failed",
      service: "mcp",
      trace_id: ctx.traceId,
      user_id: ctx.userId,
      tool_name: "suggest_gifts",
      flow: "gift_recommendation",
      step: "fetch_candidates",
      error_type: "upstream",
      error_message: error.message,
    });
    throw error;
  }
}

Nun kann man anhand einer trace_id sehen:

  • dass das Tool überhaupt aufgerufen wurde;
  • wie viele Kandidaten gefunden wurden;
  • in welchem Schritt es fehlgeschlagen ist.

Dabei tauchen nirgends E‑Mail oder Nutzername auf – nur die interne user_id.

6. Wo die trace_id in der ChatGPT-App entsteht

Schauen wir uns an, wo die trace_id entstehen sollte. Wichtig: Sie ist nicht an eine konkrete Anfrage gebunden. Die trace_id ist die ID der Geschäftsoperation. Daher muss man zwei typische Situationen unterscheiden:

„Schmales“ MCP‑Tool

Das ist, wenn das Tool eine kompakte Operation ausführt und sofort ein Ergebnis zurückgibt (ohne interaktives UI):

  • get_gifts_for_budget
  • calculate_price
  • save_lead usw.

In diesem Fall ist es praktisch zu sagen: ein MCP‑Tool‑Aufruf = eine Geschäftsanforderung = ein Trace. Die End‑to‑End‑trace_id entsteht auf der MCP‑Gateway‑/MCP‑Server‑Seite beim Eintreffen des Tool‑Calls (oder wird aus einem bereits existierenden Tracing‑Kontext übernommen, wenn Sie OpenTelemetry verwenden). Danach wird diese trace_id in allen internen Aufrufen (REST‑Services, Datenbanken, Queues) verwendet und taucht als Feld trace_id in den Logs auf.

ChatGPT und das Apps SDK greifen hier nicht ein: Sie senden einfach den JSON‑RPC‑Tool‑Call, und das Tracing beginnt bei Ihnen im kontrollierten Bereich.

„Breites“ MCP‑Tool (liefert ein Widget zurück)

Hier beendet das Tool die Geschäftsoperation nicht vollständig, sondern startet eine interaktive Szene: Es liefert ein Widget zurück, das in der Sandbox Dutzende fetch()‑Anfragen ausführt (Geschenkeliste laden, Filter, Checkout usw.).

In so einem Szenario ist die End‑to‑End‑Tracierung anders aufgebaut:

  • die wesentlichen Geschäftsoperationen leben in den HTTP‑Anfragen des Widgets an das Backend;
  • daher erhält jeder bedeutende fetch() vom Widget an Ihr Backend seine eigene trace_id, die erst im Backend/Gateway entsteht (der erste serverseitige Hop für diesen fetch).

Weder ChatGPT noch das Widget selbst sind die „Single Source of Truth“ für die trace_id: Sie können in der Anfrage lediglich Hilfs‑IDs mitgeben (session_id, widget_id, user_id), aber Erstellung und Verwaltung der trace_id passieren auf dem Server.

„Schmales“ MCP‑Tool: ein Trace pro Tool‑Call

Schauen wir, wie der Flow für ein „schmales“ Tool ohne Widget aussieht:

sequenceDiagram
    participant ChatGPT as ChatGPT / Agent
    participant MCP as MCP Server
    participant GiftAPI as Gift API
    participant Pricing as Pricing API

    ChatGPT->>MCP: JSON-RPC tools.call get_gifts
    MCP->>MCP: start trace (trace_id = T-123)
    MCP->>GiftAPI: GET /gifts (x-trace-id = T-123)
    GiftAPI-->>MCP: 200 OK (trace_id = T-123)
    MCP->>Pricing: GET /price (x-trace-id = T-123)
    Pricing-->>MCP: 200 OK (trace_id = T-123)
    MCP-->>ChatGPT: tool result (optional mit trace_id)

Muster:

  • beim Eingang des Tool‑Calls im MCP erstellen Sie den Trace (oder übernehmen einen vorhandenen aus traceparent/x-trace-id);
  • der gesamte weitere Weg dieses Tool‑Calls (Service‑, DB‑, Cache‑Aufrufe) wird mit derselben trace_id geloggt;
  • im Log kommt kein Widget vor, weil es kein Widget gibt.

Dieser Ansatz liefert:

  • einen klaren „Snapshot“ einer Operation: „MCP‑Tool suggest_gifts → Gift API → Pricing API → Antwort“;
  • eine trace_id pro Tool‑Aufruf.

„Breites“ MCP‑Tool: Widget und mehrere Traces

Nun das GiftGenius‑Szenario, bei dem das MCP‑Tool ein Widget zurückgibt:

  1. ChatGPT ruft ein MCP‑Tool auf, z. B. open_gift_widget.
  2. Das MCP‑Tool erstellt die Widget‑Beschreibung (Layout, Initialzustand) und gibt sie zurück.
  3. Das Widget wird in der Sandbox gemountet und beginnt zu „leben“:
    • GET /api/gifts?budget=50&page=1
    • GET /api/gifts?budget=50&filter=for_developers
    • POST /api/checkout
    • POST /api/save-lead
  4. Jede solche HTTP‑Anfrage kommt in Ihrem Next.js‑Backend/Gateway an – und dort erzeugen Sie einen neuen Trace:
fetch #1  -> trace_id = T-501  (erste Seite der Geschenke laden)
fetch #2  -> trace_id = T-502  (Filter „für Entwickler“ anwenden)
fetch #3  -> trace_id = T-503  (Checkout erstellen)
...

Das heißt:

  • das MCP‑Tool ist „breit“: seine Hauptaufgabe ist es, das Widget zu öffnen, nicht die gesamte Business‑Kette zu erledigen;
  • die eigentliche Geschäftslogik (Geschenkeliste, Top‑Geschenk wählen, Checkout) lebt im Backend, das die fetch()‑Anfragen des Widgets verarbeitet;
  • die Gruppe von fetch()‑Anfragen, die durch ein Geschäftsszenario verbunden ist, hat ihre eigene trace_id, die Sie beim Eintreffen des HTTP‑Requests auf dem Server erzeugen.

Zusätzlich können Sie in jeden Trace einspeisen:

  • session_id (ChatGPT‑Sitzungs‑ID, falls vorhanden),
  • widget_id,
  • user_id,
  • tool_run_id oder jeden anderen Kontext.

Über die trace_id betrachten Sie die konkrete Operation („Checkout #3“), über session_id / widget_id – alles, was innerhalb eines Widgets/einer Sitzung passiert ist.

7. Korrelation von Anfragen: wie die trace_id durch App, MCP, Widget und Backend läuft

Jetzt zum spannendsten Teil: Wie erreicht man, dass die nötigen IDs alle Schichten durchlaufen: ChatGPT, MCP‑Server, Widget, Commerce‑Backend und Webhooks.

Request‑Flow mit trace_id (Diagramm des „breiten“ Falls)

Eine kleine Skizze, wie das für GiftGenius aussieht:

sequenceDiagram
    participant ChatGPT as ChatGPT UI
    participant MCP as MCP Server
    participant Widget as GiftGenius Widget
    participant Backend as Next.js Backend
    participant ACP as Commerce API
    participant WH as Webhook Handler

    ChatGPT->>MCP: tools.call open_gift_widget
    MCP-->>ChatGPT: Widget description (layout, config)
    ChatGPT->>Widget: Widget-Rendering in der Sandbox

    Widget->>Backend: GET /api/gifts (trace_id = T-501, entsteht im Backend)
    Backend->>ACP: GET /gifts (x-trace-id = T-501)
    ACP-->>Backend: 200 OK (trace_id = T-501)
    Backend-->>Widget: JSON mit Geschenken (trace_id = T-501 in Logs)

    Widget->>Backend: POST /api/checkout (trace_id = T-503, entsteht im Backend)
    Backend->>ACP: POST /checkout (x-trace-id = T-503)
    ACP-->>Backend: 200 OK (trace_id = T-503)
    ACP-->>WH: webhook order.created (x-trace-id = T-503)
    WH->>WH: Loggt Ereignis (trace_id = T-503)

Beachten Sie:

  • in diesem Schema wird die trace_id nicht im Widget generiert;
  • sie entsteht am Eintrittspunkt des HTTP‑Requests in Ihr Backend (Next.js Route Handler, API‑Gateway usw.);
  • danach wird diese trace_id durchgereicht:
    • in die Backend‑Logs,
    • in den Header x-trace-id beim Aufruf von ACP,
    • in die Webhooks, sofern ACP sie zurückgibt/weiterreicht.

6.5. trace_id im Backend für Widget‑Aufrufe generieren und durchreichen

Wir schreiben das Beispiel so um, dass klar ist: Die trace_id entsteht im Backend und nicht im Widget.

// app/api/mcp/tools/call/route.ts (Next.js backend, Proxy zum MCP)
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@/mcp/logging";

export async function POST(req: NextRequest) {
  // Falls eine trace_id aus der Außenwelt kommt (z. B. vom Gateway) — verwenden wir sie.
  // Falls nicht — generieren wir beim Eintritt ins Backend eine neue.
  const incomingTraceId = req.headers.get("x-trace-id");
  const traceId = incomingTraceId ?? uuidv4();
  const requestId = uuidv4();

  logger.info({
    message: "mcp.tools.call received from widget",
    service: "backend",
    trace_id: traceId,
    request_id: requestId,
  });

  const body = await req.json();

  const res = await fetch(process.env.MCP_SERVER_URL!, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-trace-id": traceId,
    },
    body: JSON.stringify(body),
  });

  const json = await res.json();

  logger.info({
    message: "mcp.tools.call completed",
    service: "backend",
    trace_id: traceId,
    request_id: requestId,
  });

  return NextResponse.json(json);
}

Auf der MCP‑Server‑Seite lesen wir diesen Header einfach aus und verwenden die trace_id in unseren Logs (wie in den Beispielen aus Abschnitt 5).

Das Widget muss dabei nicht einmal wissen, dass es eine trace_id gibt – es genügt, /api/mcp/tools/call aufzurufen. Wenn Sie jedoch UI‑Aktionen im Zusammenhang mit der Tracierung anzeigen oder loggen möchten, können Sie die trace_id in der Antwort zurückgeben und z. B. service: "app-widget" in die eigenen JSON‑Logs schreiben (clientseitig oder via SaaS‑Analytics).

Beispiel für einen MCP‑Aufruf aus dem Widget

// app/lib/mcpClient.ts (Widget)
export async function callMcpTool(toolName: string, args: unknown) {
  const res = await fetch("/api/mcp/tools/call", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // trace_id wird HIER NICHT generiert — sie entsteht im Backend
    },
    body: JSON.stringify({ toolName, args }),
  });

  // Wenn das Backend die trace_id im Body zurückgibt, kann man sie speichern:
  const data = await res.json();
  return data;
}

Wenn Sie möchten, können Sie den Backend‑Handler erweitern, sodass er die trace_id in die JSON‑Antwort aufnimmt; dann kann das Widget:

  • Ereignisse loggen vom Typ "service": "app-widget", "trace_id": "...",
  • Trace‑Links für Entwickler anzeigen.

Das Prinzip bleibt dasselbe: Quelle der trace_id ist der Server, nicht das Widget.

trace_id weiter an ACP/Commerce durchreichen

Nun rufen wir innerhalb des MCP‑Tools create_checkout_session Ihr Commerce‑API auf und tragen die trace_id weiterhin in die Header:

// mcp/tools/createCheckout.ts
import { logger } from "../logging";

export async function createCheckoutTool(
  args: CreateCheckoutArgs,
  ctx: { traceId: string; userId?: string }
) {
  logger.info({
    message: "create_checkout called",
    service: "mcp",
    trace_id: ctx.traceId,
    user_id: ctx.userId,
    tool_name: "create_checkout_session",
    flow: "checkout",
    step: "create_session",
  });

  const res = await fetch(process.env.COMMERCE_URL + "/checkout", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-trace-id": ctx.traceId,
    },
    body: JSON.stringify({
      userId: ctx.userId,
      ...args,
    }),
  });

  if (!res.ok) {
    logger.error({
      message: "checkout API failed",
      service: "mcp",
      trace_id: ctx.traceId,
      user_id: ctx.userId,
      flow: "checkout",
      step: "create_session",
      error_type: "upstream",
      error_code: String(res.status),
    });
    throw new Error("Checkout API failed");
  }

  const data = await res.json();

  logger.info({
    message: "checkout session created",
    service: "mcp",
    trace_id: ctx.traceId,
    user_id: ctx.userId,
    flow: "checkout",
    step: "create_session",
    checkout_session_id: data.sessionId,
  });

  return data;
}

Das Commerce‑Backend liest seinerseits die x-trace-id und schreibt sie in seine JSON‑Logs. So sehen Sie mit einer trace_id:

  • den eingehenden HTTP‑Request vom Widget ins Backend (wo der Trace entstanden ist);
  • das Proxying zum MCP (falls vorhanden);
  • den internen Aufruf von create_checkout_session;
  • die Anfrage an das Commerce‑API;
  • die Antwort des Commerce‑Backends;
  • und – falls ebenfalls durchgereicht – den Webhook order.created.

8. Log‑Level: DEBUG, INFO, WARN, ERROR im Kontext einer LLM‑Anwendung

Log‑Level helfen, nicht in Informationen zu ertrinken. In der ChatGPT‑App ist folgende Interpretation praktisch:

  • DEBUG – detaillierte technische Informationen, nützlich in dev/staging. Zum Beispiel gekürzte Prompts, Zwischenzustände des Agents, „rohe“ Antworten externer APIs (ohne PII). In Produktion sehr vorsichtig einsetzen.
  • INFO – normale Geschäftsereignisse: „suggest_gifts succeeded, 10 Kandidaten“, „checkout session created“, „webhook order.created processed“. Diese Logs können in Produktion aktiv bleiben.
  • WARN – etwas lief unüblich, aber das System hat weiter gearbeitet. Zum Beispiel: „fallback to cached catalog because upstream timeout“, „model returned invalid tool args, retry with different schema“.
  • ERROR – klarer Fehlschlag: Das Szenario endete nicht wie vorgesehen. Zum Beispiel: „checkout API failed“, „failed to persist order“, „tool crashed with unhandled exception“.

Zur Bequemlichkeit kann man einen einfachen Helper hinzufügen, damit man keine Strings per Hand schreiben muss:

type LogLevel = "debug" | "info" | "warn" | "error";

function isProd() {
  return process.env.NODE_ENV === "production";
}

export function shouldLogLevel(level: LogLevel): boolean {
  if (isProd()) {
    return level === "info" || level === "warn" || level === "error";
  }
  return true; // in dev alles einschalten
}

Und logger.debug nur aufrufen, wenn shouldLogLevel("debug") true zurückgibt.

Besonders gefährlich in Produktion sind DEBUG‑Logs mit vollständigem Prompt und Modellantwort: Dort können leicht Passwörter, Schlüssel, jegliche PII landen, die der Nutzer versehentlich in den Chat eingefügt hat.

9. Log‑Sicherheit: PII‑Scrub und Secrets

Mit Logs kann man leicht übertreiben. Wenn Sie „alles“ schreiben, dann:

  • verstoßen Sie gegen Datenschutzgesetze;
  • erleichtern Sie Angreifern das Leben (Secrets und Tokens können einfach aus Logs gezogen werden);
  • haben Sie selbst Angst, wem Sie Zugriff auf das Log‑System geben.

Daher gilt das einfache Prinzip: In Logs steht genug, um zu verstehen, was passiert ist, aber nicht genug, um Daten zu stehlen.

Gute Praktiken:

  1. Wir loggen user_id, nicht E‑Mail oder Telefonnummer. Wenn Sie E‑Mail für das Debugging unbedingt brauchen, loggen Sie den Hash oder maskieren sie ("a***@gmail.com").
  2. Niemals vollständige Tokens ("sk-..."), Refresh‑Tokens, client_secret, Passwörter loggen. Wenn es unbedingt sein muss – nur die ersten/letzten 4 Zeichen und den Typ („sk-***1234“).
  3. Vorsicht mit tool_input und tool_output. Darin kann alles stehen, was der Nutzer geschrieben hat. In Produktion entweder nicht vollständig loggen oder:
    • nur typisierte Felder loggen, die bereits validiert wurden;
    • auf eine sinnvolle Größe kürzen und Scrub anwenden – Maskierung per Regex (E‑Mail, Kartennummern usw.).

Ein einfachstes Sanitizer‑Beispiel (stark vereinfacht):

export function sanitize(text: string): string {
  return text
    .replace(/sk-[a-zA-Z0-9]{20,}/g, "sk-***redacted***")
    .replace(/\b\d{16}\b/g, "****-****-****-****"); // Karten
}

Und beim Loggen der Nutzereingabe:

logger.debug({
  message: "raw_user_message",
  service: "app-widget",
  trace_id,
  user_id,
  raw: sanitize(userMessage),
});

Dieser Code ist weit vom Produktionsniveau entfernt, zeigt aber die Idee gut: erst säubern, dann loggen.

10. Praxis: Ereignis gift_recommended für GiftGenius

Jetzt die Übung: Wir entwerfen das Log‑Ereignis gift_recommended, das geschrieben wird, wenn GiftGenius endgültig das „Top‑Geschenk“ für den Nutzer auswählt.

Das Ereignis soll Antworten liefern auf die Fragen:

  • welcher Nutzer (interne ID);
  • welches Geschenk (SKU);
  • nach welchem Szenario und in welchem Schritt;
  • welche trace_id, um es mit den übrigen Logs zu verknüpfen.

Und gleichzeitig darf es keine PII oder Secrets enthalten.

Beispiel:

{
  "timestamp": "2025-11-21T10:22:33.456Z",
  "level": "info",
  "service": "agent",
  "env": "prod",
  "message": "gift_recommended",
  "trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
  "agent_run_id": "run_7f1d2c",
  "user_id": "u_123456",
  "flow": "gift_recommendation",
  "step": "final_choice",
  "recommended_sku": "SKU-SPACE-MUG-001",
  "price_cents": 2499,
  "currency": "USD",
  "reason_summary": "recipient_likes_space_and_practical_gadgets"
}

Worauf es hier ankommt:

  • Wir loggen die user_id, aber nicht E‑Mail und nicht den Namen;
  • SKU und Preis sind normale Geschäftsdaten und gelten nicht als PII;
  • reason_summary ist ein kurzer technischer Tag, kein vollständiger Nutzersatz;
  • es gibt trace_id und agent_run_id, um sehen zu können, welche Tools der Agent auf dem Weg zu dieser Auswahl aufgerufen hat.

Was man auf keinen Fall loggen sollte:

  • den vollständigen Antworttext des Modells mit „menschlicher“ Erklärung;
  • den Prompt des Nutzers („Ich brauche ein Geschenk für eine Kollegin, sie heißt …, hat die Telefonnummer …, Adresse …“);
  • irgendwelche Zahlungsdaten.

11. Log‑Beispiele: erfolgreicher Tool‑Call und ACP‑Fehler

Zur Festigung – zwei kleine JSON‑Beispiele.

Erfolgreicher tools.call auf dem MCP

{
  "timestamp": "2025-11-21T10:20:00.000Z",
  "level": "info",
  "service": "mcp",
  "env": "prod",
  "message": "tools.call completed",
  "trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
  "request_id": "req_01JCQ5CZ0YQ6TM7E5W8H3N3F2Y",
  "tool_name": "suggest_gifts",
  "user_id": "u_123456",
  "flow": "gift_recommendation",
  "step": "rank_candidates",
  "result_count": 12,
  "latency_ms": 430
}

Aus einem einzigen solchen Log sieht man bereits:

  • w welches Tool;
  • für welchen Nutzer;
  • in welchem Szenario;
  • wie lange es dauerte und wie viele Kandidaten zurückgegeben wurden.

Über die trace_id finden Sie leicht die UI‑ und Agent‑Logs, die zur selben Anfrage gehören.

ACP/Checkout‑Fehler

{
  "timestamp": "2025-11-21T10:21:05.789Z",
  "level": "error",
  "service": "commerce",
  "env": "prod",
  "message": "checkout failed",
  "trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
  "checkout_session_id": "cs_test_9YpQvJH8",
  "user_id": "u_123456",
  "flow": "checkout",
  "step": "charge_customer",
  "error_type": "upstream",
  "error_code": "PAYMENT_DECLINED",
  "error_message": "payment provider declined card",
  "provider": "stripe",
  "amount_cents": 2499,
  "currency": "USD"
}

Wieder keine Kartennummer – nur der Fehlercode und eine sichere Nachricht. Und erneut dieselbe trace_id, sodass Sie dieses Log mit gift_recommended verknüpfen und erkennen können, an welcher Stelle die Kette gebrochen ist.

12. Wie man verhindert, dass Logs zu Müll werden

Es ist sehr verlockend: „Wenn wir schon alles schön loggen können, dann loggen wir absolut alles.“ So erhalten Sie schnell Gigabytes an JSON‑Rauschen, in dem sich nützliche Ereignisse verlieren.

Einige praktische Tipps:

  • Duplizierende Logs „Ich bin in Funktion X eingetreten“ ohne zusätzliche Informationen sind wenig hilfreich. Loggen Sie lieber bedeutende Ereignisse: Start/Ende eines Szenarios, Aufruf externer APIs, Schrittwechsel im Workflow, Fehler.
  • Für häufige Operationen (z. B. Anfragen an den Produktkatalog) kann man Sampling aktivieren: jeden N‑ten Request vollständig loggen, die übrigen – nur bei Fehlern.
  • In Produktion DEBUG ausgeschaltet lassen (oder sehr selektiv). Wenn Prompt/Antwort geloggt werden müssen – dann begrenzt und mit Scrub.

Über Metriken und SLO sprechen wir in der nächsten Vorlesung separat, aber es ist schon jetzt wichtig: Logs sind nicht nur „zum Debuggen“, sie sind das Fundament der Observability des gesamten ChatGPT‑Stacks.

Erinnern Sie sich an den Product Owner vom Anfang mit der „leeren Liste“ und dem abstürzenden Checkout? Mit dem beschriebenen Log‑Schema hätten Sie in ein paar Minuten alle Anfragen mit der passenden trace_id gefunden, suggest_gifts angesehen (wie viele Kandidaten das Tool zurückgab, in welchem Schritt es fiel) und die Logs von "checkout failed" mit error_code vom Zahlungsanbieter. Das ist kein „Ermitteln im Log‑Brei“ mehr, sondern ein verständliches Szenario „von der Anfrage bis zum Webhook“.

Am Ende ist ein guter Logging‑Stack für die ChatGPT‑App nicht „wir schreiben irgendwas nach stdout“, sondern:

  • korrekte Entstehungsorte der trace_id (im MCP‑Gateway/Server für „schmale“ Tools und am Backend‑Eingang für die fetch()‑Aufrufe des Widgets in „breiten“ Szenarien);
  • eine einheitliche trace_id durch App → MCP → Commerce → Webhooks für jeden sinnvollen Geschäftsaufruf;
  • ein gemeinsames JSON‑Log‑Schema (service, env, user_id, flow, step, tool_name usw.);
  • sorgsamer Umgang mit PII und Secrets (Scrub, Maskierung, eingeschränktes DEBUG in Produktion);
  • sinnvolle Log‑Level und kein Lärm.

Mit dieser Basis werden alle anderen Observability‑Werkzeuge (Metriken, SLO, Alerts) deutlich nützlicher und helfen nicht nur „Logs zu sammeln“, sondern die Qualität und Stabilität Ihrer ChatGPT‑App tatsächlich zu steuern.

13. Typische Fehler im Umgang mit strukturierten Logs und Korrelation

Fehler Nr. 1: Es gibt keine einheitliche trace_id über alle Services hinweg.
Der Klassiker: Das MCP‑Gateway erzeugt eine ID, das Commerce‑Backend eine zweite, die Webhooks wissen gar nichts über Korrelation, und in den Widget‑Logs kommt die trace_id nicht vor. Ergebnis: Korrelation wird zur manuellen Suche „naja, die Zeiten scheinen zu passen“. Richtig ist: die trace_id an kontrollierten Eintrittspunkten erzeugen (MCP‑Server für „schmale“ Tools, Backend/Gateway – für fetch() aus dem Widget) und über alle Grenzen durchreichen: HTTP‑Header, JSON‑Felder, Agent‑Kontext.

Fehler Nr. 2: Versuch, die trace_id im Widget zu generieren und als „Wahrheit“ zu betrachten.
Es wirkt manchmal logisch: „Lass uns direkt im React‑Widget crypto.randomUUID() machen und sie in Header setzen.“ Problem: Dann lebt die trace_id auf dem Client und kann mit dem realen Server‑Tracing (OpenTelemetry, Gateway, andere Services) nicht übereinstimmen. Weit zuverlässiger ist es, wenn die trace_id dort entsteht, wo Sie den gesamten Server‑Pfad kontrollieren: im Next.js‑Backend, API‑Gateway oder MCP‑Server. Das Widget kann diese ID bei Bedarf nur lesen und loggen.

Fehler Nr. 3: PII und Secrets „der Bequemlichkeit halber“ loggen.
Zu Beginn der Entwicklung ist es „sehr bequem“, einfach den gesamten Prompt, Tokens, Kartennummern und E‑Mails zu loggen. Nach ein paar Monaten wird das zur tickenden Zeitbombe: Der Zugang zu Logs wird toxisch, das Security‑Audit stellt unangenehme Fragen, und Sie haben Angst, sogar einen Fehler‑Screenshot zu zeigen. Führen Sie Scrub von Anfang an ein und loggen Sie nicht, was Sie morgen panisch löschen müssten.

Fehler Nr. 4: Stringlogs ohne Struktur in einer der Schichten.
Manchmal macht das Team tolle JSON‑Logs im MCP und Commerce, lässt im Widget aber console.log("step 1", data). Dadurch bleiben Anfang und Ende der Kette getrennt.

Fehler Nr. 5: Missbrauch des Levels ERROR.
Wenn jede unwesentliche Abweichung (etwa „das Modell lieferte 0 Kandidaten, wir zeigen einen Fallback“) als ERROR geloggt wird, stehen die Production‑Alerts ständig auf Rot. Das Team reagiert bald gar nicht mehr. Trennen Sie ehrlich: „WARN – ungewöhnlich, aber wir haben es abgefangen; ERROR – das Nutzerszenario ist wirklich kaputt“.

Fehler Nr. 6: Uneinheitliche Log‑Schemata zwischen Services.
Wenn in einem Service das Feld traceId heißt, im zweiten correlation_id und im dritten requestId, hilft kein Log‑System. Wichtig ist, sich auf ein einheitliches Schema zu einigen (so wie wir es mit LogEvent getan haben) und es in allen Komponenten einzuhalten: App‑Widget, MCP‑Server, Agents, ACP, Webhooks. Dann werden End‑to‑End‑Dashboards und Incident‑Analysen zur Sache von Minuten, nicht Tagen.

Fehler Nr. 7: Versuch, die Log‑Größe zu „optimieren“, indem Schlüssel‑Felder entfernt werden.
Manchmal entscheidet jemand aus Sparsamkeit: „Lass uns user_id oder flow entfernen, das ist ja nur Kleinkram.“ Plötzlich soll man beantworten, „bei welchen Nutzern der Checkout am häufigsten scheitert?“ – und die Information fehlt. Falls Sie entscheiden müssen, was rausfliegt, dann lange Text‑Payloads (Request/Response‑Bodies) und Debug‑Felder, nicht aber IDs und zentrale Kontextattribute.

Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION