Skip to content
Fig. 00 — Note 01 · Agent observabilityJuly 2026 · 12 min

Agent observability

Wiring agent observability

Observability used to be logs, then metrics, then distributed traces. Agents added a fourth axis: cost. The Customer Support Crew, wired three ways. Flue's global event bus, Think's per class lifecycle hooks, AgentCore's auto instrumented microVM. Same headline outcome, three completely different wiring philosophies.

Notes / wiring-agent-observabilityfawzyatwa.com
Note sheetFile 01 / 07 · Notes
Date

July 2026

Reading time

12 min

Related workAgentra
Slugwriting / wiring-agent-observability
Share this note/writing/wiring-agent-observability
LinkedInX

captured against @flue/runtime 1.0 beta · @cloudflare/think 0.15 · bedrock agentcore 0.4 · july 2026

The way we watch systems doesn't change often, and then it does. For most of the 2000s it meant logs. In 2010 a Google paper called Dapper gave us a real word for the shape of a request across services: a trace, made of a tree of spans. Google was moving a terabyte of sampled trace data a day at the time, and the rest of the industry copied the primitives.

By 2019 there were two competing standards for those primitives (OpenTracing and OpenCensus) and they merged into OpenTelemetry. One API, one wire format, everyone shipping to whatever backend they liked. That is the world most application code lives in now. Spans, attributes, a parent link, a duration, done.

Then agents arrived and the trace unit shifted. The important spans are not "database query" or "HTTP call". They are "turn", "tool call", "subagent handoff", "retry". And a new attribute became first class next to latency: cost. Every span carries four token counts (input, output, cacheRead, cacheWrite), and every one is priced differently. Rolling your own input × 3 + output × 15 math undercounts the real invoice by about half after turn 1, because prompt caching dominates.

Tracking that in an agent is trickier than it looks. The control flow is non deterministic (the LLM decides the shape of the trace as it goes). Subagents may live in a different process or across an RPC boundary. Streaming responses finish after the trace endpoint expects them to. A retry must not double count the cost. And the newest crop of tools (Langfuse, LangSmith, Braintrust) had to rebuild "trace" from scratch to fit that shape.

I've been building the same customer support crew three ways in a benchmark repo, once on Flue, Think, and Bedrock AgentCore. That earlier post covered the whole picture. This one lives in one section only: how observability gets wired. When the crew misclassifies a ticket, where do you look? Each harness has a different answer. And the effort to get there differs by more than an order of magnitude.

Fig. 01 · three vendors, one crew

Flue
event bus · N subscribers
Think
6 lifecycle hooks per DO
AgentCore
auto OTEL · zero code

One outcome, three roads

All three harnesses ship the same headline. Every LLM turn lands in a Braintrust trace tree with model, tokens, cost and latency attached. A live server sent events feed powers the left panel of the app (chat on the right, traces / cost on the left). Get there via completely different plumbing.

The one line to hold in your head before the rest of this post:

Flue keeps one bus. Think exposes many hooks. AgentCore emits OTEL from the microVM itself. Same signal, three opposite bets on where correlation happens.
Fig. 02where the observability signal is born · same LLM call, three roads out
FLUE · ONE BUS, MANY TAPSTHINK · PER DO HOOKSAGENTCORE · ZERO CODEany agent codesubagents share the busobserve() bus~20 typed eventsotelbraintrustsentrysseadd sink = one observe(fn)class Agentextends Think · 6 lifecycle hooksRunView storemodule globalbraintrustregisterTelemetry()otelnot wiredsentrynot wiredeach subclass opts in per hookStrands agentruns in microVMCloudWatchGenAI dashboardtoggle Transaction Search · ~10 min lagauto OTEL

The three lanes in Fig. 02 are the same question asked three times. Where does the "something happened" signal come from? Flue owns a bus that everyone listens on. Think owns the class you extend, and the hooks fire on that instance. AgentCore doesn't own anything you can see. The microVM emits OTEL and CloudWatch catches it.

Where the event is born · in code

Listing 01 is the smallest possible "hello observability" for each harness. Read each tab as a complete file. The line count is the story.

