Documentation Index
Fetch the complete documentation index at: https://docs.voight.xyz/llms.txt
Use this file to discover all available pages before exploring further.
withTrace is the request-boundary primitive shared by @voightxyz/openai and @voightxyz/anthropic. It opens a logical span — typically your HTTP handler — and every wrapped LLM call inside that span gets grouped under one trace in the dashboard, automatically.
It also carries tags (the foundation of per-user spend tracking) and a routeTag (so traces are attributable to which endpoint produced them). Under the hood it’s AsyncLocalStorage — Node’s async-context primitive — so there’s nothing to thread through your function signatures.
Why grouping matters
A single user request often makes multiple LLM calls: a planner, a retrieval reranker, a final answer, maybe a moderation check on the output. Without grouping, those are five disconnected events in the audit log. WithwithTrace, they’re one trace card on the dashboard with one total cost, one total latency, and a drillable timeline.
The same shape industry tools (Datadog APM, Sentry, OpenTelemetry) use for HTTP request tracing — Voight applies it to LLM workflows.
The API surface
Two functions, exported identically from both wrappers:withTrace(fn, options)
fn, returns whatever fn returned. Every wrapped LLM call that happens inside fn (including in helper functions, await points, callbacks — anything reachable through async context) gets stamped with the trace’s routeTag and tags.
The trace closes automatically when fn resolves or rejects. Errors propagate normally — withTrace doesn’t swallow.
log(message, extra?)
log('cache hit')— mark a code pathlog('retrieval returned 0 results', { query })— capture a domain signallog('fallback to gpt-4o')— annotate a routing decision
log() events appear in the Traces timeline interleaved with LLM calls, carry the same tags, and show up in the audit log. Calling log() outside a withTrace block is a no-op (with a one-line console warning) — the event has nowhere to belong.
Minimal example
log events bookending the LLM call, all tagged with the user.
Composing across helpers
You don’t need to pass anything explicitly through function signatures —AsyncLocalStorage carries the trace context through any awaited call:
log events, one trace. The helpers don’t know they’re being traced — they just call log() and use the wrapped client.
Mixing providers in one trace
If you use both OpenAI and Anthropic in the same request (router pattern, fallback chain, A/B test), wrap both clients and importwithTrace from either package:
Tags propagate everywhere
Anything you set inwithTrace({ tags }) lands on metadata.tags of every event produced inside. The dashboard surfaces:
tags.userId→ the Users sub-tab + the global User filter pill- All other tags → queryable via
GET /v1/me/ai-apps/*?tag.<key>=<value>(e.g.?tag.plan=pro)
userId, plan, org, feature. You can still use other names freely; these four just have dedicated dashboard surfaces. See per-user spend for the full conventions.
Route tagging
routeTag is a freeform string that becomes the trace card’s headline label. Conventions that work well:
- HTTP method + path:
'POST /api/chat' - gRPC:
'ChatService.GenerateReply' - Job names:
'cron:daily-summary' - Background workers:
'queue:embed-doc'
'untagged'. Nothing breaks, but you lose the ability to slice metrics by endpoint.
Nested withTrace calls
If you call withTrace inside an already-open trace, the inner call inherits the outer trace’s context — same routeTag, same tags, same trace ID. The inner block doesn’t open a new trace. This is intentional: nesting is common in middleware (a logger middleware wraps every handler in withTrace, then a handler-specific wrapper does the same), and we don’t want to fragment one logical request into multiple traces.
If you genuinely need a separate trace inside the same async stack (rare), end the outer one explicitly and start a new one after.
Errors
Performance
AsyncLocalStorageis part of Node’s built-inasync_hooks. Overhead is sub-microsecond per await — negligible compared to any network call.log()events are buffered in memory and flushed when the trace closes. One HTTP request out per trace, not perlog()call.withTraceis safe in serverless (Vercel Functions, AWS Lambda, Cloudflare Workers — wherever Node 18+ runs).
Comparing to library-mode voight.log()
If you’re using the library mode SDK for autonomous bots, you’re already familiar with voight.log(). The two are siblings:
Library mode voight.log() | Wrapper log() | |
|---|---|---|
| Where it lives | @voightxyz/sdk | @voightxyz/openai and @voightxyz/anthropic |
| Needs an open trace? | No — every call is its own event | Yes — must be inside withTrace |
Carries tags? | Pass as metadata per call | Inherited from withTrace({ tags }) automatically |
| Returns a promise? | Yes — { ok, error? } shape | No — fire-and-forget |
| Use case | Autonomous loops, agent decisions | Request-boundary instrumentation in apps |
voight.log() from a background worker AND withTrace / log from request handlers, under the same agent.
FAQ
What if my framework doesn't preserve async context?
What if my framework doesn't preserve async context?
AsyncLocalStorage is preserved across native async/await, Promise.then, Node’s stream events, and most modern frameworks (Express, Fastify, Hono, Koa, Next.js Route Handlers).A few cases lose it: explicit thread-pool workarounds (worker_threads), some legacy callback-based libraries, and certain promise libraries that drop domain context. If you use withTrace and the inner LLM call doesn’t get tagged, the async context was probably dropped somewhere on the path.Workaround: pass the tags explicitly to a fresh withTrace call deeper in the stack.Can I open a trace from a webhook / queue worker?
Can I open a trace from a webhook / queue worker?
Yes — The trace lifecycle matches the job lifecycle.
withTrace is just a function. Call it from your queue handler the same way you’d call it from an HTTP handler:Can I tag without using withTrace?
Can I tag without using withTrace?
Not via
tags. The wrapper accepts a top-level agent and privacy option, but per-request tagging is scoped to withTrace. This is deliberate — global mutable tags on a wrapper instance would be racy across concurrent requests.If you really need static tags (e.g. env: 'production' everywhere), you can wrap a single withTrace at process boot and run your whole app inside it, but the typical pattern is per-request withTrace.What's the maximum size of tags?
What's the maximum size of tags?
Next
- Per-user spend — the killer use case for
withTrace({ tags }) - OpenAI SDK —
wrapOpenAI+ everything captured per LLM call - Anthropic SDK —
wrapAnthropic+ everything captured per LLM call - AI Apps overview — what the dashboard renders from these traces