📡

AI時代のObservability設計 - アプリケーション / インフラ / CI / LLMすべてを監視する(設計編)

に公開

みなさまこんにちは!エアークローゼットでCTOをしているです。

直前の連載code-graph deep dive後編で、「46リポジトリに跨るコードベースをAIにセマンティック検索できる形にした」という話を書きました。その最後に残した課題のひとつが「動的解析の不在」でした:

graphに乗っているのは「edgeが静的に存在する」という事実だけで、「実際にそのedgeが本番でどれくらい使われているか」は分かりません。

graphが「静的事実」を渡してくれても、本番でいま何が起きているかは別軸でAIに渡してあげる必要があります。つまり、静的解析と同じ発想を、そのまま観測スタックにも持ち込まないといけない。

今回はその話を、設計編(本記事)と実践編の2つに分けて書きます。本記事は4つの監視対象(アプリケーション / インフラ / CI / LLM)を、それぞれの問いの性質に合わせて違う形でObservableにする設計判断の話。

4つの観測対象を別々の形でObservableにする

code-graph連載で得た一番大きな教訓は、「AIに渡す前にデータを正しい形にしてあげる」必要がある、ということでした。46リポジトリ分のソースコードをそのまま投げてもcontext windowは足りないしハルシネーションも起きる。だから静的解析でグラフ化して、境界ノードに意味を載せて、SAME_ENTITYで繋いで、とObservableにしてから渡す。

観測スタックも全く同じ構造の問題を持っています。本番の生ログをそのままAIに渡しても、

  • ログの量でcontextが埋まる
  • どこがerrorでどこが正常なlogかAIには区別がつかない
  • メトリクスとログとトレースが断絶している
  • そもそも「いま何にいくらかかってるか」の答えは生ログには載っていない

= AIに渡せる形でObservableにする必要がある、という同じ問題。

ここで重要なのは「Observableにする形」はAIが何を答えるべきかで変わる、という点です。cortex(社内AIプラットフォーム)では、監視対象を4つに分けて、それぞれ別の問いに対応した形で乗せています:

4つの監視対象を、それぞれ問いの質に合わせて別の形でAIに渡す

監視対象 AIに答えさせたい問い
アプリケーション 「いま本番で何が起きてる?」(探索) log + trace
インフラ 「リソースは足りてる?落ちてない?」(時系列) metric
CI 「何が壊れた?いつから壊れてる?」(alert + 履歴探索) log + alert
LLM 「いくらかかってる?誰がどれだけ使った?」(リアルタイム + 構造化集計) metric + 構造化レコード

「全部OTelに乗せて全部Lokiに流せばいいじゃん」という選択肢は確かにあります。でもそうすると、「リアルタイムの『今いくら』」と「『先月の累計をteam別に集計』」のような質的に違う問いを1つのbackendで答えようとして、どこかが苦しくなる。ここを目的別に分けたのがcortexの選択です。

以下、4つの軸を順に書きます。アプリケーションとインフラは「土台」として簡単に触れ、CIとLLMはAI時代特有の設計判断が出るので深掘りします。

アプリケーション ── OTel + Loki + Tempoの標準スタック

土台はシンプルです。cortexの各アプリケーションはOpenTelemetryで計装していて、traceはTempo、logはLoki、metricはMimirに流す ── というGrafana Cloudの標準形。

ここは特別な工夫はしておらず、重要なのは「全アプリが同じ形でログとトレースを出す」という統一だけです。これがあるから、後でMCP経由でAIが {service_name="<service>"} |~ "error" のようなLogQLを投げて横断的に調査できる。

具体的な計装方針は別記事AI Harness連載Part 4 (Self-Healing)で触れたので、本記事では深掘りしません。「標準的なOTelスタックがちゃんと敷かれているか」が、この後のAI駆動運用の前提になる、とだけ書いておきます。

インフラ ── Cloud Run / BigQuery / Pub/SubのmetricをMimirに集約

cortexはGCP上で動いていて、Cloud Run / Cloud Run Job / BigQuery / Pub/Sub / Cloud Tasks等を組み合わせて使っています。各GCPリソースのmetric (CPU / memory / 実行回数 / レイテンシ / queue滞留時間等)は、Cloud Monitoring経由でMimirにexportしています。