lib/observability/braintrust.ts
event bus
// lib/observability/braintrust.ts
import { observe, instrument } from '@flue/runtime';
import { braintrustFlueInstrumentation } from 'braintrust';

instrument(braintrustFlueInstrumentation());

observe((event) => {
  if (event.type === 'turn') {
    console.log('[obs]', event.request.requestedModel, event.response.usage);
  }
});

Flue exposes two entry points into the same bus. observe(fn) is a fire and forget subscriber, one line to add. instrument(plugin) is a heavier third party integration (a plugin declares a key, an observer, an optional interceptor, and a dispose hook). Braintrust is one instrument() call. About twenty typed event types cross the bus.

Think exposes six lifecycle hooks on the class you extend: beforeTurn, onStepFinish, beforeToolCall, afterToolCall, onChatResponse, onChatError. Each subclass opts in to any hook it cares about. Braintrust is a different animal: one call to registerTelemetry() from the AI SDK, sitting a layer below the hooks, catching every model call regardless of which class fired it.

AgentCore ships nothing you write. The Strands agent runs inside a Runtime microVM, and the microVM is auto instrumented with OTEL. Model calls, tool invocations, memory operations, reasoning steps all appear on the CloudWatch GenAI dashboard without a single line of instrumentation code. The one time price is toggling CloudWatch Transaction Search on for the account. Spans start showing up about ten minutes later.

Margin Note · primer · what's an event bus?note
A shared channel that emitters push events onto and any number of subscribers listen to. Adding a sink is one observe(fn) call; removing it is the returned dispose function. The bus does not care what the sink does. It could log, it could POST to Braintrust, it could hold the last event in memory for the UI. That symmetry is what makes Flue's plumbing cheap to extend.
Margin Note · primer · what's OTEL auto instrumentation?note
A pattern where the runtime (a language SDK, a container, a mesh sidecar) attaches spans to your code without you asking. AgentCore takes it further: the microVM ships with the OTEL agent already wired, so a Strands Agent gets a full span tree (per turn, per tool call, per reasoning step) with no imports. What you pay for the convenience is control. Adding a custom span later means adding OTEL imports and code, at which point you're back in Think's world.

Bootstrap · module top vs beforeTurn vs one toggle

Where does the wiring code actually run on cold start? Different answer per harness, because the shape of the request path is different.

Flue registers everything at the top of a module. The OTel console exporter, the Sentry observer bridge and the run store are each one function call at import time (bootstrapOtel(), bootstrapSentryObserver(), bootstrapRunStore()). Braintrust is the exception. It needs environment variables, so it is wired inside a monkey patched onRequest that runs on the first request the isolate serves. All observer references are stashed on globalThis so Vite's hot module reload does not stack duplicates across saves.

Think has no onRequest. Think Durable Objects receive RPC calls, not HTTP, so the first callback where this.env is in scope is beforeTurn(ctx). A single bootstrapBraintrust(this.env) at the top ofbeforeTurn handles it. AI SDK v7's registerTelemetry returns nothing to unregister, so double registration is gated on a boolean flag on globalThis. About thirty lines of code once, and every turn after that costs a single property read.

AgentCore's bootstrap is a checkbox. Enable CloudWatch Transaction Search once per account (one API call or one console toggle). Wait about ten minutes on the first run. Traces appear. No import, no wrapper, no telemetry registration.

Margin Note · gotcha · why the globalThis trick?watch
Vite's dev server hot reloads the module without evicting the previous copy from memory. If your observer registration runs at module top, saving the file doubles the sinks (two OTel exporters, two Braintrust writers, one printed message becomes two). Stashing the dispose function or a ready flag on globalThissurvives the reload. Flue awaits an async dispose before re-registering; Think just checks the flag and skips. Small discipline, saves a lot of duplicated log lines.

The trace tree · one root, three roots, one root

Same triage → resolve → escalate turn, three different trace shapes. The tree is the point.

