Trace every model call
Every later part of the day reads from the traces, so start by recording each model call. Record its cost, latency and failure where WorkBot builds the model, not at every call site, and attribute each call to a tenant and a purpose, set once per request. On this page, a tenant is the name a call's cost is charged to, such as a visitor's id or admin.
Study first
- What a trace needs. Record the model, input and output tokens, cached tokens, dollars, latency, success, and who the call was for. Leave out the content, because a trace store otherwise fills with personal data, and this build keeps none.
- The AI SDK's middleware.
wrapLanguageModel({ model, middleware })takeswrapGenerateandwrapStream, which see the call's result, or its stream'sfinishpart, and its error. AsyncLocalStorage, from Node's async context module, which holds a value you set at the top of a request. Everything awaited inside the request can read it, including a stream's later parts.- Langfuse, the tool in today's topics, takes the same records a different way. Set
experimental_telemetry: { isEnabled: true }on each call, and the SDK emits OpenTelemetry spans, which@langfuse/otel'sLangfuseSpanProcessorships. Register it on the Node tracer provider withLANGFUSE_PUBLIC_KEY,LANGFUSE_SECRET_KEYandLANGFUSE_BASE_URL. The reference run had no Langfuse account, so this build writes the records to its own table and dashboard. The seam, the one place where the records leave the model call, is the same for both.
Build
- Add a
tracestable with time, tenant, name, prompt, model, input, output, cached, dollars, ms, ok and error. Createsrc/lib/tracing.tsand writewithTrace({ tenant, name, prompt }, fn)onAsyncLocalStorage. Writetraced(model), which wraps the model with a middleware that records each call. - Price each call from the price table in Day 6's
providers.ts. A cache read costs a tenth of the input price on Haiku 5.5 and a twentieth on Sonnet 5.5 and Opus 5.5. A cache write costs 1.25 times the input price. These are Anthropic's published rates on 2026-10-25. - Make
aiModelinsrc/lib/chat.tsreturn traced models, so WorkBot records every call throughchatModel. The terminal chat,pnpm chatfrom Day 6, talks to the provider SDKs directly. So makeSession.record, which already keeps each turn's tokens for/cost, also callrecordTracewith the tenantterminal. - Call
withTraceat the top of each route and action. The chat traces for its visitor, the intake and documents routes trace asapi, the admin actions asadmin, and the workflow's model steps asworkflow. - Write
traceSummary(hours), which returns calls, errors, dollars and cached tokens, and the p50 and p95 latency, the median and the 95th percentile, frompercentile_cont, one of Postgres's aggregate functions. It adds spend by tenant, spend and latency by prompt version, and the latest calls. Build/admin/tracesto show the last 24 hours. - Test that a call and a stream through a mock model land in the table with tenant, tokens, cached tokens and dollars. Test that a failing call is recorded as failed and still throws, and that the summary adds up.
Done when
- After you send a chat message,
/admin/tracesshows the call under the visitor's id, with its tokens, dollars and milliseconds. - Turns from
pnpm chatappear underterminal.

Technical notes
- Capture the context in the middleware when the call starts, not when the stream ends, because a stream's
flushcan run after the request's store is gone. - A stream can fail in 2 ways.
doStreamcan reject, and the middleware records that as it throws, or the stream can carry anerrorpart, which the middleware records atflush. recordTracenever throws into the call. It logs a tracing failure, and the reply still goes out.- Cost per tenant is the table's
group by tenant. A tenant is whatever the request names, which in WorkBot is a visitor cookie and in a product with sign-in would be an account id.

