Newer
Older
client_dwatch / lib / logger / events.ts
/**
 * ログイベントの allowlist 型。
 *
 * 許可フィールドだけを型に載せるのは、P1 が実在施設の個人情報を扱うため。
 * ログはデバッグ起点として AI チャットへ貼られる経路があるので、
 * 自由記述・氏名系のキーを型から排除し、誤って渡すとコンパイルを落とす。
 */
import type {
  AdviceRuleId,
  ErrorCode,
  EscalationLevel,
  Grade,
} from "@/lib/types";

/** mixin が付与するデータ源。`USE_FIXTURES` をログ側で再解釈しない */
export type LogDataSource = "fixtures" | "prisma";

/**
 * 確定期間は TeamWorks サマリ、当期は DFA のその場集計(D-023)。
 * どちらで起きたかで疑う先が変わるため、該当イベントに載せる。
 */
export type PeriodMode = "summary_table" | "on_the_fly";

/** メインDBへ書いてよい2テーブル(CLAUDE.md §3) */
export type DbWriteTable =
  | "robot_error_reports"
  | "control_error_histories";

/** メインDBへ書いてよい4カラム */
export type DbWriteColumn =
  | "status"
  | "comment"
  | "responded_at"
  | "resolved_at";

/**
 * サーバー起動時(instrumentation.ts の register())に1回だけ出す。
 *
 * `data_source` は mixin が付けるので持たせない。
 * `route` / `method` も持たせない(リクエスト起因ではないので、
 * 埋めると不正確な推測情報になる。不明な情報は書かない)。
 */
export interface AppStartLogEvent {
  event: "app.start";
  /** 実際に効いているレベル。LOG_LEVEL 未設定時の既定値も含めて確認できるようにする */
  log_level: string;
  node_env: string;
  /** nodejs / edge。register() は両ランタイムで呼ばれる */
  next_runtime: string;
}

/**
 * try/catch で捕まえ損ねたサーバーエラー(instrumentation.ts の onRequestError())。
 *
 * `api.error` を流用しない。`api.error` は「ルートの catch 節で意図的に処理した」
 * という意味を持たせてあり、捕捉漏れとは性質が違う。混ざると障害調査で区別できない。
 */
export interface AppUncaughtLogEvent {
  event: "app.uncaught";
  /** 実際のパス。**クエリ文字列は落とす**(`facility_name` 等の値が載るため) */
  route: string;
  method: string;
  /** ルートファイルのパス(例: /api/clean-logs/[id]/advice) */
  route_path: string;
  router_kind: "Pages Router" | "App Router";
  route_type: "render" | "route" | "action" | "proxy";
  revalidate_reason?: "on-demand" | "stale";
  /** React 経由で元の例外が差し替わった場合の識別子 */
  error_digest?: string;
}

/**
 * API ルートの catch 節。
 * `error_message` はクライアント定型文のみ。生の例外メッセージは転記しない。
 */
export interface ApiErrorLogEvent {
  event: "api.error";
  route: string;
  method: string;
  error_code: ErrorCode;
  error_message: string;
  http_status: number;
}

/**
 * `judgeGrade()` 呼び出し直後。
 * 丸める前の生値としきい値スナップショットを残し、後から判定日の設定差を再現できるようにする。
 */
export interface JudgeGradeLogEvent {
  event: "judge.grade";
  route: string;
  method: string;
  facility_id?: number;
  clean_log_id?: number;
  raw_completion_rate: number | null;
  thresholds_used: {
    excellent: number;
    good: number;
    poor: number;
  };
  result_grade: Grade;
  period_mode?: PeriodMode;
}

/**
 * `judgeEscalationLevel()` 呼び出し直後。
 * 連続日数はスケジュール存在日ベース(CLAUDE.md §4.5)。
 */
export interface JudgeEscalationLogEvent {
  event: "judge.escalation";
  route: string;
  method: string;
  facility_id?: number;
  consecutive_problem_days: number;
  thresholds_used: {
    level2Days: number;
    level3Days: number;
  };
  result_level: EscalationLevel | null;
  period_mode?: PeriodMode;
}

/**
 * `matchAdviceRules()` 呼び出し直後。
 * 文言ではなく発火したルール ID だけを残す(自由記述のアドバイス本文は禁止)。
 *
 * 入力の生値としきい値スナップショットを持つ理由:
 * `matchAdviceRules()` は `GRADE_THRESHOLDS`(R2 / B1)と
 * `ESCALATION_THRESHOLDS`(R3 / R4)の両方を読んでいる。
 * F-10(M5)でしきい値が編集可能になると、同じ入力でも判定日によって
 * 発火するルールが変わるため、結果だけでは後から再現できない(§5 の3点セット)。
 */
export interface JudgeAdviceLogEvent {
  event: "judge.advice";
  route: string;
  method: string;
  facility_id?: number;
  clean_log_id?: number;
  /** 入力: スケジュール通り出発したか */
  departed: boolean;
  /** 入力: **丸める前の生値**(CLAUDE.md §4.3)。未出発は null */
  raw_completion_rate: number | null;
  /** 入力: スケジュール存在日ベースの連続問題日数 */
  consecutive_problem_days: number;
  /**
   * 入力: 事前チェックのステータス(enum 相当。B2 / B3 の発火条件)。
   * ★`null` = 事前チェックの予定が無い★(API の値をそのまま記録する。D-171)
   */
  status_1h?: string | null;
  status_30m?: string | null;
  thresholds_used: {
    excellent: number;
    good: number;
    poor: number;
    level2Days: number;
    level3Days: number;
  };
  result_rules: AdviceRuleId[];
}

/**
 * `buildAreaCompletions()` 呼び出し直後(GET /clean-logs/{id}/areas。指示書Z 段階5-2)。
 *
 * ★★分子・分母(`overall_completion_rate_sum` / `overall_completion_rate_count`)を残す理由★★:
 *   全体率は ★行の完了率の単純平均(清掃1回=1票。未実施の行は除く)★(D-151。指示書AQ §5-5)。
 *   あとで「全体率が行の平均と違う」と言われたときに ★ログだけで検算できる★ようにしておく。
 *   ★`raw_overall_completion_rate === overall_completion_rate_sum / overall_completion_rate_count`★
 *   (分母0なら `null`)が成り立つ。★分子・分母は `overallCompletionTally()`
 *   (`lib/clean-logs/areas.ts`)で数える★ — 全体率を出すのと同じ関数なので、ログと画面が食い違わない。
 *   ★以前は面積の合計(Σclean_area / Σtask_area)を出していた★(指示書Z 決定7 の「意図した非対称」の頃)が、
 *   ★全体率を単純平均に揃えたあとは面積から検算できない★ので差し替えた(指示書AR 段階4)。
 *   ★★項目名に式の名前(`clean_ratio`)を入れない★★(指示書AS D8)— 中身は `items[].completion_rate`
 *   (=1回ごとの完了率。`runCompletionRate()`)の合計と件数。式の名前を入れると、式を戻したり変えたりする
 *   たびに名前と中身がずれる(AR 段階4 の `overall_clean_ratio_sum` / `_count` から改名した)。
 *   ★本番稼働後は改名しないこと★(この名前で拾う集計・監視が作られると、改名した瞬間に黙って反応しなくなる。
 *   D-151 6b と同じ理由。2026-10-02 時点で集計・監視している仕組みは無い)。
 *   `judge.grade` を流用しないのは、全体率が単一行の判定ではなく合算値だからで、
 *   分子・分母を持たない `judge.grade` に混ぜると出所が追えなくなる。
 *
 * ★エリア名(`section_name`)は載せない★ — 実データでは売り場名など施設の内部情報に
 *   なりうるため、識別子(`section_id`)で足りる場面で名前を出さない
 *   (`FORBIDDEN_PII_FIELDS` は止めてくれないので実装側の判断。`AuthLogEvent` と同じ考え方)。
 */
export interface JudgeAreasLogEvent {
  event: "judge.areas";
  route: string;
  method: string;
  facility_id?: number;
  /** 入口になった清掃ログの id(エリアを特定するキーではない。指示書Z 決定1) */
  clean_log_id?: number;
  /** JST の `YYYY-MM-DD`。どの日のエリア一覧かを特定する */
  date: string;
  /** problem = △× のみ / all = 全件。★件数と全体率は filter の前に数えている★ */
  filter: "problem" | "all";
  /** 出力: **丸める前の生値**(CLAUDE.md §4.3)。分母0(全エリア未実施等)は null */
  raw_overall_completion_rate: number | null;
  /** 入力: 全体率の分子。`completion_rate`(1回ごとの完了率)が null でない行の `completion_rate` の合計(★丸めない★) */
  overall_completion_rate_sum: number;
  /** 入力: 全体率の分母。`completion_rate` が null でない行の数(★未実施は数えない★) */
  overall_completion_rate_count: number;
  /** 出力: filter の前に数えた全エリア数 */
  total_count: number;
  /** 出力: filter の前に数えた △× のエリア数 */
  problem_count: number;
  thresholds_used: {
    excellent: number;
    good: number;
    poor: number;
  };
  result_overall_grade: Grade;
}

