← Kembali ke Blog

Trace AI Agent Kamu pakai OpenTelemetry GenAI Semconv (Python)

Misalnya ada user yang melaporkan suhu Jakarta salah. Kamu buka log dan lihat 41 baris output model, dua eksekusi tool, dan satu retry jam 03:12. Langkah mana yang nyerahin angka salah itu ke model? Log yang datar nggak bisa jawab, karena satu kali jalan agent itu bentuknya pohon, bukan garis: satu loop agent, beberapa panggilan model, dan tiap tool call nempel di panggilan model yang minta.

Di microservices, masalah ini sudah lama selesai pakai OpenTelemetry. Sejak 2024, GenAI SIG ngerjain hal yang sama untuk agent LLM, dan hasilnya kosakatanya kecil dan bisa kamu pakai hari ini: atribut gen_ai.*, segelintir nama span yang sudah baku, dan aturan kapan prompt boleh direkam.

Semua yang ada di bawah ini dijalankan di mesin saya, dengan loop agent dua turn menghadap model mock lokal. Versi paket di bagian prasyarat adalah versi persis dari run itu, dan dump span yang saya kutip bukan ilustrasi, tapi output asli.

Apa yang sebenarnya diatur konvensi ini

Konvensi GenAI memisahkan span untuk lapisan agent dan lapisan model. Yang paling sering kamu pakai:

Operation Nama span Kind Isinya
create_agent create_agent {gen_ai.agent.name} CLIENT Bikin agent: graph, asisten SDK, atau class buatan sendiri
invoke_agent invoke_agent {gen_ai.agent.name} CLIENT atau INTERNAL Panggil agent. CLIENT kalau agent-nya jalan di tempat lain, INTERNAL buat loop di proses yang sama
invoke_workflow invoke_workflow {nama} CLIENT Satu step workflow atau node subgraph
chat chat {gen_ai.request.model} CLIENT Satu panggilan model
execute_tool execute_tool {gen_ai.tool.name} runtime agent Saat agent benar-benar menjalankan function, retrieval, atau shell

Hampir semua span wajib bawa gen_ai.operation.name dan gen_ai.provider.name. Di panggilan model wajib ada gen_ai.request.model, plus gen_ai.usage.input_tokens dan gen_ai.usage.output_tokens kalau provider mengirim hitungan token.

Ada satu atribut yang sengaja nggak dikarang oleh spec: gen_ai.conversation.id. Konvensinya bilang instrumentation TIDAK boleh nge-fallback ke UUID baru atau trace id kalau memang nggak ada identifier percakapan, dan developer aplikasi boleh nambahin conversation id sendiri lewat span processor. Kalimat itu alasan kenapa Step 5 ada, dan juga pembeda antara tumpukan trace yang nggak nyambung dan satu percakapan yang bisa kamu baca dari awal sampai akhir.

Prasyarat

  • Python 3.10 atau lebih baru (instrumentasi OpenAI-nya butuh >=3.10)
  • Versi dari run ini: opentelemetry-sdk==1.44.0, opentelemetry-instrumentation-openai-v2==2.4b0, openai==3.19.2, httpx==0.28.1
  • Nggak butuh API key. Script-nya ngarahin client OpenAI ke mock lokal, jadi kamu bisa ikut praktik gratis dan outputnya deterministik.

Step 1: Install

python3 -m venv .venv
source .venv/bin/activate
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http \
  opentelemetry-instrumentation-openai-v2 openai httpx

Dua paket ini dipin karena konvensinya masih berstatus Development, bukan karena saya suka pin. Instrumentasi OpenAI-nya masih pre-release (2.4b0), dan default-nya nempel ke versi konvensi yang lebih lama kalau kamu nggak opt-in. Pin juga client OpenAI-nya, karena instrumentasi nge-patch layer HTTP yang dipakai client dan layer itu berubah: openai 3.x pakai httpx2, sementara instrumentasinya import httpx. Ketidakcocokan itu jadi gotcha pertama di Step 8.

Step 2: Endpoint model yang deterministik

Jalankan ini di terminal kedua. Dia menjawab pertanyaan cuaca dalam dua langkah: pertama minta tool call, lalu kasih jawaban akhir begitu hasil tool-nya masuk.

