Add telemetry

This guide wires OpenTelemetry into an llmkit client so every call — success and rejection alike — emits one OTEL GenAI span. The shape is identical across the four SDKs: attach a Telemetry config whose export callback receives the finished OTLP/JSON bytes. llmkit builds the span; you decide where the bytes go.

Telemetry is off unless you attach it, and a config with no export is a ValidationError — there is no silent enabled-but-no-sink state.

Option A — point at a collector (batteries)

The HTTPExport / httpExport / http_export convenience returns an export callback that POSTs each span to <endpoint>/v1/traces. Use it for low volume: the POST is synchronous and fail-open (a hung collector never fails your call, but it can add latency up to the timeout).

import "github.com/aktagon/llmkit-go"

c := llmkit.New("openai", os.Getenv("OPENAI_API_KEY")).
    AddTelemetry(llmkit.Telemetry{
        Export: llmkit.HTTPExport("https://collector:4318", nil),
    })

resp, err := c.Text.Prompt(context.Background(), "Hello")
import { openai, httpExport } from "@aktagon/llmkit-ts";

const client = openai(process.env.OPENAI_API_KEY).addTelemetry({
  export: httpExport("https://collector:4318"),
});

const resp = await client.text.prompt("Hello");
from llmkit import Telemetry, http_export, add_telemetry
from llmkit.builders import openai

client = add_telemetry(
    openai(os.environ["OPENAI_API_KEY"]),
    Telemetry(export=http_export("https://collector:4318")),
)

resp = await client.text.prompt("Hello")
use std::collections::HashMap;
use llmkit::builders::openai;
use llmkit::{http_export, Telemetry};

let client = openai(&key).add_telemetry(Telemetry {
    export: http_export("https://collector:4318", HashMap::new()),
    capture_content: false,
});

let resp = client.text().prompt("Hello").await?;

Option B — bring your own transport

If you already run an OpenTelemetry SDK, skip the built-in POST and hand the bytes straight to your batch processor. llmkit does no network I/O and spawns no worker — the callback runs synchronously on each call, so keep it non-blocking (enqueue and return).

c.AddTelemetry(llmkit.Telemetry{
    Export: func(b []byte) { batchProcessor.Enqueue(b) },
})
client.addTelemetry({ export: (b) => batchProcessor.enqueue(b) });
add_telemetry(client, Telemetry(export=lambda b: batch_processor.enqueue(b)))
use std::sync::Arc;

client.add_telemetry(Telemetry {
    export: Arc::new(|b| batch_processor.enqueue(b)),
    capture_content: false,
});

What lands in your collector

Each span carries the OTEL GenAI attributes for the call:

  • gen_ai.operation.name — the operation (e.g. chat, generate_content)
  • gen_ai.system — the provider (e.g. openai)
  • gen_ai.request.model — the model
  • gen_ai.usage.input_tokens / gen_ai.usage.output_tokens — token counts
  • error.type — present only on a rejection, with the span status set to error

The span shape is byte-identical across all four SDKs, so a shop running llmkit in more than one language reports into one collector with one set of dashboards and queries.

Next steps

  • For high volume, always use Option B — the built-in POST is deliberately naive and has no batching or backpressure of its own.
  • Content capture (prompt and completion text) is a separate, default-off tier; it is gated by the captureContent flag on the Telemetry config.