Skip to content

Python — manual instrumentation

Pure manual spans with no instrumentation library — just OTel SDK + raw HTTP. Universal template for any provider/language.

Before you start

These examples export to a local OpenTelemetry Collector over OTLP/gRPC. Deploy the collector first — see Code examples → Deploy an OpenTelemetry Collector.

Install

pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-grpc httpx

Environment variables

export OPENAI_API_KEY="sk-..."
export CX_OTEL_ENDPOINT="http://<COLLECTOR_HOST>:4317"

Script

import json, os
import httpx

# --- OTel imports ---
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import SpanKind, StatusCode

# --- OTel setup: configure tracer provider and OTLP exporter ---
CX_ENDPOINT = os.environ.get("CX_OTEL_ENDPOINT", "http://<COLLECTOR_HOST>:4317")
OPENAI_KEY = os.environ.get("OPENAI_API_KEY", "")

resource = Resource.create({"service.name": "manual-genai-demo",
    "cx.application.name": "my-genai-app", "cx.subsystem.name": "my-service"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(
    OTLPSpanExporter(endpoint=CX_ENDPOINT, insecure=True)))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("genai-manual", "1.0.0")


def to_semconv(messages):
    """Convert OpenAI messages to new semconv JSON parts format."""
    result = []
    for m in messages:
        parts = []
        if m.get("content"): parts.append({"type": "text", "content": m["content"]})
        if m.get("tool_calls"):
            for tc in m["tool_calls"]:
                parts.append({"type": "tool_call", "id": tc["id"],
                    "name": tc["function"]["name"], "arguments": tc["function"]["arguments"]})
        entry = {"role": m["role"], "parts": parts}
        if m.get("tool_call_id"): entry["tool_call_id"] = m["tool_call_id"]
        result.append(entry)
    return json.dumps(result)


# --- Your app logic with manual OTel GenAI spans ---
def chat(messages, model="gpt-4o-mini", max_tokens=200, user=None):
    # OTel: create a GenAI span with required attributes
    with tracer.start_as_current_span(f"chat {model}", kind=SpanKind.CLIENT,
        attributes={"gen_ai.operation.name": "chat", "gen_ai.provider.name": "openai",
            "gen_ai.request.model": model, "gen_ai.request.max_tokens": max_tokens,
            "gen_ai.input.messages": to_semconv(messages),
            "server.address": "api.openai.com", "server.port": 443}) as span:
        if user: span.set_attribute("gen_ai.request.user", user)

        body = {"model": model, "messages": messages, "max_tokens": max_tokens}
        if user: body["user"] = user

        resp = httpx.post("https://api.openai.com/v1/chat/completions",
            headers={"Authorization": f"Bearer {OPENAI_KEY}",
                     "Content-Type": "application/json"}, json=body, timeout=60.0)
        resp.raise_for_status()
        data = resp.json()

        usage = data.get("usage", {})
        choices = data.get("choices", [])
        span.set_attribute("gen_ai.response.model", data.get("model", model))
        span.set_attribute("gen_ai.response.id", data.get("id", ""))
        span.set_attribute("gen_ai.response.finish_reasons",
            json.dumps([c.get("finish_reason") for c in choices]))
        span.set_attribute("gen_ai.usage.input_tokens", usage.get("prompt_tokens", 0))
        span.set_attribute("gen_ai.usage.output_tokens", usage.get("completion_tokens", 0))

        out = []
        for c in choices:
            m = c.get("message", {})
            parts = []
            if m.get("content"): parts.append({"type": "text", "content": m["content"]})
            if m.get("tool_calls"):
                for tc in m["tool_calls"]:
                    parts.append({"type": "tool_call", "id": tc["id"],
                        "name": tc["function"]["name"], "arguments": tc["function"]["arguments"]})
            out.append({"role": m.get("role", "assistant"), "parts": parts})
        span.set_attribute("gen_ai.output.messages", json.dumps(out))
        return data


result = chat(
    messages=[{"role": "system", "content": "You are a concise assistant."},
              {"role": "user", "content": "What is OpenTelemetry in one sentence?"}],
    user="user-42")
print(f"Response: {result['choices'][0]['message']['content']}")

provider.force_flush()
provider.shutdown()

Universal template

To adapt for Anthropic, Gemini, or any other provider, change the HTTP endpoint, request body format, and response parsing. The OTel span attributes remain identical.

Next steps

Look up which open-source library to use for your provider in Compatibility matrix.