AI AGENT OBSERVABILITY · SIX SOURCES, ONE SPAN SHAPEBORROWClaudeTRUSTCursorINVENTCopilotINVENTCodexINVENTGeminiINVENTLangChainNORMALIZEDone span shapeQUEUEKafkapartitioned by tenantASYNC WRITERSconsumersflush · 5s or 100 eventsRECONSTRUCTED CONVERSATION · QUERY TIMEmsg_01H8…promptborrowed idappend-onlymsg_01H8…tool_callsame sessionappend-onlymsg_01H9…responseordered by seqappend-onlyaryan.devai-infrastructure · observability
← Back/obervabilityService
ai-infrastructureobservability

Six AI Agents, Three Ways to Find a Message ID

July 12, 2026·5 min read

Our customers stopped asking whether we could capture AI traffic and started asking what was in it. Which employees used which agents, what the conversations were, what each interaction cost.

We already supported six agents (Claude, Cursor, Copilot, Codex, Gemini, LangChain) with installers hooked into each, capturing every request and response and shipping it to our backend. Capture wasn't the problem.

Every request was stored as an independent event. No sessions, no message ordering, no tool-call spans. And to hold storage down we retained only the latest ten samples per user, so a customer trying to understand how their team used AI was looking at ten disconnected fragments. The job was turning independent requests into conversations you could search and walk through.

Six agents don't agree on what a message is

The assumption to break first is that there's a standard representation of a conversation. There isn't. Some agents hand you a stable message identifier on every event. Some expose only a session id. Some expose nothing you can build on.

Everything downstream needed the same normalized record regardless of source: session ID, conversation ID, message ID, timestamp, user, span.

Supporting any single agent was easy. The engineering problem was making sure the seventh integration would be another adapter rather than another redesign.

Across six agents, identity resolution collapsed into three strategies. The question was never "what's the message ID," it was "how would we even find one for this agent."

Trust it

Cursor was the easy case. Every hook invocation already carried a stable identifier for the current turn, so we propagated it.

Borrow it

Claude exposes no message identifier through its hooks, but it maintains its own local transcript. We read the latest entry from that transcript and reuse its identifier for the current event.

This is the decision in the system I'm least comfortable with, and I'd still make it again. That transcript is an internal file, not an interface. Nobody promised us its format, its location, or that the last entry corresponds to the event our hook just fired on. It works, and it can break on any Claude update without warning, which makes it a maintenance liability we accepted deliberately rather than a clever trick. The alternative was putting Claude in the "invent it" bucket below and losing the ability to correlate our events with Claude's own record of the same conversation, which was worth more than the stability we gave up.

Invent it

Copilot and Gemini were hardest: a stable session identifier, nothing identifying an individual message, and no secondary source to borrow from. Codex and LangChain landed in the same bucket, so all four fall back to generating our own message identifiers from a per-session counter.

That sounds trivial until you remember every hook invocation is stateless. Each event arrives with no memory of previous requests, so the counter can't live in process memory. It has to be persisted and recovered between invocations or numbering silently drifts, and silently is the operative word: a broken counter produces plausible-looking message ordering that's wrong, which is worse than an error.

What made this reusable was hiding all three strategies behind one abstraction. Storage, search and the dashboard never learn whether an identifier came from the agent, from a transcript, or from us. They consume one event shape.

Batching fixed one bottleneck and created another

Every incoming event originally triggered a direct write to Elasticsearch.

The first optimization was obvious: each tenant's aggregator accumulates events in memory and flushes every five seconds or every hundred events, whichever comes first. That cut network overhead substantially.

It also moved the problem rather than solving it. Instead of many small writes, every tenant was now issuing concurrent bulk writes against the same cluster. Under sustained load Elasticsearch couldn't drain them fast enough and the shared write service started exhausting resources. We'd traded a request-volume problem for a concurrency problem, and the second one was worse, because a saturated write path fails in a way that loses data while a chatty one merely wastes bandwidth.

Kafka went in between:

Aggregator
    │
    ▼
Kafka (partitioned by tenant)
    │
    ▼
Consumers
    │
    ▼
Elasticsearch

Partitioning by tenant preserves ordering within a tenant's stream while absorbing bursts. Consumers write into Elasticsearch at a rate the cluster sustains, so peaks buffer instead of dropping.

