観測可能性とインストゥルメンテーション
概要
観測できないコードは運用できません。観測可能性とは、「システムは何をしているのか、なぜそうなっているのか」を、外部からコードが発するテレメトリを使って答えられる能力です。インストゥルメンテーションはリリース後のおまけではなく、テストと同様に機能と並行して記述されるものです。テレメトリなしで機能が出荷された場合、最初のユーザー報告バグは、クエリではなく発掘調査になります。
使用すべきタイミング
- 本番環境で実行されるあらゆる機能を構築する場合
- 新しいサービス、エンドポイント、バックグラウンドジョブ、外部統合を追加する場合
- 本番インシデントの診断に時間がかかりすぎた場合(「何が起きたかわからなかった」)
- アラートルールを設定またはレビューする場合
- I/O、リトライ、キュー、クロスサービスコールを追加するPRをレビューする場合
使用すべきでないタイミング:
- 現在発生している障害の診断 —
debugging-and-error-recoveryスキルを使用してください(観測可能性は、次回そのスキルを高速化するためのものです) - 計測されたパフォーマンス低下のプロファイリングと最適化 —
performance-optimizationスキルを使用してください - リリース日の監視チェックリストとロールバックトリガー —
shipping-and-launchスキルを参照してください。このスキルはそれらに情報を提供するインストゥルメンテーションをカバーします
プロセス
1. インストゥルメントする前に「動作する」を定義する
質問のないテレメトリはノイズです。インストゥルメンテーションを追加する前に、オンコールのエンジニアがこの機能について尋ねるであろう2〜4の質問を書き留めてください。
FEATURE: checkout payment retry
QUESTIONS ON-CALL WILL ASK:
1. What fraction of payments succeed on first attempt vs after retry?
2. When a payment fails permanently, why? (provider error? timeout? validation?)
3. Is the payment provider slower than usual?
→ Every signal below must help answer one of these.
質問を明確にできないなら、インストゥルメントする準備はできていません — すべてを記録しても何も学べません。
2. 各質問に適切なシグナルを選ぶ
| シグナル | 答えられること | コストプロファイル | 例 |
|---|---|---|---|
| 構造化ログ | 「この特定のケースで何が起きたか?」 | イベント単位; トラフィックに応じて増加 | payment_failed とプロバイダエラーコード |
| メトリクス | 「どのくらいの頻度・速度で、集約するとどうか?」 | 系列あたり固定; クエリが安価 | プロバイダ呼び出しのp99レイテンシ |
| トレース | 「サービス全体で時間はどこで費やされたか?」 | リクエスト単位; 通常サンプリング | 1つの遅いチェックアウト、ホップごとの内訳 |
経験則: メトリクスは何かが問題であることを教え、トレースはどこで問題が起きているかを教え、ログはなぜ問題が起きたかを教えます。
3. 構造化ログ
イベントを記録し、散文を記録しないでください。すべてのログ行は、安定したイベント名と機械可読なフィールドを持つJSONオブジェクトです。
// BAD: string interpolation — unqueryable, inconsistent
logger.info(`Payment ${id} failed for user ${userId} after ${n} retries`);
// GOOD: stable event name + structured fields
logger.warn({
event: 'payment_failed',
paymentId: id,
provider: 'stripe',
errorCode: err.code,
attempt: n,
}, 'payment failed');
ログレベル — 一貫して使用する:
| レベル | 意味 | オンコール対応 |
|---|---|---|
error | 不変条件が破られた; 誰かが対応する必要がある可能性 | 調査 |
warn | 低下したが処理済み(リトライ成功、フォールバック使用) | 傾向を監視 |
info | 重要なビジネスイベント(注文確定、ジョブ完了) | なし |
debug | 診断詳細 | デフォルトで本番ではオフ |
相関IDは必須です。 システム境界でリクエストIDを生成(または受け入れ)、すべてのログ行、スパン、および外部呼び出しに付加します。これがないと、インターリーブされたログから単一のリクエストを再構築することはできません。
// Express: child logger per request, ID propagated downstream
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] ?? crypto.randomUUID();
req.log = logger.child({ requestId: req.id });
res.setHeader('x-request-id', req.id);
next();
});
複数のエントリポイントが1つのログに書き込む場合は、エントリポイントに名前を付けます。 相関IDは実行を識別しますが、どのコードパスがそれを開始したかは示しません。スケジューラー、リプレイエンドポイント、手動CLI実行によって到達した同じジョブは、1つのシンク内で交換可能な行を生成するため、行の属性を特定するには消去法に頼ることになります — スケジューラーの履歴、プロセステーブル、デプロイログを相互参照する必要があり、その議論はそれらの外部レコードがたまたままだ存在する限り有効です。エントリポイントを実行開始時に、相関IDの隣にスタンプし、両方を同じ方法で伝播させてください。
// One helper for every entry point: the run's own logger carries both fields.
// `entryPoint`, not `source` — ECS reserves `source.*` for network fields.
export const runLog = (entryPoint: 'scheduler' | 'replay_endpoint' | 'cli', runId: string) =>
logger.child({ entryPoint, requestId: runId });
// scheduler tick -> runLog('scheduler', crypto.randomUUID())
// POST /jobs/:id/replay -> runLog('replay_endpoint', req.id)
// CLI invocation -> runLog('cli', process.env.RUN_ID ?? crypto.randomUUID())
両方のフィールドは、相関IDと同じ境界(キュー・メタデータ、HTTPヘッダー)を越える必要があります。さもなければ、ワーカーがエントリポイントを再導出して推測することになります。エントリポイントと単に相関するだけのフィールドは、ヒントであり、属性の特定ではありません。ジョブを呼び出せるものは何でもそれを再現できます。
シークレット、トークン、パスワード、完全なPIIを決してログに記録しないでください。 これは security-and-hardening スキルからの厳格なルールです — テレメトリパイプラインは古典的なデータ漏洩経路です。フィールドを許可リストで管理し、リクエストボディ全体をログに記録しないでください。
4. メトリクス
リクエスト駆動型サービスの場合、すべてのエンドポイントとすべての外部依存関係に対してREDをインストゥルメントします: Rate(リクエスト/秒)、Errors(障害率)、Duration(レイテンシヒストグラム、平均ではない)。リソース(キュー、プール、ホスト)にはUSEを使用します: Utilization(利用率)、Saturation(飽和度)、Errors(エラー率)。
トレーシングと同様に、ベンダーニュートラルなパスはOpenTelemetryメトリクスAPIです(ステップ5と同じSDKとコンテキスト)。以下の例ではPrometheusのprom-clientを使用しています — これは一般的なバックエンドの1つであり、唯一の選択肢ではありません。RED/USEとカーディナリティのルールはどちらの場合でも同じです。
import { Histogram } from 'prom-client';
const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration',
labelNames: ['method', 'route', 'status_class'], // '2xx', not '200'
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});
カーディナリティは障害モードです。 一意のラベル値の組み合わせごとに個別の時系列が作成されます。ラベルは、小さく固定されたセット(ルートテンプレート、ステータスクラス、プロバイダ名)から取得する必要があります。ユーザーID、生のURL、エラーメッセージ、その他の無制限の値をラベルとして決して使用しないでください — それらはログとトレースに属します。
OK as label: route="/api/tasks/:id" status_class="5xx" provider="stripe"
NEVER a label: user_id, email, request_id, full URL, error message text
平均は決して追跡せず、パーセンタイルを常に追跡してください。平均は、ひどい時間を過ごしている1%のユーザーを隠してしまいます。ヒストグラムを使用し、p50/p95/p99を読み取ってください。
5. 分散トレーシング
OpenTelemetryを使用してください — これはベンダーニュートラルな標準であり、自動インストゥルメンテーションはHTTP、gRPC、および一般的なDBクライアントをほぼコードゼロでカバーします。
// tracing.ts — must be imported before anything else
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const sdk = new NodeSDK({
serviceName: 'checkout-service',
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
意味のある内部作業単位(例: applyDiscounts、chargeProvider)の周りにのみ手動スパンを追加し、オンコール担当者がフィルタリングできる属性を付加してください。すべての非同期境界(HTTPヘッダー、キューメッセージメタデータ)を越えてコンテキストを伝播させないと、トレースはそのギャップで途絶えます。デフォルトでは低いレートでヘッドベースのサンプリングを行い、バックエンドがテールサンプリングをサポートしている場合はエラーを100%保持してください。
6. アラート
ユーザーが感じる症状にアラートを設定し、原因には設定しないでください。
SYMPTOM (page-worthy): CAUSE (dashboard, not a page):
error rate > 1% for 5 min CPU at 85%
p99 latency > 2s one pod restarted
queue age > 10 min disk at 70%
原因ベースのアラートは、何も問題がないときに発報し、予測していなかった障害を見逃します。症状ベースのアラートは、原因に関係なく、ユーザーが実際に被害を受けているときに発報します。
作成するすべてのアラートのルール:
- アクション可能でなければなりません。 対応が「無視する、自己回復する」である場合は、アラートを削除してください。
- ランブックへのリンクがあること — たとえ3行でも: それが何を意味するか、最初に実行するクエリ、エスカレーションパス。
- しきい値と期間は、SLOまたは過去のデータによって正当化されていなければならず、推測によるものであってはなりません。
- 2つの重要度のみを使用してください: ページ(ユーザー影響あり、すぐに対応)とチケット(低下、今週中に対応)。3つ目の階層はノイズとなり、人々がすべてを無視するよう訓練することになります。
ランブックの作成
上記ルール2では、すべてのアラートにランブックへのリンクが必要です。ランブックの役割は、読者が考えることなく3つの質問に答えることです: 何が起きているのか、最初に何を確認すべきか、それが解決しない場合に誰に連絡すべきか。docs/runbooks/にアラート名で保存してください。
最小限のランブック(3行):
# Runbook: High Error Rate on /api/tasks
**Means:** DB connection pool likely exhausted, or a bad deploy.
**First check:** `SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';`
— if count > pool limit, see Step 2. (Swap in the equivalent for your database.)
**Escalate to:** #db-oncall or engineering on-call rotation.
いつ3行を超えて拡張するか: 最初の確認だけでは判断に十分でない場合にのみステップを追加してください。最も一般的な3つの原因をカバーする5ステップのランブックは、すべてのエッジケースをカバーして流し読みされる20ページのドキュメントよりも優れています。
ランブックを最新に保ってください。 インシデントがクローズされるたびに、そのインシデントで使用されたランブックを更新してください — 古いランブックは誤った自信を築きます。ステップが間違っていたり欠けていたりした場合は、インシデントを解決済みとしてマークする前に修正してください。
7. テレメトリ自体を検証する
インストゥルメンテーションはコードです。間違っている可能性があります。作業が完了したと呼ぶ前に、パスをトリガーして実際の出力を確認してください。
- ステージングでエラーを強制的に発生させる →
requestIdでログ内を見つけ、フィールドが構造化されていることを確認する([object Object]ではない) - テストトラフィックを送信 → メトリクス系列が期待されるラベルと妥当な値で表示されることを確認する
- トレーシングUIで1つのリクエストをサービス間で追跡する → 壊れたスパンがないこと
- 新しいアラートをそれぞれ一度発報させる(一時的にしきい値を下げる) → 正しいチャネルに届き、ランブックリンクが機能することを確認する
よくある言い訳
| 言い訳 | 現実 |
|---|---|
| 「動作確認ができたらログを追加する」 | 「後で」は「最初のインシデントの後」になり、それが最も高くつく瞬間です。構築と同時にインストゥルメントしてください。 |
| 「ログが多ければ観測可能性が高い」 | 非構造化ノイズはインシデントを遅くし、速くしません。クエリ可能な3つのイベントが、300行の散文よりも勝ります。 |
| 「console.logで十分だ」 | 非構造化出力はフィルタリング、相関付け、アラート設定ができません。構造化ロガーは一度だけの5分の追加コストです。 |
| 「何か壊れたらダッシュボードを見ればいい」 | 定義された質問なしで構築されたダッシュボードは、答え以外のすべてを表示します。オンコールの質問から始めてください。 |
| 「重要なものすべてにアラートを設定し、後で調整する」 | ノイズの多いページャーは人々に無視するよう訓練します。調整は決して行われず、本当のページを見逃します。 |
| 「ユーザーIDをメトリクスラベルにするとデバッグが楽になる」 | 同時にメトリクスバックエンドをダウンさせます。高カーディナリティのルックアップはログとトレースに属します。 |
| 「トレーシングは2つのサービスにはオーバーキルだ」 | 2つのサービスはすでに、ログが答えられないクロスサービスレイテンシの質問を意味します。自動インストゥルメンテーションはコストを些細なものにします。 |
レッドフラグ
- リトライ、キュー、または外部呼び出しを含む機能PRで、新しいテレメトリがゼロ
- 構造化フィールドではなく文字列補間で構築されたログ行
- 相関/リクエストIDがない — 各ログ行は孤立している
- スケジューラー、webhook、手動実行によって供給される単一のログストリームで、どのエントリポイントが行を生成したかを示すフィールドがない
- ユーザーID、生のURL、またはエラーメッセージテキストでラベル付けされたメトリクス(カーディナリティ爆弾)
- パーセンタイルなしで平均として追跡されたレイテンシ
- 毎日発報され、アクションなしで確認されるアラート
- CPUやメモリなどの原因にアラートを設定し、ユーザー向けエラー率は監視されていない
- ログにシークレット、トークン、または完全なリクエストボディが出現する
- 「自分のマシンでは動く」が本番機能の健全性の唯一の証拠
検証
機能をインストゥルメントした後、以下を確認してください。
- この機能に関するオンコールの質問が書き留められ、各シグナルがその1つに対応している
- すべてのログ出力が構造化(JSON)され、安定したイベント名とすべての行に相関IDがある
- 複数のエントリポイントによって書き込まれるすべてのログシンクに、実行開始時に設定され、下流で推測されるのではなく相関IDとともに伝播されるエントリポイントフィールドがある
- ログ行にシークレット、トークン、または編集されていないPIIがない(実際の出力をスポットチェックする)
- すべての新しいエンドポイントとすべての外部依存関係にREDメトリクスが存在し、ラベルセットが制限されている
- レイテンシがヒストグラムであり、p95/p99がクエリ可能である
- 単一のリクエストがトレーシングUIでエンドツーエンドで追跡でき、壊れたスパンがない
- すべての新しいアラートが症状ベースであり、ランブックリンクがあり、一度テスト発報されている
- ステージングで誘発された障害が、ソースコードを読まずにテレメトリだけで特定された
このリストの簡易版(リリース前インストゥルメンテーションゲートを含む)については、../../references/observability-checklist.md を参照してください。