"""Canned OpenAI-compatible endpoint untuk eksperimen tracing agent lokal."""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        body = json.loads(self.rfile.read(length) or b"{}")
        already_ran_tool = any(m.get("role") == "tool" for m in body.get("messages", []))
        if not already_ran_tool:
            message = {
                "role": "assistant",
                "content": None,
                "tool_calls": [
                    {
                        "id": "call_abc123",
                        "type": "function",
                        "function": {
                            "name": "get_weather",
                            "arguments": '{"city":"Jakarta"}',
                        },
                    }
                ],
            }
            finish = "tool_calls"
        else:
            message = {"role": "assistant", "content": "Jakarta is 31C and humid."}
            finish = "stop"
        payload = {
            "id": "chatcmpl-mock",
            "object": "chat.completion",
            "created": 1789000000,
            "model": body.get("model", "gpt-4o-mini"),
            "choices": [{"index": 0, "message": message, "finish_reason": finish}],
            "usage": {"prompt_tokens": 412, "completion_tokens": 96, "total_tokens": 508},
        }
        data = json.dumps(payload).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):
        pass


if __name__ == "__main__":
    HTTPServer(("127.0.0.1", 8099), Handler).serve_forever()
python mock_llm.py

Kalau nanti pakai provider beneran, hapus base_url dan isi API key. Sisanya di Step 3 nggak berubah.

Step 3: Instrumentasi agent-nya, bukan cuma panggilan model

Simpan ini sebagai agent_demo.py. Ada tiga bagian yang perlu kamu baca pelan-pelan: span processor yang nyap langsung conversation id, span invoke_agent yang jadi parent semua span lain, dan span execute_tool yang membungkus eksekusi function-nya.

"""Loop agent dua turn dengan konvensi OpenTelemetry GenAI."""
import json
import os
import uuid
from contextvars import ContextVar

from opentelemetry import trace
from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from openai import OpenAI

CONVERSATION_ID: ContextVar[str | None] = ContextVar("conversation_id", default=None)

AGENT_NAME = "travel-concierge"
AGENT_ID = "travel-concierge-001"
MODEL = "gpt-4o-mini"

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Current weather for a city",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            },
        },
    }
]


class ConversationSpanProcessor(SpanProcessor):
    """Nempel gen_ai.conversation.id di setiap span yang dibuka selama satu run."""

    def __init__(self, inner: SpanProcessor):
        self._inner = inner

    def on_start(self, span, parent_context=None):
        conversation_id = CONVERSATION_ID.get()
        if conversation_id is not None:
            span.set_attribute("gen_ai.conversation.id", conversation_id)
        self._inner.on_start(span, parent_context=parent_context)

    def on_end(self, span):
        self._inner.on_end(span)

    def shutdown(self):
        self._inner.shutdown()

    def force_flush(self, timeout_millis=30000):
        return self._inner.force_flush(timeout_millis=timeout_millis)


def setup_tracing() -> TracerProvider:
    provider = TracerProvider(resource=Resource.create({"service.name": "travel-agent"}))
    exporter = BatchSpanProcessor(ConsoleSpanExporter())
    if os.getenv("NO_CONV_PROCESSOR"):
        provider.add_span_processor(exporter)
    else:
        provider.add_span_processor(ConversationSpanProcessor(exporter))
    trace.set_tracer_provider(provider)
    return provider


tracer = trace.get_tracer("travel.concierge")
client = OpenAI(api_key="local-mock", base_url="http://127.0.0.1:8099/v1")


def dispatch_tool(name: str, args: dict) -> str:
    if name == "get_weather":
        return json.dumps({"city": args["city"], "temp_c": 31, "humidity": 78})
    raise ValueError(f"unknown tool {name}")


def run_tool(call) -> str:
    args = json.loads(call.function.arguments)
    with tracer.start_as_current_span(f"execute_tool {call.function.name}") as span:
        span.set_attribute("gen_ai.operation.name", "execute_tool")
        span.set_attribute("gen_ai.tool.name", call.function.name)
        span.set_attribute("gen_ai.tool.type", "function")
        span.set_attribute("gen_ai.tool.call.id", call.id)
        try:
            result = dispatch_tool(call.function.name, args)
            span.set_attribute("gen_ai.tool.call.result", result)
            return result
        except Exception as exc:
            span.set_attribute("error.type", type(exc).__name__)
            span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
            raise


def run_agent(user_message: str, conversation_id: str) -> str:
    token = CONVERSATION_ID.set(conversation_id)
    try:
        with tracer.start_as_current_span(f"invoke_agent {AGENT_NAME}") as span:
            span.set_attribute("gen_ai.operation.name", "invoke_agent")
            span.set_attribute("gen_ai.agent.name", AGENT_NAME)
            span.set_attribute("gen_ai.agent.id", AGENT_ID)
            span.set_attribute("gen_ai.provider.name", "openai")
            span.set_attribute("gen_ai.request.model", MODEL)

            messages = [
                {"role": "system", "content": "You answer weather questions with the tool."},
                {"role": "user", "content": user_message},
            ]
            for step in range(3):
                reply = client.chat.completions.create(
                    model=MODEL, messages=messages, tools=TOOLS
                )
                choice = reply.choices[0]
                if choice.finish_reason == "tool_calls" and choice.message.tool_calls:
                    messages.append(choice.message)
                    for call in choice.message.tool_calls:
                        messages.append(
                            {
                                "role": "tool",
                                "tool_call_id": call.id,
                                "content": run_tool(call),
                            }
                        )
                    continue
                return choice.message.content or ""
            raise RuntimeError("agent did not finish within 3 steps")
    finally:
        CONVERSATION_ID.reset(token)


