kashinoki38 blog

something like tech blog

AgentCore Observability のログ構造と、Strands / LangGraph の OTel 仕様

AgentCore Observability が CloudWatch のどのロググループに、どの OpenTelemetry スキーマで着地するかを仕様理解が浅かったので整理した。
モチベーションとしては、AgentCore Evaluations に渡すデータをどのように準備するといいのかを理解したかった。(これは次回)

Strands と LangGraph で若干仕様の差があり混乱した。

結論

ポイントは 2 つ。

  1. AgentCore O11y のロググループは 4 種類で、評価に使うのは 2 種類。aws/spans(構造情報)と runtime の otel-rt-logs(意味情報)。
  2. その 2 本の中身がフレームワークで変わる。
    • Strands: 構造を aws/spans、メッセージ(ユーザー発話・応答)を otel-rt-logs に分けて出す
    • LangGraph: メッセージを aws/spans の span attributes に入れる。加えて AWS distro(aws-opentelemetry-distro)の GenAI 計装が同じ内容を message log record として出し、これが otel-rt-logs に着地する

どちらも spanId / traceId で span と log record が紐づく。otel-rt-logs に message log record が出るかは local / Runtime の差ではなく、aws_distro の log exporter が有効かで決まる。

前提: OTel GenAI semantic conventions

AgentCore の aws/spans に出てくる gen_ai.* は AgentCore 独自ではなく、OpenTelemetry の GenAI semantic conventions に従う。Strands / LangGraph の差も、突き詰めればこの仕様のどこに乗っているかの違い。

GenAI semconv はまだ開発段階(development / experimental, semconv 1.42 時点)で、専用リポジトリ semantic-conventions-genai に分離された。telemetry の信号は trace(span)・metric・event/log の 3 系統で構成される(OpenTelemetry for Generative AI)。

操作を表す gen_ai.operation.name

span の種類は gen_ai.operation.name で識別する。well-known 値は次のとおり(registry/attributes/gen-ai)。該当が無ければ独自値も可。

  • chat / text_completion / generate_content — LLM 推論呼び出し
  • embeddings — 埋め込み生成
  • execute_tool — ツール実行
  • create_agent / invoke_agent — エージェント生成・呼び出し
  • invoke_workflow — ワークフロー実行
  • retrieval — 検索

span 名は操作と対象を組み合わせる規約。ツール実行 span は execute_tool {gen_ai.tool.name}、エージェント呼び出しは invoke_agent {agent} になる。AgentCore で見る invoke_agent test_agent や execute_tool web_search はこの規約そのもの。

主な attribute

採点や可視化で使う属性は scope をまたいで共通。

attribute 用途
gen_ai.input.messages / gen_ai.output.messages 入出力メッセージ本体(parts 形式の構造体)
gen_ai.system_instructions system プロンプト(chat history と分離して保持)
gen_ai.tool.name / gen_ai.tool.call.id / .arguments / .result ツール名・呼び出し ID・入力・結果
gen_ai.usage.input_tokens / output_tokens トークン数
gen_ai.request.model / gen_ai.provider.name モデル ID・プロバイダ識別子(例: aws.bedrock)

gen_ai.provider.name は telemetry のフレーバーを示す discriminator。Bedrock 由来なら aws.bedrock を立て、aws.bedrock.* 属性を伴う(registry/attributes/gen-ai)。

2026/7/24 update:

aws.amazon.com

メッセージのスキーマ

gen_ai.input.messages / output.messages は role + parts の配列。parts の type で text / tool_call / tool_call_response を切り替える。output 側には finish_reason が付く(registry/attributes/gen-ai)。

[
  { "role": "user",
    "parts": [{ "type": "text", "content": "Weather in Paris?" }] },
  { "role": "assistant",
    "parts": [{ "type": "tool_call", "id": "call_1",
                "name": "get_weather", "arguments": { "location": "Paris" } }] },
  { "role": "tool",
    "parts": [{ "type": "tool_call_response", "id": "call_1", "result": "rainy, 57°F" }] }
]

content を event に置くか attribute に置くか

