n4nAI

Building a structured logger for streaming LLM responses

Learn to build a structured logger for streaming responses from LLM APIs. Step-by-step Python tutorial with runnable code and sample JSON output.

n4n Team3 min read705 words

Audio narration

Coming soon — every post will get a voice note here.

Streaming LLM outputs break traditional logging. A structured logger for streaming responses lets you capture token deltas, latency, and errors as discrete events instead of dumping raw text into stdout. This tutorial builds a minimal, production-minded version in Python that works with any OpenAI-compatible chat completions stream.

Prerequisites

  • Python 3.11 or newer
  • openai Python package v1.0+ (pip install openai)
  • An API key and base URL for an OpenAI-compatible endpoint (OpenAI, a self-hosted vLLM server, or a gateway)
  • Familiarity with context managers and generator iteration

No external logging framework is required; we write JSON Lines to a file or stderr.

Why naive logging fails for streams

Printing each token as it arrives produces interleaved, unparseable output when multiple requests run concurrently. You lose request identity, cannot compute time-to-first-token (TTFT), and error handling becomes guesswork. A structured logger for streaming responses emits one JSON object per event, tagged with a request ID and event type, so you can grep, ingest to ClickHouse, or replay later without custom parsers.

The core problem is that streaming inverts the normal request/response cycle. Instead of one log line per call, you get N lines for N tokens plus lifecycle events. If you treat them as plain strings, you cannot answer basic questions: which provider served the request? How many tokens were cached? Did the stream fail after 200 tokens?

Designing the event schema

We use four event types: start, token, stop, error. Every event shares a base set of fields:

{
  "ts": "2024-05-12T18:22:01.123Z",
  "request_id": "req_01H9...",
  "model": "gpt-4o-mini",
  "event": "token",
  "data": { "text": "Hello", "tokens": 1 }
}

ts is UTC ISO-8601 with milliseconds. request_id is a client-generated UUID. data carries event-specific payload. Keeping the schema append-only (never rename event to type later) avoids breaking downstream consumers.

Building the logger class

Create stream_logger.py. The class wraps the OpenAI client and yields tokens while logging each event.

import json
import sys
import time
import uuid
from datetime import datetime, timezone

class StreamLogger:
    def __init__(self, client, out=None, model="gpt-4o-mini"):
        self.client = client
        self.out = out or sys.stdout
        self.model = model
        self.request_id = None

    def _emit(self, event, data):
        rec = {
            "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
            "request_id": self.request_id,
            "model": self.model,
            "event": event,
            "data": data,
        }
        self.out.write(json.dumps(rec) + "\n")
        self.out.flush()

    def chat(self, messages, **kwargs):
        self.request_id = uuid.uuid4().hex
        start = time.monotonic()
        self._emit("start", {"messages": messages})
        first_token_ts = None
        try:
            stream = self.client.chat.completions.create(
                model=self.model,
                messages=messages,
                stream=True,
                stream_options={"include_usage": True},
                **kwargs,
            )
            full_text = ""
            for chunk in stream:
                if not chunk.choices:
                    continue
                delta = chunk.choices[0].delta
                text = delta.content or ""
                if text:
                    if first_token_ts is None:
                        first_token_ts = time.monotonic()
                    full_text += text
                    self._emit("token", {"text": text})
            usage = getattr(chunk, "usage", None)
            self._emit("stop", {
                "full_text": full_text,
                "ttft_ms": int((first_token_ts - start) * 1000) if first_token_ts else None,
                "total_ms": int((time.monotonic() - start) * 1000),
                "usage": usage.model_dump() if usage else None,
            })
            return full_text
        except Exception as e:
            self._emit("error", {"error": str(e), "type": type(e).__name__})
            raise

The stream_options={"include_usage": True} flag asks the provider to send a final chunk with token counts. We compute TTFT from the first emitted token, not the stop event.

Wiring the client

import sys
from openai import OpenAI
from stream_logger import StreamLogger

client = OpenAI(api_key="sk-...", base_url="https://api.openai.com/v1")
logger = StreamLogger(client, out=sys.stderr, model="gpt-4o-mini")

text = logger.chat([
    {"role": "user", "content": "Explain streaming logging in one sentence."}
])

Run it. The function returns concatenated text; stderr receives JSON Lines.

Expected output at checkpoint

For a short response, stderr shows:

