ログをAPIのように設計する

CloudWatch Logs Insightsでビジネスの問いに答える:ログ行の契約、新しい行を足すより既存の行を広げるべき理由、JSONエスケープの罠、そしてリテンションという一方通行のドア。

ログ行の書き方を変えるきっかけになった質問は、技術的なものではありませんでした。ステークホルダーからの「昨日アップロードは何件失敗した?そのうち顧客自身のミスは何件?」という問いです。それを出せるダッシュボードはなく、テーブルにも残っていない——処理済みレコードは設計上、定期的にパージされます。確実に覚えている唯一の遺物がログでした。そしてログがビジネスの問いに答えられるかどうかは、何か月も前に誰かがログ文をタイプしながら、何気なく下した決定に完全に依存しているのです。

これがこの記事の主張です:クエリに読まれるログ行はAPIである。 消費者がいて、スキーマがあり、互換性の義務がある——最初の難しい質問が届くまで、そう扱われないだけで。

問いに答えられた理由

このプラットフォームはサーバーレスのバッチパイプライン(Lambda、SQSステージ)で、各ステップは安定したメッセージコード付きの構造化JSONをログに出していました。あとで効いてきた行には、真似する価値のある3つの性質があります:

  1. イベント種別ごとの安定した、grep可能なコード — filter message like /MSG_RESULT_SUMMARY/ は文言の変更に耐えます。filter message like /finished processing/ は、誰かが文章を「改善」した瞬間に死にます。
  2. 重要なペイロードをインラインで持つ。 各処理バッチのサマリー行はレコードごとの結果——[{"id":519,"status":"REGISTER_ERROR"}, …]——を運んでいたので、集計はシステム横断のJOINではなく、1本のクエリで済みます。
  3. 境界で、他の何かが失敗しうる前に記録する。 サマリーはメッセージ受信時、バリデーションが何かを拒否できるより前に出力されていました。条件分岐の中で発火するログ行は、その分岐のすべてのバグを相続します。

これで Logs Insights は日次のエラー/成功合計に1クエリで答えました。そして次の質問——どの失敗がどの宛先のものか——が限界を露呈させたのです。

新しい行を足すな、今ある行を広げよ

結果ペイロードは宛先を区別していませんでした(このケースでは:失敗通知がエンドユーザー宛か社内オペレーター宛か)。ディスパッチのコードは知っていましたが、ログには記録されていない。最初の試みは、判定ポイントで新しいログ行を出すことでした。動きはしましたが、それでも間違った手で、レビューの指摘が正しかった:

  • 新しい行は、既存のすべてのクエリと将来のすべての消費者に、レコードごとに2行の突き合わせを強います。
  • 新しい行は古い行と異なる頻度で発火しうる(リトライ、部分失敗)ため、集計が微妙にずれていきます。
  • 古い行は永遠に不完全なままです。

正しい修正はフィールド1個でした:サマリー行がすでにログしている配列の各要素に "type":"user" / "type":"admin" を刻む。既存のクエリはすべてそのまま動き、新しい質問はフィルタ1つになりました。並行する契約を増やすより、既存の契約を広げる——API設計と同じルール、同じ理由です。

後方互換性も、いつものスキーマ進化の問題そのものでした。旧コードが生成した処理中のメッセージには新フィールドがないため、バリデーションはこれをオプショナルとして扱う必要がありました。輸送手段がログストリームだからといって、スキーマ進化がスキーマ進化でなくなるわけではありません。

実際に時間を吸った罠

  • シリアライザのエスケープが素朴なマッチングを壊す。 ロガーはJSONを出力し、ペイロードはメッセージ文字列に埋め込まれた配列だったので、生の行では引用符がバックスラッシュ付きで届きます。自明に見えるクエリ——filter message like /"status":"REGISTER_ERROR"/——は何にもマッチしませんでした。裸のトークンを数える(行内の件数は部分文字列長の算術で)方法は機能し、コードが出力しているように見えるものを前提にする方法は機能しませんでした。クエリは必ず保存済みの実物の行に対してテストすること。
  • ログのタイムスタンプはUTC、ステークホルダーは違う。 「日次」集計はすべて、タイムゾーンのオフセットを意図的に、一度だけ、クエリの中で適用する必要があります。インシデントのたびに再発見するものではありません。
  • リテンションは一方通行のドア。 データベースは代替になりません:運用テーブルは短いスケジュールでハード削除され、ログのリテンションにも窓があります。書き込み時に行に入っていないものは、修正前の全期間について回復不能です。片方の宛先は履歴からきれいに導出できました(そのアドレスパターンが特徴的だったから)が、もう片方はできない——欠けていたフィールド1つが生んだ、恒久的な非対称です。

チームの資産にする

クエリ自体は、リポジトリと並んで保守されるバイリンガルのマニュアルになりました。各レシピには、答える質問、依存するメッセージコード、既知の限界が添えられています。効果は2つ。属人的な知識が属人的でなくなり——クエリを書いた人が依存先でなくなった。そしてマニュアルは消費者の台帳として機能します:ログ行を編集する前にgrep一発で、どの質問が壊れるかがわかる。

数えられる可能性のあるログ行に、私が今あてるチェックリスト:安定したコード、自己完結したペイロード、境界での出力、広げることによる進化、保存された実物での検証、消費者と一緒に文書化。1行あたり15分の手間です——その値段で買うか、あるいは後で「もう答えられない質問」という値段で払うか。