TL; DR
- 2025年12月にGAとなった、DatadogのLLM ObservabilityのOpenTelemetry Gen AI Semantic Conventionsサポートを試してみました。
- 現時点では計装ライブラリ側の仕様準拠不足により可視化は不完全となりました。
- なぜ不完全だったのかの検証の過程でDatadog LLM ObservabilityがGen AI Semantic Conventionsの仕様をどのように解釈しているかがわかりました。
DatadogのLLM Observabilityとは?
LLM Observability機能は、大規模言語モデル(LLM)を利用したアプリケーションのパフォーマンス・信頼性を監視・分析するためのDatadogの機能です。
APMと同様にトレースによるパフォーマンス監視はもちろん、LLMアプリケーション特有の概念であるトークン使用量・コストであったり、どのようなプロンプト・レスポンスだったのか、回答品質の評価など、LLMアプリケーションに特化した観点での監視・分析が可能です。
このように、LLMによるアプリケーションを運用するなら入れておきたい機能であり、機能自体は2024年6月にGAとなった機能ですが、これまではDatadog SDKでの計装やDatadog APIでシグナルを送信する必要がありました。 私のように計装はできるだけベンダーに縛られない形で行いたいユースケースを持っている場合、これまでは魅力的とは思いつつあまり手を出せませんでした。
しかし、2025年12月1日に、DatadogのLLM ObservabilityがOpenTelemetryのGen AI Semantic Conventionsに準拠したシグナルの収集を正式にサポートしました。 これによって、LLMアプリケーション側はDatadogに送信することを前提とせずにベンダーフリーなOpenTelemetryでの計装を行うことが可能になりました1。
OpenTelemetryのGen AI Semantic Conventionsとは?
ここまでOpenTelemetryのGen AI Semantic Conventionsを何度か出してきましたが、改めて説明します。
まずOpenTelemetryのSemantic Conventionsは、OpenTelemtryで収集されたテレメトリシグナルが、どのようなフォーマットを持つべきかを定義した仕様で、2025年12月時点では、1.38.0というバージョンが最新となっています。 そしてこのうち生成AIによるアプリケーションに関するフォーマットを定義しているのが、Gen AI Semantic Conventionsとなっています。
DatadogのLLM ObservabilityはSemantic Conventions v1.37.0以上のGen AI Semantic Conventionsに準拠したテレメトリシグナルをサポートすると明言されており、
- gen_ai.client.token.usageメトリクス: LLM APIクライアントのトークン使用量を指すメトリクス
- gen_ai.provider.name属性: スパンなどに付与されるLLMプロバイダ名の属性
などが使用可能であると想定されます。
サンプルアプリケーション
それでは実際に試してみましょう。
今回用意したアプリケーションは以下のようなLangChainとベクトルDB(Chroma)を利用した簡単なRAG構成です2。