Fig. 05the same turn · three trace shapes on Braintrust
FLUE · ONE NESTED TREETHINK · THREE SEPARATE ROOTSAGENTCORE · ONE TREE, ONE PROCESShandle_ticket2.3s · $0.020triage turn 10.4s · $0.002task(agent=resolver)tool callresolver turn 11.5s · $0.014tool: kb_lookup74msone DO isolate · one nested treetriage-agentroot · 0.4s · $0.002onStepFinish1 stepresolver-agentroot · 1.5s · $0.014onStepFinish3 stepstool: kb_lookup74msescalation-agentroot · 0.3s · $0.001no OTEL context across DO RPCinvoke_agent_runtime2.5s · $0.019classify0.3sresolve1.8s · $0.017tool: kb_lookup62msescalate0.3stool: record_escalation18msone process · Agent.asTool() nests

Flue's tree nests naturally because subagents share the parent Durable Object. The three agents (triage, resolver, escalation) are profiles that live in one isolate. The Braintrust span for the parent turn is the parent of every child span the subagents emit. You get a single, walkable tree with cost rolled up at every level, for free.

Think's trace comes back as three roots, one per agent. Think's subagents are their own Durable Object classes with their own isolates. Delegating from triage to resolver crosses a DO to DO RPC boundary, and Think does not propagate an OTEL trace context across it. Braintrust never sees the parent link, so the resolver turn opens a new root. Cost still rolls up correctly into the ticket's total in the SSE UI (the run store correlates by an instance id you thread yourself), but the picture on the Braintrust dashboard is three trees, not one.

AgentCore's tree is nested for a different reason. Strands does not have a first class SubAgent; you pass an Agent into another agent's tools array and it auto wraps via Agent.asTool(). Everything runs inside the same microVM process, so the OTEL SDK's context propagation works out of the box. Same shape as Flue, different reason.

Margin Note · primer · what's a span?note
The unit of work in a distributed trace. Dapper's 2010 paper called them "the basic unit of work". Each span has a start and end time, a name, attributes (like tokens or cost), and a parent link to another span. A tree of spans is a trace. In an LLM app, one span is one LLM turn, one tool call, or one subagent handoff. The attributes have grown: latency and status now share the row with input tokens, output tokens, cached tokens, and dollars.
Margin Note · gotcha · why the RPC boundary breaks the tree?watch
Trace context propagates via a small piece of state (a traceparent header for HTTP, or an equivalent field on an RPC envelope). When one Durable Object calls another, the receiving DO's OTEL SDK starts from a clean slate unless the caller serialized the context and the callee restored it. Think does not do that today. Fixing it means passing a context object across every RPC hop and rehydrating it inside the child's beforeTurn. Doable, but not free.
Plate 01 · live tracing UI · trace tree + per turn costscreen capture · 3x

captured on the flue build

One tree, four token counts, one running dollar total.

Left panel is the live trace tree fed by the observe() bus over SSE. Right panel is per turn cost aggregated from usage.cost.total across the ticket. Same picture in all three harnesses; different plumbing gets us there.

The invoice problem · four token counts, three answers

Cost is a first class attribute on every span now. The math is where people get hurt.

Fig. 06 · cost accounting · four token counts, three sources of truth5 rows
dimensionFlueThinkAgentCore
token countsusage.input · output · cacheRead · cacheWritesame four, from AI SDK v7same four, from the Runtime metadata event
$ source of truthevent.response.usage.cost.totaltokenlens + models.dev catalogBraintrust server catalog · CloudWatch metric
cross checkAI Gateway logs · BraintrustAI Gateway logs · BraintrustCloudWatch GenAI dashboard
where it lands in the UImetrics.ticketCostUsd in the SSE feedsame, priced per turn on the way throughDynamoDB row written by the BFF Lambda
naive input × 3 + output × 15~50% undercount after turn 1~50% undercount after turn 1~50% undercount after turn 1

Flue is the shortest path. Anthropic returns four token counts and a precomputed dollar total per turn, and Flue passes it through as usage.cost.total. Read it, display it, done.

Think does not get a dollar total from the AI SDK, so pricing is handled in the app. A small pricing.ts uses the tokenlens npm package against the models.dev catalog, fetched once per isolate and cached on globalThis. The number that lands in the UI comes from this local math; the AI Gateway dashboard and Braintrust are the two cross checks. All three should agree within pennies. When they don't, the local math is usually the one that drifted.