ここが Strands / LangGraph 分岐箇所。
GenAI semconv は当初、メッセージ本体を独立した log event(gen_ai.user.message / gen_ai.choice / gen_ai.system.message)として記録していた。現行仕様はこれを span attribute(gen_ai.input.messages / output.messages)へ移した(gen-ai-events は Moved 扱い、Traceloop 側の追従が openllmetry issue #3515)。

仕様上、記録先で形式の縛りが変わる。

  • event に記録するなら structured 形式が MUST
  • span に記録するなら structured が SHOULD、未対応なら JSON 文字列でも MAY

この「event か attribute か」の選択が、後述の Strands(Span Event → otel-rt-logs)と LangGraph(span attribute)の分かれ目に直結する。版差で attribute の置き場所(gen_ai.input.messages か gen_ai.task.input か)が動くのも、この移行の途上であるため。

CloudWatch Logs ロググループは 4 種類

ロググループ ストリーム 用途
aws/spans default OTel span(構造情報。LangGraph はメッセージも attributes に含む)
/aws/bedrock-agentcore/runtimes/<id>-DEFAULT otel-rt-logs OTel log record(input/output。Strands は常時、LangGraph は aws_distro の log exporter 有効時)
/aws/bedrock-agentcore/runtimes/<id>-DEFAULT runtime-logs-<session> アプリの stdout/stderr(評価には不使用)
/aws/bedrock-agentcore/evaluations/results/<id> evaluation-results-<id> 評価結果(score + explanation)

evaluate に必要なのは上 2 行。3 行目はアプリログ、4 行目は評価結果の配置先。

メッセージの分離機構

OTel の Span には 3 種類のデータを付けられる。

Span
├── Attributes  ... span 全体のメタデータ(key-value)。例: gen_ai.request.model, session.id
├── Events      ... span 実行中の「出来事」のタイムスタンプ付き記録。span.add_event() で追加
└── Links       ... 別トレースへの参照

2 つの経路で otel-rt-logs の中身は同じ封筒構造(body.input.messages)に見えるが、生成元が異なる。

Strands: Span Event → ADOT 変換

tracer.py が span.add_event("gen_ai.client.inference.operation.details", {"gen_ai.input.messages": ...}) で Span Event を出す。AgentCore Runtime 内の ADOT パイプラインがその Span Event を検知して body.input.messages 形式の log record に変換し、otel-rt-logs に書く。SDK が出した形と着地形は別物で、変換は ADOT の責任範囲。メッセージを span attributes に置かないため、aws/spans のレコードにはメッセージが現れない。

LangGraph: span attribute + AWS distro GenAI 計装

Traceloop の langchain instrumentor(opentelemetry-instrumentation-langchain)は span.set_attribute() でメッセージを span attributes(gen_ai.input.messages / gen_ai.output.messages、版により gen_ai.task.input/output)に置く。Span Event は出さない。AWS distro に組み込まれた GenAI 計装が span の中身を見て body.input.messages 形式の log record を独立に logger.emit() する。AgentCore Runtime が span に付ける telemetry.extended フラグは「メッセージ本体を log record 側に切り出した」という印。Strands にはこのフラグが付かない(span attributes にメッセージを入れていないため切り出し操作が発生しない)。

otel-rt-logs に message log record が出る条件

LangGraph の message log record は AWS distro の log exporter が運ぶ。送り先が決まる条件で 3 パターンに分かれる。

実行形態 message log record 理由
ローカル + InMemorySpanExporter 出ない log 経路が無く span のみ。evaluate は span attribute を直読みするので採点は通る
ローカル + aws_distro + log exporter 出る console / collector / OTLP など exporter の送り先に着地
AgentCore Runtime otel-rt-logs に出る AgentCore が aws_distro + OTLP log export(送り先 otel-rt-logs)+ capture flag を自動配線

分岐は local / Runtime ではなく aws_distro の log exporter の有無。Runtime で本体を log record へ切り出した span には telemetry.extended の印が付く。

ログ構造

ロググループ別のデータ比較

Strands LangGraph
aws/spans 構造情報のみ(メッセージなし) 構造 + span attributes にメッセージ
otel-rt-logs body.input.messages(常時) aws_distro の log exporter 有効時に message log record

LangGraph は構造側(span attributes)にもメッセージを重複して持つ点が Strands と違う。

span の識別キー

ルートスパン・ツールスパンの識別、ツール名の取り出し先は scope ごとに別物。

観点 Strands LangGraph
scope.name strands.telemetry.tracer opentelemetry.instrumentation.langchain
ルートスパンの識別 gen_ai.operation.name: invoke_agent traceloop.span.kind: workflow
ツールスパンの識別 gen_ai.operation.name: execute_tool traceloop.span.kind: tool
ツール名の取り出し attributes.gen_ai.tool.name attributes.traceloop.entity.name

span の実データ

値は中立な例に置換、構造はそのまま。

Strands ルートスパン(invoke_agent)。

{
  "scope": { "name": "strands.telemetry.tracer" },
  "name": "invoke_agent test_agent",
  "traceId": "69fef8a049b528ab76919816242cf797",
  "spanId": "f14f97728bf5084a",
  "parentSpanId": "bedeaf1409c9d978",
  "kind": "INTERNAL",
  "attributes": {
    "gen_ai.agent.name": "test_agent",
    "gen_ai.operation.name": "invoke_agent",
    "gen_ai.request.model": "us.amazon.nova-lite-v1:0",
    "session.id": "sess-001",
    "gen_ai.system": "strands-agents"
  }
}

Strands ツールスパン(execute_tool)。ツール名は gen_ai.tool.name。

{
  "scope": { "name": "strands.telemetry.tracer" },
  "name": "execute_tool web_search",
  "traceId": "69fef8a064084ae046df5182052e7080",
  "spanId": "e48797ee5c0920c2",
  "parentSpanId": "19c00bac9e1d9616",
  "attributes": {
    "gen_ai.tool.name": "web_search",
    "gen_ai.tool.call.id": "tooluse_nmlu6BwDzZ4ZccXmwhkLun",
    "gen_ai.tool.status": "success",
    "session.id": "sess-001"
  }
}

LangGraph ツールスパン。名前は <tool>.tool 形式、ツール名は traceloop.entity.name。

{
  "scope": { "name": "opentelemetry.instrumentation.langchain" },
  "name": "web_search.tool",
  "traceId": "691f4a5c0a7ab761407a1a9a36991613",
  "spanId": "b58bd6568e00fc64",
  "parentSpanId": "aaee94b5bd16f3b0",
  "kind": "INTERNAL",
  "attributes": {
    "traceloop.span.kind": "tool",
    "traceloop.entity.name": "web_search",
    "traceloop.entity.path": "tools",
    "session.id": "sess-001"
  }
}

log record の実データ

otel-rt-logs の log record は aws/spans の span と同じ spanId で紐づく。input/output の中身がここに入る。

Strands。

{
  "scope": { "name": "strands.telemetry.tracer" },
  "traceId": "6a267ec723c4be72618164676ff4c17d",
  "spanId": "c1d39bc358b83913",
  "attributes": { "session.id": "sess-001" },
  "body": {
    "input": {
      "messages": [
        { "role": "system",
          "content": { "content": "[{\"text\": \"あなたは検索アシスタントです\"}]" } },
        { "role": "user",
          "content": { "content": "[{\"text\": \"東京の明日の天気を教えて\"}]" } }
      ]
    },
    "output": {
      "messages": [
        { "role": "assistant",
          "content": { "message": "[{\"text\": \"晴れ時々曇りです\"}]",
                       "finish_reason": "end_turn" } }
      ]
    }
  }
}

content.content が文字列化された JSON 配列なのは、Strands SDK の tracer.py が serialize() でそう出力するため。ただし body.input.messages という封筒構造は Strands の OTel 仕様ではない。責任を 3 層に分けると区別がつく。

  • Strands SDK: gen_ai.input.messages の Span Event を出す。content の二重シリアライズはここ由来
  • Runtime / O11y パイプライン: その Span Event を otel-rt-logs の log record(body.input.messages 形式)へ変換。aws/spans と otel-rt-logs への振り分けもここ
  • Evaluate API: 変換後の body.input.messages を読んで user_query を取り出す

手書きで evaluate を直叩きするとき再現すべきは、SDK の生出力ではなく Runtime/O11y 変換後の形。content 値の二重シリアライズが SDK 由来、封筒は下流由来。

LangGraph の log record も otel-rt-logs 上は同じ body.input/body.output 形式(scope は opentelemetry.instrumentation.langchain)。ただし content は parts 形式({"role": ..., "parts": [{"type": "text", "content": ...}]})を文字列化したもので、Strands と中身が違う。メッセージは span attributes(gen_ai.input.messages / gen_ai.task.input / traceloop.entity.input、版により置き場所が変わる)にも重複して入る。

Runtime / ローカルで動かすための実装

Strands(Runtime): 追加コード不要。BedrockAgentCoreApp を opentelemetry-instrument で起動すれば両ロググループに書き込む。

from bedrock_agentcore.runtime import BedrockAgentCoreApp
app = BedrockAgentCoreApp()
CMD ["opentelemetry-instrument", "python", "-m", "eval_agent_strands"]

LangGraph(Runtime): 明示 LangchainInstrumentor().instrument() は不要。opentelemetry-instrumentation-langchain を入れておけば opentelemetry-instrument が entry point(opentelemetry_instrumentor group の langchain)から自動計装する。capture flag を立てると AWS distro が otel-rt-logs に message log record を出す。

import os
os.environ.setdefault("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "true")
from bedrock_agentcore.runtime import BedrockAgentCoreApp
app = BedrockAgentCoreApp()
ENV OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
CMD ["opentelemetry-instrument", "python", "-m", "eval_agent_langgraph"]

依存はバージョンをピン留めする。semconv が非互換だと langchain instrumentor が import 時にクラッシュして計装が立ち上がらない(Appendix「版非互換」)。

opentelemetry-instrumentation-langchain>=0.59.0   # 実機で 0.61.0 解決
opentelemetry-semantic-conventions==0.61b0
aws-opentelemetry-distro==0.17.1

分かれ目は明示呼び出しの有無ではなく、langchain instrumentor が import に成功して GenAI span が出ているか。span が出ていれば、message log record は capture flag + log exporter の条件で出る。

AgentCore Evaluations との連携

evaluate に渡すデータの収集経路

Strands Runtime LangGraph Runtime LangGraph ローカル
収集元 aws/spans + otel-rt-logs span に message 内容あり。otel-rt-logs にも record(UserIntent 等はこちらを読む) InMemorySpanExporter
ジョイン spanId で必要 evaluate は span 1 本で可。StartBatchEvaluation は API 側が処理 不要

evaluate(オンデマンド)を手で呼ぶ場合、Strands は 2 本のクエリで集めて spanId でジョインする。StartBatchEvaluation(public preview)はロググループ名と sessionId を渡せば API 側が収集とジョインを引き受ける。

# ① aws/spans から span レコード
aws logs filter-log-events \
  --log-group-name "aws/spans" \
  --filter-pattern '"strands.telemetry.tracer"' \
  --start-time <epoch_ms> --end-time <epoch_ms>

# ② otel-rt-logs から log record(Strands、および UserIntent 等を回す LangGraph)
aws logs filter-log-events \
  --log-group-name "/aws/bedrock-agentcore/runtimes/<id>-DEFAULT" \
  --log-stream-name "otel-rt-logs" \
  --filter-pattern '"strands.telemetry.tracer"' \
  --start-time <epoch_ms> --end-time <epoch_ms>

手書きで span を組む場合

ライブエージェントが無い手元データから手書きで span を組むときも、置き場所の違いがそのまま効く。

scope メッセージの置き場所 tool 評価に必須の attribute
strands.telemetry.tracer log record の body.input.messages(content 二重シリアライズ) tool span + log record のペア
opentelemetry.instrumentation.langchain span attributes gen_ai.tool.call.arguments / gen_ai.tool.call.result

LangGraph で tool 評価(ToolSelectionAccuracy 等)まで通すには、ツール I/O を gen_ai.tool.call.arguments / gen_ai.tool.call.result に入れる。traceloop.entity.input/output だけだと LogEventMissingException になる。UserIntent など意味情報を読む評価は body.input.messages 形式の log record を要求する。

ライブの LangGraph 実 span を evaluate に渡すときは、tuple 値の正規化が要る。OTel の attribute 値は tuple になることがあり(langgraph_triggers など)、boto3 の evaluate は tuple を受け付けず ParamValidationError になる。

attrs = {k: (list(v) if isinstance(v, tuple) else v)
         for k, v in dict(span.attributes).items()}

評価結果の保存先

評価結果は /aws/bedrock-agentcore/evaluations/results/<id> に書かれる。

{
  "name": "gen_ai.evaluation.result",
  "attributes": {
    "gen_ai.evaluation.name": "Builtin.InstructionFollowing",
    "session.id": "sess-001",
    "gen_ai.evaluation.score.value": 1.0,
    "gen_ai.evaluation.score.label": "Yes",
    "aws.bedrock_agentcore.evaluation_level": "Trace"
  }
}

書き込むのはオンライン評価のときだけ。オンデマンドの evaluate は結果を API レスポンスの evaluationResults で返すだけで、このロググループには書かない。

評価方式 結果の返り先 CloudWatch 書き込み
オンライン評価(config 作成) evaluations/results/<config-id> あり(自動)
オンデマンド evaluate API レスポンス なし

Evaluate API と OpenTelemetry の関係

evaluate は OTel プロトコルで通信するわけではない。普通の boto3 / HTTPS リクエストで、OTel が関わるのはデータのスキーマだけ。

エージェント実行の記録
  ↓ 表現形式として採用
OpenTelemetry の span/trace 形式
  ↓ API のスキーマとして採用
Evaluate API の sessionSpans(OTEL-like JSON の配列)
  • evaluate は OTEL プロトコルで通信するか → しない。boto3 / HTTPS
  • evaluate は OTEL 形式のデータを入れるか → 入れる。sessionSpans が OTEL-like JSON
  • OTEL SDK がないと使えないか → 使える。自分で JSON を組めばいい
  • CloudWatch がないと使えないか → 使える。手書き span を直接投げてもいい

OTel はデータのスキーマを規定する存在で、評価そのものとは独立している。

参考