if __name__ == "__main__":
    provider = setup_tracing()
    OpenAIInstrumentor().instrument()
    conversation_id = f"conv-{uuid.uuid4().hex[:12]}"
    for turn in range(2):
        answer = run_agent("How hot is it in Jakarta right now?", conversation_id)
        print(f"turn {turn + 1}: {answer}", flush=True)
    print(f"conversation_id: {conversation_id}", flush=True)
    provider.shutdown()

Step 4: Jalanin dengan dua environment variable penting

Dua-duanya harus di-export di shell. OTEL_SEMCONV_STABILITY_OPT_IN dibaca waktu modul instrumentasinya di-load, jadi di-set dari dalam Python setelah import itu terlambat, dan gejalanya halus: atributmu diam-diam muncul dengan nama versi lama.

export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only
python agent_demo.py > spans.jsonl

Dua turn menghasilkan delapan span, empat per turn, dengan struktur begini:

invoke_agent travel-concierge            (INTERNAL, root)  gen_ai.conversation.id
├── chat gpt-4o-mini                     (CLIENT)          finish_reasons=['tool_calls']
├── execute_tool get_weather             (hasil tool ada di span)
└── chat gpt-4o-mini                     (CLIENT)          finish_reasons=['stop']

Ini span chat pertama seperti yang dicetak console exporter, dipotong ke atribut GenAI saja:

span='chat gpt-4o-mini' kind=CLIENT  parent=invoke_agent
    gen_ai.conversation.id       = conv-ea5e82771faf
    gen_ai.operation.name        = chat
    gen_ai.provider.name         = openai
    gen_ai.request.model         = gpt-4o-mini
    gen_ai.response.model        = gpt-4o-mini
    gen_ai.response.id           = chatcmpl-mock1
    gen_ai.response.finish_reasons = ['tool_calls']
    gen_ai.usage.input_tokens    = 412
    gen_ai.usage.output_tokens   = 96

Dan span tool, yang justru paling sering kamu cari jam 3 pagi:

span='execute_tool get_weather'
    gen_ai.operation.name  = execute_tool
    gen_ai.tool.name       = get_weather
    gen_ai.tool.type       = function
    gen_ai.tool.call.id    = call_abc123
    gen_ai.tool.call.result = {"city": "Jakarta", "temp_c": 31, "humidity": 78}

Nesting-nya itu inti manfaatnya. parent di dua span anak menunjuk ke span invoke_agent, jadi error di dalam tool cuma sejauh satu klik dari panggilan model yang minta tool itu, dan gen_ai.tool.call.id bisa dipakai buat nyambungin span ke tool call id di log provider.

Step 5: Conversation id, yang nggak akan ditebak instrumentasi

Set NO_CONV_PROCESSOR=1 dan script yang sama menghasilkan nol span dengan gen_ai.conversation.id. Instrumentasinya sendiri sudah mengisi provider, model, jumlah token, dan finish reason, tapi dia nggak tahu di mana satu percakapan user mulai dan selesai, dan spec memang melarang dia mengarang.

Jadi kamu yang nyap, dari ContextVar, pakai span processor yang didaftarkan paling luar di provider. Karena on_start jalan untuk setiap span yang dibuka proses itu, span chat hasil auto-instrumentation yang nggak pernah kamu sentuh juga kena atributnya. Di run di atas, delapan span jatuh di dua trace id tapi berbagi satu conversation id, satu per turn, jadi percakapannya tetap bisa dibaca utuh walaupun dua trace-nya berbeda.

Di service beneran, kamu nggak generate id itu di dalam agent. Id-nya datang dari request: handler web kamu baca session atau thread id lalu mengalirkannya ke bawah.

Step 6: Token, biaya, dan metric yang gratis

Jumlah token mendarat di setiap span chat tanpa usaha tambahan: gen_ai.usage.input_tokens dan gen_ai.usage.output_tokens, plus gen_ai.usage.cache_read.input_tokens kalau provider melaporkan cache hit. Biaya itu dua angka tadi dikali harga per token provider kamu, dan karena itu konvensinya nggak pura-pura tahu mata uang. Satu detail yang gampang bikin tagihan salah hitung: gen_ai.usage.input_tokens sudah termasuk input yang kena cache, jadi jangan ditambah lagi dengan angka cache read.

