Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

エラーメッセージを運用者向けの日本語にした副作用で、dead-letterから原因を辿れなくなった

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

運営フォームにそのまま表示する日本語のエラーメッセージを、dead-letterの理由文としてもそのまま使うと、機械可読な識別子が失われ、ログからは原因の学校を特定できなくなる。

結論

運用者向けに人間可読な日本語へローカライズしたエラーメッセージは、そのまま別の用途(機械が読むログ)へ転用すると、機械可読な識別子を道連れに失うことがある。 サイネージ配信基盤の被覆検証validateMonitorSchoolCoverageが返すエラー文言は、運営フォームの画面にそのまま表示する日本語として設計されていた。この同じ文言をdead-letterのlast_errorにも使ったところ、画面では自明だった「どの校の話か」がログからは読み取れなくなった。レビューで指摘され、検証結果にcodeschoolsという機械可読なフィールドを足し、UI向けの文言とログ向けの識別子を分離して直した。

発端

キミテラスportalは、複数校に同時配信する広告枠(loop placement)でモニタを直指定する場合、「対象校すべてに1台以上モニタが割り当たっているか」をvalidateMonitorSchoolCoverageという純関数で検証する。この関数は元々、運営フォームの保存時バリデーションのために作られたもので、失敗理由は画面にそのまま出す前提の日本語だった。

// 修正前相当(slot-model.ts)
export type LoopTargetingResult =
  | { ok: true }
  | { ok: false; error: string };

複数校ファンアウトのレビュー指摘を受けて、配信ペイロード組み立て側(delivery-payload.ts)が独自に書き直していた被覆チェックを廃止し、このvalidateMonitorSchoolCoverage一本に統一した。狙いは「対象校でないモニタが混ざっていないか」という不変条件の見落としを構造的に防ぐことだった。この一本化自体は正しい設計判断で、ここでの主題ではない。

原因

一本化した結果、validateMonitorSchoolCoverageok: falseを返したとき、配信側はcoverage.error(運営フォーム向けの日本語)をそのままreasonに詰めて呼び出し元へ返すようになった。

// 一本化直後(delivery-payload.ts、修正前相当)
if (!coverage.ok) {
  return {
    ok: false,
    reason: `placement ${placementId} monitor targeting is inconsistent with its ${targetSchoolIds.length} target schools: ${coverage.error}`,
  };
}

このreasonは、配信キューの失敗としてsync_outbox.last_errorに記録され、dead-letterの調査で運用者が最初に読む文字列になる。ところがcoverage.errorは「対象校に含まれない学校のモニタが選ばれています」のような、学校IDを一切含まない日本語だった。運営フォームの画面ではすでに特定のplacementを開いているため、対象校もモニタも一覧で見えており、「どの校か」を文言に含める必要がそもそも無い。しかしdead-letterのログにはそのplacement周辺の画面文脈が無く、運用者はlast_errorの文字列だけを手がかりに調査する。学校IDが無いエラー文言は、複数校ループのうちどの校が原因かをログから特定できず、結局placementを開き直して手作業で突き合わせるしかなかった。

直し方

validateMonitorSchoolCoverageの戻り値に、UI向けのerrorとは別に、機械可読なcodeparity / stray_school / uncovered_school)とschools(原因の学校ID配列)を追加した。

// 修正後(slot-model.ts)
export type MonitorSchoolCoverageResult =
  | { ok: true }
  | {
      ok: false;
      error: string;
      /** parity=添字対応が壊れた / stray_school=対象外の校 / uncovered_school=モニタ未選択の校。 */
      code: "parity" | "stray_school" | "uncovered_school";
      /** 問題のある学校の portal schools.id(parity では特定できないので空)。 */
      schools: string[];
    };

配信側では、このcodeschoolsreasonの末尾に埋め込む形にした。error(画面向け日本語)はそのまま残しつつ、識別子だけを別立てで足している。

// 修正後(delivery-payload.ts)
if (!coverage.ok) {
  // 理由文は運用者が sync_outbox.last_error だけで犯人の校まで辿れる形にする
  // (coverage.error は運営フォーム向けの日本語で ID を持たないため code/schools を添える)。
  return {
    ok: false,
    reason:
      `placement ${placementId} monitor targeting is inconsistent with its ` +
      `${targetSchoolIds.length} target schools [${coverage.code}` +
      `${coverage.schools.length ? `: ${coverage.schools.join(", ")}` : ""}]: ${coverage.error}`,
  };
}

運営フォームの呼び出し元はcoverage.errorだけを見る従来どおりの動作のままで、表示は一切変わらない。dead-letterに載るreasonだけが、[stray_school: <school-id>]のような機械可読な断片を含むようになった。

再発防止

この関数を直接呼び出す単体テストの期待値にもcodeschoolsを追加し、3つの失敗パターン(parity / stray_school / uncovered_school)それぞれで正しいcodeと対象校IDが返ることを固定した。

一般化すると、ある検証関数の戻り値が「画面にそのまま表示する文言」と「ログとして機械的に読まれる文字列」の両方に使われうる場合、両者を1つのerror: stringだけで兼用してはいけない。 画面向けの文言は読み手(人間)が文脈をすでに持っている前提で書かれることが多く、その前提が成り立たない別の消費先(ログ、通知、監視)に同じ文字列を横流しすると、文脈依存で省略していた識別子がそのまま失われる。UI向けの文言とは別に、識別子を持つ機械可読なフィールドを最初から用意しておくのが、この種の転用を安全にする唯一の方法だった。

よくある質問

Q1なぜエラーメッセージから学校IDが抜けていたのですか?

被覆検証の戻り値の`error`は、運営フォームの画面にそのまま表示する日本語の文言として設計されていました。画面はすでに対象のplacementを開いた状態で表示するため文脈上どの校の話かは自明で、文言自体に学校IDを含める必要がありませんでした。この同じ`error`を、文脈を持たないdead-letterのログにもそのまま転用したため、学校IDが失われました。

Q2なぜレビューで見つかったのですか?本番では発生しなかったのですか?

被覆検証をslot-model側の単一の関数に一本化する変更のレビュー時に、この関数の戻り値がdead-letterのlast_errorとしても使われる経路があることが指摘されました。一本化のコミットから5分後に同じ日のうちに追い修正のコミットが入っており、この状態のコードが長く残っていた形跡はありません。

Q3この直し方は他のバリデーション関数にも当てはまりますか?

当てはまります。ある検証関数の戻り値が「画面に表示する文言」と「ログとして機械的に読む文字列」の両方に使われる可能性がある場合、文言用の`error`とは別に、識別子を持つ`code`と`schools`のような機械可読なフィールドを持たせておくと、呼び出し側が用途に応じて選べます。

確認した環境

  • kimiteras-portal: Next.js 16.2.7 / @supabase/supabase-js ^2.106.2
  • 2026-07-24 にレビュー指摘で発見、同日中に修正

この記事の根拠

  • TypeScriptファイル 306〜323行目コミット 3cb1f8a
  • TypeScriptファイル 339〜397行目コミット 3cb1f8a

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。