Observabilityの計装には、TraceloopのOpenLLMetryプロジェクトによるOpenTelemetry実装を使います。 traceloop自体はSaaS型のLLMアプリケーション用プラットフォームですが、OpenLLMetryはオープンソースで提供されており、このプロジェクトでの仕様がOpenTelemetryのGen AI Semantic Conventionsとして採用された、という経緯があります。 そのため、traceloop用のsdkも存在するのですが、traceloopに依存しないOpenTelemetryのライブラリも提供しており、今回はそちらを利用します。
以下が実際のコードとなります。 やっていること自体はシンプルで、ラーメン二郎のWikipedia記事をベクトルDBに埋め込みRAGによって参照させることで簡単なラーメン二郎チャットボットを実装しています。
# Observability計装(後述)from observability import setUpOpenTelemetry, setUpDataDog, setUpTraceLoop# 計装方法を切り替えsetUpOpenTelemetry()# setUpDataDog()# setUpTraceLoop()
# LLMアプリケーションdef main(): from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings from langchain_core.messages import SystemMessage from langchain_core.prompts import ChatPromptTemplate, HumanMessagePromptTemplate from langchain_core.runnables import RunnablePassthrough from langchain_community.document_loaders import WebBaseLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_chroma import Chroma
# ベクトルDBにWikipediaのラーメン二郎の記事を埋め込み loader = WebBaseLoader( "https://ja.wikipedia.org/wiki/%E3%83%A9%E3%83%BC%E3%83%A1%E3%83%B3%E4%BA%8C%E9%83%8E") docs = loader.load()
splitter = RecursiveCharacterTextSplitter( chunk_size=1000, chunk_overlap=200, ) chunks = splitter.split_documents(docs)
embedding_model = GoogleGenerativeAIEmbeddings( model="gemini-embedding-001")
vector_store = Chroma(collection_name="example-collection", embedding_function=embedding_model) vector_store.add_documents(chunks)
# ベクトルDBをRAGのretrieverとして利用 retriever = vector_store.as_retriever( search_type="similarity", search_kwargs={"k": 3})
# プロンプトテンプレートの定義 template = ChatPromptTemplate.from_messages([ SystemMessage(content="""Answer the question based on the context below. If the question cannot be answered using the information provided, answer with "I don't know"."""), HumanMessagePromptTemplate.from_template("Context: {context}"), HumanMessagePromptTemplate.from_template("Questtion: {question}"), ])
# RAGチャットボットの定義 model = ChatGoogleGenerativeAI(model="gemini-2.5-flash-lite") ramen_chatbot = ( { "question": RunnablePassthrough(), "context": lambda q: retriever.invoke(q), } | template | model ) response = ramen_chatbot.invoke("ラーメン二郎とは何ですか?")
print(response.text)
main()そしてObservabilityの計装は、OpenTelemetryネイティブな計装と、比較用にDatadog SDK、Traceloop SDKを使った計装の3種類を用意しました。 いずれの計装方法でも、datadog agentを経由してDatadogにシグナルを送信しています。
import osservice = os.getenv("OTEL_SERVICE_NAME")otlp_endpoint = '{}/v1'.format(os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
# Datadog SDKを使った計装def setUpDataDog(): # cf. https://docs.datadoghq.com/ja/llm_observability/instrumentation/sdk/?tab=python from ddtrace.llmobs import LLMObs LLMObs.enable( service=service, )
# Traceloop SDKを使った計装def setUpTraceLoop(): from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from traceloop.sdk import Traceloop Traceloop.init(exporter=OTLPSpanExporter( endpoint=f'{otlp_endpoint}/traces', insecure=True))
# OpenTelemetryネイティブな計装def setUpOpenTelemetry(): from opentelemetry import trace, _logs, metrics # Trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter # Metrics from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter # Log import logging from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler from opentelemetry.sdk._logs.export import BatchLogRecordProcessor from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
# LLM from opentelemetry.instrumentation.langchain import LangchainInstrumentor from opentelemetry.instrumentation.google_generativeai import GoogleGenerativeAiInstrumentor from opentelemetry.instrumentation.chromadb import ChromaInstrumentor
# Trace provider = TracerProvider() processor = BatchSpanProcessor(OTLPSpanExporter( endpoint=f'{otlp_endpoint}/traces', insecure=True)) provider.add_span_processor(processor) trace.set_tracer_provider(provider)
# Metrics metric_reader = PeriodicExportingMetricReader( OTLPMetricExporter(endpoint=f'{otlp_endpoint}/metrics', insecure=True)) provider = MeterProvider(metric_readers=[metric_reader]) metrics.set_meter_provider(provider)
# Log provider = LoggerProvider() processor = BatchLogRecordProcessor( OTLPLogExporter(endpoint=f'{otlp_endpoint}/logs', insecure=True)) provider.add_log_record_processor(processor) _logs.set_logger_provider(provider)
handler = LoggingHandler(level=logging.INFO, logger_provider=provider) logging.basicConfig(handlers=[handler], level=logging.INFO)
LangchainInstrumentor().instrument() GoogleGenerativeAiInstrumentor().instrument() ChromaInstrumentor().instrument()利用ライブラリとバージョン
aiohappyeyeballs==2.6.1aiohttp==3.13.2aiosignal==1.4.0annotated-types==0.7.0anthropic==0.75.0anyio==4.12.0attrs==25.4.0backoff==2.2.1bcrypt==5.0.0beautifulsoup4==4.14.3boto3==1.38.0botocore==1.38.46bs4==0.0.2build==1.3.0bytecode==0.17.0cachetools==6.2.2certifi==2025.11.12charset-normalizer==3.4.4chromadb==1.3.7click==8.3.1colorama==0.4.6coloredlogs==15.0.1cuid==0.4dataclasses-json==0.6.7ddtrace==4.0.0Deprecated==1.2.18distro==1.9.0docstring_parser==0.17.0durationpy==0.10envier==0.6.1filelock==3.20.0filetype==1.2.0flatbuffers==25.9.23frozenlist==1.8.0fsspec==2025.12.0google-auth==2.43.0google-genai==1.55.0googleapis-common-protos==1.72.0grpcio==1.76.0h11==0.16.0hf-xet==1.2.0httpcore==1.0.9httptools==0.7.1httpx==0.28.1httpx-sse==0.4.3huggingface-hub==0.36.0humanfriendly==10.0idna==3.11importlib_metadata==8.7.0importlib_resources==6.5.2inflection==0.5.1Jinja2==3.1.6jiter==0.12.0jmespath==1.0.1jsonpatch==1.33jsonpointer==3.0.0jsonschema==4.25.1jsonschema-specifications==2025.9.1kubernetes==34.1.0langchain==1.1.3langchain-chroma==1.1.0langchain-classic==1.0.0langchain-community==0.4.1langchain-core==1.1.3langchain-google-genai==4.0.0langchain-text-splitters==1.0.0langgraph==1.0.4langgraph-checkpoint==3.0.1langgraph-prebuilt==1.0.5langgraph-sdk==0.2.15langsmith==0.4.59markdown-it-py==4.0.0MarkupSafe==3.0.3marshmallow==3.26.1mdurl==0.1.2mmh3==5.2.0mpmath==1.3.0multidict==6.7.0mypy_extensions==1.1.0numpy==2.3.5oauthlib==3.3.1onnxruntime==1.23.2opentelemetry-api==1.39.0opentelemetry-exporter-otlp==1.39.0opentelemetry-exporter-otlp-proto-common==1.39.0opentelemetry-exporter-otlp-proto-grpc==1.39.0opentelemetry-exporter-otlp-proto-http==1.39.0opentelemetry-instrumentation==0.60b0opentelemetry-instrumentation-agno==0.49.8opentelemetry-instrumentation-alephalpha==0.49.8opentelemetry-instrumentation-anthropic==0.49.8opentelemetry-instrumentation-bedrock==0.49.8opentelemetry-instrumentation-chromadb==0.49.8opentelemetry-instrumentation-cohere==0.49.8opentelemetry-instrumentation-crewai==0.49.8opentelemetry-instrumentation-google-generativeai==0.49.8opentelemetry-instrumentation-groq==0.49.8opentelemetry-instrumentation-haystack==0.49.8opentelemetry-instrumentation-lancedb==0.49.8opentelemetry-instrumentation-langchain==0.49.8opentelemetry-instrumentation-llamaindex==0.49.8opentelemetry-instrumentation-logging==0.60b0opentelemetry-instrumentation-marqo==0.49.8opentelemetry-instrumentation-mcp==0.49.8opentelemetry-instrumentation-milvus==0.49.8opentelemetry-instrumentation-mistralai==0.49.8opentelemetry-instrumentation-ollama==0.49.8opentelemetry-instrumentation-openai==0.49.8opentelemetry-instrumentation-openai-agents==0.49.8opentelemetry-instrumentation-pinecone==0.49.8opentelemetry-instrumentation-qdrant==0.49.8opentelemetry-instrumentation-redis==0.60b0opentelemetry-instrumentation-replicate==0.49.8opentelemetry-instrumentation-requests==0.60b0opentelemetry-instrumentation-sagemaker==0.49.8opentelemetry-instrumentation-sqlalchemy==0.60b0opentelemetry-instrumentation-threading==0.60b0opentelemetry-instrumentation-together==0.49.8opentelemetry-instrumentation-transformers==0.49.8opentelemetry-instrumentation-urllib3==0.60b0opentelemetry-instrumentation-vertexai==0.49.8opentelemetry-instrumentation-watsonx==0.49.8opentelemetry-instrumentation-weaviate==0.49.8opentelemetry-instrumentation-writer==0.49.8opentelemetry-proto==1.39.0opentelemetry-sdk==1.39.0opentelemetry-semantic-conventions==0.60b0opentelemetry-semantic-conventions-ai==0.4.13opentelemetry-util-http==0.60b0orjson==3.11.5ormsgpack==1.12.0overrides==7.7.0packaging==25.0posthog==5.4.0propcache==0.4.1protobuf==6.33.2pyasn1==0.6.1pyasn1_modules==0.4.2pybase64==1.4.3pydantic==2.12.5pydantic-settings==2.12.0pydantic_core==2.41.5Pygments==2.19.2PyPika==0.48.9pyproject_hooks==1.2.0python-dateutil==2.9.0.post0python-dotenv==1.2.1PyYAML==6.0.3referencing==0.37.0regex==2025.11.3requests==2.32.5requests-oauthlib==2.0.0requests-toolbelt==1.0.0rich==14.2.0rpds-py==0.30.0rsa==4.9.1s3transfer==0.12.0safetensors==0.7.0sentry-sdk==2.47.0setuptools==80.9.0shellingham==1.5.4six==1.17.0sniffio==1.3.1soupsieve==2.8SQLAlchemy==2.0.45sympy==1.14.0tenacity==9.1.2tiktoken==0.12.0tokenizers==0.22.1tqdm==4.67.1trace-attributes==7.2.1traceloop-sdk==0.49.8transformers==4.57.3typer==0.20.0typing-inspect==0.9.0typing-inspection==0.4.2typing_extensions==4.15.0ujson==5.11.0urllib3==2.3.0uuid_utils==0.12.0uvicorn==0.38.0uvloop==0.22.1watchfiles==1.1.1websocket-client==1.9.0websockets==15.0.1wrapt==1.17.3xxhash==3.6.0yarl==1.22.0zipp==3.23.0zstandard==0.25.0このアプリケーションを実行すると、「ラーメン二郎とは何ですか?」という質問に対して、以下のようなWikipediaの記事内容を元にした回答が得られます3。
ラーメン二郎は、東京都港区三田に本店を構えるラーメン店、およびそこからのれん分けした同名の店舗です。店主・創業者である山田拓美が商標を登録しており、三田本店、全国各地の「ラーメン二郎〇〇店」(直系店)、および 「二郎系」「二郎インスパイア系」と呼ばれるラーメン店舗のジャンルも含まれます。
OpenTelemetry計装によるDatadog LLM Observability
それでは実際にDatadogのLLM Observabilityでどのように見えるか確認してみましょう。
5分ほど経つとちゃんとLLM Observabilityの機能には認識されているものの、InputやOutputの内容がないなど少し不完全に見えます。

トレースを開いてみると、スパンが2つしか認識されておらず、LLM呼び出しやベクトルDB呼び出しは認識されていないようです。
また、プロンプトの内容はもちろん、モデルやトークン使用量の情報も取得できていません。

APMの方で改めてトレースを確認してみると、埋め込み用モデルの呼び出しスパンは無いように見えるものの、ベクトルDBクエリや回答生成時のスパンが認識されています。 スパンの属性を確認してみても、モデル名やトークン使用量・プロンプトの内容も取得できているようです。

つまり、LLM Observability機能がシグナルから必要な情報を取得する部分で不完全な状態となっているようです。 この挙動はtraceloop SDKを使った計装でも同様でした4。
この原因については後ほど考察するとして、先にDatadog SDK計装による結果と比較してみましょう。
Datadog SDK計装によるDatadog LLM Observability
Datadog SDKを使った計装では、(当然ではありますが)LLM Observability機能に完全に対応した形で計装されており、以下のようにプロンプトや使用モデル・トークン使用量、さらには使用コストまで正しく認識されています5。

APM側で見てみるとOpenTelemetry計装とは異なり、スパン属性にはあまり情報がなく、代わりにLLM ObservabilityのUIとして可視化されるようです。

このように、Datadog SDKを使った計装では、さすが公式というだけあってLLM Observability機能に完全に対応した形で計装されていることがわかります。
計装の違いによる可視化のされ方を比較したところで、この原因について考察していきます。
OpenTelemetry計装だとなぜ不完全なのか?
OpenTelemetry計装におけるスパンの属性とGen AI Semantic Conventionsを比較してみると、不完全な理由としてスパンが完全にはSemantic Conventionsに準拠していないということが見えてきます。
具体的に確認できた仕様との乖離点は以下の通りです。
- スパン名:仕様では利用目的(埋め込み・生成)に応じてスパン名を適切に命名するべきとされていますが(SHOULD)、それに準拠していません。
- 属性の欠落:仕様でRequiredとなっているgen_ai.operation.nameとgen_ai.provider.nameというスパン属性が不足しています。
- Input・Outputの欠落:仕様ではLLM呼び出し時に含めるメッセージ履歴や出力をgen_ai.input/output.messagesとして含めるべきとされていますが(SHOULD)、違う名前で含めています。
実際にこれらのうちのどれが原因で正しく機能していないかを確かめるために、実験をしてみましょう。
以下のコードは、アプリケーションの動作としては1秒待機するだけですが、OpenTelemetryを使ってGen AI Semantic Conventionsに準拠したスパンを生成するようにしています。
def dummy_llm(): import time from opentelemetry import trace from opentelemetry.trace import SpanKind tracer = trace.get_tracer(__name__)
# 実験1: 仕様に準拠したダミーのスパンを生成(ベースライン)
# 実験2: スパン名を変更 with tracer.start_as_current_span("generate_content gemini-2.5-flash-lite", kind=SpanKind.CLIENT) as llm_span: # 実験3: Requiredとなっているgen_ai.operation.nameを削除 llm_span.set_attribute("gen_ai.operation.name", "generate_content") # 実験4: Deprecatedであるgen_ai.system及びRequiredとなっているgen_ai.provider.nameを削除 # 実験5: 実験3と4を両方実施 llm_span.set_attribute("gen_ai.provider.name", "google") llm_span.set_attribute("gen_ai.system", "google")
llm_span.set_attribute( "gen_ai.request.model", "gemini-2.5-flash-lite") llm_span.set_attribute("gen_ai.request.temperature", 0.5) llm_span.set_attribute("gen_ai.response.model", "gemini-2.5-flash-lite") llm_span.set_attribute("gen_ai.usage.input_tokens", 50) llm_span.set_attribute("gen_ai.usage.output_tokens", 100)
input_messages = [ { "role": "user", "parts": [ { "type": "text", "content": "Weather in Paris?" } ] } ]
output_messages = [ { "role": "assistant", "parts": [ { "type": "text", "content": "The weather in Paris is currently rainy with a temperature of 57°F." } ], "finish_reason": "stop" } ]
# 実験6: Input/Outputの属性を削除 llm_span.set_attribute("gen_ai.input.messages", json.dumps( input_messages, ensure_ascii=False)) llm_span.set_attribute("gen_ai.output.messages", json.dumps( output_messages, ensure_ascii=False)) time.sleep(1)
dummy_llm()このコードを使って、以下の6つの実験を行います。
- 仕様に準拠したダミーのスパンを生成(ベースライン)
- スパン名を変更したスパンを作成
- gen_ai.operation.name属性を削除したスパンを作成
- gen_ai.system(deprecated)・gen_ai.provider.name属性を削除したスパンを作成
- 実験3,4の両方を同時に実施したスパンを作成
- Input/Outputの属性を削除したスパンを作成
これらの実験を行いLLM Observabilityでの見え方を確認したところ、実験5以外のスパンがLLM Observability機能に認識されました。

また、それぞれの結果は、以下の通りです。
まず、実験1では、Datadog SDK計装と同様にプロンプト・使用モデル・コストを含めLLM API呼び出しスパンとして正しく認識されました。

実験2では、スパン名が変わったものの、実験1と同様に正しく認識されました6。

実験3では、LLM Observability機能に認識されたものの、LLM API呼び出しスパンとしては認識されず、使用モデルやトークン情報・コストが取得できていません。

実験4では、LLM API呼び出しスパンとして認識されているものの、使用モデルがGoogleではなくcustomとなってしまい、コストが取得できていません。

実験6では、プロンプトと出力内容が取得できていませんが、LLM API呼び出しスパンとしては正しく認識されました。

これらの結果をまとめると、DatadogのLLM Observability機能は以下のようにGen AI Semantic Conventionsの仕様を解釈していることがわかります。
- gen_ai.operation.name・gen_ai.provider.name・gen_ai.system属性のどれかがあればLLM Observability機能に認識される7
- gen_ai.operation.name属性によってスパンの種類(LLM呼び出し・ツールの呼び出し)が判断される
- LLM呼び出しスパンでないと使用モデルやトークン使用量・コストは取得されない
- gen_ai.provider.name属性によってLLM APIのプロバイダが判断されコスト情報の根拠となる
- gen_ai.input/output.messages属性があればプロンプト・出力内容が取得される
そして、この挙動に基づくと、今回サンプルアプリケーションで実施したOpenTelemetry計装が不完全だったのは以下のような理由であると結論づけられます。
- 多くのスパンでgen_ai.operation.name・gen_ai.provider.name・gen_ai.system属性が不足しており、LLM Observability機能に認識されなかった
- LLM呼び出しを実行しているスパンにおいてgen_ai.operation.name属性が不足しており、LLM呼び出しスパンとして認識されなかった
- プロンプトや出力内容を含む属性がgen_ai.input/output.messagesではなく、gen_ai.prompts/completionsというdeprecatedな属性名で含まれていた。
つまり、DatadogのLLM Observability機能は確かにOpenTelemetryのGen AI Semantic Conventionsに準拠したシグナルをサポートしていますが、肝心の計装ライブラリ側が完全に使用に準拠しているわけでは無いため、現時点では不完全な形での計装となってしまうということ身も蓋もない結論となりました。
まとめ
最初はOpenTelemetry計装でもLLM Observability機能が利用できてよかったね、という記事にするつもりでしたが、計装ライブラリ側がそもそも仕様に準拠していないということもあって細かい検証をした結果非常に長くなってしまいました。
OpenTelemetryのGen AI Semantic Conventions自体はまだ新しい仕様であり、仕様の変化にライブラリ側がついていけていないという現状があるため、「OpenTelemetryに準拠しておけばベンダーフリーでLLMのObservabilityが実現できる」という世界線はまだ遠いかもしれません。
その世界を実現するためにも、OpenTelemetryプロジェクトにはぜひ頑張っていってほしいところです。 (人任せすぎるのでライブラリ側の非準拠についてはIssueを切るなどしていくつもりです。まずはプロンプト系のdeprecatedを議題とするissueを立ててみました。)
Footnotes
-
そしていつでもDatadogから乗り換えることができるようになりました。 ↩
-
MCPやLangGraphなどによる本格的なLLMアプリケーションを用意するのが面倒だったので、非常にシンプルにしています。 ↩
-
個人的にはラーメン二郎の中にはインスパイアは含まない派です。 ↩
-
内部ではOpenTelemetry計装を用いているので当然かもしれません。 ↩
-
LLM APIは本来2回の呼び出しのはずですが、親スパンも含めてカウントしているようです。 ↩
-
実際Datadog SDK計装でもスパン名はoperation.nameに余計な文字列(上記の画像ではModes.)がついていたり、モデル名が入っていなかったりと仕様に準拠していません。 ↩
-
deprecatedな属性なので書いていませんが、gen_ai.system属性だけでも認識自体はされました。 ↩