Instrumentasinya juga mengirim metric dari konvensi yang sama: gen_ai.client.token.usage dan gen_ai.client.operation.duration untuk panggilan model, plus gen_ai.invoke_agent.duration, gen_ai.invoke_agent.tool_calls, dan gen_ai.execute_tool.duration untuk lapisan agent. Dari situ ada dua alert yang layak dipasang sejak hari pertama: p99 durasi agent, dan counter kegagalan tool call.

Kalau datanya sudah masuk backend, query yang berguna: group by gen_ai.agent.name dan gen_ai.request.model, jumlahkan token input dan output per percakapan, lalu urutkan menaik. Percakapan yang menghabiskan 40 panggilan model untuk pertanyaan yang butuh tiga biasanya masalah prompt atau deskripsi tool, bukan masalah modelnya.

Step 7: Content capture punya empat nilai dan satu jebakan

Isi pesan dimatikan secara default, dan itu keputusan yang benar karena prompt bawa PII. Menyalakannya pakai enum, bukan boolean:

  • true: mode lama. Isinya keluar sebagai log event, jadi exporter yang cuma kirim trace nggak akan menampilkan apa-apa.
  • span_only: isi pesan jadi atribut span (gen_ai.input.messages, gen_ai.output.messages, gen_ai.system_instructions).
  • event_only: isi pesan jadi event.
  • span_and_event: dua-duanya.

Saya jalankan loop-nya tiga kali untuk lihat bedanya. Dengan true dan tanpa log exporter, span chat nggak bawa isi pesan sama sekali: span tetap delapan, atribut tetap sama, pesannya kosong. Dengan span_only, keempat span chat bawa gen_ai.input.messages dan gen_ai.output.messages. Kalau kamu sudah set variable-nya tapi prompt-nya tetap nggak kelihatan, biasanya ini penyebabnya.

Untuk production ada default yang lebih aman daripada menaruh prompt di span: simpan metadata di span dan kirim payload-nya ke storage yang kamu kontrol, pakai hook bawaan (OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK=upload plus OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH yang menunjuk ke path atau bucket fsspec-compatible). Jadi trace-nya cuma nyimpen referensi, dan teks sensitifnya ikut aturan retensi kamu sendiri.

Step 8: Kirim ke backend, dan urusan tool MCP

Ganti console exporter dengan backend beneran itu tiga environment variable:

export OTEL_SERVICE_NAME=travel-agent
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc

Semua backend yang bisa bicara OTLP jalan, dan pindah backend nanti nggak makan biaya migrasi karena kontraknya nama atribut, bukan SDK vendornya.

Kalau tool-mu lewat MCP, konvensinya punya sub-spec sendiri. Span MCP bawa mcp.method.name (initialize, tools/call, dan seterusnya), mcp.protocol.version, dan mcp.session.id, dengan nama span {mcp.method.name} {target}. Trace context-nya ikut di dalam property bag params._meta pada JSON-RPC sebagai traceparent dan tracestate tanpa prefix, sesuai SEP-414, jadi span di server MCP tetap jadi anak span client meskipun satu request HTTP bisa membawa beberapa pesan MCP. Satu aturan bisa nyelametin kamu dari span dobel: kalau instrumentasi MCP tahu eksekusi tool-nya sudah ditrace instrumentasi GenAI, dia harus nambahin atribut MCP ke span yang sudah ada, bukan bikin span kedua.

Gotcha dari run ini

  • ModuleNotFoundError: No module named 'httpx' saat import pertama. Instrumentasinya import httpx waktu modul di-load, sementara openai 3.19.2 bergantung ke httpx2. Install httpx secara eksplisit menyelesaikannya. Pin client dan instrumentasinya bersamaan, dan jalankan ulang test setelah bump apa pun, karena jalur patching itu bagian yang rusaknya senyap.
  • Opt-in stabilitas dibaca saat import. Set di environment proses, jangan di dalam Python.
  • Konvensinya masih berstatus Development. Beberapa atribut pernah ganti nama, jadi taruh semua string atribut di satu modul dan perlakukan upgrade sebagai kejadian yang disengaja.
  • gen_ai.conversation.id itu tugasmu. Tanpa processor, nggak ada grouping, dan spec melarang fallback yang malas.

Checklist untuk agent kamu

  1. invoke_agent membungkus seluruh loop, satu execute_tool per tool call, gen_ai.operation.name di setiap span.
  2. Nama provider, model, dua hitungan token, dan finish reason ada di setiap span chat.
  3. Conversation id disap dari processor, sumbernya session asli, bukan UUID acak.
  4. Content capture mati di production, atau diupload ke storage sendiri.
  5. Alert untuk p99 durasi agent dan jumlah kegagalan tool, sebelum kamu butuh jam 3 pagi.

Referensi

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis