Newer
Older
client_dwatch / docs / logging-design.md

ログ設計 — 動作ログ機構

全社共通の作業ルール7(「アプリ開発では関数コメント・エラーメッセージ・動作ログファイルを 必ず用意し、デバッグはログを起点に行う」)への対応。この文書が実装の正。

2案(運用・インフラ観点/ドメイン要件・データ保護観点)を独立に立てて突き合わせ、 対立点を裁定した結果を記録している。却下した案とその理由も残す。


0. 前提(実測値。推測ではない)

項目 値 確認方法
Next.js 16.3.3 package.json
React / Prisma / TypeScript / Vitest 19.2.8 / ^7.10.0 / ^5 / ^4.1.11 同上
pino 未導入(本設計で追加する) 同上
proxy.ts / middleware.ts / instrumentation.ts いずれも未作成 リポジトリ確認
lib/logger/ 未作成 同上
実際のAPIルート app/api/ 配下の全ルート(2026-09-03時点で9本) find app/api -name route.ts

このバージョン固有の重要事項

  • Edge Runtime は非推奨。 Route Handler も Proxy も既定が Node.js ランタイム。 「Edge 制約で fs が使えない」という一般論はこの版に当てはまらない。フル Node.js API が使える。
  • middleware.ts は v16 で非推奨。proxy.ts に改名された。 middleware.ts を作らないこと。
  • instrumentation.ts の register() / onRequestError() は安定(v15.0.0〜)。
  • next.config.js の logging オプションは開発時のターミナル出力にしか効かない。 本番ログには無関係なので、別途 LOG_LEVEL 環境変数を用意する。
  • 判断に迷う点は node_modules/next/dist/docs/ 配下の当該バージョンのガイドを読むこと。 訓練データの Next.js 知識は当てにしない。

1. 決定サマリ

論点 決定
ライブラリ pino
出力先 アプリは stdout のみ。ファイル化は systemd の StandardOutput=append: に任せる
ローテーション アプリでは実装しない。 logrotate(OS側)
形式 1行1JSON(構造化)。日時は ISO 8601 JST +09:00
相関ID proxy.ts で x-request-id を発行 → ヘッダで伝播 → ルート側で AsyncLocalStorage に載せ、pino の mixin() で全ログへ自動付与
判定ロジック lib/judge/ は一切変更しない。 呼び出し側(APIルート)で記録する
マスキング allowlist 型で型レベルに強制 + 禁止フィールド名のユニットテスト
ログの読者 開発者のみ。 運用担当向けのログビューは作らない

出力先の裁定理由(両案の対立点)

運用観点の案は「stdout → journald、アプリはファイルを書かない」を推奨した。理由は妥当で、 アプリにファイルI/O・ローテーション・ディスク満杯対策を持たせるほど事故率が上がる。

しかし全社ルール7は「動作ログを log ファイルとして 残す」と要求しており、journald は ファイルではない。そこで アプリは stdout に出し、systemd 側でファイルに落とす形に裁定した。 アプリ側の失敗モードは排除したまま、実ファイルが存在する状態を満たす。

却下した案

案 却下理由
自作の薄いロガー JSON.stringify(error) が message / stack を落とす罠、循環参照、レベル制御を自前で正しく作る必要があり、静かに壊れる(エラーが {} とだけ出る)リスクが高い
winston 強みはローテーションだが、OS側に委譲する設計では活きない。pino よりオーバーヘッド大
アプリからのファイル直書き ローテーション・権限・ディスク満杯を全部アプリが面倒を見ることになる
PM2 でのログ管理 素の Linux VM に systemd があるのに Node 専用の常駐レイヤーを増やす理由がない
proxy.ts と各 route.ts で AsyncLocalStorage インスタンスを共有 公式ガイドが「Proxy は共有モジュールやグローバルに依存すべきでない」と明記。別バンドルの可能性がある
判定関数に logger を引数で渡す CLAUDE.md §9.4 の「判定関数は純粋関数(DBアクセスを混ぜない)」規約に違反する
ログ専用のコード体系(LOG_E001 等)を作る クライアント向け error.code と二重管理になる
ログを取らない/P2の監査ログ機能に先送り P2スコープ外の「監査ログ」はユーザー管理と紐づく説明責任トレイル。本件はデバッグ用の技術ログで別物。ルール7によりP1でも必須

2. ディレクトリ構成