/**
 * 総清掃面積(`GET /clean-area`。F-08)の集計直後(指示書AS 段階4 / D-156 論点2)。
 *
 * ★★取り寄せた行数(`rate_rows`)と所要時間(`elapsed_ms`)を残す理由★★ —
 *   1回ごとの完了率(上限100・小数第1位の切り捨て)は `groupBy` で書けず生SQL も禁止なので、
 *   ★完了率・実施回数の材料は行を取り寄せて JS で数える★。★期間を指定しないと全期間(保持2年)を運ぶ★ため、
 *   実データで重いかどうかをこのログで見る(重ければ別途手当てする。D-156 論点2)。
 *
 * ★施設名・屋号名は載せない★(`JudgeAreasLogEvent` と同じ考え方)。件数と生値だけ。
 */
export interface JudgeCleanAreaLogEvent {
  event: "judge.clean_area";
  route: string;
  method: string;
  /** 完了率・実施回数のために取り寄せた `clean_task_reports` の行数(fixtures 版は材料にした行数) */
  rate_rows: number;
  /** データ層(`db.cleanArea()`)の所要時間(ミリ秒) */
  elapsed_ms: number;
  /** 出力: 全体の実施回数(★本清掃に紐づくレポートの行数。予定外を含まない★) */
  total_executed_count: number;
  /** 出力: 全体の完了率(**丸める前の生値**。CLAUDE.md §4.3)。平均を取れる清掃が0回なら null */
  raw_total_completion_rate: number | null;
}

/**
 * メインDB書き込みの監査(成功・失敗とも必須)。
 * `comment` の内容は残さず、変わったかどうかの boolean だけにする。
 *
 * ★最初の利用者は `PATCH /prechecks/{id}/status`(指示書AT §3-7)★。
 * ★空パッチ(何も書かない要求)では出さない★(書き込みが起きていない)。
 */
export interface DbWriteLogEvent {
  event: "db.write";
  route: string;
  method: string;
  table: DbWriteTable;
  /**
   * ★文字列★(`robot_error_reports` は `error_id` = `varchar(50)`)。
   * ★`Number()` を通さないこと★ — NaN になっても例外が出ず静かに壊れる(D-105 項2)
   */
  record_id: string;
  /** ★実際に SET した列だけ★ */
  columns: DbWriteColumn[];
  status_old?: string;
  status_new?: string;
  responded_at_old?: string | null;
  responded_at_new?: string | null;
  resolved_at_old?: string | null;
  resolved_at_new?: string | null;
  comment_changed: boolean;
  success: boolean;
  /**
   * 失敗時。★Prisma のエラーコード(`P2025` 等)かドライバのエラー番号(例: 列の権限エラー `1143`)だけ★。
   * ★エラーメッセージは載せない★(DB のユーザー名が入る)。行が消えていたときは `no_matching_row`
   */
  db_error_code?: string;
}

/** `clean_timetables` と `clean_timetable_histories` で中身を比べる列(指示書AU 段階3b-7 Q1/Q2) */
export type TimetableConflictColumn = "facility_id" | "section_id" | "start_at";

/**
 * 予定の2表(`clean_timetables` / `clean_timetable_histories`)に同じ `id` があり、中身が違った
 * (指示書AU 段階3b-7 Q2。TeamWorks 第11回 P-7。D-158)。
 *
 * ★履歴の `id` は本表の `id` のコピー★(ユーザー回答 2026-10-05)なので、同じ `id` は同じ予定のはず。
 * ★アプリは本表(`clean_timetables`)を採る★(`lib/db/timetable-source.ts`)。違いは期待値と実測だけを残し、
 * 原因の断定(例: TeamWorks の移し替えの不具合)は書かない(`data.anomaly` と同じ作法)。
 *
 * `route` / `method` は持たせない — データ層はどのルートから呼ばれたかを知らない(不明な情報は書かない)。
 * ★値は比べた列のうち違ったものだけ★。日時は `jstIsoFromDb()` を通した文字列、id は文字列。
 */
export interface DbTimetableConflictLogEvent {
  event: "db.timetable_conflict";
  /** 同じ `id`(★文字列★。`BigInt` を JSON にできないため) */
  timetable_id: string;
  /** 採った表(★常に本表★) */
  adopted_table: "clean_timetables";
  /** 違った列 */
  columns: TimetableConflictColumn[];
  main_facility_id?: string;
  history_facility_id?: string;
  main_section_id?: string;
  history_section_id?: string;
  main_start_at?: string;
  history_start_at?: string;
}

