📡

GitHub Copilot cloud agent を OTel 的に観測できるか検証してみた

に公開

背景

前回、GitHub Copilot coding agent の セッションログを深掘りしてみる という記事で、gh agent-task --loggh run view --log を使って GitHub Copilot cloud agent の挙動をどこまで追えるかを調べました。

ただ生のログのままだと分析がしづらいため、OpenTelemetry(以降 OTel)的な分析をしてみたいと思いました。

そのきっかけになったのが、VS Code や Copilot SDK では OTel の導線があると知ったことです。GitHub Copilot cloud agent でも近いことができないか、を試してみたくなりました。この記事を書く上で、以下の 2 本の記事を参考にしました。

TL;DR

  • 2026-04-04 時点で確認した範囲では、GitHub Copilot cloud agent にネイティブな OTel エクスポーターを有効化する公式導線は見つけられませんでした
  • リポジトリ側のフックとセットアップで、セッション / ツール実行イベントを構造化ログとして取得できました
  • そのイベントをローカルコレクターに OTLP/HTTP で流し、受信ペイロードまで確認できました
  • ビューワーとしてローカル Jaeger を使い、ツール実行イベントをトレース / スパン相当として可視化できました
  • ただし今回見えたのはネイティブな model span ではなく、フックから組み立てた疑似トレースです

検証対象は、自分が管理しているリポジトリと、自分が確認権限を持つセッションデータに限っています。

公式ドキュメントをチェック

先ほど紹介した記事の通り、VS Code の Copilot Chat と Copilot SDK では、OTel を外部バックエンドへ送ることが公式にサポートされています。

では GitHub Copilot cloud agent はどうか

このページをはじめ、関連する公式ドキュメントを確認してみましたが、VS Code のような otel.enabled / otlpEndpoint 相当の記述は見つけられませんでした。

なので今回は、「ネイティブな OTel を有効化する」ではなく、「フックで構造化イベントを取り、OTLP ペイロードに変換して OTel 的に扱えるか」を検証してみました。

検証した構成

先に今回の結果を短くまとめると、次の通りです。

  • GitHub Copilot cloud agent にネイティブな OTel exporter を有効化する公式導線は見つけられませんでした
  • ただし、repository 側の hooks と setup steps を使えば、イベントを構造化ログとして取り出し、OTLP/HTTP で送るところまではできました
  • さらに、そのペイロードをローカル Jaeger でスパン相当として確認できました
  • 一方で、今回見えたのはフックから組み立てた疑似トレースであり、ネイティブな model span そのものではありません

検証用に、リポジトリに次の 3 つを置きました。

  • .github/hooks/otel-observability.json
  • .github/scripts/copilot-hook-log.sh
  • .github/workflows/copilot-setup-steps.yml

hook 設定は最小構成で、

  • sessionStart
  • userPromptSubmitted
  • preToolUse
  • postToolUse
  • sessionEnd
  • errorOccurred

を全部同じ Bash スクリプトに流すだけです。

{
  "version": 1,
  "hooks": {
    "sessionStart": [
      { "type": "command", "bash": "EVENT_NAME=sessionStart ./.github/scripts/copilot-hook-log.sh" }
    ],
    "userPromptSubmitted": [
      { "type": "command", "bash": "EVENT_NAME=userPromptSubmitted ./.github/scripts/copilot-hook-log.sh" }
    ],
    "preToolUse": [
      { "type": "command", "bash": "EVENT_NAME=preToolUse ./.github/scripts/copilot-hook-log.sh" }
    ],
    "postToolUse": [
      { "type": "command", "bash": "EVENT_NAME=postToolUse ./.github/scripts/copilot-hook-log.sh" }
    ],
    "sessionEnd": [
      { "type": "command", "bash": "EVENT_NAME=sessionEnd ./.github/scripts/copilot-hook-log.sh" }
    ],
    "errorOccurred": [
      { "type": "command", "bash": "EVENT_NAME=errorOccurred ./.github/scripts/copilot-hook-log.sh" }
    ]
  }
}

スクリプト側は、stdin の JSON を読んでエンベロープを作り、.copilot-observability/hooks.jsonl に JSONL で追記します。

さらに COPILOT_OTLP_HTTP_ENDPOINT が設定されている場合は、フックイベントを簡単な OTLP/HTTP トレースペイロードに変換して送信します。

preToolUse だけは protocol 上の都合で {"permissionDecision":"allow"} を stdout に返し、ログ行は stderr に出しています。

以下は、メイン処理の流れが分かる部分だけ抜粋しています。

# file: .github/scripts/copilot-hook-log.sh
event_name="${EVENT_NAME:-unknown}"
log_dir=".copilot-observability"
log_file="${log_dir}/hooks.jsonl"
otel_file="${log_dir}/otel-send.log"

mkdir -p "${log_dir}"