d-watch/
  proxy.ts                    # 新規。x-request-id の発行と伝播のみ(※認証との共存は §9)
  instrumentation.ts          # 新規。register() でロガー初期化、onRequestError() で捕捉漏れを記録
  lib/
    logger/
      index.ts                # pino インスタンス。mixin() で request_id / data_source を自動付与
      context.ts              # AsyncLocalStorage と runWithRequestId() ヘルパー
      events.ts               # ★イベント型(allowlist)。許可フィールドのみで構成する
      redact.ts               # 禁止フィールド名の実行時チェック。大文字小文字を無視する
      timestamp.ts            # ISO 8601 JST(ミリ秒付き)。pino 既定の epoch ms を置き換える
      api.ts                  # logApiError()。クライアントに返す ApiError をそのまま受け取り、レスポンスとログのズレを構造的に防ぐ
      __tests__/
        redact.test.ts        # 禁止フィールドが混入しないことを機械的に検証(キーの件数と並びも固定する)
        api.test.ts           # logApiError() が ApiError から error_code / error_message / http_status を写すことを検証
        context.test.ts       # withRequestId() / normalizeRequestId()。クライアント由来の値を信頼せず上書きすることを検証
        smoke.test.ts         # ★§10 完了条件5・6。pino の実出力を捕捉して共通フィールドと禁止フィールド除去を検証
        timestamp.test.ts     # timestamp が ISO 8601 +09:00(ミリ秒付き)になることを検証
    judge/                    # ★変更禁止。純粋関数のまま維持する
  app/api/**/route.ts         # console.error を logger 経由に置換 + runWithRequestId() でラップ
  .env.example                # LOG_LEVEL=info を追記
  eslint.config.mjs           # no-console を追加(lib/logger 以外での console.* を禁止)

lib/logger/ は lib/settings/ と同格の非純粋 I/O 層。lib/judge/ から import してはいけない (依存方向を一方向に保つ)。


3. ログイベントと共通フィールド

イベント名はドット区切りの名前空間にする。点検記録系は inspection.* として メインDB系(db.*)と名前空間で分ける(docs/decisions.md D-004 の分離方針をログにも反映)。

イベント 発生箇所
api.error APIルートの catch 節
judge.grade / judge.escalation / judge.advice 判定関数の呼び出し直後
db.write メインDBへの書き込み(4カラム限定。M3以降)
db.timetable_conflict 予定の2表(clean_timetables / clean_timetable_histories)に同じ id があり中身が違った(warn。本表を採る。lib/db/timetable-source.ts。指示書AU 段階3b-7 Q2)。データ層から出すので route / method は持たない
inspection.* 点検記録ストアの操作
data.anomaly 期待値と実測の乖離を見つけたが原因は不明、という記録。★ルート層から出すときは route / method を埋める★。データ層から出すとき(清掃詳細ログの事前チェックが3つ以上 precheck_rounds_per_main_run。D-178)は持たない(型は D-178 で任意にした)
settings.store_corrupt 独自ストア(JSON)のファイル破損を検知した時

共通フィールド(全イベント必須)

フィールド 型 備考
timestamp string ISO 8601 +09:00(CLAUDE.md §9.3 の日時規約に合わせる。pino 既定の epoch ms から変更する)
level info / warn / error
event string 上表の名前空間
request_id string mixin() で自動付与
route / method string 実際のパスを入れる(/api/v1/... と書かない)
data_source fixtures / prisma USE_FIXTURES 由来。必須

data_source を必須にする理由: lib/db/index.ts は USE_FIXTURES=false で Prisma 未実装だと 意図的に throw する設計になっている。この「意図的な未実装エラー」と「本番DBの本当の障害」を ログで区別できないと誤診断する。

エラーイベントの追加フィールド

フィールド 型 備考
error_code ErrorCode union クライアントに返す error.code と同一の変数を再利用する。 ログ側で別途文字列を組み立てないこと(ズレを構造的に防ぐ)
error_message string クライアントに返す定型文のみ。生の例外メッセージをそのまま転記しない(予期せぬ値が展開される)
http_status integer

lib/types/index.ts の ApiError.error.code を、現在使われている4値 (validation_failed / not_found / internal_error / not_implemented)を列挙した union 型に強化する。以後コードを増やすには型に追加が必要になり、実質 enum の強制力を持つ。