ここも特別な工夫はしてなくて、標準的なGCP metricをGrafana CloudのMimirに集約しているだけ。ただ「全インフラmetricが1ヶ所に集約されている」状態を作っておくと、AIが「先週、一番CPU使ってたserviceは?」「queueが詰まってるworkerある?」みたいな問いに自然に答えられるようになる ── これも後でMCP経由で効きます。

ここまでが「土台」です。一般的な観測スタックの話なので、詳しくはGrafanaやOpenTelemetryの公式ドキュメントを当たってください。

ここから先(CIとLLM)がAI時代特有の設計判断が出る部分です。

CI ── webhook pushではなくpost-hoc pullでLokiに流す

cortexはGitHub ActionsでCIを回していますが、CIのログをそのままGrafana Lokiに流しています。

「GitHub ActionsのログはGitHub UIで見られるじゃん」という疑問は当然あって、ただこれには明確な理由があります:

  • 調査のたびにAIにGitHub APIを叩かせるのは遅く認証も煩わしい。一度Lokiに構造化して入れれば、AIがアドホックに横断検索できる状態になる
  • 別レポジトリのCI結果も、アプリログも、全部1つのLokiに乗ってると横断クエリできる
  • LogQL alertで失敗を構造化された判定にできる
  • AIが「先週から壊れてるテストある?」みたいな問いを自然言語で投げられる

ただし送信の仕方が普通と違います。cortexの選択は:

CI実行中にログをpushするのではなく、完了後にGitHub APIからpullする

CIログをwebhook pushでなくpost-hoc pullで流す

具体的には:

  1. Testジョブが終わると workflow_run イベントが発火
  2. ログ送信用の別workflowが起動される
  3. そのworkflowがGitHub API (/repos/.../actions/jobs/.../logs)からログを取得
  4. 構造化されたJSON (job / status / ref / pr / commit / output等)としてOTLP /v1/logs でGrafana Cloudに送信

{service_name="ci", ref="main", status="failure"} でフィルタすると、mainブランチでのCI失敗だけが綺麗に拾える。

なぜpush方式じゃなくpull方式か:

  • CI実行とobservabilityを切り離せる:もし送信側が失敗してもテスト本体は影響を受けない。逆に送信だけリトライ / 再実行も可能
  • PR由来コードがkeyに触れる経路自体が存在しない: workflow_run でトリガーされる別workflowはデフォルトブランチのcontextで実行され、fork PR側のコードではなくbase repoのsecretを使う。つまりテスト本体はGrafana API keyにそもそも触れない構造的保証
  • 送信失敗の検知が独立できる:送信自体がCIと同じレイヤーなら、送信が壊れた時に観測スタックが沈黙して気付けない。分離していれば、ログ送信workflowの成否それ自体が観測対象になるので、そこにalertを貼れる

そしてmainブランチでfailureが出た瞬間にLogQL alertが発火し、Slackに通知される ── これが実践編で扱うSelf-Healingの起点になります。

LLM ── GeminiとClaude Code、2つの違う形

最後の軸がLLMの利用観測です。cortexはGemini APIとClaude Code (Anthropicの公式CLI)を両方ヘビーに使っていて、どちらもお金がかかる以上、利用状況を把握したい(ただし課金形態は違っていて、Geminiは従量課金、Claude Codeはsubscription。この違いは後で効いてきます)。両者でbackendを分けている根本的な理由は計装地点(instrumentation locus)の違いにあります:

  • Geminiは呼び出し元のコードを自分で握っているので、共通wrapperでinline emitできる → metric (Prometheus)が自然
  • Claude Codeは外部CLIでwrapperでラップできない。利用ログは事後レコードとして降ってくる → 構造化保存先(BigQuery)が自然

「リアルタイムで見たいか / SQL集計したいか」という問いの性質は、この計装地点の違いから派生的に決まります。以下、順に深掘りします。

Gemini ── Prometheusで「いま、何が高い」を即時可視化

cortexはGeminiをdb-graphのテーブル説明文生成、code-graphのフィールド型推論、各種コンテキスト生成 ── 至るところで叩いています。ここで答えたいのは「いま、何が高い」を遅延なしに見ること。暴走promptや暴走batchが走った時に、翌朝のbillingまで待ちたくない。