{"ts":"2024-05-12T18:22:01.123Z","request_id":"req_01h9...","model":"gpt-4o-mini","event":"start","data":{"messages":[{"role":"user","content":"Explain streaming logging in one sentence."}]}}
{"ts":"2024-05-12T18:22:01.450Z","request_id":"req_01h9...","model":"gpt-4o-mini","event":"token","data":{"text":"Streaming"}}
{"ts":"2024-05-12T18:22:01.502Z","request_id":"req_01h9...","model":"gpt-4o-mini","event":"token","data":{"text":" logging"}}
{"ts":"2024-05-12T18:22:01.980Z","request_id":"req_01h9...","model":"gpt-4o-mini","event":"stop","data":{"full_text":"Streaming logging captures token events.","ttft_ms":327,"total_ms":857,"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}}

The usage object appears only if the provider supports include_usage. A structured logger for streaming responses should treat usage as optional and never assume its shape.

Testing without an API key

Use unittest.mock to simulate a stream and validate the logger offline:

class FakeChunk:
    def __init__(self, text, usage=None):
        self.choices = [type("C", (), {"delta": type("D", (), {"content": text})()})]
        self.usage = usage

class FakeStream:
    def __init__(self, chunks):
        self._c = chunks
    def __iter__(self):
        return iter(self._c)

class FakeCompletions:
    def create(self, **kwargs):
        return FakeStream([
            FakeChunk("Hello "),
            FakeChunk("world"),
            FakeChunk(None, usage=type("U", (), {"model_dump": lambda self: {"total_tokens": 2}})()),
        ])

class FakeClient:
    chat = type("Chat", (), {"completions": FakeCompletions()})()

logger = StreamLogger(FakeClient(), out=sys.stdout)
logger.chat([{"role": "user", "content": "test"}])

This prints start, two token events, and stop with mocked usage. Drop it into a pytest suite to catch schema regressions.

Handling provider fallback and routing

If you point the client at a gateway that fronts multiple providers, the model string may not tell you who served the token. Some gateways add response headers like x-provider or x-routed-to. Capture them from the underlying HTTP response if your SDK exposes it. For example, n4n.ai offers automatic fallback when a provider is rate-limited or degraded, and honors client routing directives; log the resolved provider by reading stream.response.headers (OpenAI v1 exposes response on the stream object in some versions, or use client.with_streaming_response). When fallback occurs before the stream starts, the header tells you the winner.

# After creating stream, if available:
resp = getattr(stream, "response", None)
if resp and "x-provider" in resp.headers:
    self._emit("start", {"provider": resp.headers["x-provider"]})

If the header is absent, fall back to logging the requested model only. Never block the stream waiting for headers that may not exist.

Error and retry visibility

Streaming errors often surface as APIConnectionError or RateLimitError mid-iteration. The except block in StreamLogger.chat emits an error event with the exception type. Pipe these to alerting. Because the logger does not swallow exceptions, callers can implement retry logic; the request ID stays constant across retries if you move ID generation outside, letting you correlate attempts.

def chat_with_retry(logger, messages, tries=3):
    for i in range(tries):
        try:
            return logger.chat(messages, request_id=logger.request_id)
        except Exception as e:
            if i == tries - 1:
                raise

Refactor chat to accept an optional request_id parameter so retries share correlation.

Async variant

The same pattern works with AsyncOpenAI. Replace time.monotonic with asyncio.get_event_loop().time() and use async for. Emit via synchronous write to stdout; under uvloop this is safe for moderate volumes.

async def chat_async(self, messages):
    self.request_id = uuid.uuid4().hex
    self._emit("start", {"messages": messages})
    stream = await self.client.chat.completions.create(model=self.model, messages=messages, stream=True)
    async for chunk in stream:
        # same delta handling as sync version

Shipping the logs

JSON Lines is natively ingested by Vector, Fluent Bit, or jq for debugging:

python app.py 2>stream.log
jq 'select(.event=="error")' stream.log

A structured logger for streaming responses turns opaque token firehoses into queryable timelines. You now have TTFT, per-request text, and error context without custom parsers.

Extending the schema

Add fields as needed: temperature, finish_reason, cached_tokens (if the provider returns cache-control hints). If your gateway forwards provider cache-control hints, log prompt_tokens_details.cached_tokens from usage to track cache hit rates. Keep the schema append-only; never rename fields in place. With this foundation, you can add sampling, redaction of PII in messages, or per-tenant routing metadata.

Tagsstructured-loggingstreamingllm-apistutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All structured logging for llm apis posts →