d-watch-api.yaml は type: string のまま変更しない。API契約の変更は合意コストを伴うため、 まず内部の TS 型で運用し、コード一覧が安定してから追認する。


4. マスキング規約

なぜ必須か

P1 の対象顧客は実在企業で、実在の施設名・住所・電話番号・担当者氏名を扱う。 加えて docs/spec.md M0 に「Cursor の Privacy Mode を両名ON(会社コードのため必須)」とあり、 このチームは機密の外部流出を意識している。ログは「デバッグの起点」である以上、 調査時にログの中身をAIチャットに貼り付ける経路が現実に発生する。 よってマスキングは「念のため」ではなく必須要件とする。

記録禁止(allowlist 型に含めないこと)

禁止キーは性質の違う2群に分ける。漏れたときの被害の種類が違うため、まとめて1つの リストにすると片方の群だけが忘れられる(実際、本節は当初 (1) だけを列挙していた)。

(1) 個人情報・自由記述(13件)

自由記述または個人氏名のフィールド。担当者が電話番号や別の顧客名を書き込む可能性があり、 内容を予測できない。

facility_name / address / phone / email / manager_name / charger_name / inspector_name / customer_name / notes / comment(内容) / memo / note / detail

(2) 認証情報(8件)

個人情報と違い、漏れると「読まれる」だけでなく「なりすまされる」。ログを調査で AIチャットに貼る経路(前述)がある以上、認証情報の混入は権限そのものの流出になる。

cookie / set-cookie / authorization / token / session / secret / password / api_key

HTTP ヘッダは Authorization / Set-Cookie のように大文字で来るため、キー名の判定は 大文字小文字を無視する(完全一致だと素通りする)。

cookie 名そのもの(dwatch_session 等)はここに載せない。 P1 の認証方式が未決で (§9・lib/auth/ は予定)、名前を先に決め打ちすると実装とズレる。 認証を実装して名前が決まった時点で、lib/logger/redact.ts と本節の両方へ追加する。

追加・変更は3箇所セット

上記は合計 21件。lib/logger/redact.ts が FORBIDDEN_PII_FIELDS(13件)と FORBIDDEN_CREDENTIAL_FIELDS(8件)として同じ順序で保持し、 __tests__/redact.test.ts が件数(21)と並びの両方を機械検証している。 したがって禁止キーを増減するときは

  1. lib/logger/redact.ts の該当配列
  2. lib/logger/__tests__/redact.test.ts の期待値(件数・並び)
  3. 本節のリスト

の3箇所を必ず同時に直すこと。1つでも欠けるとテストが落ちるか、文書が実装より 少ない状態(本節が認証情報を欠いていた状態)に戻る。

本節の列挙順は redact.ts の並びに揃えてある。 読み合わせを目diffで済ませられるようにするため、 並べ替えないこと。

記録可(数値・enum・ID)

facility_id / clean_log_id / report_id / precheck_id / alert_id / record_id / serial_number(機器識別子であり個人情報ではない) / 各種率・件数 / grade / escalation_level / status_1h / status_30m / 日付・時刻 / request_id

施設は必ず facility_id で参照する。名前を書かない。

強制方法(3段構え)

  1. lib/logger/events.ts のイベント型を許可フィールドのみで構成する。 自由記述の string フィールドを型に含めない → 誤って渡すとコンパイルが通らない
  2. lib/logger/redact.ts で実行時にも禁止フィールド名を弾く
  3. __tests__/redact.test.ts で、イベント型のキー集合に禁止フィールド名が含まれないことを 機械的に検証する(docs/decisions.md D-041 の「テストで固定する」文化を流用)

キー名検査で防げないもの

上の3段構えはキー名に対する検査である。redact.ts は値の中身を一切見ていない (自由記述かどうかは内容からは判定できないため、キー名で判定する設計にしてある)。 したがって許可されたキーの「値」に個人情報が入っている場合は防げない。 実際に起こりうる経路:

経路 何が混じるか
api.error の error_message クライアント定型文のはずのフィールドに、生の例外文字列(e.message 等)をそのまま入れてしまう
err シリアライザの message / stack Prisma のエラーメッセージ等に施設名が含まれうる(§10 参照。err は error レベルのみに付ける)
data.anomaly の expected / actual 型が number または string なので、自由記述由来の文字列を渡すと PII が混じる