AgentCore captures the token counts from metadata events in the Runtime SSE stream and writes normalized totals to a DynamoDB row from the BFF Lambda. The CloudWatch GenAI dashboard has its own accounting, and Braintrust (if you layer it on) has a third. Same three way triangulation as Think, just with different vendors on each corner.

Margin Note · gotcha · what's prompt caching?watch
Anthropic reports four token counts per turn: input, output, cacheRead, cacheWrite. Cached tokens after turn 1 are priced at about ten percent of fresh input, but often twenty times the count. If you roll your own cost math, trust the precomputed total, not a formula. A naive input × 3 + output × 15 undercounts the real invoice by about half.
Margin Note · gotcha · a wire types drift you will hitwatch
On Think, braintrust@3.24.0 declares an AISDKV7Telemetry interface with an [key: string]: unknown index signature that predates ai@7.0.36's narrower typed events. At runtime the callbacks are structurally compatible; at compile time you need an as unknown as Telemetry cast at the registerTelemetry call site. Version drift between two packages, not a wiring bug. Log the cast so future readers know why it's there.

The sink board · what's wired today

A sink is anywhere a trace or an error can land. The three harnesses publish to overlapping sets. This is the table that answers "if I pick X, what does my dashboard look like out of the box?".

Fig. 07 · sinks · what each harness has wired today6 rows
sinkFlueThinkAgentCore
console OTELwired · custom terminal exporternot wiredincluded in the CloudWatch pipe
Braintrustwired · instrument(braintrustFlueInstrumentation())wired · registerTelemetry(...)opt in · one bootstrap plus Braintrust API key
Sentry error capturewired · observer + DO wrappernot wirednot wired
live SSE feed to browserwired · event bus subscriberwired · module global run store + compat flagnot yet · Lambda buffers the whole SSE
gateway request logsAI Gateway · filterable by cf-aig-metadata.ticketIdsamen/a. no analogous Cloudflare gateway
CloudWatch GenAI dashboardn/an/awired · one Transaction Search toggle

Flue's row reads like a menu because the bus makes fan out cheap. Add Sentry after the SSE feed already worked, and the whole change was one observe() subscriber that mapped event.type === 'log' && event.level === 'error' onto Sentry.captureException. Think's row is shorter on purpose. The team wired what mattered (Braintrust for the external dashboard, the run store for the UI) and deferred everything else. AgentCore's row is a different shape entirely. Everything on the CloudWatch line comes for free, but the two Cloudflare specific rows have no equivalent in AWS today.

The sharpest architectural difference is on the Braintrust row. Flue uses a Flue native plugin that subscribes to bus events and emits Braintrust spans mirroring them. Think uses the AI SDK's telemetry channel, which sits one layer below Think's own hooks and catches every model call regardless of which class fired it. Both are one call; the calls live at completely different layers.

The live UI feed · server sent events, three shapes

Both Cloudflare harnesses push a per ticket RunView object to the browser over server sent events. The left panel of the app watches it. Every turn, tool call, error, cost update appears without a page reload.

Flue subscribes inside the Durable Object isolate. The run store lives in DO memory, the SSE endpoint is a route on the same DO, and push happens by walking a subscribers set every time the store mutates. All state is co located with the isolate that emitted the event.

Think keeps the run store in a module global map on the Worker instead. The subagent DO's onStepFinish hook calls a mutator that updates the map, and a separate SSE route reads from the map. This shape means a subscriber registered by a GET fires from inside a POST's hook chain, and Cloudflare's default policy cancels the resulting promise as a cross request leak. The fix is one line in wrangler.jsonc: compatibility_flags: ["no_handle_cross_request_promise_resolution"].

AgentCore has no live feed yet. The Runtime does stream SSE, but API Gateway HTTP API v2 does not stream Lambda responses. The BFF Lambda has to read the whole Runtime stream (transformToString('utf-8')), parse the event: message · data: {...} frames, and return a single JSON payload to the SPA. The observability panel on the AgentCore build renders zeros and em dashes today. That is a Phase 6 concern; the wire is not the problem, the Lambda in front is.