input_json="$(cat)"
tmp_input="$(mktemp)"
trap 'rm -f "${tmp_input}"' EXIT
printf '%s' "${input_json}" > "${tmp_input}"
# file: .github/scripts/copilot-hook-log.sh
entry = {
    "eventName": event_name,
    "hookLoggedAt": datetime.now(timezone.utc).isoformat(),
    "repo": os.environ.get("GITHUB_REPOSITORY"),
    "runId": os.environ.get("GITHUB_RUN_ID"),
    "job": os.environ.get("GITHUB_JOB"),
    "payload": payload,
}

print(json.dumps(entry, ensure_ascii=True))
# file: .github/scripts/copilot-hook-log.sh
trace_id = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
span_seed = f"{session_id}:{event_name}:{timestamp_ms}:{payload.get('toolName','')}"
span_id = hashlib.sha256(span_seed.encode("utf-8")).hexdigest()[:16]

event_payload = {
    "resourceSpans": [
        {
            "resource": {
                "attributes": [
                    {"key": "service.name", "value": {"stringValue": "copilot-hook-observability"}},
                    {"key": "service.version", "value": {"stringValue": "experiment-v1"}},
                ]
            },
            "scopeSpans": [
                {
                    "scope": {"name": "copilot-hook-log.sh"},
                    "spans": [
                        {
                            "traceId": trace_id,
                            "spanId": span_id,
                            "name": f"copilot.hook.{event_name}",
                            "kind": 1,
                            "startTimeUnixNano": start_ns,
                            "endTimeUnixNano": end_ns,
                            "attributes": attributes,
                        }
                    ],
                }
            ],
        }
    ]
}

セットアップステップ側ではローカルコレクターをセッション開始前に起動し、

  • receiver: 0.0.0.0:4318
  • exporter: file
  • output: .copilot-observability/otel-traces.jsonl

という最小構成で、受けたトレースペイロードを file exporter に流すようにしました。

こちらも、セットアップの要点だけ抜粋しています。

# file: .github/workflows/copilot-setup-steps.yml
- name: Prepare observability workspace
  run: |
    mkdir -p .copilot-observability
    touch .copilot-observability/otel-traces.jsonl
    cat > .copilot-observability/collector-config.yaml <<EOF
    receivers:
      otlp:
        protocols:
          http:
            endpoint: 0.0.0.0:4318
    exporters:
      file:
        path: $PWD/.copilot-observability/otel-traces.jsonl
    processors:
      batch:
    service:
      pipelines:
        traces:
          receivers: [otlp]
          processors: [batch]
          exporters: [file]
    EOF
# file: .github/workflows/copilot-setup-steps.yml
- name: Start local OpenTelemetry Collector
  run: |
    nohup ./.copilot-observability/otelcol-contrib \
      --config ./.copilot-observability/collector-config.yaml \
      > ./.copilot-observability/collector.log 2>&1 &
    echo $! > ./.copilot-observability/collector.pid

- name: Set collector endpoint for Copilot hooks
  run: echo "COPILOT_OTLP_HTTP_ENDPOINT=http://127.0.0.1:4318/v1/traces" >> "$GITHUB_ENV"

まずローカルでチェック

いきなり GitHub 上で試す前に、ローカルで hook input を模擬して確認しました。

例えば sessionStartpreToolUse に相当する JSON を流すと、次のような JSONL が出ました。

{"eventName": "preToolUse", "hookLoggedAt": "2026-04-04T02:47:45.088491+00:00", "repo": null, "runId": null, "job": null, "payload": {"toolName": "bash", "toolArgs": "{\"command\":\"git status\"}"}}
{"eventName": "sessionStart", "hookLoggedAt": "2026-04-04T02:47:45.088503+00:00", "repo": null, "runId": null, "job": null, "payload": {"sessionId": "test-session", "cwd": "/workspaces/zenn-articles"}}

この時点で、

  • stdin JSON の受け取り
  • envelope 化
  • JSONL 追記
  • preToolUse の permission response

は問題なさそうだと分かりました。

GitHub Copilot cloud agent のセッションで確認

ローカルコレクターまで含めた最終確認では、GitHub Copilot cloud agent に依頼を投げてセッションを起動し、次のコマンドで確認しました。

  • gh agent-task view --log
  • gh run view --log

今回の検証では、次の 4 層で結果を追っています。

  • hooks.jsonl: 元の構造化イベント
  • otel-send.log / otel-traces.jsonl: OTLP/HTTP 送信結果と受信ペイロード
  • session log: agent が見ていた内容
  • run log: 生の実行痕跡

その上で、copilot-setup-steps.yml でローカルコレクターが起動したセッションの中で、

  • .copilot-observability/hooks.jsonl
  • .copilot-observability/otel-send.log
  • .copilot-observability/otel-traces.jsonl
  • .copilot-observability/collector.log