/**
 * 点検記録ストア操作。メインDB系(`db.*`)と名前空間を分ける(D-004)。
 */
export interface InspectionLogEvent {
  event: "inspection.read" | "inspection.write" | "inspection.photo";
  route: string;
  method: string;
  record_id?: number;
  facility_id?: number;
}

/**
 * 期待値と実測の乖離。原因の断定(例: TeamWorks のバグ)は書かない。
 *
 * ★`route` / `method` は任意★(D-178 で任意にした。2026-10-16)— ★ルート層から出すときは必ず埋める★。
 * データ層(`lib/db/`)から出すとき(例: `cleanLogs()` の事前チェックが3つ以上)は、どのルートから
 * 呼ばれたかを知らないので載せない(★不明な情報は書かない★。`db.timetable_conflict` と同じ)。
 */
export interface DataAnomalyLogEvent {
  event: "data.anomaly";
  route?: string;
  method?: string;
  metric: string;
  expected: number | string;
  actual: number | string | null;
  period_mode?: PeriodMode;
  /** 異常のあった本清掃の `clean_timetables.id`(★文字列★。`BigInt` を JSON にできないため)。分かるときだけ */
  clean_timetable_id?: string;
  /** 詳細ログの事前チェック列に入りきらず捨てた分数(`precheck` の生値。D-178) */
  dropped_precheck_minutes?: number[];
}

/**
 * 独自ストア(JSON1ファイル)の破損を検知し、`.bak` へ退避した事実の記録。
 *
 * `db.*` を流用しない。書き込み先はメインDBではなく DFA 独自ストアであり
 * (CLAUDE.md §2 / §7)、名前空間を分けないと監査時に混ざる。
 * 点検記録ストア(`inspection.*`)とも分ける。運用設定の JSON は別ストアなので、
 * 同じ名前空間に入れると「どのストアが壊れたか」がログから判別できない。
 *
 * `reason` は壊れ方の分類だけを入れる(例: "JSON.parse失敗" / "オブジェクトではない")。
 * **壊れたファイルの中身そのもの、および備考・アドバイス文の本文は絶対に含めない。**
 * 破損データにこそ自由記述(アドバイス文言等)が丸ごと残っている可能性が高く、
 * ログは AI チャットへ貼られる経路があるため、原文を載せると流出経路になる。
 * 原本は `.bak` に残るので、中身が要る調査はファイルを直接見る。
 *
 * フィールド名は `lib/logger/redact.ts` の `FORBIDDEN_PII_FIELDS` を避けてある。
 * `detail` / `comment` 等に改名すると非本番で例外になるので変えないこと。
 *
 * `route` / `method` は任意。`lib/settings/advice.ts` の `loadAdviceRules()` は
 * `/api/settings/advice` と `/api/clean-logs/[id]/advice` の2ルートから共有され、
 * どちらが呼んだかをライブラリ側は関数シグネチャを変えずには知れない(指示書J §3-1)。
 * 分かるときだけ埋めればよい。
 */
export interface SettingsStoreCorruptLogEvent {
  event: "settings.store_corrupt";
  route?: string;
  method?: string;
  /** 壊れていたファイルの絶対パス */
  file_path: string;
  /** 退避先 `.bak` ファイルの絶対パス */
  backup_path: string;
  /** 壊れていた理由の分類のみ。ファイル内容・自由記述の本文は載せない */
  reason: string;
}

/**
 * 独自ストアに保存されていた値が、読み込み時の検証で弾かれた事実の記録(指示書AF 段階3-3)。
 *
 * ★`settings.store_corrupt` を流用しない★ — あちらは「JSONとして壊れていたので `.bak` へ
 * 退避した」という事実の記録で、`backup_path` が必須。こちらは
 * ★ファイル自体は正しいJSONだが、中の値が範囲外・型違いだった★という別の事象で、
 * 退避は行わない(退避していないのに退避したと書かないため)。
 *
 * ★なぜログを出すのか★: 不正な値は黙って既定値に倒れる(`loadConsecutiveDaysMax()`)。
 * 何も残さないと、運用担当には「保存したのに効かない」としか分からない。
 *
 * ★`invalid_value` に数値だけを載せる理由★: 数値1つは自由記述ではないので、
 * `SettingsStoreCorruptLogEvent` の「本文を載せない」規律に触れない。
 * ★数値以外(文字列・配列・オブジェクト)は値を載せず `value_type` の型名だけにする★ —
 * 手で編集されたファイルには自由記述が入りうるため(ログは AI チャットへ貼られる経路がある)。
 *
 * フィールド名は `lib/logger/redact.ts` の `FORBIDDEN_PII_FIELDS` /
 * `FORBIDDEN_CREDENTIAL_FIELDS` を避けてある(`detail` / `note` / `comment` 等に
 * 改名すると非本番で例外になる)。
 */