つまりここは値を組み立てる側の責任である。許可キーへ渡してよいのは 定型文・enum・ID・数値・boolean に限る。 自由記述の「中身が変わった」ことを伝えたい場合は、§6 の comment_changed のように boolean や件数へ落として渡すこと(内容そのものは載せない)。


5. 判定ロジックのトレーサビリティ

設計

lib/judge/ は純粋関数のまま一切変更しない。判定関数を呼んだ直後に、呼び出し側から lib/logger のヘルパーを明示的に呼んで記録する。

記録先は「実行時に判定している場所」であって、APIルートとは限らない

当初この節は「各APIルートが呼んだ直後」と書いていたが、実装を確認したところ実態が異なっていた。 記録先は呼び出し箇所ごとに次のように決める。

判定 実行時の呼び出し箇所 記録先
matchAdviceRules / judgeEscalationLevel app/api/clean-logs/[id]/advice/route.ts・app/api/clean-logs/unscheduled/[report_id]/advice/route.ts(共通関数 lib/clean-logs/advice-response.ts 経由) 同ルート内(実装可)
judgeGrade 実行時の呼び出し箇所が無い Prisma 実装時に lib/db/ へ付ける(下記)

judgeGrade は現在 lib/fixtures/clean-logs.ts の中でしか呼ばれていない。これは データ生成時の計算であり、利用者に返す判定ではない。ここで記録しても起動時にログが 溢れるだけで「判定への異議に答える」用途を満たさないため、fixtures からは記録しない。

Prisma 実装(lib/db/index.ts の prismaSource)が入ると、DB の行を CleanLogItem に 変換する際に judgeGrade を呼ぶことになる。そこが judge.grade の記録先である。 lib/db/ は非純粋層なので lib/logger/ を import してよい(依存の向きに反しない)。

したがって、後述の記録条件4(しきい値の近傍)が実際に効き始めるのは Prisma 実装以降。 条件4 は完了率のしきい値(90 / 70 / 10)に対するものであり、エスカレーションのしきい値は 日数(2日 / 3日)で整数のため近傍という概念が意味を持たない。

記録するのは3点セット。

フィールド 内容
raw_completion_rate 等 丸める前の生値(CLAUDE.md §4.3)
thresholds_used その時点の GRADE_THRESHOLDS / ESCALATION_THRESHOLDS のスナップショット
result_grade / result_level / result_rules 判定結果

thresholds_used を残す理由: docs/spec.md F-10(運用設定)でしきい値が編集可能になる予定 (M5、未着手)。そうなると同じ生値でも判定日によって結果が変わる。判定関数自体は決定的で テストで担保されているので、後から再現するために本当に必要なのは実行過程の逐一記録ではなく 「その時点で使われた入力値としきい値のペア」である。

記録条件(★ここが本設計で最も重要)

記録条件は1回の呼び出しで何件を判定するかで分ける。1リクエストで1件しか判定しない 経路と、一覧生成で数百件を一度に判定する経路を同じ条件で縛ると、前者では 「根拠が必要な場面に限って記録が無い」事故のほうが起きやすい。

経路 1回あたりの判定件数 記録条件 状態
app/api/clean-logs/[id]/advice/route.ts・app/api/clean-logs/unscheduled/[report_id]/advice/route.ts(judge.advice / judge.escalation。共通関数 lib/clean-logs/advice-response.ts 経由) 1件(行クリックで呼ばれる) 条件を設けず無条件に記録する 実装済み
lib/db/ の一覧変換(judge.grade) 約360行/日・年間約13万行 下記のいずれか 未実装(Prisma 実装と同時)

一覧変換側は全行常時記録をしない。次のいずれかに該当する行のみ記録する。

  1. 判定が △ または ×
  2. escalation_level が null でない
  3. アドバイスが1件以上ある
  4. 生値がいずれかのしきい値の近傍(±0.5)にある

条件4を必須にする理由: CLAUDE.md §4.3 は「表示用に丸めた値で判定すること」を最も事故る箇所と 明記している。89.5 は表示90%だが判定は ○ であり、条件1〜3だけだとこの行は ◎○側なので記録されず、まさに疑義が生じる場面のログが無いことになる。近傍の幅(±0.5)は lib/config/ から読む(ハードコード禁止)。

全行記録が必要な調査のために、環境変数による verbose モードを用意する(既定 OFF)。