Worth being precise about what this bought, because "we added Kafka" is not an explanation. Kafka didn't make the cluster faster. It made the ingest path's failure mode a queue depth rather than a dropped write, which is the tradeoff we wanted: we chose availability with eventual consistency, so a customer seeing their data thirty seconds late is fine and a customer not seeing it at all is not.

Elasticsearch over ClickHouse

ClickHouse looked like the obvious choice at first. We were ingesting something that resembled logs.

But customers weren't asking "show me every request from yesterday." They wanted to search for a specific prompt, find every conversation for a user, filter sessions across several fields, and walk an interaction start to finish.

That's a search workload, not an analytical one. ClickHouse is excellent at scanning a column across millions of rows and it isn't what you want for "find the session containing this phrase." Elasticsearch won on the shape of the question, not on ingest characteristics.

The next choice was granularity, and this one is a genuine fork.

We store one document per span. Every LLM invocation and every tool call is its own document, and there is no session document anywhere in the index. A session exists only implicitly, as the set of spans sharing a session identifier.

The alternative is a session-level document that accumulates token counts, models, users and metadata as spans arrive. Reads get very cheap. Every write then needs merge logic, read-before-write semantics, and concurrency handling on a document that multiple spans are racing to update.

We kept writes append-only and pushed that complexity into the query layer. I'd make the same call again for one reason: an append-only write path has no correctness bugs available to it. A merge-on-write path has several, and they'd surface as quietly wrong token counts rather than as errors.

Reconstructing conversations at query time

Since sessions aren't stored, the dashboard rebuilds them.

The session list groups spans with a terms aggregation on the session identifier, and nested aggregations compute token counts, models used and span counts from the matching spans. Nothing is maintained at ingest; the dashboard computes what it needs when someone asks.

Opening a session is simpler: filter by session identifier, sort spans chronologically. Every span already carries a timestamp and a duration, so the conversation and its execution trace fall out of the ordering.

The two views paginate differently. Session browsing uses search_after, because deep offset pagination in Elasticsearch gets more expensive the further you go as the engine skips results internally. Carrying forward the last document's sort values keeps cost flat regardless of scroll depth. Individual session views are naturally bounded and don't need it.

That's the cost of the append-only decision, stated plainly: every session-list page view pays for aggregations that a session document would have precomputed. We're paying at read time on purpose, and if the session list ever becomes the slow part of the product, the fix is a materialized session document and the concurrency problems that come with it. It hasn't, so we haven't.

0·5 min read
Continue Reading

Similar Architecture Notes

AGENT LOOPS · THE LIMIT IS THE HYPERPARAMETERSCOPEpromptnarrow the spaceRETRIEVEtop-Nno rerankerPLANdecomposesteps up frontReActstepreason · actTOOLobserveback into stateobserve → re-act, unbounded by defaultMIDDLEWARE AGENTcap 8PATH RESOLVERcap 27DEEP LOOKUPcap ~100SUMMARIZE-ON-LIMIT FALLBACKpresent in 1 of 6 agents · the other 5 return emptyBOUNDING THE LOOP IS HALF THE JOB · THE OTHER HALF IS WHAT HAPPENS AT THE CEILINGaryan.devai-infrastructure
ai-infrastructure

Nobody Tunes the Recursion Limit

4 min read
RED TEAM ORCHESTRATOR · MULTI-TURN, ADAPTIVETARGETAI Agent+ MCP servertool calls visibleCrescendoTree JailbreakBad LikertROT13Base64<raw_prompt>BUDGET GUARDper-run mutexVALIDATIONlast turn onlyHISTORY WINDOWlast 10 messagesaryan.devsecurity · ai-infrastructure
security

I Built a Red Team Orchestrator for AI Agents. Here's What Made It Hard.

7 min read
PARALLEL CONSUMER · ONE PARTITION, MANY IN-FLIGHTTEST BACKLOG70,000 testsWORK UNITW0per-key queueWORK UNITW1per-key queueWORK UNITW2per-key queueWORK UNITW3per-key queueWORK UNITW4per-key queueWORK UNITW5per-key queueTHREADT0offset trackedTHREADT1offset trackedTHREADT2offset trackedTHREADT3offset trackedTHREADT4offset trackedTHREADT5offset trackedCOMMIT BOUNDARYmaxConcurrency(100)ONE CONSUMER GROUP · 100 IN FLIGHT · OFFSETS SAFE ACROSS A HARD KILLaryan.devdistributed-systems · observability
distributed-systems

Kafka, Parallel Consumers, and the 6-Hour Testing Bottleneck

6 min read