を確認しました。

確認できたこと

1. preToolUse / postToolUse の構造化イベントも取れた

今回のセッションでは、agent は viewgithub-mcp-server-get_file_contents を使っていました。

そのたびに JSONL へ、

  • toolName
  • toolArgs
  • toolResult.resultType
  • toolResult.textResultForLlm

が入っていました。

これにより、少なくともツールレベルでは、

  • 何を呼ぼうとしたか
  • 成功したか
  • agent に見えていた戻り値は何か

を構造化して取れることがわかります。

ネイティブなスパンではないですが、これで OTel 形式のトレースペイロードとして扱えることがわかりました。

2. セットアップステップでローカルコレクターをセッションに持ち込めた

最終確認では、run log 上でセットアップステップの

  • Download local OpenTelemetry Collector
  • Start local OpenTelemetry Collector
  • Wait for collector
  • Set collector endpoint for Copilot hooks
  • Verify collector process

がすべて成功しました。

3. フックからコレクターへの OTLP/HTTP 送信も通った

セッションログ上で、agent 自身が .copilot-observability/otel-send.log.copilot-observability/otel-traces.jsonl を確認していました。

otel-send.log には、例えば次のような行が入ります。

{"eventName": "postToolUse", "endpoint": "http://127.0.0.1:4318/v1/traces", "status": 200}

これで少なくとも

  • フックスクリプトが OTLP endpoint に POST した
  • ローカルコレクターが HTTP 200 で受けた

までは確認できます。

さらに otel-traces.jsonl にはコレクターが受け取ったペイロードが file exporter 経由で保存されていました。

つまり、

  • hooks.jsonl: 元の構造化イベント
  • otel-send.log: OTLP/HTTP 送信結果
  • otel-traces.jsonl: コレクターが受けたペイロード

の 3 段で追えるようになっています。

4. gh run view --log でも run 全体の痕跡が残る

Actions run log 側では、前回記事と同じように生に近い処理が見えました。

今回も例えば、

  • PR summary に相当する出力
  • git rev-parse HEAD
  • git push
  • Everything up-to-date
  • agent 実行時の環境変数やプロンプト関連の痕跡

が見えています。

つまり今回の構成では、最初に挙げた 4 層を実際に横断して追えることが確認できました。

Jaeger にリプレイするとビューワーでも確認できた

ここまでだと「コレクターが受けた」までは確認できますが、「ビューワーでどう見えるか」はまだ分かりません。

そこで、GitHub Copilot cloud agent のセッション内で生成した otel-traces.jsonl をローカルに持ち出し、ローカル Jaeger にリプレイしてみました。

ここで扱ったのは、自分が検証目的で生成し、確認権限を持っているセッションデータだけです。第三者のセッションや権限のない実行データを持ち出して分析することは想定していません。

ローカルでは以下のような簡単な補助スクリプトを使っています。

  • scripts/run-jaeger-local.sh
  • scripts/replay-otel-jsonl.py

流れはこうです。

  1. ローカルで Jaeger all-in-one を起動し、OTLP endpoint が ready になるまで待つ
  2. otel-traces.jsonl の各行を http://127.0.0.1:4318/v1/traces に再送する
  3. Jaeger UI でトレースを検索する

実際に Jaeger UI では、

  • service: copilot-hook-observability
  • operation: copilot.hook.postToolUse

のトレースを確認できました。

Jaeger でトレースを検索した画面

さらにスパン詳細には、

  • copilot.cwd
  • copilot.event_name
  • copilot.session_id
  • copilot.tool_name
  • copilot.tool_result_type

がタグとして入り、copilot.hook.payload event の中にペイロード JSON も入っていました。

Jaeger でスパン詳細を開いた画面

つまり今回の方式は、単に JSONL を残すだけでなく、

  • フックイベントを OTLP ペイロードに変換し
  • コレクターで受け
  • ビューワーでスパン / ログイベントとして辿る

ところまで実証できたことになります。

まとめ

前回は run log を読む話でした。今回はその続きとして、実際に hooks と setup steps を仕込んで GitHub Copilot cloud agent の structured event を OTLP payload として扱えるかを試しました。

結果として、GitHub Copilot cloud agent でも、セッション / プロンプト / ツール実行イベントを JSONL で記録し、ローカルコレクターに OTLP/HTTP で送信し、そのペイロードをローカル Jaeger でビューワー表示するところまで確認できました。

一方で、今回見たのはネイティブな model span ではなく、フックから組み立てた疑似トレースです。外部バックエンドへの直送や、event をトレース / スパン構造へどう正規化するかは、含めていません。

また、この手順は自分が扱う権限のある検証用セッションだけを対象にしています。第三者のセッションや組織データにそのまま適用することは想定していません。

参考

Discussion