上記のうち条件1〜4・verbose モードは未実装。 JUDGE_LOG_BOUNDARY_MARGIN(§10)は lib/config/ に、LOG_JUDGE_VERBOSE は .env.example にまだ無い。一覧変換側の judge.grade を実装するときに同時に追加する。


6. DB書き込みの監査

メインDBへの書き込みは robot_error_reports / control_error_histories の status / comment / responded_at / resolved_at の4カラムのみ(CLAUDE.md §3・§7)。 書き込みは lib/db/robot-error-write.ts の writeRobotErrorColumns() だけで、最初の利用者は PATCH /prechecks/{id}/status(指示書AT。robot_error_reports)。PATCH /ds-alerts/{id}/status (control_error_histories)は未実装で、実装するときに同じイベントを出す。

成功・失敗ともに必ず記録する。 根拠は docs/decisions.md D-042 自身の理由づけ 「DBアカウントもカラム単位GRANTで権限を絞る(アプリのバグでデータを破壊できない構造にする)」。 これは「バグで想定外の書き込みが起きうる」前提の設計であり、権限エラーで弾かれた瞬間こそ ログで可視化すべき最重要イベントである。ここが唯一 DFA 側のコードがメインDBに影響を 与えうる導線なのに、現状もっとも観測性が低い。

フィールド 備考
table 上記2テーブルのみ
record_id 文字列(robot_error_reports は error_id = varchar(50)。Number() を通さない)
変更対象カラム名 許可4カラムのうちどれか
status_old / status_new enum なので記録可
responded_at_old/new / resolved_at_old/new 日時なので記録可
comment_changed boolean のみ。内容は絶対に記録しない(自由記述)
success
db_error_code 失敗時。どのカラムへの書き込みが拒否されたか分かる形

「誰が」の欄は設けない。 P1 にはユーザーアカウントが無く共有トークン1本のみ (CLAUDE.md §3)。個人識別子を無理に入れると「今使われている共有トークンの持ち主」という 不正確な推測情報になる。不明な情報は書かない。


7. 責任分界の切り分け

データ収集とサマリ集計は TeamWorks 担当(CLAUDE.md §2)。DFA 側から見えるのは 「読み取った結果」だけで、TeamWorks 側の処理ログには触れない。したがって DFA のログにできるのは 結論を出すことではなく、確認依頼に使える証跡を残すことである。

フィールド 切り分けの用途
data_source(fixtures / prisma) fixtures モードでの異常は即「DFAコードの問題」に絞れる
period_mode(summary_table / on_the_fly) 確定期間は TeamWorks のサマリバッチ、当期は DFA 自前の lib/judge/kpi.ts(D-023)。どちらで起きたかで疑う先が変わる
data.anomaly イベント 「見つけたが原因不明」を warn で記録

data.anomaly の例: 想定外の分母0、facilities.contract に存在しない facility_id の参照、 1日あたりの件数が想定レンジ(約360行/日)から大きく外れる、確定済みのはずの期間に サマリテーブルの行が無い。

メッセージに「TeamWorks側のバグ」のような断定を書かないこと。 期待値と実測値の差分のみを 記録する。DFA 側は TeamWorks の内部処理を観測できないため断定はできず、誤った断定が残ると 後から読む人(開発者本人を含む)を誤誘導する。


8. 実装ステップ

  1. pino を導入(package.json)
  2. lib/logger/events.ts — イベント型を allowlist で定義(§4)
  3. lib/logger/index.ts — pino インスタンス。mixin() で request_id / data_source を自動付与。 日時は ISO 8601 +09:00、既定レベルは LOG_LEVEL 環境変数から
  4. lib/logger/context.ts — AsyncLocalStorage と runWithRequestId()
  5. lib/logger/redact.ts + __tests__/redact.test.ts — 禁止フィールドの機械チェック
  6. lib/types/index.ts の ApiError.error.code を union 型に強化(§3)
  7. proxy.ts を新規作成。x-request-id の発行と伝播のみ。 クライアント由来の x-request-id は信頼せず常に上書き生成する
  8. instrumentation.ts を新規作成。register() で初期化ログ、onRequestError() で捕捉漏れを記録。 外部送信(Slack通知等)は本設計のスコープ外。 追加する場合は必ず try/catch で囲む
  9. app/api/ 配下の全APIルートの console.error("[METHOD /path]", e) を api.error イベントへ 一括置換。各ハンドラ先頭で runWithRequestId() ラップを追加する。 対象は§10の一覧(2026-09-03時点で9ファイル10箇所)。同一パターンなので一気に行う。 ルートは今後も増えるので、件数ではなく「全ルート」を基準に確認すること
  10. eslint.config.mjs に no-console を追加(lib/logger 以外での console.* を禁止し回帰を防ぐ)
  11. 判定呼び出し直後の judge.* ロギングを追加(§5)。 advice ルート(1件判定)は記録条件を設けず無条件に記録する。 一覧変換側の judge.grade は lib/db/ の Prisma 実装と同時に入れ、 そのとき §5 の記録条件(条件4「しきい値の近傍」を含む)を実装する
  12. .env.example に LOG_LEVEL=info を追記
  13. docs/decisions.md に本設計の決定を追記