Margin Note · primer · what's SSE?note
Server sent events. A one direction stream from server to browser over a long lived HTTP response. The browser's EventSource API speaks it natively, with automatic reconnection and event ids for resume. Simpler than WebSocket when the browser only needs to listen, which is exactly the shape of a live trace feed.
Margin Note · gotcha · what's the no_handle_cross_request_promise_resolution flag?watch
A Cloudflare Workers compatibility flag. By default, a promise created inside request A that resolves inside request B triggers a runtime warning and cancellation ("promise resolved from different request context"). It is a good default; it usually catches real leaks. Module global SSE subscribers are the one intentional case: register the subscriber in request A, resolve it inside another request's hook chain. Enabling the flag silences the check for that pattern. Flue does not need it because its SSE runs inside the DO isolate itself.
Margin Note · billing · why AgentCore's panel is empty for nowbilled
Not a bug in the microVM. The Runtime does emit an SSE stream, and the CloudWatch GenAI dashboard fills in on its own. What is missing is the browser side feed, because HTTP API v2 does not stream Lambda responses. Options for later: switch to a Lambda function URL with response streaming, add a Lambda Web Adapter in front, or move the browser to WebSockets on API Gateway. All three trade a live feed for a heavier surface.

Reach for X when…

Fig. 08 · the picker

Flue
one bus · fan out to any sink cheaply
Think
typed hooks · per DO override points
AgentCore
free CloudWatch spans · zero code

Reach for Flue when your dashboards will grow. Every new sink is one observe() subscriber, and the bus already carries the events the sink cares about. Braintrust, Sentry and a live UI feed cost about six hundred lines of lib/observability/ once, and a fifth sink costs one more call.

Reach for Think when your team likes typed override points close together. Every hook is typed by Think and fires on the class you extend. If you also want an idempotent durable ledger on the same substrate, Actions plus the run store plus the SSE feed compose cleanly. The tradeoff is that any sink you add lives inside the hook you happen to be in, not on a shared bus.

Reach for AgentCore when you are on AWS and want traces before you want to think about traces. Toggle one thing, wait ten minutes, watch spans appear. If you outgrow the built in dashboard, layer Braintrust on top (about thirty lines) and triangulate.

Gotchas on the way

Each harness has its own set of surprises. Keep these near.

Fig. 09 · gotchas · what bites and where8 rows
gotchawhere it bites
Transaction Search ~10 min lagAgentCore. First run after enabling the toggle, spans take about ten minutes to appear. Not obvious from the console.
braintrust@3.24 × ai@7.0.36 type driftThink. Needs an as unknown as Telemetry cast at the registerTelemetry call site. Runtime shape is fine.
no_handle_cross_request_promise_resolutionThink. Required when SSE subscribers live in module global state. Flue does not need it because SSE flows through the DO isolate.
prompt cache undercountAll three. Anthropic reports four token counts. Rolling your own input × 3 + output × 15 undercounts the invoice by about half after turn 1.
HMR pile up on observersBoth Cloudflare harnesses. Vite reload does not evict the previous copy of the module. Stash dispose or a ready flag on globalThis to survive the reload.
HTTP API v2 buffers Lambda SSEAgentCore. The Runtime streams SSE, but the BFF Lambda has to buffer the whole response before returning JSON. Kills the live UI feed until you swap to Lambda function URLs or WebSockets.
AgentCore Memory extraction lagAgentCore. On a freshly provisioned memory resource, the SEMANTIC long term extractor took about forty minutes to make the first event queryable. Not related to observability directly, but the two often appear stale together.
PromptUsage key namesFlue. usage.input and usage.output, not inputTokens/outputTokens. The first version of the UI summed the wrong keys and read zero.

Closing

Three answers to one question. Flue keeps the signal on a bus because bus fan out is what makes new sinks cheap. Think keeps the signal on the class because that is the shape Cloudflare's harness settled on for everything else it exposes. AgentCore keeps the signal in the microVM because AWS wants "did you look at CloudWatch?" to be the answer.

The picker is boring on purpose. If dashboards are going to grow, reach for Flue. If the team already lives in Think, extend the hooks and skip the ceremony. If you are on AWS, toggle the box and come back in ten minutes. Only pay for what your first outage teaches you to want.

The full code for all three implementations lives on GitHub: fawzy-tat/agent-harness-benchmarking. The observability code sits under each harness's lib/observability/ folder if you want to read the wire instead of the description.