OpenTelemetry SDK for Python that tracks LLM cost, conversations, and agent workflows — with one-call setup for OpenAI and AutoGen apps.
install()— one call wires TracerProvider, LoggerProvider, processors, and OpenAI instrumentation- Conversation tracking —
gen_ai.conversation.idacross multi-turn sessions - Workflow cost aggregation — group LLM calls by workflow, track total spend
- Agent identity —
gen_ai.agent.*attributes per OTel GenAI semantic conventions - Cost calculation — automatic for 20+ models; bring-your-own pricing for the rest
- Log-to-span bridge — promotes
opentelemetry-instrumentation-openai-v2log events onto spans so the Last9 LLM dashboard renders prompts, completions, and tool calls @observedecorator — manual span creation with tags, metadata, and category- Full OTel GenAI v1.28.0 compliance
pip install last9-genai opentelemetry-exporter-otlp-proto-grpcfrom last9_genai import install
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
handle = install()
handle.tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))That's it. All providers, processors, and OpenAI instrumentation are wired automatically.
Set these environment variables before running:
OTEL_SERVICE_NAME=my-llm-app
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.last9.io
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <your-token>"Python 3.14 + openai-v2: pin
wrapt<2. A kwarg rename in wrapt 2.0 breaksopentelemetry-instrumentation-openai-v2instrumentation silently.
from last9_genai import install, conversation_context, workflow_context
handle = install()
# ... wire OTLP exporter ...
with conversation_context(conversation_id="thread-123", user_id="user-456"):
with workflow_context(workflow_id="support-flow", workflow_type="chat"):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Hello!"}]
)
# Span has gen_ai.conversation.id, workflow.id, user.id automaticallyContexts nest — conversation_context wraps multiple workflow_context calls, all spans in scope get tagged.
from last9_genai import agent_context
with conversation_context(conversation_id="thread-1"):
with agent_context(agent_name="support-bot", agent_id="bot-001"):
# All spans: gen_ai.agent.name, gen_ai.agent.id
response = client.chat.completions.create(...)agent_context composes with conversation_context and workflow_context. Use it for multi-agent handoffs — each agent sets its own identity on its spans.
When install() isn't enough — bring your own providers:
from opentelemetry import trace, _logs
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor
from last9_genai import Last9SpanProcessor, Last9LogToSpanProcessor
import os
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
log_bridge = Last9LogToSpanProcessor()
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(Last9SpanProcessor(log_processor=log_bridge))
trace.set_tracer_provider(tracer_provider)
logger_provider = LoggerProvider()
logger_provider.add_log_record_processor(log_bridge)
_logs.set_logger_provider(logger_provider)
OpenAIInstrumentor().instrument(logger_provider=logger_provider)from last9_genai import observe
from openai import OpenAI
client = OpenAI()
@observe(
tags=["production"],
metadata={"category": "customer_support"}
)
def handle_query(query: str):
return client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": query}]
)category appears in the Last9 LLM dashboard Category column and filter dropdown. Use underscores for multi-word categories (data_analysis → "data analysis").
| Variable | Description | Default |
|---|---|---|
OTEL_SERVICE_NAME |
Service name in traces | unknown-service |
OTEL_EXPORTER_OTLP_ENDPOINT |
OTLP endpoint URL | required |
OTEL_EXPORTER_OTLP_HEADERS |
Auth headers (key=value) |
required |
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT |
Capture prompt/completion bodies | false |
OTEL_RESOURCE_ATTRIBUTES |
Additional resource attributes | — |
| Kwarg | Description | Default |
|---|---|---|
capture_content |
Sets OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true |
True |
instrument_openai |
Call OpenAIInstrumentor().instrument(logger_provider=...) |
True |
set_global |
Register providers as OTel globals | True |
tracer_provider |
Provide an existing TracerProvider |
None |
logger_provider |
Provide an existing LoggerProvider |
None |
**span_processor_kwargs |
Forwarded to Last9SpanProcessor (e.g. custom_pricing) |
— |
install() returns an InstallHandle with .tracer_provider, .logger_provider, and .shutdown().
from last9_genai import install, ModelPricing
handle = install(
capture_content=True,
custom_pricing={
"gpt-4o": ModelPricing(input=2.50, output=10.0),
"gpt-4o-mini": ModelPricing(input=0.15, output=0.60),
"claude-sonnet-4-5": ModelPricing(input=3.0, output=15.0),
}
)Prices are USD per million tokens. Conversion: per-token $0.000003 → 3.0; per-1K $0.003 → 3.0.
| Provider | Model | Input $/1M | Output $/1M |
|---|---|---|---|
| Anthropic | claude-opus-4-6 | 15.0 | 75.0 |
| Anthropic | claude-sonnet-4-5 | 3.0 | 15.0 |
| Anthropic | claude-haiku-4-5 | 0.8 | 4.0 |
| OpenAI | gpt-4o | 2.50 | 10.0 |
| OpenAI | gpt-4o-mini | 0.15 | 0.60 |
| OpenAI | o1 | 15.0 | 60.0 |
| gemini-1.5-pro | 1.25 | 10.0 | |
| gemini-2.0-flash | 0.075 | 0.30 |
For current pricing: Anthropic · OpenAI · Google · llm-prices.com
Use "azure/gpt-4o" for Azure, "ollama/llama3.1" with input=0.0, output=0.0 for self-hosted.
your app
└── install()
├── TracerProvider
│ └── Last9SpanProcessor ← enriches spans with cost, conversation,
│ │ workflow, agent attrs
│ └── (your OTLP exporter)
└── LoggerProvider
└── Last9LogToSpanProcessor ← bridges openai-v2 log events
onto the active span
How Last9LogToSpanProcessor works: opentelemetry-instrumentation-openai-v2 emits prompt/completion content as OTel log records (new GenAI semconv), not span attributes. The bridge listens to those log records and writes gen_ai.prompt, gen_ai.completion, span events, and indexed gen_ai.prompt.{i}.* / gen_ai.completion.{i}.* onto the active span — so the Last9 LLM dashboard can render them.
Two likely causes:
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENTis nottrue.install(capture_content=True)sets this automatically.OpenAIInstrumentor().instrument()was called withoutlogger_provider=. The bridge only receives events if openai-v2 routes logs to the sameLoggerProvider.install()handles this automatically.
install() does not add an exporter — you must wire one:
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
handle.tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))OTLPSpanExporter reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS at instantiation time.
TypeError: wrap_function_wrapper() got an unexpected keyword argument 'module'
Pin wrapt<2 — wrapt 2.0 renamed the kwarg and opentelemetry-instrumentation-openai-v2 2.3b0 hasn't caught up yet.
execute_tool span content capture (tool arguments and results) is Phase 2 work — not yet implemented. Tracked in the project issues.
| Attribute | Description |
|---|---|
gen_ai.conversation.id |
Thread / session identifier |
gen_ai.prompt |
JSON array of prompt messages |
gen_ai.completion |
JSON array of completion choices |
gen_ai.prompt.{i}.role / .content |
Indexed prompt messages |
gen_ai.completion.{i}.role / .content |
Indexed completion choices |
workflow.id |
Workflow identifier |
workflow.type |
Workflow type |
user.id |
User identifier |
gen_ai.agent.id |
Agent identifier |
gen_ai.agent.name |
Agent name |
gen_ai.usage.cost |
Computed cost in USD |
gen_ai.l9.span.kind |
llm / tool / prompt |
See examples/ directory:
basic_usage.py— Simple LLM trackingopenai_integration.py— OpenAI SDKanthropic_integration.py— Anthropic SDKlangchain_integration.py— LangChainfastapi_app.py— FastAPI web app
- Fork the repository
- Create a feature branch
- Run tests:
uv run pytest - Submit a pull request
MIT
- Issues: GitHub Issues
- Email: hello@last9.io