Skip to content

Automatic capture

How capture at the fetch boundary turns provider traffic into runs, generations, tool steps and sessions.

On this page

Capture happens at the HTTP boundary. The SDK observes the runtime's global fetch, so any client or framework that uses it is recorded without being wrapped. The request body names the model call; the response is read from a clone while your code consumes the original.

Which requests become spans

RequestRecorded as
POST …/chat/completionsgeneration span, chat.completions
POST …/responses, GET …/responses/{id}, POST …/responses/{id}/cancelgeneration span, responses; polls complete a background response
POST …/embeddingsembedding span
POST …/messages on api.anthropic.com, or with an anthropic-version headergeneration span, messages

gen_ai.system is openai or anthropic on the official hosts; a known name for Azure OpenAI, Groq, Mistral, Together, OpenRouter, DeepSeek, xAI, Perplexity, Fireworks, Cerebras, Gemini's OpenAI-compatible endpoint and Ollama; and the hostname otherwise. Where the system is unknown, pricing follows the model name.

Recorded per call: the model and request settings, reported token usage (including cached, cache-creation and reasoning tokens), finish reasons, tool call counts, time to first token and tokens per second for streams, the HTTP status, and the provider error type — rate_limit, timeout, auth, invalid_request, provider_error, incomplete_stream or incomplete_response. A provider client's retry of the same request within a minute of a failure is recorded as attempt 2, 3 and so on.

Not captured

  • Clients constructed before the SDK loaded; they kept the original fetch.
  • Clients given their own fetch option.
  • Non-JSON request bodies, such as audio uploads.
  • Endpoints outside the table above. Record those yourself with a manual generation span.

How runs are drawn

Inside an inbound HTTP request handled by http.Server or https.Server — Express, Fastify, NestJS, Next.js — every model call belongs to one run named METHOD /path. The run is sent only when a model call or an explicit span actually happened, ends when the response finishes, and is marked failed on a 5xx status.

Outside a request — scripts, queues, workers — a model call starts a run. If the response asks for tools, the run stays open for the tool step and the follow-up call carrying the tool results. It ends on a response without tool calls, a failure, a new user message, ten idle minutes, or process exit.

An explicit trace() takes precedence over both.

Automatic runs also fill their transcript with the current user message from the first provider call and the latest textual model answer; tool arguments and reasoning stay on the generation span. They carry metadata["tracehatch.capture"] = "auto".

How sessions are chosen

In order:

  1. The x-tracehatch-session-id and x-tracehatch-user-id request headers.
  2. setSession() / setUser() in your code.
  3. The OpenAI Responses conversation id or previous_response_id.
  4. A fingerprint of the conversation's system prompt and first user message, which every later turn resends.

The OpenAI user field and Anthropic metadata.user_id name the end user. Derived session ids are stable across processes, restarts and the gateway, so the same conversation lands in one session even behind a load balancer.

How tool steps are found

Tool calls in a response are paired with the tool results in the next request — chat tool messages, Responses function_call_output items, Anthropic tool_result blocks — and recorded as tool spans with the call id, name, arguments, output, error flag and the time between the two calls.

If you also time that tool with tool(), pass the provider's call id so the two are recognised as one execution:

Let an explicit span replace the inferred oneTypeScript
await tool("search docs", () => search(args), {
  attributes: { "tool.call_id": call.id },
})

The explicit span then wins, keeping its real timing and retry events. A matching name alone cannot identify one tool execution reliably.

Streams

The SDK reads its own copy of the stream while your code reads the response, so a stream returned out of a trace() callback and consumed later is still recorded. The run's end waits for the stream, bounded by two minutes, and flush() waits for those ends too.

  • A stream your code neither reads nor cancels is recorded as cancelled once that bound passes.
  • Cancelling through an AbortSignal records a cancelled span.
  • For OpenAI chat streams, stream_options.include_usage: true is added when you have not set the option, so usage is reported. An explicit false stays false.
  • Assembled output is bounded; beyond it the span is marked truncated and keeps the full byte count.

Prompt identity

The system prompt each call carries — chat system/developer messages, Responses instructions, Anthropic system — identifies a prompt and one of its versions. The version hashes the whole text after normalisation; the prompt hashes the opening line, which edits below it leave alone.

Only those two hashes and the opening line travel on the span. Turning body capture off therefore still yields prompts, versions, usage and cost — just no text to show or diff.

The same rules without the SDK

The provider gateway applies all of the above to traffic it proxies, including session derivation and tool pairing, because it parses the same provider shapes. The log endpoint derives sessions from chat-style messages and creates one tool span per entry you send, but it cannot infer what it never sees.