Skip to content
anecticoDocsDashboard
Browse documentation
Guide

Record LLM calls

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

On this page

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.

JavaScript / 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:

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

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:

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

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
}

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 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:

runCtx, run := client.StartAgentRun(ctx, "checkout-agent", anectico.AgentRunOptions{
	AgentVersion: "checkout-agent@2026.08.24",
})
const run = client.startAgentRun({
  agent: 'checkout-agent',
  agentVersion: 'checkout-agent@2026.08.24',
});
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.

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 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.