M3 以降に合流する項目: db.write の監査ログ(§6)。 M7(USE_FIXTURES=false 切替)の前に「ログ出力のPIIレビュー」をチェックリストへ追加すること。 fixtures はダミーデータなのでこのレビューを代替できない。


9. 対象外・未確定(推測で埋めていない)

インフラ側(アプリ実装とは独立。情報待ち)

  • VM のプロセス管理方式が不明(systemd / PM2 / Docker)。本設計は systemd 前提。 PM2 なら pm2-logrotate、Docker なら docker logs + ログドライバ設定に置き換わる
  • 前段にリバースプロキシ(nginx 等)を置くかが不明。置く場合、IP制限を nginx 層でやるか proxy.ts でやるかが変わり、アクセスログが二重管理になる可能性がある
  • ログ保持期間の正式要件が不明。 暫定 90日(月次レポートの振り返りに当月+前月分が あれば足りるという想定)。ディスク上限は 500M 程度から始めて実測で調整
  • docs/spec.md F-02 の「清掃ログデータ2年保持」は業務データの話で、動作ログとは別物

設計上の申し送り

  • proxy.ts はプロジェクトに1ファイルしか置けない。 認証(簡易トークン + IP制限、 CLAUDE.md §3)もここに実装されるはず。相関IDの発行を先、認証を後に置くこと。 認証失敗もログで追跡できるようにするため
  • F-10(しきい値編集機能、M5)を実装する際、lib/settings/advice.ts と同じ 「現在値を上書きするだけ」の実装にすると、過去の判定を再現する手がかりが失われる。 変更履歴を追記型で残す設計を検討すること
  • error.code を将来 d-watch-api.yaml の enum として契約化するかは未決(合意次第)
  • ログの閲覧手段(grep / CLI 運用か簡易ビューアを作るか)は本設計の対象外

10. 実装指示(実装者向けの補足)

§1〜§9 は設計。この章は実装者が判断で埋めないよう先に確定させた事項と完了条件。

触ってはいけないファイル

