# Record LLM calls

> Capture model, token, latency, error, and cost data and attach it to the customer who caused it.

Canonical page: https://anectico.com/docs/instrument/llm-calls/


Anectico represents an LLM call as a trace span using OpenTelemetry `gen_ai.*` attributes. When customer
identity is active, the call and its cost appear in the same customer story as errors and application
activity.

## Use an SDK wrapper

The JavaScript, Go, and Python wrappers all cover unary and Chat Completion streaming OpenAI calls
plus unary and streaming Anthropic calls; a streamed Anthropic call is recorded as one span when the
stream is exhausted, fails, or is abandoned.

<div data-language-tabs="llm-wrapper" data-language-tabs-label="Choose an SDK"></div>

**JavaScript / TypeScript**

```typescript
import OpenAI from 'openai';
import { wrapOpenAI } from '@anectico/sdk/openai';

const openai = wrapOpenAI(new OpenAI(), anectico);
const response = await openai.chat.completions.create({
  model: process.env.OPENAI_MODEL!,
  messages,
});
```

For a JavaScript stream, request the final usage chunk and consume the iterator:

```typescript
const stream = await openai.chat.completions.create({
  model: process.env.OPENAI_MODEL!,
  messages,
  stream: true,
  stream_options: { include_usage: true },
});
for await (const chunk of stream) {
  consume(chunk);
}
```

**Python**

```python
import os
from openai import OpenAI
from anectico.integrations.openai import wrap_openai

openai = wrap_openai(OpenAI(), client)
response = openai.chat.completions.create(
    model=os.environ['OPENAI_MODEL'],
    messages=messages,
)
```

For Python streaming, request the final usage chunk and consume the iterator:

```python
stream = openai.chat.completions.create(
    model=os.environ['OPENAI_MODEL'],
    messages=messages,
    stream=True,
    stream_options={"include_usage": True},
)
for chunk in stream:
    consume(chunk)
```

**Go**

```go
import (
	"github.com/openai/openai-go"
	"github.com/openai/openai-go/option"
	anecticoopenai "github.com/anectico/anectico/sdks/go/instrumentation/openai"
)

real := openai.NewClient(option.WithAPIKey(os.Getenv("OPENAI_API_KEY")))
openaiClient := anecticoopenai.WrapClient(anecticoClient, &real)
response, err := openaiClient.Chat.Completions.New(
	ctx,
	openai.ChatCompletionNewParams{
		Model:    os.Getenv("OPENAI_MODEL"),
		Messages: messages,
	},
)

stream := openaiClient.Chat.Completions.NewStreaming(
	ctx,
	openai.ChatCompletionNewParams{
		Model:    os.Getenv("OPENAI_MODEL"),
		Messages: messages,
	},
)
defer stream.Close()
for stream.Next() {
	consume(stream.Current())
}
if err := stream.Err(); err != nil {
	return err
}
```

<div data-language-tabs-end="llm-wrapper"></div>

Call `identify` or establish request-scoped identity before the model request.

## Use existing OpenTelemetry instrumentation

Anectico reads standard `gen_ai` span attributes from any OTLP source. At minimum, record the provider,
model, input tokens, output tokens, duration, and error status. Anectico can calculate cost for recognized
models when token counts are present.

Both the current and the superseded `gen_ai` attribute names are read, as are OpenInference's `llm.*`
attributes and the span shapes written by the common agent frameworks. See the
[framework compatibility table](/docs/instrument/opentelemetry) for what is read from each and for the
few places a framework's own attribute names limit what appears.

For tool spans, `gen_ai.tool.type` accepts the bounded values `function`, `extension`, `datastore`,
and `mcp`. Use `mcp` when the producer explicitly identifies the tool as an MCP tool; Anectico keeps
that declaration distinct from a missing tool type.

## Declare the agent release

Set a release on the agent-run helper when you need to compare behavior across deployments. The
three server SDKs emit the same OpenTelemetry attribute:

```go
runCtx, run := client.StartAgentRun(ctx, "checkout-agent", anectico.AgentRunOptions{
	AgentVersion: "checkout-agent@2026.08.24",
})
```

```typescript
const run = client.startAgentRun({
  agent: 'checkout-agent',
  agentVersion: 'checkout-agent@2026.08.24',
});
```

```python
with client.start_agent_run(
    "checkout-agent",
    agent_version="checkout-agent@2026.08.24",
) as run:
    pass
```

The wire key is `gen_ai.agent.version`, the standard version of the agent itself. It is deliberately
different from `service.version`: a service can host several agents, and the service and agent can
ship independently. Use the same release label you name in quality gates.

The field is optional. Runs without it remain visible and explicitly report that no release was
declared. Release-aware list responses also distinguish **no runs**, **runs with no declared
releases**, **partial release coverage**, and **complete release coverage**, so an empty exact-release
result never has to stand in for “this project does not declare releases.”

## Prompt and completion content

Content capture is off by default. Leave it off unless the debugging value outweighs the privacy and
security cost. If you enable it, review retention, user consent, PII handling, and access controls
before production rollout.

### A tool call's arguments and its result

The same opt-in covers tool calls. When you pass `arguments` and `result` to the tool-call helper,
they are recorded as `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result` on the tool's span,
and they belong to the `tool_arguments` content class described below — not to `model_transcript`,
so the two are permitted or refused independently.

Both are recorded as *what the tool was given* and *what the tool answered*. Nothing rewrites either
after the fact.

