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
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.
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
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);
}
});// agents/resolver.ts
import { Think } from '@cloudflare/think';
import { bootstrapBraintrust } from '../lib/observability/braintrust';
import { recordTurn, beginTool, endTool } from '../lib/observability/run-store';
export class ResolverAgent extends Think<Env> {
beforeTurn(ctx) { bootstrapBraintrust(this.env); }
onStepFinish(ctx) { recordTurn(this.name, ctx); }
beforeToolCall(ctx) { beginTool(this.name, ctx); }
afterToolCall(ctx) { endTool(this.name, ctx); }
}# agents/support-crew/src/main.py
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from strands import Agent
app = BedrockAgentCoreApp()
agent = Agent(model='us.anthropic.claude-sonnet-4-6')
# no code. CloudWatch GenAI dashboard auto-fills once
# Transaction Search is toggled on for the account
@app.entrypoint
def invoke(payload):
return agent(payload['prompt'])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.
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.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.
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.
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.
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.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.
| dimension | Flue | Think | AgentCore |
|---|---|---|---|
| token counts | usage.input · output · cacheRead · cacheWrite | same four, from AI SDK v7 | same four, from the Runtime metadata event |
| $ source of truth | event.response.usage.cost.total | tokenlens + models.dev catalog | Braintrust server catalog · CloudWatch metric |
| cross check | AI Gateway logs · Braintrust | AI Gateway logs · Braintrust | CloudWatch GenAI dashboard |
| where it lands in the UI | metrics.ticketCostUsd in the SSE feed | same, priced per turn on the way through | DynamoDB 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.
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.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?".
| sink | Flue | Think | AgentCore |
|---|---|---|---|
| console OTEL | wired · custom terminal exporter | not wired | included in the CloudWatch pipe |
| Braintrust | wired · instrument(braintrustFlueInstrumentation()) | wired · registerTelemetry(...) | opt in · one bootstrap plus Braintrust API key |
| Sentry error capture | wired · observer + DO wrapper | not wired | not wired |
| live SSE feed to browser | wired · event bus subscriber | wired · module global run store + compat flag | not yet · Lambda buffers the whole SSE |
| gateway request logs | AI Gateway · filterable by cf-aig-metadata.ticketId | same | n/a. no analogous Cloudflare gateway |
| CloudWatch GenAI dashboard | n/a | n/a | wired · 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.
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.Reach for X when…
Fig. 08 · the picker
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.
| gotcha | where it bites |
|---|---|
| Transaction Search ~10 min lag | AgentCore. 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 drift | Think. Needs an as unknown as Telemetry cast at the registerTelemetry call site. Runtime shape is fine. |
no_handle_cross_request_promise_resolution | Think. Required when SSE subscribers live in module global state. Flue does not need it because SSE flows through the DO isolate. |
| prompt cache undercount | All 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 observers | Both 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 SSE | AgentCore. 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 lag | AgentCore. 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 names | Flue. 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.