そこで、全Gemini呼び出しを共通のwrapper (traceGeminiCall)で包んで、呼び出しごとに4本のメトリクスを出力する設計にしています:

  • gemini.tokens.total ── 累積トークン(labels: model / service / type=prompt|completion)
  • gemini.requests.total ── リクエスト数(labels: model / service / status)
  • gemini.request.duration ── レイテンシヒストグラム
  • gemini.cost.usd ── 推定コスト(labels: model / service)

ここで設計判断が分かれるのが、「コストを誰が計算するか」です。選択肢は2つ:

  • A. Google Cloud Billing APIから後追いで取得する ── 正確、でもbilling反映まで数時間〜1日のラグ + タスク単位のコスト粒度がない
  • B. 呼び出し直後にトークン数 × 単価表でクライアントサイド計算する ── 即時 + タスク単位の粒度を自前で付けられる、でも単価表のメンテが要る

採用したのはBです。単価表は GEMINI_PRICING という定数で持っていて、Googleが値段を変えたら手動で更新する。gemini-3-flashgemini-3-pro ごとにinput / output単価を持っているだけの素朴な表。

なぜAを捨ててBを採ったか。本命の理由はリアルタイム性です:

  • billingは数時間〜1日遅れる。暴走promptや暴走batchが走ったら、翌朝のbillingで気付くまで一晩コストを垂れ流すことになる。クライアントサイドで呼び出し直後にトークン数×単価で計算すれば、service単位(code-graph / gcs-transformer / db-dictionary など、アプリ / パイプライン単位の粒度)で「いま何が高いか」が数分で見える ── billingがリアルタイムには決して答えられない速度です
  • 単価表のメンテ頻度が低い(Googleも値段はそうそう変えない) ── 負債としては小さい
  • Cloud Billing APIの認証・取得・正規化・再分配のパイプライン自体がそれなりに重い

そしてPrometheusの累積カウンタとして gemini_cost_usd_USD_total に出すと(OTel側のmetric名 gemini.cost.usd + unit USD がPrometheus exporterで合成された形)、GrafanaのPromQLでそのまま sum(increase(gemini_cost_usd_USD_total[1h])) のような形で「直近1時間でいくら使った?」が答えられる。これを「$1/hour超えたらinfoアラート」でSlackに飛ばすシンプル設計。実運用でも、後からPromQLで集計するというより、この閾値超えのSlack通知で暴走に即気付くのが主用途になっています。

ここで一つ線を引いておくと、このgemini.cost.usdカウンタが持つラベルはmodelserviceの2つだけで、service粗い(アプリ / パイプライン単位のbounded集合)です。「あのprompt一つにいくら」という呼び出しコンテキスト単位の粒度をラベルに載せようとすると、多数のリポジトリ×各種推論でラベルの組み合わせが爆発し、時系列DB (TSDB)が耐えられません。だからPrometheus側はtripwire(暴走検知)に徹して、service単位の粗い粒度で即時アラートを出す役割に割り切っています。プロンプト単位の細かいコスト帰属が欲しいなら、それは時系列ではなくSQLの問いです ── tokenレコードをBigQueryに落として後から好きな軸で集計する形になり、これは次に述べるClaude CodeをBQに流すのと同じ理由です。「計装できる」と「時系列であるべき」は別の問いで、細粒度の集計はGeminiもClaude Codeも同じBQに寄っていく。

リアルタイムの「いま」を答えるのは、Prometheusがいちばん向いてる形です。

Claude Code ── BQに溜めて構造化集計に強くする

社内の開発者は全員Claude Codeを使っています。ただしこちらはGeminiと事情が違って、subscriptionなのでトークン使用量がそのまま金額になるわけではありません。ここで見たいのはコストそのものより利用状態です。誰がどれだけ使っているか、どのリポジトリでどれだけトークンを消費しているか、cacheがどれだけ効いているかを把握して、使い方の改善につなげたい。

ここで設計が分かれた質問:「Claude Codeの利用ログもLokiに流すべきか?」

答え: NO、BQに溜める