export interface SettingsValueInvalidLogEvent {
  event: "settings.value_invalid";
  route?: string;
  method?: string;
  /** 値が入っていたファイルの絶対パス */
  file_path: string;
  /** 弾かれた設定キー名(例: `consecutive_days_max`) */
  setting_key: string;
  /** ★数値だったときだけ入れる★。文字列等は載せない */
  invalid_value?: number;
  /** 弾かれた値の型名(`typeof` 相当。`null` / `array` は個別に表す) */
  value_type: string;
  /** 弾かれた理由の分類のみ。ファイル内容・自由記述の本文は載せない */
  reason: string;
}

/**
 * 認証イベント(指示書I 段階5-1)。ログイン成功 / ログイン失敗 / IP拒否 の3つ。
 *
 * ★トークンの値・cookie の値・入力値を絶対に載せないこと★
 *   段階4 で `dwatch_session` をマスキング対象に入れたが、
 *   ★そもそも渡さない★のが正しい。この型に値を持たせるフィールドを足さないこと。
 *
 * ★接続元IPを載せるかの判断(D-101)★
 *   `redact.ts` の `FORBIDDEN_PII_FIELDS`(13) にも `FORBIDDEN_CREDENTIAL_FIELDS`(9) にも
 *   `ip` / `client_ip` / `x_forwarded_for` は入っていない = ★redact は止めてくれない。
 *   出す / 出さないは実装者の判断★。結論は次のとおり:
 *
 *   - ★`auth.ip_denied` のときだけ `client_ip` を出す★。
 *     理由: `ALLOWED_IPS` は★表記ゆれを正規化しない完全一致比較★なので、
 *     拒否された接続元の★生の表記★が分からないと許可リストに登録できない。
 *     「全員403 / 特定の人だけ403」の切り分け(`docs/deploy.md`)が
 *     このログ無しでは成立しない。
 *   - ★`auth.login_success` / `auth.login_failed` には出さない★。
 *     この2つはIP判定を通過した後に出るので、接続元は定義上 `ALLOWED_IPS` に
 *     載っている既知の値であり、診断価値が無い。
 *   - ★`X-Forwarded-For` の生値は絶対に出さない★。
 *     先頭側はクライアントが自由に書ける文字列(= ログ injection の入口)なので、
 *     出すのは `clientIp()` が解決した1個の値と、要素数だけにする。
 */
export interface AuthLogEvent {
  event: "auth.login_success" | "auth.login_failed" | "auth.ip_denied";
  route: string;
  method: string;
  /**
   * ★`auth.ip_denied` のときだけ入れる★。`clientIp()` が解決した1個の値。
   * 取れなかった(XFF が無い=直接アクセス)場合は `null`。
   */
  client_ip?: string | null;
  /**
   * `X-Forwarded-For` の `,` 区切り要素数。★生値は入れない★。
   * 段数の思い込み(`TRUSTED_PROXY_HOPS`)が原因の全員403 を切り分けるために持つ。
   */
  xff_element_count?: number;
}

/**
 * ロガー自身が出す内部イベント。本番で禁止フィールドを取り除いた事実を伝える。
 * 載せるのはキー名のみ。値(施設名・電話番号・自由記述の中身)は絶対に載せない。
 * 呼び出し側が偽装できないよう LogEvent の union には含めない。
 */
export interface ForbiddenFieldsStrippedLogEvent {
  event: "log.forbidden_fields_stripped";
  route?: string;
  method?: string;
  forbidden_keys: string[];
}

/** 呼び出し側が渡すイベント本体。共通の timestamp / request_id / data_source は mixin が付与する */
export type LogEvent =
  | AppStartLogEvent
  | AppUncaughtLogEvent
  | ApiErrorLogEvent
  | JudgeGradeLogEvent
  | JudgeEscalationLogEvent
  | JudgeAdviceLogEvent
  | JudgeAreasLogEvent
  | JudgeCleanAreaLogEvent
  | DbWriteLogEvent
  | DbTimetableConflictLogEvent
  | InspectionLogEvent
  | DataAnomalyLogEvent
  | SettingsStoreCorruptLogEvent
  | SettingsValueInvalidLogEvent
  | AuthLogEvent;