**Large answers are trimmed rather than dropped, and a trimmed answer says so.** Each of the two
values is bounded to 32,768 bytes. A longer value is sent as its first 32,768 bytes, cut at a
character boundary so the text stays valid, and the SDK sends the size it had beforehand alongside
it — `anectico.gen_ai.tool.call.arguments.byte_size` and
`anectico.gen_ai.tool.call.result.byte_size`. Anectico reports a value as truncated exactly when
that declared size exceeds what it holds, so a partial answer is never presented as a complete one.

The bound exists because a single oversized attribute is rejected on arrival — and the rejection
takes the whole batch of spans it was sent with, not just the one field. Trimming one value is the
recoverable version of that.

Those two size attributes are metadata, not content: they survive even when the `tool_arguments`
class is not permitted, which is what lets a record say "this call had a 40 KB answer that was not
kept" rather than being indistinguishable from a call that answered nothing.

A tool call ID recorded here can also anchor a context-assembly source, tying the model step back to
exactly the tool call that fed it — see [Record context assembly](/docs/agents/context).

### Your project decides whether content is recorded at all

Turning content capture on in an SDK says what your application *sends*. Your project's
[content policy](/docs/reference/permissions#your-projects-content-policy) says what Anectico *keeps*. The
policy is the stricter of the two: a project that does not permit model transcripts to be stored
records the call without them, however the sending application is configured. That is the control to
reach for when the decision has to hold across every service and every SDK you run, rather than
being re-made in each one.

The two content classes this applies to are `model_transcript` — prompts, completions, system
instructions, retrieved documents and a judge's written explanation — and `tool_arguments`, the
arguments and results of a tool or MCP call. They are decided separately, so keeping tool arguments
while dropping transcripts is a supported posture.

**What a call looks like when a class is not permitted.** The call is still recorded. Its timing,
status, model, provider, token counts, cost, trace and span identifiers, session, turn and the
person it belongs to are all there, and every dashboard, alert, cost report and person timeline
built on them works exactly as before. Only the bodies are missing.

**A recorded call says so.** Wherever content was not written down, the record carries an
`anectico.content.not_stored` attribute naming exactly which fields were dropped — for example
`gen_ai.prompt.0.content,gen_ai.tool.call.arguments`. Its absence means the call genuinely carried
no content. So an empty prompt is never ambiguous: either something says it was withheld, or nothing
was there. The marker names field names only; it never contains any of the content itself.

**This one is not reversible.** The other boundaries a content policy governs — reading, exporting,
sending to an evaluation model — withhold a copy of something Anectico still holds, so widening the
policy and asking again returns it. Not storing is different: the bodies were never written down,
and no later change brings back the calls recorded while the class was refused. Widening applies
from the moment it takes effect, forwards only. Decide this one before you need the data, not after.

**Refusing at the reading boundary is the reversible version of the same control**, and it is
enforced. A call recorded with its bodies but refused at `read` comes back with those fields absent
and an `anectico.content.policy_withheld` attribute naming them; permit the class again and the same
request returns them. That field is a different statement from `anectico.content.not_stored` above,
which is permanent, and from `anectico.content.withheld`, which means the credential rather than the
policy. Note that reading is where an export or an evaluation gets the content it sends, so refusing
a class at `read` withholds it from those too.

**A change takes up to 30 seconds to apply, in both directions.** That is worth reading twice for
this boundary specifically. A *tightening* means up to 30 seconds of content still being recorded
after you refused it — which you can clean up, because recorded content can be deleted. A
*widening* means up to 30 seconds of content still **not** being recorded after you permitted it,
and that half cannot be cleaned up: those calls are already stored without their bodies. If you are
turning storage on before a debugging session, turn it on a minute early.

**A brand-new project records content until you say otherwise.** Every project starts on the
standard posture, which permits storing both classes, and that posture applies from the project's
very first span — including before the organization has finished setting the project up. Anectico
never treats "this project has not been configured yet" as a refusal, because the alternative is
destroying telemetry nobody asked us to destroy. If a project must not record content, set its
policy as part of creating it rather than after its first traffic.

**If Anectico cannot read your policy, it keeps recording.** During an outage of our own, the last
policy we successfully read for your project stays in force — so a refusal you have configured is
still honoured. Only a project we have never successfully read a policy for falls back to the
standard posture, and it errs toward recording, because content recorded in error can be deleted and
content never recorded cannot be recovered.

Read the policy with `GET /api/v1/projects/{projectId}/content-policy` and replace it with `PUT` on
the same path; reading needs `governance:read` and changing needs `governance:write`.

All three SDKs record a streamed OpenAI or Anthropic call as one span when the stream is exhausted,
fails, or is abandoned (the consumer stops iterating early — a `break`, an early `close()`, or
leaving the `with`/using block). Only a provider failure is an error; abandonment keeps the span's
normal status and carries `anectico.gen_ai.stream.abandoned` instead, because it is billed provider
usage, not a failure. For OpenAI, token and cost attributes require the stream's terminal usage
chunk — the wrappers do not inject `stream_options: { include_usage: true }` for you — so without it
latency and status are still recorded but token/cost fields are unavailable.

## Verify

Open **Signals → Traces** and select **LLM Costs**, then open the customer who made the request. The same call should appear
in both views with matching model, token, and cost values.

- [Explain LLM spend per customer](/docs/investigate/llm-spend)
- [Investigate agent runs](/docs/investigate/agent-runs)