ファイル 理由
lib/judge/** 純粋関数を維持する(CLAUDE.md §9.4)。ログは呼び出し側で記録する
lib/judge/__tests__/** 既存の期待値表。ログ導入で書き換える理由がない
d-watch-api.yaml API契約の正。error.code の enum 化は未決(§3・§9)
prisma/schema.prisma prisma db pull で生成。手で書き換えない(CLAUDE.md §7)
CLAUDE.md の <!-- BEGIN:nextjs-agent-rules --> ブロック next dev が自動生成する

フェーズ分割(一度に全部やらない)

フェーズ §8 のステップ 区切る理由
1 1〜6 ロガー基盤とマスキング。ここまで終わったら止めて報告する
2 7〜10 ★完了(指示書I / D-101)★。proxy.ts に相関IDの発行と認証を同居させた。★順序は「相関IDの発行が先、IP制限→認証が後」★ — 弾いたリクエストのログにも request_id が付くようにするため。proxy.ts は runWithRequestId() で全体を包んでおり、auth.ip_denied にも相関IDが載る(実測で確認済み)
3 11〜13 判定ログ。advice ルートは無条件記録。一覧変換側の judge.grade は Prisma 実装と同時で、そのとき §5 の記録条件(条件4を含む)を実装する

確定させた名前(実装者が決めない)

対象 名前 置き場所・既定値
ログレベル LOG_LEVEL 環境変数。開発は debug、本番は info
全行記録モード LOG_JUDGE_VERBOSE 環境変数。未設定=OFF
しきい値近傍の幅 JUDGE_LOG_BOUNDARY_MARGIN(値 0.5) lib/config/。ハードコード禁止
相関IDヘッダ x-request-id —

pino の具体的な注意

  • 日時: pino の既定は time フィールドの epoch ms。timestamp オプションにカスタム関数を渡し、 フィールド名 timestamp・ISO 8601 +09:00 の文字列にすること(CLAUDE.md §9.3 の日時規約)。 既定のまま放置しない。ミリ秒まで含めること(2026-08-27T18:32:20.123+09:00)。 秒精度だと同一秒内の複数リクエストの順序が追えず、pino 既定より後退する
  • 開発時の可読性: 生 JSON は人間には読めない。pino-pretty を devDependency として導入し、 npm run dev のときだけ通す。本番(npm start)では通さない(stdout は素の JSON のまま)
  • Error の直列化: pino 標準の err シリアライザを使う。JSON.stringify(error) は message / stack を落とすので使わない
  • 禁止フィールド検出時の挙動: NODE_ENV が production 以外では例外を投げて開発中に気づかせる。 本番では投げず、該当キーを取り除いて warn で記録し、ログ呼び出し自体は成功させる。 理由: ログ呼び出しは catch 節の中から行われる。そこで例外を投げると元のエラー処理を壊し、 ログのための機構がリクエストを 500 にしてしまう。「ログの失敗でアプリを落とさない」が優先
  • 例外オブジェクトの扱い(§3 の補足): §3 の「生の例外メッセージをそのまま転記しない」は error_message フィールドについての規則。err シリアライザが出す message / stack は デバッグに必要なので出力してよい。ただし本番で err.message に施設名等が含まれうる (Prisma のエラーメッセージなど)。err は error レベルのみに付け、info / warn には付けない

data_source の取得元

lib/db/index.ts:40 が export const USE_FIXTURES = process.env.USE_FIXTURES !== "false" を 公開している。ログ側で process.env.USE_FIXTURES を再解釈しないこと。 この export を import して mixin() で読む。二重解釈すると判定条件がズレる。

既存の全APIルート(§8 ステップ9 の対象)

対象は app/api/ 配下の全ルート。下記は 2026-09-03 時点の実測(9ファイル10箇所)で、 確認は find app/api -name route.ts で行う。この一覧を件数の根拠にしない(ルートは増える)。

app/api/dashboard/route.ts
app/api/clean-logs/route.ts
app/api/clean-logs/export/route.ts
app/api/clean-logs/[id]/areas/route.ts
app/api/clean-logs/[id]/advice/route.ts
app/api/clean-logs/unscheduled/[report_id]/advice/route.ts
app/api/settings/advice/route.ts     # GET と PUT の2ハンドラ
app/api/ds-devices/route.ts
app/api/ds-alerts/route.ts
app/api/prechecks/route.ts

関数コメント(全社ルール7)

新規に作る関数・型には日本語の JSDoc を付ける。 「何をするか」だけでなく 「なぜそうするか」を書く(例: なぜ mixin() で付与するのか、なぜ allowlist 型なのか)。 これはルール7の「関数コメント」要件そのものであり、任意ではない。

完了条件

フェーズごとに以下すべてを満たすこと。通らないまま「完了」と報告しない。

  1. npx tsc --noEmit — 型エラーなし
  2. npm run lint — no-console を追加した後も通る(通らない箇所は移行漏れ)
  3. npm test — 既存の5テスト(lib/judge/__tests__/ 4本、lib/clean-logs/__tests__/ 1本)が 壊れていない、かつ redact.test.ts が通る
  4. npm run build — ビルドが通る
  5. lib/logger/__tests__/smoke.test.ts が通る。 pino の実際の出力を捕捉し、 timestamp が ISO 8601 +09:00(ミリ秒付き)、level が文字列、request_id と data_source が入っていることをアサートする
  6. 同じスモークテストで、§4 の記録禁止フィールドが出力に含まれないことをアサートする (本番モードでは取り除かれ、非本番モードでは例外になることの両方)

当初この2項目は「実ログ1行を目視で確認」としていたが、フェーズ1には呼び出し側が存在せず 実ログが出ないため、スモークテストによる機械検証に変更した。目視確認は再現性が無く、 「書いたが一度も動いていない」状態を見逃す。

コミット

git コミットはしない。 作業ツリーの変更のみで報告する。