なぜか。Claude Code利用ログは本質的に構造化された帳簿だからです:

  • email ── 利用者
  • repository ── どのリポジトリでの利用か
  • timestamp ── いつ
  • input_tokens / output_tokens
  • cache_creation_input_tokens / cache_read_input_tokens ── prompt-cacheの効きを含む

これを引きたい問いはこんな形:

  • 「先週、チームAのメンバーが累計でいくら使った?」
  • 「リポジトリXの編集に1ヶ月でいくらかかってる?」
  • 「prompt-cacheのhit ratioはチーム間でどれくらい差がある?」

全部SQL集計向きの問いです。LokiのLogQLではaggregationもjoinも辛い。一方BigQueryならDAYパーティション + emailを主キーに普通に書ける。

そこでClaude Code → BQパイプラインを4段階で組んでいます:

  1. Emit ── Claude Code側に組み込んだanalyzerが UsageInput (email無しのtoken情報のみ)を社内エンドポイントにPOST
  2. Auth proxy ── Cloudflare Edge Router workerが CORTEX_API_KEY を検証して、そこで初めて利用者emailを X-Cortex-User-Email として付与
  3. Ingest ── Cloud Run受信APIがdedupしてPub/Subにpublish
  4. Persist ── Cloud Run workerがPub/Subからpull、schema検証、BQにstreaming insert

設計的に効いているポイントを2つ:

  • identity authorityをEdge Routerに集約:利用者識別はEdge Routerでしかやらない。emit側(Claude Code)はemailを持たない。これでクライアント側のid詐称や、social engineering系の経路を構造的に閉じる
  • Pub/Subでasync分離: ingestとworkerを分けて、worker側で詰まってもingestの応答時間に影響しない。失敗時はPub/Sub DLQで最大5回retry

そしてBQに溜まったものは、実践編で扱う社内ポータルから「誰がどれだけ」を毎日見られる状態にしています。実物がこれです:

Claude Code利用量ダッシュボード — 過去30日で77.5Bトークン、cache読込が96%を占める

数字が興味深いので軽く触れておくと、過去30日で77.5Bトークン / 382Kメッセージ / 47ユーザー / 79リポジトリ。そして注目すべきはCache読込Inputが74.7B(全体の96%)という点です。これはprompt-cacheが劇的に効いていることを示しています。subscriptionなのでこれがそのまま金額に跳ねるわけではありませんが、cache読込の実効input単価は標準input単価の約1/10なので、仮にAPI従量課金で同じ使い方をしたら、cacheなし前提と比べてinput実効単価ベースで7倍前後効率が良い計算になります。利用効率をこうやって具体的な数字で把握できるのが可視化の狙いで、「集計の質的な性質に合わせたbackend」という設計判断のおかげで、こういう指標が自然にSQLで引けて毎日見える状態になっています。LogQLで同じことをやろうとしたら大変です。

ちなみにMCPの利用ログも似た形でBQに溜めています(cortex.mcp_tool_calls)。こちらはOTelですらなく、各MCPサーバーが直接BQにレコードを書き込む構造。前回の連載で「annotation graphのMCPが約50,000回 / 約73人に使われている」と数字を出したのは、全部このテーブルから取っています。

「全部OTel」教義に寄せきらず、集計の質的な性質に応じて道具を分けているのがこの層の核心です。

つづく

ここまでで4つの監視対象(アプリケーション / インフラ / CI / LLM)と、それぞれの設計判断を書きました。観測スタックの書き込み側の話としては一旦完結します。

ただ、「Observableにしただけ」では話は終わらない。観測スタックには本番データが流れる以上、PIIとAIの検索性をどう両立させるかという問題が必ず出ます。そして全部繋がると、自動修復(Self-Healing)の本当の駆動源が観測スタック側から見えてくる ── というのが実践編の話です。

長文をお読みいただきありがとうございました。実践編「AI時代のObservability設計 - PIIとAIの検索性を両立させ、自動修復する」も公開済みです。続けてどうぞ。


私がCTOをしている株式会社エアークローゼットでは、AIと共に新しい開発体験を作り上げていくエンジニアを募集しています。興味のある方は、ぜひエンジニア採用サイトエアクロクエストをご覧ください!

GitHubで編集を提案
エアークローゼットテックブログ

Discussion