---
title: "客戶端診斷包與錯誤追蹤 — 設計文件 (FR-071.1)"
brand: "Guidant AI · **FR-071.1** 遠端可重現分析"
eyebrow: "FR-071.1 · log 地基／錯誤追蹤 SDK 接線／診斷包雙入口 · 設計文件 · 2026-09-18（D1–D13 全數定案）"
h1: "客戶機房出錯時，原廠不必到場也查得出原因 — 設計定案"
lede: "落地版裝在客戶自己的機房，出錯時案發現場全留在那邊。本案分四棒補齊：**把 log 地基改對**（環境明訂、每一筆帶同一條追蹤編號、5xx 記得下夠資訊、把淹沒稽核事件的垃圾擋掉），**接上錯誤追蹤 SDK**（可接客戶自有的外部錯誤收集站，出貨不裝站台），**做診斷包雙入口**（一支指令、一顆按鈕），再**在支援頁直接列最近的錯誤**讓一線先瞄一眼。驗收條款寫死：**故意弄壞一個功能、產包、丟給一個什麼都不知道的 AI，它要能指出錯在哪與怎麼修。**"
chips: [
  {text: "D1–D13 全數定案", kind: ok},
  {text: "4 子需求", kind: accent},
  {text: "跨 3 repo：BE／FE ＋ jedi-common／jedi-log", kind: accent},
  {text: "前一棒 FR-071.0 已完成（試用通過）", kind: ok},
  {text: "陷阱：出貨機沒設 RUN_ENV，實際跑的是 dev 那套", kind: crit},
  {text: "陷阱：稽核事件被 99.9% 垃圾淹沒", kind: crit}
]
footer: "FR-071.1 · 客戶端診斷包與錯誤追蹤 — 設計文件 · 2026-09-18 · 前作：FR-071.0 錯誤追蹤試用（母卡 CM-1906）· 討論稿：[discussion.html](discussion.html)（含選項與被排除方案的完整推演）· 沿革見 LOG 與 git log"
---

> 狀態：**設計定案（D1–D13 全數拍板）**｜日期：2026-09-18｜前作：FR-071.0 錯誤追蹤試用（母卡 CM-1906）
> 討論稿（含選項與被排除方案的完整推演）：[`discussion.html`](./discussion.html)
> 原白話需求：[`requirement-draft.md`](./requirement-draft.md)（2026-09-02）

> 🔴 **這份是設計決策，不是現況。** 內文的行號、筆數與待辦停在 2026-09-18 定稿當時，之後實作改動的結果**不回頭改寫這裡**——現況一律看 `FINAL-SPEC.md`（實作完成後產出）。

## 變更紀錄 {#changelog nav="變更紀錄"}

| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-09-18 | 初版設計定案。D1–D10 隨討論稿裁定，D11（支援頁 ERROR 列表）與 D12（log 表分區維護排程化）於本日新增；另追加兩項子任務（出貨環境明訂 `RUN_ENV`、操作日誌時間區間查詢）。**同時修正討論稿的錯誤前提**：出貨機實際跑的是 dev 那套 logging 設定，不是「沒有 log 檔」（見 §1.2）。 | FR-071.1 母案 |
| 2026-09-18 | D13 定案：錯誤收集站台不進出貨包，產品只保留錯誤追蹤 SDK 接線（可接客戶自有站台）；②那一棒的出貨側子任務退出，SDK 側全部保留。同時新增 T-5.2（診斷包遮罩與時區缺口）與稽核事件品質卡。 | CM-1944 |

---

## 1. 需求背景與端到端流程 {#background nav="背景"}

### 1.1 前一棒做完了什麼

FR-071.0（母卡 **CM-1906**）做的是「先接一套錯誤收集，看看實際效果值不值得往下做」：

| 落點 | 做了什麼 |
|---|---|
| `jedi-log` 套件 | 新增 `jedi_api_log/error_tracking/`——接 Sentry SDK、送出前先過遮罩（`masking.py` 的 `scrub_event` 連堆疊裡的區域變數都掃）。**沒設連線字串就整支不啟用**，連 SDK 都不 import |
| 主專案 | `core/plugins/api_log.py:168-187` 接線——帶版號＋commit hash 當版本識別，帶環境名，另掛 `_attach_actor` 把登入者塞進事件 |
| 前端 | `src/main.js:155-176` 接 `@sentry/vue`，同樣「沒設連線字串就不載入」 |
| 設定範本 | `.env.sample:204-208` 加 `SENTRY_DSN`，註明留空＝完全不啟用 |

本機接一套 Sentry 相容站台實測六項全過，決策者看過效果後拍板往下做正式落地（見 §3 定案 A～E）。

### 1.2 🔴 前提修正：出貨機跑的是 dev 那套 logging 設定

::: {.callout .crit}
**討論稿寫「落地版根本沒有 log 檔」是錯的，本節取代它**

實查（2026-09-18）：**出貨的 compose、installer、build 腳本沒有任何一處設定 `RUN_ENV`**——`grep` 過 `docker/production/`、`scripts/installer/install.sh`、`scripts/build/*.sh`，**零命中**；唯一寫到它的是 `.env.sample:63` 的 `RUN_ENV=dev`（開發機範本，不隨出貨包走）。

而 `jedi_common/logger/config_logger.py:28` 是 `os.getenv("RUN_ENV", "dev")`、`:73` 是 `_CONFIGS.get(RUN_ENV, logging_config_dev)`——**沒設就退 dev**。

也就是說客戶機房實際生效的是 `logging_config_dev`：
- **檔案輸出是開的**（`RotatingFileHandler`，`log/app.log` 1MB × 5 檔）
- **資料庫輸出是開的**（`DBLogHandler` → `system_logs`）
- log 格式帶 `otelTraceID` / `otelSpanID`（無收集器，值恆為 `0`）

`config_prod.py` / `config_stg.py` 那兩份把檔案輸出整段註解、無資料庫輸出的設定，**從來沒有在任何機器上生效過**。
:::

**連帶推翻的三個結論**（討論稿的洞一、D3 前段、風險七都以它們為前提）：

| 討論稿寫的 | 實際狀況 |
|---|---|
| 「落地版沒有 `log/app.log`」 | **有**。dev 設定的檔案輸出一直在寫，只是按大小輪轉（1MB × 5＝5MB，量大時幾分鐘就被輪掉） |
| 「prod 沒掛資料庫輸出，客戶機房的稽核事件可能是空的」 | **不是空的**。STG（188 stack `guidant_ai`）`system_logs` 實查 **586,498 筆**，當天仍在寫；DEV 768,726 筆 |
| 「`system_logs` 在落地版本來就沒在寫，建議退役」 | **一直在寫，而且問題相反**——它被除錯 log 淹沒了（見下） |

**STG 實查的分佈**（2026-09-18，`guidant_ai` 庫）：

::: statgrid
::: stat
[586,498]{.v}[system_logs 總筆數（STG）]{.k}
:::
::: {.stat .crit}
[586,031]{.v}[event_code 為 `-`（非稽核事件）]{.k}
:::
::: stat
[467]{.v}[真正的稽核事件]{.k}
:::
:::

等級分佈：INFO 585,875／ERROR 503／WARNING 126。真稽核事件只有幾百筆（階段推進 `6082` 268 筆、登入 `4625` 163 筆…）。

最新三筆是 `app_mw.py` 的 `before_request` 印的 `Request Body`／`Request Headers`／`API Request: GET /healthz`——**每一個請求（含每次健康檢查）固定寫三筆 INSERT 進資料庫，而且請求標頭裡的 `Authorization` 未遮罩就進了 DB**。

::: {.callout .crit}
**🔴 所以第一棒的形狀變了**

原本的形狀是「開檔案 log」，現在的形狀是 **「把 dev 那套改對，並且明訂出貨環境用哪一套」**：

- 檔案輸出已經有，要改的是**輪轉方式**（按大小 1MB×5 → 按天保留 30 天）與**格式**（otel 假欄位 → 真追蹤編號）
- 資料庫輸出已經有，要改的是**加一道 filter**（只有稽核事件與 ERROR 才寫），不是開啟它
- 追加一項原本不在討論稿裡的子任務：**出貨 compose 與 installer 明訂 `RUN_ENV`**，不再靠預設撞對

「靠預設撞對」是本案最該收掉的東西——今天撞對了，明天有人把 `config_logger.py:28` 的預設改成 `prod`（那看起來完全合理），客戶機房的 log 檔與稽核事件會**在沒有任何錯誤訊息的情況下一起消失**。
:::

### 1.3 這一棒要補的洞（修正後）

::: {.callout .crit}
**🔴 洞一：出貨環境用哪一套 logging 設定，沒有人明訂過**

見 §1.2。現況能運作純屬預設值恰好是 `dev`，三份設定檔之間的差異（檔案輸出、資料庫輸出、格式、`error_handler` 的級別）沒有一份對應「客戶機房實際需要什麼」。

而且 dev 那套是**開發機的取捨**：1MB×5 的輪轉在開發機夠用（重開就清），在客戶機房等於「出事前十分鐘的 log 已經被輪掉了」。
:::

::: {.callout .crit}
**🔴 洞二：追蹤編號全庫零命中**

`grep` 全庫找 request id / correlation id 相關概念，**零命中**。最接近的是 `common/middleware/app_mw.py:73` 的 `g.trace_id`，但它是**存取紀錄表的流水號**（`api_logs.id`），而且：

- **沒有進 log 格式**——log 每一行看不到它
- **沒有回給前端**（回應標頭沒有）
- **沒有進錯誤事件**——錯誤收集站上的事件與存取紀錄對不起來

log 格式裡有兩個看起來像追蹤編號的欄位（`config_dev.py:12` 的 `otelTraceID` / `otelSpanID`），但那是 OpenTelemetry 的欄位、**沒有對應的收集器，值恆為 `0`**（`custom_formatter.py` 明文寫了這個預設值的由來）。

**這是整個診斷包的關鍵前提**——白話需求原文寫得很清楚：「分析端不必靠時間戳猜對應」。沒有它，包裡的 log、存取紀錄、錯誤事件是三堆對不起來的東西，AI 只能猜。
:::

::: {.callout .crit}
**🔴 洞三：存取紀錄的等級欄全是 INFO，撈不出「出錯的那幾筆」**

原因是三處連鎖：

1. `app_mw.py:60` 建立紀錄時 **`level='INFO'` 是寫死的**
2. 回應階段用的 `UpdateApiLogDTO`（jedi-log 套件）**根本沒有 level 這個欄位**，所以事後也改不了
3. 回應階段（`app_mw.py:80-113`）**不看 HTTP 狀態碼**，500 跟 200 走同一條路

連帶後果：`app_mw.py:106` 把 `message` 欄位清成空字串——請求階段本來存了路徑進去，回應階段把它抹掉。

**症狀**：要在存取紀錄裡撈「出錯的那幾筆」撈不出來，只能靠時間範圍全撈再自己翻。診斷包若照這樣切，等於把整段時間的紀錄原封不動裝進去。
:::

::: {.callout .crit}
**🔴 洞四：稽核事件被 99.9% 的除錯垃圾淹沒（取代討論稿的風險七）**

`system_logs` 混住兩種東西：**除錯用的 log**（誰在哪一行印了什麼）與**稽核事件**（誰核准了哪一關，`event_code` 欄位有值那些）。後者是產品功能、是合規交付的一部分；前者是除錯輔助。

現況是後者被前者以 **1,255 : 1** 的比例淹沒（STG：586,031 vs 467）。三個具體代價：

- **查稽核事件要在五十八萬筆裡撈**——而那張表沒有 `event_code` 的索引
- **每個請求三筆 INSERT**，含健康檢查。這是每分鐘持續的資料庫寫入負載，純粹為了留一份 `log/app.log` 也有的東西
- **`Request Headers` 整份進 DB，`Authorization` 未遮罩**——憑證明文躺在資料庫裡（已在冊：`followup_be_logs_request_body_plaintext_credentials`）

**不是退役整張表**（那會連稽核一起帶走，三支守衛測試會當場擋下——見 §3 D3）。
:::

::: {.callout .crit}
**🔴 洞五：請求標頭與內容原樣印進 log，未遮罩**

`app_mw.py:45-48` 把請求標頭（含 `Authorization`）與請求內容**原樣**印進 log。那兩行的註解自己就寫了這件事：「只遮資料庫那條，憑證照樣明文躺在 `log/app.log` 裡，而看 log 的人比查資料庫的人多」。

本案**必須順手收掉**——因為診斷包會把那個檔打包帶走，等於把這個洞從「客戶自己的機器」擴大到「郵件 → 原廠 → AI 對話」的整條路徑。
:::

### 1.4 端到端流程

```{.mermaid cap="圖 1 — 出錯當下、匯出、分析三段，追蹤編號串起全鏈"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    participant U as 客戶使用者
    participant BE as Guidant AI 後端
    participant L as log 檔／存取紀錄表
    participant GT as 外部錯誤收集站（選配）
    participant OP as 客戶管理員／到場工程師
    participant HQ as 原廠
    participant AI as 乾淨的 AI 對話

    U->>BE: 操作某功能
    BE->>BE: 產生追蹤編號 req-xxxx
    BE->>L: 每一行 log 都帶 req-xxxx
    BE->>BE: 炸了（5xx）
    BE->>L: 存取紀錄該筆標 ERROR，帶 req-xxxx
    BE->>GT: 送錯誤事件（有接站台時：堆疊＋變數值＋req-xxxx）
    BE-->>U: 回錯誤，標頭帶 X-Request-ID: req-xxxx

    Note over U,OP: 客戶回報「剛剛按下去就壞了」

    alt 一線先瞄一眼
        OP->>BE: 系統設定 → 支援 → 看最近 N 筆 ERROR
    end

    alt 客戶自己來（畫面）
        OP->>BE: 系統設定 → 支援 → 選時間範圍 → 匯出
        BE-->>OP: 下載 guidant-diag-*.tar.gz
    else 到場或 ssh（指令）
        OP->>OP: sudo guidant diag --since 2h
    end

    Note over OP,HQ: 客戶把包傳給原廠（不做自動回傳）

    OP->>HQ: 傳檔
    HQ->>AI: 只給「包 ＋ 原始碼倉庫」
    AI->>AI: 讀 manifest.json 知道每個檔是什麼
    AI->>AI: 用 commit hash 把原始碼切到同一版
    AI->>AI: 用 req-xxxx 把 log／存取紀錄／錯誤事件串起來
    AI-->>HQ: 指出根本原因與修法
```

**追蹤編號在四個地方出現**（這就是「串得起來」的全部機制）：

| 出現在哪 | 長什麼樣 | 沒有它會怎樣 |
|---|---|---|
| log 每一行 | `[req-3f8a2c1b] 某某訊息` | 一個時段有上千行 log，不知道哪幾行屬於出錯那一次 |
| 存取紀錄表一個欄位 | `request_id = 'req-3f8a2c1b'` | 知道哪一行 log 壞了，但不知道使用者當時送了什麼 |
| 錯誤事件的標籤 | 收集站上該事件有 `request_id` 標籤 | 站台上看得到堆疊，但對不回那一次請求與那個使用者 |
| 回應標頭 | `X-Request-ID: req-3f8a2c1b` | 使用者回報問題時沒有東西可以念給客服 |

### 1.5 誰碰什麼（角色邊界）

決策者定調：**客戶端的人不碰 Linux、不碰 docker**。

| 角色 | 碰得到什麼 | 碰不到什麼 |
|---|---|---|
| 客戶的平台管理員 | 支援頁的 ERROR 列表、支援頁的匯出按鈕（若客戶自備錯誤收集站，另有該站台的瀏覽器介面） | ssh、docker、log 檔 |
| 到場／遠端工程師 | 上述全部 ＋ `guidant diag` 指令 | — |
| 原廠分析端 | 收到的診斷包 ＋ 原始碼倉庫 | 客戶的現場 |

**檔案 log 是診斷包的原料，不是給客戶看的東西**——所以本案不做「log 檢視畫面」，也不把 `system_logs` 做成一個選單頁（見 §3 D11 的排除理由）。

---

## 2. 分工概述（30 秒版） {#overview nav="概述"}

整案一句話：**讓客戶機房裡發生的錯誤留得下完整證據，並且能被打包帶回原廠分析——原廠不必到場、客戶不必會下指令。**

給非工程讀者的背景：軟體出錯時，工程師要的東西有三樣——**錯在程式的哪一行**（堆疊）、**當時使用者在做什麼**（那一次請求的內容）、**當下資料長什麼樣**（資料庫的相關資料）。這三樣現在都在客戶的機器裡，而且各自散著、對不起來。本案把它們串成一條線，再做一個「打包帶走」的功能。

::: {.callout .decided}
**四棒，①②可平行，③依賴①，④依賴③**

第一棒是地基——沒有它，後面打包出來的東西查不到東西。第二棒的錯誤追蹤 SDK 是獨立的一條線，可與第一棒同時開工。
:::

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者親手檢查法） |
|---|---|---|---|
| **① log 地基** | 出貨環境**明訂**用哪一套 log 設定（現在靠預設撞對）；log 檔改按天輪轉保留 30 天；每一筆 log／存取紀錄／錯誤事件帶**同一條追蹤編號**；5xx 記下是誰、打了哪支、送了什麼；存取紀錄的等級欄開始會標 ERROR；**把淹沒稽核事件的除錯 log 擋在資料庫外面** | 後端 log 設定 ＋ 中介層 ＋ 錯誤處理 ＋ 一支清理 migration，動到兩支共用套件 | 在 DEV 故意打一支會壞的 API → 回應標頭有 `X-Request-ID` → 拿那串編號 grep `log/app.log` 找得到完整堆疊 → 同一串編號去查存取紀錄表，查得到那一筆且**等級是 ERROR** → 再查 `system_logs`，**那一筆除錯 log 不在裡面了，但稽核事件還在** |
| **② 錯誤追蹤 SDK 接線（可接外部收集站，出貨不裝站台）** | 前後端都接上 Sentry 相容的 SDK：連線字串（DSN）留空＝完全不啟用、零負擔；設了就把錯誤事件送去該站台，事件帶追蹤編號標籤、登入者、版本識別。DSN 由後端一支免認證端點供給前端（一顆 image 賣所有客戶，建置期烙不進去） | 後端初始化 ＋ `client-config` 端點 ＋ jedi-log 的 `error_tracking/` ＋ FE `@sentry/vue` 與版本識別 | `.env` 把 `SENTRY_DSN` 指向任一 Sentry 相容站台（客戶自有的、或本機起一套）→ 故意炸一支 → **站台上出現那一筆錯誤**，點進去有完整堆疊、是誰觸發的、以及 `request_id` 標籤；**把該值留空重啟 → 完全不啟用**（前端連 SDK 都不下載） |
| **③ 指令版診斷包** | `guidant diag` 一支指令產出 `guidant-diag-<時間>.tar.gz`，內含環境快照／log 切片／資料庫診斷切片／錯誤事件／說明檔 | 打包核心（雙入口共用）＋ `guidant` 維運指令新增子命令 | ssh 進主機打 `sudo guidant diag --since 2h` → 得到一個檔案 → 解開來看，裡面有一份 `manifest.json` 說明每個檔是什麼，**而且翻遍整包找不到任何密碼與金鑰** |
| **④ 支援頁（列表＋匯出）** | 系統設定底下新增「支援」頁：上方**最近 N 筆 ERROR 列表**（一線先瞄一眼），下方選時間範圍、按一顆按鈕下載同一種包；另補操作日誌頁的**時間區間查詢** | 後端兩支端點 ＋ 前端頁面 ＋ 選單 migration ＋ jedi-log 查詢參數 | 用平台管理員登入 → 系統設定 → 支援 → 看得到剛才那筆 ERROR（含可複製的追蹤編號）→ 選「最近 2 小時」→ 按匯出 → 瀏覽器下載到同一種包；**用一般租戶管理員登入則完全看不到這一頁** |

依賴：①是地基；②可與①完全平行（兩條不同的線）；③要等①的 T-1.3 完成（沒有等級與編號就切不出東西）；④與③共用同一支打包核心，③的 T-3.1 完成後開工。

::: {.callout .crit}
**🔴 全案的驗收條款（決策者寫死，不可打折）**

在 DEV **故意弄壞一個功能** → 產出診斷包 → 把包丟進一個**乾淨的 AI 對話**（只給它這個包＋原始碼倉庫，沒有資料庫、沒有現場、沒有人講解）→ **它要能指出根本原因與修法**。

過不了這一關就不算完成。這條條款同時是設計的指北針：包裡每一樣東西都要能回答「AI 拿到它能推進一步嗎」，不能就不放。
:::

---

## 3. 決策定案 {#decisions nav="決策定案"}

::: {.callout .decided}
**五項前置定案（A–E，隨討論稿拍板）＋ 十三項本案決策（D1–D13）全部拍板**

下列每一項都寫明：定案內容、選它的理由、被排除的方案與排除原因。
:::

### 前置定案（A–E，決策者 2026-09-17）

| # | 決策 | 定案內容 | 理由 | 被排除 |
|---|---|---|---|---|
| **A** | 分兩階段做 | FR-071 原草稿四層一次做太重。**.0（錯誤追蹤試用）已完成**；本案 **.1** 是正式落地 | 先花小成本看效果，再決定要不要投入整套。試用結果決策者滿意 | 四層一次做完 — 試用沒過就全部白做 |
| **B** | 錯誤資料留在客戶那邊 | 產品不把事件送往原廠的任何站台；要收就收進**客戶自己指定的站台**（`SENTRY_DSN` 指哪裡就送哪裡），留空則完全不送 | 客戶機房多為隔離內網，連不到原廠的站台；而且錯誤資料屬於客戶，跨出機房有合約風險 | 原廠架一套集中收 — 隔離內網連不到，且客戶的錯誤資料跨出機房有合約風險 |
| **C** | 站台的存取控管由站台方負責 | 產品只送事件，不管站台的帳號、註冊、權限——那是客戶自己架站時的設定 | 堆疊裡有各層變數值，誰看得到是站台擁有者的決策，產品替他決定反而給錯誤的安全感 | 產品替客戶鎖站台設定 — 站台不在產品掌控內，鎖了也不保證生效 |
| **D** | POC 試用期不設 DSN | POC（189）的 `SENTRY_DSN` 留空＝不啟用 | POC 等同正式環境，不在開發期動它（環境異動鐵律）；且沒有站台可指 | 先在 POC 接一套 — 直接違反鐵律 |
| **E** | Loki/Grafana 與 Sentry 自架排除 | 不評估這兩條路 | Sentry 自架是 FSL 授權（有商業使用限制，不能隨產品出貨）；Loki/Grafana 是「日誌聚合」不是「錯誤追蹤」，要的堆疊去重與聚合它不做 | 見理由欄 |

### D1 — log 與事件各保留多久

| 項目 | 內容 |
|---|---|
| **定案** | **應用 log 檔保留 30 天、按天輪轉**（`TimedRotatingFileHandler`，`when='midnight'`、`backupCount=30`）；容器記錄 20MB×5 **不動**。錯誤事件的保留期由站台方自訂（產品不管） |
| **理由** | 診斷包最常見的用法是「客戶昨天出錯、今天回報」，30 天綽綽有餘。**按天輪轉是診斷包切片的前提**——按大小輪轉時「昨天下午」可能橫跨三個檔、也可能早被輪掉；按天輪轉時切片就是挑檔案 |
| **被排除** | **維持現況的 1MB × 5**（dev 設定，實際生效中）— 總共 5MB，流量稍大時「出事前十分鐘」就已經被輪掉，而那正是最需要的那十分鐘。**保留更久（90 天以上）** — 客戶機房磁碟規格不一，而 30 天涵蓋了實際的回報週期 |
| **附帶約束** | 保留期要做成設定檔可調（環境變數），因為客戶機房的磁碟大小差異很大。**第一棒實作完在 DEV 量一天的實際 log 大小**寫進安裝手冊的資源估算——現在拍一個磁碟需求數字等於猜 |

### D2 — 追蹤編號在哪裡產生、長什麼樣

| 項目 | 內容 |
|---|---|
| **定案** | **後端中介層產生**（`app_mw.py` 的 `before_request`），格式 **短碼 `req-<8 hex>`**（12 字）。背景排程另走一套：`job-<排程名>-<短碼>`。送到四處：①log 格式（**取代現在恆為 `0` 的 OTel 兩欄**）②`api_logs` 新欄 `request_id` ③回應標頭 `X-Request-ID` ④錯誤事件標籤 |
| **理由** | 產生位置選後端，是因為「有請求進來就有編號」這件事該由應用自己保證，**不該依賴部署架構**——客戶可能用自己的反向代理，那時 nginx 那層根本不存在。格式選短碼是因為**人要念得出來**：客服流程是「請問您畫面上那串編號是什麼」，36 個字沒人念得完。碰撞在診斷用途上無害（我們是拿它串同一個時間窗內的紀錄，不是當主鍵） |
| **被排除** | **前端網頁伺服器（nginx）產生** — 靜態檔的請求也會有編號（沒必要），且背景排程不經過 nginx 就永遠沒有編號。**純 UUID（36 字）** — log 每一行都佔位置，人眼難比對，客服念不出來。**沿用 OTel trace id 格式（32 字十六進位）** — 未來若真接了收集器可無縫對上，但現在沒有收集器，這個格式的意義是空的 |
| **附帶約束** | **沿用上游送來的編號**：前端已開啟追蹤標頭傳遞（`main.js:165-170` 的 `browserTracingIntegration` 與 `tracePropagationTargets`）。請求帶了上游編號就沿用，沒帶才自己產——這樣前端的錯誤與後端的錯誤在站台上會串成同一條 |

### D3 — `system_logs` 怎麼處置

| 項目 | 內容 |
|---|---|
| **定案** | **表不退役**。`DBLogHandler` 加一道 filter：**`event_code` 有值**、或 **`levelno >= ERROR`** 才寫；INFO 級的除錯 log 只走檔案與螢幕。另出一支**一次性清理 migration**，清掉既有的 `event_code='-' AND level='INFO'` 舊資料（照 `oneoff_cleanup_script_in_migration_dir_needs_guard` 加防呆）。**追加子任務：出貨 compose 與 installer 明訂 `RUN_ENV`**（值由 runner 查 `config_stg` / `config_prod` 與 `config_dev` 的差異後建議，目標是不再靠預設撞對） |
| **理由** | 這張表混住兩種東西：**稽核事件**（`event_code` 有值，是產品功能與合規交付的一部分，三支守衛測試盯著它不可掉出）與**除錯 log**（`event_code='-'`，`log/app.log` 有同樣內容）。比例是 1,255 : 1（STG 586,031 vs 467）。**留稽核、擋除錯**同時解掉三件事：稽核事件不再被淹沒、每請求三筆的資料庫寫入負載消失、未遮罩的 `Request Headers` 不再進 DB。ERROR 放行是因為出錯那幾筆的查詢價值高，而量極小（STG 共 503 筆） |
| **被排除** | **整張表退役**（討論稿的原始建議）— 兩種東西混住，一刀切會連稽核一起帶走，三支守衛測試會當場擋下。**維持現狀** — 垃圾持續累積，且憑證明文持續進 DB。**只清資料不加 filter** — 清完馬上又長回來，等於沒做 |
| **附帶約束** | filter 的判準寫在 handler 層（`DBLogHandler.emit` 前置判斷），**不是靠各棵 logger 的 level 調整**——後者會連帶影響檔案輸出，而檔案輸出正是我們要留全的東西。清理 migration 的防呆：先 `SELECT count(*)` 印出將刪筆數、限定 `event_code` 與 `level` 兩個條件都成立、分批刪（避免鎖表） |

### D4 — 收集站台自己的資料庫與 Redis

| 項目 | 內容 |
|---|---|
| **定案** | **不適用——站台不進出貨包**（見 D13）。站台的資料庫與 Redis 由架站的一方自理，產品不碰 |

### D5 — 打包核心放哪

| 項目 | 內容 |
|---|---|
| **定案** | **放主專案 `app/support/`**。遮罩不自己寫，**呼叫 jedi-log 的 `error_tracking/masking.py`** |
| **理由** | 判準是「這裡面有多少是產品知識」——答案是幾乎全部：六個服務名、`guidant.env` 的欄位、`schema_migrations`、授權狀態、安裝紀錄路徑。抽成套件等於把一份產品知識翻譯成一份設定檔，然後兩邊都要維護 |
| **被排除** | **放 jedi-log 套件** — 與 log 基礎建設同住，理論上其他產品也能用，但要把「包裡有什麼」抽象成設定，而那個設定本身就是全部的內容 |
| **附帶約束** | **唯一該進套件的是遮罩規則**——那已經在 `masking.py`，打包程式直接呼叫，**不要另寫一份**（兩套遮罩規則是資安缺口的標準長法）。指令版與畫面版**必須呼叫同一支**打包核心，不可各寫一套 |

### D6 — 畫面版匯出的包要不要含錯誤事件

| 項目 | 內容 |
|---|---|
| **定案** | **含。拿不到就在 `manifest.json` 寫明未取得與原因**，不安靜跳過 |
| **理由** | 兩個入口必須產出**同一種包**——不同的話，原廠收到包還要先問「你是用哪種方式產的」。而「拿不到」是**常態**（多數客戶根本沒接站台，另有權杖過期、網路不通等情形），要接受它但**不可安靜**：安靜缺席會讓分析端以為「這段時間沒有錯誤事件」，那是完全相反的結論 |
| **被排除** | **不含（畫面版只包 log、環境、資料庫切片）** — 兩個入口產出的包內容不同，違反「同一種包」原則。**含且拿不到就失敗** — 一個可選項的失敗不該讓整包產不出來 |
| **附帶約束** | 有接站台時後端要存一份讀事件用的權杖（多一份要保管的機密），保管照既有做法（設定檔），**不新發明機制**。權杖留空時包照樣產得出來，manifest 寫明「未取得，原因：沒有設定權杖」 |

### D7 — 診斷包要不要加密

| 項目 | 內容 |
|---|---|
| **定案** | **不加密**。靠遮罩保證包裡沒有機密 |
| **理由** | 三點：一、**與「客戶可審」直接衝突**——那是白話需求明文的隱私要求，客戶要交出去的東西自己看不到本身就是信任問題。二、**遮罩才是真正的防線**：加密只保護傳輸途中，解開之後內容照樣在原廠的機器上流轉；遮罩沒做好的話加密只是把問題延後。三、落地版客戶多為隔離內網，包的傳遞往往是隨身碟或內部檔案分享，攔截風險本來就低 |
| **被排除** | **用原廠公鑰加密** — 客戶無法自審，且要管金鑰（原廠私鑰外洩就全部解得開）。**預設不加密＋`--encrypt` 選配** — 兩條路都要做也都要測，而現在沒有人要求加密 |
| **反悔條件** | 若日後有客戶明確要求（合約有加密傳輸條款），再補選配路徑——那時遮罩管線已成熟，加一層加密是小事 |

### D8 — 前端版本識別要不要這一棒補

| 項目 | 內容 |
|---|---|
| **定案** | **這一棒補**，放在②那一批（與 SDK 接線同一批動前端）。格式 `guidant-ai-fe@<版號>+<commit>`，與後端的 `guidant-ai@<版號>+<commit>` 對齊 |
| **理由** | 三點：一、成本極小（建置時注入一個變數）；二、它與後端是**一對**——前後端錯誤要在站台上串成同一條，一邊有版本一邊沒有，串起來也看不出是哪一版的組合；三、**現在不補就會變成永久狀態**，因為沒有人會為了這一件事單獨開一棒 |
| **被排除** | **留到下一棒** — 站台上前端錯誤永遠沒有版本；而「下一棒」在實務上等於「沒有下一棒」 |

### D9 — 錯誤事件的節流值

| 項目 | 內容 |
|---|---|
| **定案** | **每分鐘 60 筆**（平均每秒一筆），環境變數 `ERROR_TRACKING_RATE_LIMIT` 可調。超過就丟棄，並**在應用 log 留一行「因節流丟棄 N 筆」**。節流在 SDK 側（產品這邊），與站台是誰的無關 |
| **理由** | 節流的目的是「防止錯誤風暴打死客戶的站台」，不是省流量——**風暴本身就是最重要的訊號**，所以丟棄時一定要留痕，不然分析端會以為錯誤停了。站台在客戶手上、規格產品不知道，因此上限要保守且可調 |
| **被排除** | **不節流** — 一個迴圈裡的錯誤每秒一百次會打死站台。**丟棄但不記錄** — 分析端看到事件停了會判斷成「問題自己好了」 |

### D10 — 診斷包大小上限

| 項目 | 內容 |
|---|---|
| **定案** | **100 MB**。超過時**從最舊的開始砍**，砍序固定：①容器 log 最舊的行 →②`api_logs` 最舊的列。**永不砍**：`manifest.json`／`version.json`／`docker ps`／`migration.txt`／錯誤事件／任何 ERROR 級的紀錄。指令版另提供 `--no-limit` 給到場工程師 |
| **理由** | 這個功能的價值全在「客戶自己傳得出來」，傳不出去的包等於沒產；但 50MB 對 log 量大的客戶砍掉太多，200MB 又超出多數即時通訊軟體。100MB 是兩者之間、且大多數企業檔案分享服務可過。**砍序與白名單是重點**：被砍掉的應該是「重複性最高、資訊密度最低」的東西，而 ERROR 那幾筆與環境快照是整包的骨架，砍了就等於沒產 |
| **被排除** | **50 MB** — log 量大的客戶會被截掉太多有用的區間。**不設上限** — 可能產出數 GB 的檔案，客戶傳不出去 |
| **附帶約束** | **截斷的事實必須進 `manifest.json`**，白話寫「原本要抓 24 小時，因大小限制實際只有最近 6 小時」。這句話不寫，分析端會把「沒資料」誤判成「沒事發生」 |

### D11 — 支援頁要不要直接列最近的錯誤

| 項目 | 內容 |
|---|---|
| **定案** | **要做**。系統設定→支援頁**上方**顯示「最近 N 筆 ERROR」列表，從 `system_logs` 撈 `level >= ERROR`，平台管理員守門，每列含**可複製的追蹤編號**；與匯出按鈕**同一頁** |
| **理由** | 一線現場最常見的動作是「使用者剛說壞了，我看一眼是什麼錯」——**出貨不含站台，這個列表就是產品內唯一看得到錯誤的地方**。列表看得到就能判斷「這是已知的還是新的」，需要深究時再匯出包。D3 的 filter 讓這張表只剩稽核事件與 ERROR，**這個列表因此變得可用**（不加 filter 的話要在五十八萬筆裡撈） |
| **被排除** | **另開一個「系統日誌」選單頁** — 那會變成第二套錯誤檢視介面（支援頁、日誌頁），兩套之間的差異會在最需要的時候咬人；而「瀏覽全部 log」的深度檢索本來就該交給診斷包與（客戶自備時的）收集站，產品不自己刻一套去重與聚合 |
| **附帶約束** | 這個列表**只讀不寫、只給平台管理員**；不提供搜尋與分頁以外的功能（要查得深就匯出診斷包）。守門與匯出端點同一套（見 §5.5） |

### D12 — log 表的分區維護怎麼排程

**事實**（2026-09-18 實查）：`api_logs` 與 `system_logs` **已按月分區**（`scripts/sql/2026-06-03-log-tables-partitioning.sql`），維護函式 `public.maintain_log_partitions()` 存在且功能完整（建本月～+2 月分區 ＋ DROP 超過 retention 的舊分區）。但它**只在裝機時 `scripts/init/99-stamp.sql:201` 跑一次，之後沒有任何人呼叫**——沒裝 pg_cron（該 migration 的檔頭註解自己寫了「排程未接」）、BE 也沒有對應排程。

STG 實查：分區只建到 `2026_10`。**2026 年 11 月之後的新資料會全部掉進 `_default` 分區**（分區失去意義，查詢退化成全表掃），而**舊分區永遠不會被砍**（retention 形同虛設，磁碟只增不減）。

| 項目 | 內容 |
|---|---|
| **定案** | **BE 內建背景排程，每日 03:30 呼叫 `maintain_log_partitions()`**；**retention 維持函式內現值**（`api_logs` 90 天／`system_logs` **180 天**），試用版**不**改成讀系統設定；**`guidant diag` 產包時檢查當月分區存在否**，缺則在 `manifest.json` 標紅 |
| **理由** | 排程放 BE 的三個理由：①**跟著產品走**——客戶不必額外裝東西、不必有 root、不必懂 cron ②**BE 已有現成的排程機制**（`core/scheduler.py` 的 APScheduler，已有六支 cron 型排程在跑，照既有形狀加一支即可）③**出問題看得到**——排程失敗會進 `log/app.log`（有接站台時也會送去），而 OS cron 的失敗是靜默的。診斷包標紅那一項是因為**「分區沒建」的症狀完全看不出來**：資料照常寫進 `_default`、查詢照常有結果、只是慢，沒有任何錯誤訊息；讓它在診斷包裡當場現形 |
| **被排除** | **pg_cron** — 官方 `postgres:16` image 沒有這個擴充，要換成自製 image 或第三方 image，牽動出貨映像清單與安全性評估，為一支每日排程不划算。**主機 cron** — 非 systemd 的環境與 Windows 容器主機不通、要 root 權限、且客戶端的人不碰 Linux（§1.5 的角色邊界）；失敗時沒有人會知道 |
| **為什麼 retention 不動** | **`system_logs` 維持 180 天**是因為 D3 的 filter 上線後這張表只剩稽核事件與 ERROR，而**稽核事件屬合規交付**——降到 90 天等於縮短合規紀錄的保存期，那是合規決策不是技術調校，不在本案範圍內順手做。**不改成讀系統設定**是因為試用版先把「排程有沒有在跑」這件事確立，多一層設定讀取會多一個「設定沒生效」的失敗面；現值已是合理預設 |
| **未來反悔條件** | 客戶提出保留期需求（磁碟不足要縮短、稽核稽查要拉長）時，再把 retention 改成讀系統設定。**改法要留意**：現況是寫死在函式體內的 `VALUES ('api_logs', 90), ('system_logs', 180)`，改成可設定必須動函式簽章（加參數）或讓函式自己查設定表，兩者都要重出這支 migration |
| **環境範圍** | **本案只套 DEV**。STG／POC 現況分區只到 `2026_10`，補建屬**環境異動，等決策者放行**（環境異動鐵律）；子任務要把「STG／POC 待補建」寫成一條待辦回報，不自己動手 |

### D13 — 錯誤收集站台要不要進出貨包

| 項目 | 內容 |
|---|---|
| **定案** | **不進**。產品只出**錯誤追蹤 SDK 接線**（前後端 ＋ 診斷包撈事件），`SENTRY_DSN` 留空＝完全不啟用；客戶自有站台或未來的雲端版要收事件，設一個環境變數即通。安裝包不含任何站台映像，裝機不建站台、不寫連線字串，維運指令沒有站台相關子命令 |
| **理由** | 決策者以**兩包實際產出的診斷包**做過分析：登入失敗次數、錯誤總數、OAuth 設定的根因，全部靠 `system_logs`／`api_logs`／`log/app.log` 就答得出來——**站台對根因分析的邊際幫助小**。它的獨有價值只剩「收前端瀏覽器端的錯誤」，而那一項換不到五個常駐容器、約 500MB 映像、裝機自動化與站台安全面（帳號、註冊、對外可達性）的長期成本。SDK 接線留著是因為**成本為零**（DSN 空就整支不載入，前端連 SDK 都不下載），且保留了「客戶自備站台」與「雲端版集中收」兩條未來路徑 |
| **被排除** | **進包但預設關** — 映像照樣要收進安裝包、compose 照樣要維護那幾個容器定義、升級照樣要驗它，成本幾乎沒省，換來一份沒有人在跑因此也沒有人在驗的設定（維護債的標準長法）。**進包預設開** — 就是被本決策推翻的那條路：五個容器與整套裝機自動化，換一項邊際價值有限的能力。**連 SDK 一起拔掉** — 拔掉容易、日後要接回來得重做一遍（前後端接線、DSN 供給端點、遮罩、節流、診斷包撈事件），而留著的成本是零 |
| **未來反悔條件** | ①客戶明確要求站台（合約或稽核要求集中錯誤檢視）→ 那時是「幫客戶架站」而非「隨產品出貨」，接法已就緒；②雲端版上線且前端錯誤成為主要盲點 → 由雲端側架一套集中站，產品端只需設 DSN |

### 追加子任務（不是決策，是本次補上的工作項）

| 項目 | 內容 |
|---|---|
| **出貨環境明訂 `RUN_ENV`** | 見 §1.2。compose 與 installer 都要顯式寫出，值由 runner 查三份設定檔的差異後建議。**歸在①那一棒**（T-1.1） |
| **操作日誌的時間區間查詢** | 既有的 `/log/user-log`（操作日誌清單）沒有時間區間參數；匯出端點 `api_log_service.py:79` 的 `export_api_log_file(**kwargs)` **不吃 route 傳來的任何參數**（`api_log_route.py:50` 呼叫時一個參數都沒帶）＝**全量倒出**。本案補 `start` / `end` 兩個參數並把匯出端點接上篩選，FE 該頁加日期區間元件。**歸在④那一棒**（T-4.4） |

---

## 4. 現況接入點盤點 {#touchpoints nav="接入點"}

::: {.callout .warn}
**下表每一列都經過實檔／實查驗證**

行號與筆數為 2026-09-18 當下。本案動到的每一處都在這張表上；沒在表上的就是不動。
:::

| 元件 | 檔案／位置 | 現況 | 本案動作 |
|---|---|---|---|
| **logging 設定選擇** | `jedi_common/logger/config_logger.py:28,73` | `os.getenv("RUN_ENV", "dev")`，未知值退 dev | **不動程式**，但出貨端要明訂該變數（下一列） |
| **出貨環境變數** | `scripts/installer/install.sh:1409` 一帶（產 `guidant.env`）＋ `docker/production/docker-compose.yml` | 寫了 `ENV=PRD` 等十餘項，**沒有 `RUN_ENV`** | **新增**：顯式寫出 `RUN_ENV`（T-1.1） |
| **開發環境 log 設定** | `jedi_common/logger/config_dev.py` | 檔案 `RotatingFileHandler` 1MB×5 ＋ `DBLogHandler`；格式含 `otelTraceID`／`otelSpanID`（值恆 `0`） | **改**：輪轉改按天 30 天（D1）；格式的 otel 兩欄換成追蹤編號（D2） |
| **正式／STG log 設定** | `config_prod.py:31-38`、`config_stg.py:31-37` | 檔案輸出整段註解、無資料庫輸出；**從未生效過** | **改**：與 dev 那套對齊成同一套實際要用的設定（T-1.1 的一部分） |
| **log formatter** | `jedi_common/logger/custom_formatter.py` | 檔頭註解記著「OTel 欄位沒 instrument 過就每行拋錯」與解法（自補預設值） | **改**：新增追蹤編號欄位並**在 formatter 端補預設值 `-`**（見 §8 風險一） |
| **資料庫 log handler** | `jedi_common/logger/db_log/db_handler.py` | `emit()` 無條件寫 `system_logs` | **改**：加 filter（`event_code` 有值 或 `levelno >= ERROR`）（D3） |
| **`system_logs` 實況** | STG `guidant_ai` | 586,498 筆；`event_code='-'` 佔 586,031；INFO 585,875／ERROR 503／WARNING 126 | **清理**：一次性 migration 清 `event_code='-' AND level='INFO'`（D3） |
| **稽核事件守衛** | `test/test_audit_event_instrumentation.py`、`test_audit_log.py`、`test_jedi_package_constant_copies_complete.py` | 三支盯著「稽核事件不可掉出 `system_logs`」 | **不動**，但 D3 的 filter 改完**必須全綠** |
| **中介層** | `common/middleware/app_mw.py:40-76`（before）／`:78-113`（after）／`:115-147`（teardown） | `g.trace_id` ＝ `api_logs.id`；`level='INFO'` 寫死（`:60`）；`message` 回應階段清空（`:106`）；`:45-48` 標頭與 body 原樣印進 log | **改四處**：產生追蹤編號、依狀態碼定等級、`message` 不清空、標頭與 body 過遮罩 |
| **存取紀錄 DTO** | jedi-log `api_log/app/dto/update_api_log.py` | `UpdateApiLogDTO` 只有 `id`／`response`／`user_uid`／`user_name`／`message`／`duration`，**無 `level`** | **改**：加 `level` 與 `request_id` |
| **`api_logs` 資料表** | jedi-log（表結構） | 無 `request_id` 欄 | **新增欄位**（migration，照 SQL migration 規範） |
| **5xx 錯誤處理** | `jedi_common/handler/handler.py:33` | 只有 `logger.error(f"Error in ...", exc_info=True)`——**沒有方法、路徑、使用者、請求內容** | **改**：補這四樣，遮罩後一行結構化 JSON |
| **錯誤收集接線** | `core/plugins/api_log.py:168-187` | FR-071.0 已完成：帶版號＋commit、環境名、登入者；`SENTRY_DSN` 空就不啟用 | **不動接線**，補「追蹤編號塞進事件標籤」＋節流（D9） |
| **遮罩規則** | jedi-log `error_tracking/masking.py` | `scrub_event` ＋ `_scrub_frames`（連堆疊裡的區域變數都掃） | **沿用**，打包核心直接呼叫，**不另寫一份** |
| **前端錯誤收集** | FE `src/main.js:155-176` | 已接 `@sentry/vue`；**未注入版本識別**（後端有帶、前端沒帶）；DSN 靠建置期烙印，一顆 image 賣所有客戶時寫不進去 | **改**：建置時注入版號＋commit（D8）；DSN 改向後端免認證端點取（T-2.8） |
| **版本錨點** | `common/util/app_version.py:75` ＋ `GET /api/1.0/version` | `get_version_info()` 回 `{version, commit}`，端點免認證 | **沿用**——commit hash 是「分析端把原始碼切到同一版」的關鍵 |
| **維運指令** | `scripts/installer/guidant`（1,326 行） | 用法 `:29-40`；服務清單 `:293` `GUIDANT_SERVICES`（六個）；子命令白名單 `:1274`；參數守門 `:1286-1290`；派送 `:1305-1316`；`logs` 子命令 `:607-617` | **新增 `diag` 子命令**（四處都要動）。服務清單維持六個 |
| **安裝紀錄** | `/etc/guidant-ai/install.conf` | 記 `GUIDANT_DATA_DIR`（客戶可自訂安裝路徑），維運指令讀它定位 | **沿用**——診斷包靠它找到資料目錄 |
| **容器組成** | `docker/production/docker-compose.yml` | 六個常駐（`guidant-db`／`-redis`／`-seaweedfs`／`-api`／`-socketio`／`-fe`）＋ 一次性 `guidant-db-init` | **不動**——本案不新增任何常駐服務（D13） |
| **容器記錄** | `docker-compose.yml:102-112` | json-file，20MB × 5 檔／容器 | **不動**。註解明寫 driver 必須維持 json-file（FR-068 轉發鏈依賴它） |
| **主機 log 目錄** | `docker-compose.yml:147` | `/srv/guidant-ai/log` 已掛進容器 `/app/log` | **不動**（掛載已就緒），按天輪轉後的檔案自然落在這裡 |
| **安裝包映像清單** | `scripts/build/build_bundle.sh:84-94` | 已包 `postgres:16`／`redis:7-alpine`／`chrislusf/seaweedfs:3.99`，`docker save` 成 tar；檔頭註解寫明 **tag 必須與 compose 逐字一致** | **不動**——本案不新增映像（D13） |
| **操作日誌清單端點** | jedi-log `api_log_route.py:19-42` → `POST` | `@auth_required` ＋ `@platform_admin_required`；吃 pager／sort／filters | **改**：filters 支援 `start`／`end` 時間區間（T-4.4） |
| **操作日誌匯出端點** | jedi-log `api_log_route.py:44-67` → `GET` | 呼叫 `export_api_log_file()` **不帶任何參數**＝全量倒出 | **改**：接上篩選參數（T-4.4） |
| **log 轉發（FR-068）** | jedi-log `forwarding/` | Syslog／GELF 轉發已上線，設定表＋畫面熱生效 | **不動**。🔴 紅線：`core/plugins/__init__.py:18` 的 log 設定初始化必須早於轉發鏈，本案改 log 設定時不可打破（見 §8 風險二） |
| **選單登記先例** | `scripts/sql/2026-09-17-fr110-google-drive-app-config-menu.sql` 一帶 | 系統設定群組 `pid=47`，FR-110 已排到 `sort=39` | **新增一支 migration**：支援頁掛 `pid=47`、`sort=40` |
| **守門先例** | `common/authz/platform.py` → `jedi_iam.authz.platform` 的 `require_platform_admin()` | FR-110／FR-107 皆採「路由掛 `@jwt_required`、守門下沉 service 層」 | **沿用**，不新發明 |

---

## 5. 詳細設計 {#design nav="詳細設計"}

### 5.1 log 設定的最終形狀

三份設定檔（`config_dev` / `config_stg` / `config_prod`）之間目前的差異是**歷史殘留**，不是刻意的環境取捨：prod／stg 把檔案輸出註解掉、去掉資料庫輸出、去掉 otel 欄位，而這三份從未在任何機器上生效過。

本案的處置：

| 項目 | 現在（dev，實際生效中） | 改後（三份共同） | 為什麼 |
|---|---|---|---|
| 檔案輸出 | `RotatingFileHandler` 1MB × 5 | `TimedRotatingFileHandler`，`when='midnight'`、`backupCount=30` | 診斷包要切時間範圍；按天輪轉時切片就是挑檔案（D1） |
| 資料庫輸出 | `DBLogHandler` 全收 | `DBLogHandler` ＋ filter（`event_code` 有值 或 `levelno >= ERROR`） | 稽核事件不再被淹沒（D3） |
| log 格式的追蹤欄 | `trace_id=%(otelTraceID)s span_id=%(otelSpanID)s`（恆為 `0`） | `[%(request_id)s]`，**formatter 端預設 `-`** | 換成真正有值的東西（D2） |
| 螢幕輸出 | 有 | 不動 | 容器記錄靠它，FR-068 轉發鏈也靠它 |

::: {.callout .crit}
**🔴 出貨環境必須顯式寫出 `RUN_ENV`**

現況能運作是因為預設值恰好是 `dev`。這條依賴是隱形的：`config_logger.py:28` 的預設值改一個字（改成 `prod`，那看起來完全合理），客戶機房的 log 檔與稽核事件會**在沒有任何錯誤訊息的情況下一起消失**。

T-1.1 要做兩件事：compose 與 installer 都顯式寫出這個變數；`.env.sample` 那行的註解補上「出貨端由 installer 寫入，改這裡只影響開發機」。

值選哪一個由 runner 查完三份設定檔的實際差異後建議——判準是「客戶機房需要什麼」，不是「名字叫 prod 所以用 prod」。
:::

### 5.2 追蹤編號中介層

**產生**（`app_mw.py` 的 `before_request`，在現有的 `g.start_time` 之後）：

```
上游帶了 X-Request-ID → 沿用（取前 64 字元，過白名單字元）
沒帶 → req-<uuid4().hex[:8]>
存進 g.request_id
```

**四個去處**：

| 去處 | 怎麼做 | 註記 |
|---|---|---|
| log 每一行 | log record factory 或 filter 把 `g.request_id` 塞進 record；formatter 讀 `%(request_id)s` | **formatter 必須自補預設值 `-`**（風險一） |
| `api_logs` 一欄 | `CreateApiLogDTO` 帶 `request_id`；資料表加欄位（migration） | 跨租戶的系統表，加欄位照 SQL migration 規範 |
| 回應標頭 | `after_request` 設 `X-Request-ID` | 並加進 CORS 的 `Access-Control-Expose-Headers`，否則前端讀不到 |
| 錯誤事件標籤 | `core/plugins/api_log.py` 的 `extra_before_send` 加一段 | 站台上可用這個編號搜尋 |

**背景排程**：`core/scheduler.py` 的各支排程沒有 request context。每次排程執行產一個 `job-<排程名>-<8 hex>`，用 context var 而非 `g`（`g` 需要 app context）。

::: {.callout .warn}
**`g.trace_id` 不移除、不改名**

現有的 `g.trace_id`（＝`api_logs.id`）在 `after_request` 與 `teardown_request` 都在用，是回填那一列的鍵。本案**新增** `g.request_id` 與它並存，不是改它的語意——改語意的話兩處回填會寫錯列，而寫錯列不會報錯。
:::

### 5.3 5xx 的脈絡行

現況 `handler.py:33` 只有 `logger.error(f"Error in {類別}: {例外}", exc_info=True)`。**堆疊有了，但不知道是誰、打了哪支、送了什麼**——而這三樣正是重建現場的必要條件。

補成一行結構化 JSON（堆疊仍照原樣多行輸出，JSON 那行是它的**前導脈絡行**）：

```json
{"request_id": "req-3f8a2c1b", "method": "POST", "path": "/api/1.0/xxx",
 "user": "someone", "body": "<遮罩後>", "exc": "KeyError"}
```

::: {.callout .warn}
**為什麼是「一行 JSON」而不是印成多行**

出錯時人最想看的是「一次看完整件事」，但 log 是一行一筆的串流——多行輸出在高併發下會被其他請求的 log 插進來切碎。一行 JSON 則是：人可以直接讀（欄位名都看得懂），AI 可以直接解析，而且**不會被切碎**。
:::

### 5.4 診斷包的內容

```
guidant-diag-20260918-143022.tar.gz
├── manifest.json              ← 先讀這個：每個檔是什麼、時間範圍、產包當下的狀態
├── env/
│   ├── version.json           ← 版號 ＋ commit hash（分析端切原始碼用）
│   ├── guidant.env.masked     ← 設定檔，所有密碼金鑰已遮罩
│   ├── docker-ps.txt          ← 哪些服務在跑、跑多久、重啟過幾次
│   ├── docker-stats.txt       ← 記憶體 CPU 用量
│   ├── disk.txt               ← 磁碟餘量
│   ├── migration.txt          ← 資料庫改版水位（schema_migrations）
│   └── license.txt            ← 授權狀態（不含金鑰）
├── logs/
│   ├── app.log.slice          ← 應用 log 指定時間範圍切片（完整堆疊）
│   └── docker-<服務名>.log    ← 各容器記錄尾段
├── db/
│   ├── api_logs.csv           ← 存取紀錄時間範圍切片（含追蹤編號、等級）
│   ├── system_logs.csv        ← 稽核事件與 ERROR（D3 之後這張表只剩這兩種）
│   ├── row-counts.csv         ← 各表筆數（判斷資料規模用）
│   └── schema-notes.md        ← 上述每個檔的欄位說明
└── events/
    └── glitchtip-events.json  ← 錯誤事件（有接外部收集站時；沒接或拿不到時 manifest 註明）
```

**`manifest.json` 是分析端的入口**，欄位至少要有：

| 欄位 | 為什麼需要 |
|---|---|
| 產包時間、時間範圍 | 分析端知道「看得到多久以前」 |
| 版號 ＋ commit hash | 把原始碼切到同一版，行號才對得上 |
| 每個檔一行說明 | AI 不必猜 `api_logs.csv` 是什麼 |
| 遮罩了哪些欄位 | 客戶可以審「帶走了什麼」，AI 也知道某欄是 `***` 不是真的空 |
| 錯誤事件清單（編號＋標題），或未取得的原因 | 不必解整包就知道有哪些錯；拿不到時要講（D6） |
| **截斷事實**（因大小限制實際只有 X 到 Y） | 不寫的話分析端會把「沒資料」誤判成「沒事發生」（D10） |
| 每個檔的雜湊 | 傳輸過程有沒有損壞，一比就知道 |

**各項資料怎麼取**：

| 資料 | 取法 | 註記 |
|---|---|---|
| 版號＋commit | `curl` 打 `/api/1.0/version`（免認證） | 服務掛了就退回讀映像標籤 |
| 容器狀態 | `docker ps` / `docker stats --no-stream` | — |
| 容器記錄 | `docker logs --since <時間> <服務名>` | 逐服務一份 |
| 應用 log | 從主機的 `/srv/guidant-ai/log` 切 | **依賴 D1 的按天輪轉**——挑檔案即可 |
| 存取紀錄 | `docker exec guidant-db psql ... \copy (SELECT ... WHERE act_time > ...) TO ... CSV` | 帶時間條件，不整表 dump |
| 稽核與 ERROR | 同上，查 `system_logs` | D3 之後這張表只剩這兩種 |
| 各表筆數 | 一句 SQL 掃 `information_schema` | 只有數字，沒有資料內容 |
| 設定檔 | 讀 `guidant.env` 過遮罩 | 見 §5.6 |
| 改版水位 | `SELECT * FROM schema_migrations` | 判斷「這台有沒有漏套某個改版」 |
| 錯誤事件 | 有設權杖時呼叫收集站的 REST API 撈時間範圍內事件（列表端點分頁） | 沒接站台是常態；此時 manifest 寫明「未取得，原因：沒有設定權杖」 |

### 5.5 兩支端點與支援頁

| 方法 | 路徑 | 做什麼 |
|---|---|---|
| `GET` | `/api/1.0/support/recent-errors?limit=N` | 最近 N 筆 ERROR（D11），回追蹤編號／時間／訊息／來源 |
| `POST` | `/api/1.0/support/diagnostic-bundle` | 產包並串流回傳 `tar.gz` |

**守門**（兩支相同，照 FR-110／FR-107 的既有做法）：

- 路由只掛 `@jwt_required`
- **守門下沉到 app service 層**，呼叫 `require_platform_admin()`（軸①，`common/authz/platform.py` → `jedi_iam.authz.platform`）
- 選單能力點沿用既有的系統設定那顆，**不新開**——新開一顆要 seed 到每個既有租戶的管理員角色，**漏掉的症狀是那個租戶永遠 403 且沒有任何錯誤訊息**

::: {.callout .crit}
**🔴 匯出端點的本質是「一鍵下載系統的完整現場」**

守門若寫錯，等於開了一個資訊外洩的大門。三個必守：

1. **守門下沉到服務層**（不只掛在路由）
2. **平台管理員判定是真正的那道門**——能力點擋不住租戶管理員（沿用的那顆已配給每個租戶管理員）
3. **每一次匯出都要留稽核紀錄**（誰在什麼時候匯出了什麼時間範圍）

第三項最容易漏——匯出行為本身就該被稽核，否則「誰把客戶資料帶走了」查不出來。D3 之後 `system_logs` 正好是乾淨的稽核落點，這筆進那裡。
:::

**支援頁版面**（系統設定 → 支援，`pid=47`、`sort=40`）：

| 區塊 | 內容 |
|---|---|
| 上方 | 「最近的系統錯誤」列表：時間／追蹤編號（可複製）／訊息摘要。空的時候寫「最近沒有系統錯誤」而不是空白表格 |
| 下方 | 時間範圍選擇（最近 2 小時／24 小時／7 天）＋「匯出診斷包」按鈕 |
| 說明區 | 「這個包裡有什麼、不會有什麼」——**客戶要能審自己交出了什麼**，這是白話需求明文的隱私要求，不是裝飾 |

### 5.6 遮罩管線

**一條管線、兩個入口共用**（指令版與畫面版各寫一套就是兩套真相的起點）。

| 來源 | 遮什麼 |
|---|---|
| 設定檔 | 鍵名含 `PASSWORD`／`SECRET`／`TOKEN`／`KEY`／`DSN` 一律換成 `***` |
| 存取紀錄的請求／回應欄 | 既有的 `mark_password` 打底，另加金鑰型欄位 |
| 應用 log | T-1.5 已在源頭遮罩，這裡是第二道 |
| 錯誤事件 | `masking.py` 已在送出時遮過，這裡是第二道 |

::: {.callout .warn}
**遮罩要「寧可多遮」，而且遮掉的要留痕跡**

遮成 `***` 而不是刪掉欄位——AI 看到 `"password": "***"` 知道「有這個欄位，值被遮了」；看到欄位不見了會以為「客戶沒填」，那是完全不同的判斷。

`manifest.json` 要列出遮罩規則，讓分析端知道哪些是遮的。
:::

### 5.7 錯誤追蹤 SDK 接線

**產品出的是接線，不是站台**（D13）。連線字串（DSN）留空時整支不啟用——後端不 import SDK，前端連 `@sentry/vue` 都不下載。設了值就把錯誤事件送往該位址，不管那是客戶自架的 GlitchTip、客戶自己的 Sentry、或未來雲端版的集中站。

**後端初始化**（`core/plugins/api_log.py` 的 `init_error_tracking()`，呼叫 jedi-log 的 `error_tracking/`）：

| 參數 | 值 | 為什麼 |
|---|---|---|
| `dsn` | `SENTRY_DSN`（空＝不啟用，回傳 False） | 唯一的開關，不另設 `ENABLE_*` 旗標——兩個開關就會有「設了 DSN 卻沒開」的靜默失敗 |
| `release` | `guidant-ai@<版號>+<commit>` | 只有版號在改版頻繁的開發期分不出是哪一次 build；**commit 是分析端把原始碼切到同一版的鑰匙** |
| `environment` | `ENV`（與前端同一個值） | 兩邊事件的環境欄要對得起來，否則同一次操作的前後端錯誤在站台上落在不同環境 |
| `extra_before_send` | 補脈絡 → 過節流（見下） | 送出前的宿主規則 |

**送出前的三道處理**（順序固定）：

| 順序 | 做什麼 | 失敗時 |
|---|---|---|
| ① 補登入者 | 從 auth context 取 `login_name` 寫進事件的 `user.username`——用帳號不是 user_id，站台上人看得懂 | 吞掉、回原事件。少一個欄位只是查案麻煩，讓 `before_send` 把事件弄丟才是真損失 |
| ② 補追蹤編號 | 寫進事件 `tags.request_id`，站台上可用它搜、也串得回 log 檔與存取紀錄。取不到時寫佔位值 `-` | 同上 |
| ③ 過節流 | 每分鐘上限（D9），超過丟棄 | 換窗時把丟棄筆數寫進 `log/app.log` |

::: {.callout .warn}
**節流放最後、佔位值不可省**

節流放最後，是因為被丟棄的事件不必浪費前面的取脈絡成本，而且「補脈絡失敗」與「被節流丟棄」是兩種不同的事，混在一起會讓丟棄計數包含根本沒打算送的事件。

`request_id` 取不到時寫 `-` 而不是略過欄位：**欄位消失會讓人以為「這版還沒接」**，佔位值才看得出「有接，但這筆沒有請求脈絡（背景排程／啟動期）」。
:::

**前端怎麼拿到 DSN**：前端是「一顆 image 賣所有客戶」，Vite 的環境變數在建置時就烙進產物，裝機腳本無法為每個客戶寫入各自的站台位址。因此出貨路徑改成**執行期向後端取**：

| 方法 | 路徑 | 回什麼 |
|---|---|---|
| `GET` | `/api/1.0/client-config`（**免認證**） | `sentry_dsn`／`environment`／`version`／`commit` |

- **免認證是必要的**：這支要在任何頁面、登入前就跑得起來——登入頁自己出錯時尤其需要
- **未設定時回空字串，不回 null 也不回 404**：前端一律走同一條路（拿到空字串＝不啟用）；用狀態碼區分會讓「沒設定」與「端點壞了」長得一樣
- **不新增環境變數**：這支只是把後端已有的 `SENTRY_DSN` 讀出來給前端
- 前端三種情況：建置時有烙 `VITE_SENTRY_DSN` → 直接用（開發機接法不變）；沒烙 → 打端點取；端點拿不到或值為空 → 當作沒設定，靜默不啟用（不報錯、不噴紅）

**前端版本識別**（D8）：建置時注入 `VITE_APP_RELEASE`，格式 `guidant-ai-fe@<版號>+<commit>`，與後端的 `guidant-ai@<版號>+<commit>` 對齊——前後端事件要在站台上串成同一條，一邊有版本一邊沒有就看不出是哪一版的組合。

**追蹤標頭傳遞**：前端開 `browserTracingIntegration` 並設 `tracePropagationTargets` 涵蓋 API 的 host。沒有它就不會產生 trace、追蹤標頭也不會送出，前後端錯誤永遠串不成同一條；API 走絕對網址時只寫 `/^\/api\//` 會漏掉。

::: {.callout .warn}
**遮罩在送出前就做完，不依賴站台**

事件送出前先過 jedi-log `error_tracking/masking.py` 的 `scrub_event`（連堆疊裡的區域變數都掃）。站台是誰的、在哪裡、誰看得到，產品都不知道——**因此不能把「站台在內網所以安全」當作遮罩可以打折的理由**。
:::

---

## 6. 拆分（4 子需求 ＋ 收口） {#breakdown nav="拆分"}

依賴鏈：**FR-071.1（log 地基）∥ FR-071.2（SDK 接線）→ FR-071.3（指令版）→ FR-071.4（支援頁）**，最後 .5 收口。

「動套件」欄標示該子任務是否觸及 jedi-common / jedi-log——**動套件的一律走 poetry path dependency，不發版**；feature 完成後還原 pin 一起 commit，且**驗收要在 pin 還原後重打一次**。

### FR-071.1 log 地基 — 依賴：無（可與 .2 完全平行）

| # | 子任務 | 做什麼 | 動哪些 repo 與檔 | 驗收條件 | 依賴 | 動套件 |
|---|---|---|---|---|---|---|
| **T-1.1** | 環境明訂 ＋ log 檔輪轉 | ①compose 與 installer 顯式寫出 `RUN_ENV`（值先查三份設定檔差異再建議）②三份設定檔的檔案輸出統一成 `TimedRotatingFileHandler`（按天、30 天）③log 格式新增 `%(request_id)s` 欄，**formatter 端補預設值 `-`** | jedi-common `logger/config_{dev,stg,prod}.py`、`logger/custom_formatter.py`；主專案 `docker/production/docker-compose.yml`、`scripts/installer/install.sh`、`.env.sample` | **只套本卡、不套 T-1.2**，重啟服務後 `log/app.log` **照常有內容**（`request_id` 欄顯示 `-`）；隔日或手動觸發輪轉後出現 `app.log.YYYY-MM-DD`；`RUN_ENV` 在 compose 與 `guidant.env` 皆可 grep 到 | — | ✅ jedi-common |
| **T-1.2** | 追蹤編號中介層 | `before_request` 產生／沿用上游編號存 `g.request_id`；塞進 log record；`after_request` 回 `X-Request-ID` 並加進 CORS expose headers | 主專案 `common/middleware/app_mw.py`；CORS 設定處 | 打一支 API → 回應標頭有 `X-Request-ID` → `log/app.log` 那幾行的 `request_id` 欄是同一串 → 前端 `fetch` 讀得到該標頭 | T-1.1 | — |
| **T-1.3** | 存取紀錄的等級與編號通道 | ①`UpdateApiLogDTO` 加 `level` 與 `request_id` ②`api_logs` 加 `request_id` 欄（migration）③回應階段依狀態碼定等級（≥500 ERROR／4xx WARNING／其餘 INFO）④`app_mw.py:106` 的 `message` 不再清空 | jedi-log `api_log/app/dto/update_api_log.py` ＋ model／mapper；主專案 `common/middleware/app_mw.py`；`scripts/sql/`（新 migration，**只套 DEV**） | 打一支會 500 的 API → `api_logs` 那筆 `level='ERROR'`、`request_id` 有值且與 log 一致、`message` 是路徑；打一支 200 的 → `level='INFO'` | T-1.2 | ✅ jedi-log |
| **T-1.4** | 5xx 補脈絡 | `handler.py` 的 `handle_exception` 與 `handle_server_error` 加一行結構化 JSON（追蹤編號／方法／路徑／登入者／遮罩後 body／錯誤類別），堆疊照原樣多行 | jedi-common `handler/handler.py` | 故意炸一支 → log 找得到那一行 JSON，六個欄位齊全且 body 已遮罩 | T-1.2 | ✅ jedi-common |
| **T-1.5** | 請求標頭與內容過遮罩 | `app_mw.py:45-48` 兩行改走遮罩（標頭走白名單或遮 `Authorization`／`Cookie`／`X-Api-Key`；body 走 `mark_password`） | 主專案 `common/middleware/app_mw.py` | 帶 token 打一支 API → `grep -i authorization log/app.log` 找不到 token 明文 | 可與 T-1.3／1.4 平行 | — |
| **T-1.6** | 資料庫 log 加 filter ＋ 清舊資料 | ①`DBLogHandler.emit` 前置判斷：`event_code` 有值 或 `levelno >= ERROR` 才寫 ②一次性清理 migration 清 `event_code='-' AND level='INFO'`（防呆：先印將刪筆數、兩條件都成立才刪、分批） | jedi-common `logger/db_log/db_handler.py`；主專案 `scripts/sql/`（**只套 DEV**） | DEV 跑一輪：`system_logs` 不再長 `event_code='-'` 的 INFO；**稽核事件照常寫入**；**三支守衛測試全綠**（`test_audit_event_instrumentation.py`／`test_audit_log.py`／`test_jedi_package_constant_copies_complete.py`） | T-1.1 | ✅ jedi-common |
| **T-1.7** | 編號進錯誤事件標籤 ＋ 節流 | `extra_before_send` 加 `request_id` 標籤；加每分鐘 60 筆節流，丟棄時在 log 留一行「因節流丟棄 N 筆」 | 主專案 `core/plugins/api_log.py` | 接一套站台驗：該事件有 `request_id` 標籤且值與 log 一致；迴圈狂炸時 log 出現節流那一行 | T-1.2 | — |
| **T-1.8** | 背景排程的編號 | 各支排程執行時產 `job-<排程名>-<8 hex>`，用 context var（非 `g`），塞進 log record | 主專案 `core/scheduler.py` ＋ log record 注入處 | 讓一支排程出錯 → log 那幾行帶 `job-xxx-yyyy`，同一次執行的行是同一串 | T-1.2 | — |
| **T-1.9** | log 表分區維護排程化（D12） | ①**開工第一件事：盤 BE 既有排程機制**（`core/scheduler.py` 已有六支 cron 型排程，照最接近的 `_job_binding_orphan_cleanup_tick` 的形狀寫：`app_context` ＋ `system_context` ＋ 整支包 try/except）②加一支**每日 03:30** 的排程呼叫 `public.maintain_log_partitions()`③`guidant diag` 產包時檢查當月分區存在否，缺則 `manifest.json` 標紅。**retention 不動**（維持函式內 `api_logs` 90／`system_logs` 180），**不改讀系統設定** | 主專案 `core/scheduler.py`、`app/support/`（診斷檢查） | 手動觸發排程 → 下個月分區被建出來、函式回傳的 `created` 列數與實際相符；砍掉當月分區再產診斷包 → `manifest.json` 標紅。**STG／POC 現況分區只到 `2026_10`，補建屬環境異動，本卡只回報不動手** | T-1.1 | — |

### FR-071.2 錯誤追蹤 SDK 接線 — 依賴：無（可與 .1 完全平行）

| # | 子任務 | 做什麼 | 動哪些 repo 與檔 | 驗收條件 | 依賴 | 動套件 |
|---|---|---|---|---|---|---|
| **T-2.6** | 前端版本識別注入（D8） | 建置時注入版號＋commit，接進 `@sentry/vue` 的 `release`，格式 `guidant-ai-fe@<版號>+<commit>` | FE `src/main.js`、`vite.config.js`、`Dockerfile` | 前端故意炸一個 → 站台上該事件的版本欄有值且含 commit | — | — |
| **T-2.8** | 前端執行期取 DSN | 後端新增免認證 `GET /api/1.0/client-config` 回 `sentry_dsn`／`environment`／`version`／`commit`（未設回空字串）；FE 改成建置期沒烙 DSN 時打這支取值 | 主專案 `api/version/`；FE `src/main.js`、`src/config/api/api.js` | 後端 `SENTRY_DSN` 有值 → 前端不必重建就送得出事件；留空 → 前端連 SDK 都不下載 | — | — |

**②那一棒的出貨側子任務（T-2.2～T-2.5、T-2.7）已取消**，見 D13 與 CM-1944：站台不進出貨包，compose 維持六常駐服務＋db-init，安裝包不收站台映像，維運指令沒有站台相關子命令。SDK 側的 T-2.6／T-2.8 保留並已完成。

### FR-071.3 指令版診斷包 — 依賴：FR-071.1 的 T-1.3

| # | 子任務 | 做什麼 | 動哪些 repo 與檔 | 驗收條件 | 依賴 | 動套件 |
|---|---|---|---|---|---|---|
| **T-3.1** | 打包核心（雙入口共用） | 新開 `app/support/`（D5）：目錄結構、`manifest.json` 產生、遮罩管線（**呼叫 `masking.py`，不另寫**）、100MB 上限與砍序（D10）、截斷事實寫進 manifest | 主專案 `app/support/`（新目錄，照 DDD 分層） | 直接呼叫打包核心產出一個包，解開結構符合 §5.4；`manifest.json` 每個檔都有說明；故意塞超量資料驗砍序與截斷註記 | T-1.3 | — |
| **T-3.2** | `guidant diag` 子命令 | 動四處：用法 `:29-40`／白名單 `:1274`／參數守門 `:1286-1290`（`--since`／`--request-id`／`--out`／`--no-limit` 的個數與形狀）／派送 `:1305-1316` | 主專案 `scripts/installer/guidant` | `guidant --help` 看得到；`sudo guidant diag --since 2h` 產出包；**參數帶錯會被擋下**（不是靜默忽略）；`--no-limit` 不套上限 | T-3.1 | — |
| **T-3.3** | 錯誤事件撈取 | 有設權杖時呼叫收集站 API 撈時間範圍內事件（列表端點分頁）；**拿不到時在 manifest 註明未取得與原因**（D6），不安靜跳過 | 主專案 `infra/support/diag_event_fetcher.py` | 接一套站台且有事件時包裡有；**權杖留空或關掉站台再產一次**，包照樣產得出來且該項標「未取得，原因：⋯」 | T-3.1 | — |

### FR-071.4 支援頁（ERROR 列表 ＋ 匯出） — 依賴：FR-071.3 的 T-3.1

| # | 子任務 | 做什麼 | 動哪些 repo 與檔 | 驗收條件 | 依賴 | 動套件 |
|---|---|---|---|---|---|---|
| **T-4.1** | 最近 ERROR 列表端點（D11） | `GET /api/1.0/support/recent-errors?limit=N`，從 `system_logs` 撈 `level >= ERROR`；**守門下沉 service 層** `require_platform_admin()` | 主專案 `api/support/`、`app/support/`、`di_containers/` | 平台管理員打得通、租戶管理員 403；回傳含追蹤編號 | T-1.6（filter 上了列表才乾淨） | — |
| **T-4.2** | 匯出端點 | `POST /api/1.0/support/diagnostic-bundle`，串流回 `tar.gz`，呼叫**同一支**打包核心；**每次匯出寫一筆稽核事件** | 主專案 `api/support/`、`app/support/` | 平台管理員下載得到、租戶管理員 403；下載後的包與 T-3.2 產的**結構一致**；`system_logs` 有該次匯出的稽核事件 | T-3.1 | — |
| **T-4.3** | 支援頁 ＋ 選單登記 | 前端新頁（ERROR 列表 ＋ 時間範圍 ＋ 匯出鈕 ＋「包裡有什麼」說明）；路由 `meta.requiresPlatformAdmin`；i18n；選單 migration（`pid=47`、`sort=40`，**只套 DEV**） | FE（頁面／路由／i18n／service）；主專案 `scripts/sql/`（新 migration） | 平台管理員看得到並下載得到，ERROR 列表的編號可複製；**租戶管理員看不到這一頁**；說明區客戶讀得懂自己交出了什麼 | T-4.1 ＋ T-4.2 | — |
| **T-4.4** | 操作日誌時間區間查詢 | ①清單端點 filters 支援 `start`／`end` ②匯出端點把篩選**接上**（現況 `export_api_log_file()` 不吃 route 參數＝全量倒出）③FE 該頁加日期區間元件 | jedi-log `api_log/api/routes/api_log_route.py`、`api_log/app/service/api_log_service.py`；FE 操作日誌頁 | 選了區間後清單只回該區間；**按匯出得到的檔也只有該區間**（現況會拿到全部）；不選區間時行為不變 | 可與 T-4.1／4.2 平行 | ✅ jedi-log |

### FR-071.5 收口

| # | 子任務 | 做什麼 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| **T-5.1** | 端到端驗收 | 照 §7 的十二步逐項做 | 見 §7；**第 11 步（乾淨 AI 對話）過不了不算完成** | 全部 |
| **T-5.2** | 診斷包遮罩與時區缺口（CM-1942） | 補三個缺口：收集站連線字串未遮、畫面版永遠缺 `app.log`、log 行的第二道遮罩形同虛設 | 主專案 `infra/support/` | 產包後 grep 不到連線字串；畫面版包內有 `app.log` 切片；log 行第二道遮罩實際生效 | T-4.2 |
| **T-5.3** | 稽核事件品質（CM-1943） | 心跳灌 WARNING 進表、登入成功事件沒帶 user——兩者都讓 ERROR 列表與稽核紀錄失去可讀性 | 主專案 ＋ 相關套件 | 心跳不再灌表；登入成功事件帶得到登入者 | T-1.6 |

**序列摘要**：`.1` 內部 T-1.1 → T-1.2 → {T-1.3, T-1.4, T-1.7, T-1.8}，T-1.5／T-1.6／T-1.9 可與它們平行（T-1.9 只依賴 T-1.1）；`.2` 的 T-2.6／T-2.8 全程可平行；`.3` 等 T-1.3；`.4` 等 T-3.1，T-4.4 全程可平行。

---

## 6b. 第一版 POC 的建議設定 {#poc-settings nav="POC 設定"}

決策者 2026-09-18 裁定：**POC 就用出貨設定 `RUN_ENV=prod`**，不為了「怕問題多」留在 dev——留得住 log、錯誤看得到這兩件 prod 都有；dev 多出來的只是每個請求三筆 INSERT 進 DB 的垃圾。POC 等同 production，一開始就用出貨設定，驗的才是客戶會拿到的東西。「怕問題」時該放寬的是下面這幾個旋鈕，全部用環境變數開，不動程式：

| 旋鈕 | 出貨預設 | 第一版 POC 建議 | 為什麼 |
|---|---|---|---|
| `RUN_ENV` | `prod` | `prod` | 見上 |
| `LOG_RETENTION_DAYS` | 30 | **60** | 第一版問題多，客戶回報常隔一兩週 |
| `LOG_LEVEL`（T-1.7 新增，控 `api`／`app`／`infra` 三棵 logger） | `INFO` | **`DEBUG`**（前兩個月） | 多印脈絡，穩了再收回 INFO |
| `SENTRY_DSN` | 空 | **空** | 出貨不含站台；POC 沒有可指的站台，留空＝不啟用 |
| `system_logs` filter（T-1.6） | 稽核事件＋ERROR 以上 | 同 | 不用調 |
| `GUNICORN_WORKERS` | 4 | 4 | STG 的 8 是撞出來的不是規劃 |

第一版問題多時的查法是**支援頁的 ERROR 列表 ＋ 匯出診斷包**——這兩樣不依賴任何外部站台。

---

## 7. 端到端驗收 {#acceptance nav="驗收"}

::: {.callout .decided}
**前置：找一個「壞得有代表性」的缺陷**

不要挑「打錯字導致語法錯誤」那種——那種一看堆疊就知道。挑一個**要串三種紀錄才查得出來**的：例如在某支服務裡加一個條件，讓「使用者送了特定內容時」才炸。這樣 AI 必須從存取紀錄看到使用者送了什麼，才推得出觸發條件。
:::

**全部在 DEV 做**（環境異動鐵律：不碰 STG／POC）。

| # | 步驟 | 通過的標準 |
|---|---|---|
| 1 | 在 DEV 埋一個上述的缺陷，重啟服務 | 服務起得來 |
| 2 | 從畫面操作觸發它 | 畫面回錯誤，**回應標頭有 `X-Request-ID`** |
| 3 | `grep` 那串編號查 `log/app.log` | 找得到完整堆疊 ＋ 那一行 JSON 脈絡（方法／路徑／使用者／請求內容） |
| 4 | 拿同一串編號查 `api_logs` | 查得到那一筆，**等級是 ERROR**，`message` 是路徑，請求內容欄有使用者送的東西 |
| 5 | 查 `system_logs` | **那一筆 ERROR 在**；同一時段的一般 INFO 除錯 log **不在**；稽核事件照常寫入 |
| 6 | `.env` 把 `SENTRY_DSN` 指向一套 Sentry 相容站台，重跑步驟 2，開該站台 | 該錯誤已出現，點進去有堆疊、有登入者、**有 `request_id` 標籤且值一致**；把該值留空重啟後**完全不啟用**（前端連 SDK 都不下載） |
| 7 | 系統設定 → 支援 → 看 ERROR 列表 | 剛才那筆在最上面，追蹤編號可複製 |
| 8 | `sudo guidant diag --since 1h` | 產出 `.tar.gz`；解開後結構符合 §5.4；`manifest.json` 每個檔都有說明 |
| 9 | **翻遍整包 grep 密碼／權杖／金鑰** | **一個明文都找不到**（含 `Authorization` 標頭、`guidant.env` 的各項密鑰、DSN） |
| 10 | 從畫面匯出同時間範圍的包 | 與步驟 8 的包**結構與內容一致**（除時間戳與事件那項可能的差異）；`system_logs` 多一筆匯出稽核事件 |
| 11 | **開一個全新的 AI 對話**，只給它：這個包 ＋ 原始碼倉庫連結。不給任何提示 | **它能指出根本原因（哪一支、哪一行、什麼條件觸發）與修法** |
| 12 | 在一台**乾淨的機器**上裝一次安裝包 | 六個服務都起得來；`guidant diag` 產得出包；包裡有容器狀態與各容器記錄 |

::: {.callout .crit}
**🔴 步驟 11 的執行紀律**

「乾淨的 AI 對話」是字面意思——**不可以是本案的實作者去問**（他知道埋了什麼），也不可以在提示裡透露任何線索（「你看看 XX 服務有沒有問題」就毀了）。

正確做法：把包與倉庫連結交給一個**不知道埋了什麼**的人（或另開一個沒有本案脈絡的對話），提示就一句「這是客戶回報的問題，請分析」。

若沒過，**不要調整提示重來**——那是在騙自己。要回頭看「包裡少了什麼讓它推不下去」，補完再測。這一條是全案存在的理由，打折就等於整案白做。
:::

::: {.callout .warn}
**驗收環境打折要當場講明**

步驟 12 若因手上沒有乾淨機器而用既有環境模擬（改埠避開佔用、拿既有資源頂替），**回報時要寫明打折在哪**——T-6.6 的教訓就是驗收環境掩蓋了真缺口（那台「乾淨機」剛好早就有第三方映像）。
:::

### 各子需求的決策者親手檢查法

| 子需求 | 不必看程式碼，這樣檢查 |
|---|---|
| **① log 地基** | ①`grep RUN_ENV` 出貨的 compose 與 `guidant.env`，兩邊都要有值 ②在 DEV 打一支壞的 API，看回應標頭有沒有 `X-Request-ID`，拿那串去 `grep log/app.log` ③`SELECT count(*) FROM system_logs WHERE event_code='-' AND level='INFO'`，改版後跑一輪應該不再增加 ④`grep -i authorization log/app.log` 應該找不到 token |
| **② SDK 接線** | `.env` 的 `SENTRY_DSN` 指一套 Sentry 相容站台 → 故意出錯 → 站台上出現、點進去有堆疊、有登入者、有 `request_id` 標籤與版本（前後端各一筆）→ 把該值清空重啟，前端開發者工具看不到 SDK 的網路請求 |
| **③ 指令版** | `sudo guidant diag --since 2h` → 解包 → 先讀 `manifest.json` 應該看得懂每個檔是什麼 → `grep -ri "password\|secret\|token" .` 應該只看到 `***` |
| **④ 支援頁** | 平台管理員登入 → 系統設定 → 支援 → 上方看得到剛才那筆錯誤且編號可複製 → 下方選 2 小時按匯出下載得到 → **登出改用租戶管理員登入，這一頁應該完全不存在**；另去操作日誌頁選一個日期區間按匯出，下載的檔應該只有那個區間 |

---

## 8. 風險與陷阱 {#risks nav="風險"}

::: {.callout .crit}
**🔴 一：log 格式先上、填的人沒上 → log 整段消失且無錯誤訊息**

Python 的 logging 會**吞掉 formatter 拋出的錯誤**。新增格式欄位而沒有人填，每一行 log 都在格式化階段拋錯被吞，症狀是 **`log/app.log` 整段沒有內容、服務照常起、沒有任何錯誤訊息**。

`custom_formatter.py` 的檔頭註解記著同型的坑（OTel 欄位沒 instrument 過就每行拋錯），解法也在那裡：**formatter 自己補預設值**。T-1.1 必須照做。

驗收方式：**只套 T-1.1、不套 T-1.2**，確認 log 照常有內容（欄位值是 `-`）。這一步不驗，等於把一顆炸彈交給下一棒。
:::

::: {.callout .crit}
**🔴 二：改 log 設定會打到 FR-068 的轉發鏈**

`core/plugins/__init__.py:18` 有一條紅線：log 設定的初始化**必須早於**轉發鏈掛載。`config_logger.py` 的檔頭註解也寫了：`dictConfig` 會**整批替換** handlers 清單，把轉發鏈掛上的 QueueHandler 一併丟掉。本案動的正是 log 設定。

順序被打破的症狀：轉發鏈掛不上去，**Syslog／GELF 轉發安靜地停止工作**——而客戶那邊可能正靠它把 log 送到自己的資安平台，停了不會有人立刻發現。

驗收方式：改完後**實際驗一次轉發**（設一個轉發目標，確認收得到），不是看程式碼順序對不對。
:::

::: {.callout .crit}
**🔴 三：`RUN_ENV` 的預設值是一條隱形依賴**

見 §1.2。今天能運作是因為預設恰好是 `dev`。這條依賴不寫在任何地方，也不會有測試守著。

**T-1.1 明訂之後還要留一句註解**在 `config_logger.py` 的預設值旁邊（一到兩行，只寫陷阱）：這個預設值是出貨機曾經實際依賴過的路徑，改它之前先確認出貨端已顯式設定。
:::

::: {.callout .warn}
**四：`DBLogHandler` 加了 filter，但稽核事件的判準寫錯就是合規缺口**

D3 的 filter 是「`event_code` 有值 或 `levelno >= ERROR`」。判準寫錯的兩個方向：

- **太嚴**（例如誤判 `event_code` 的空值形式）→ 稽核事件掉出去，**三支守衛測試會擋**，這是好的
- **太鬆**（例如沒擋到 `event_code='-'`）→ 垃圾照樣進來，等於沒做

實際的空值形式要**先查清楚**：現況是字串 `'-'` 還是 `None`，兩者在 Python 的真值判斷下行為不同。T-1.6 開工第一件事是查這個，不是寫 filter。
:::

::: {.callout .warn}
**五：遮罩漏一個欄位，就是把客戶的憑證寄給原廠**

診斷包會經過：客戶的機器 → 客戶的郵件／隨身碟 → 原廠的機器 → AI 對話。**每一跳都是一次外洩機會**，而遮罩是唯一的防線（D7 不加密）。

三個最容易漏的地方：

- **堆疊裡的區域變數**——`masking.py` 的 `_scrub_frames` 已處理，但打包程式要確認有**呼叫到**
- **請求標頭的 `Authorization`**——目前原樣印進 log，T-1.5 要收掉
- **設定檔裡不叫「password」的機密**——授權權杖、加密鑰匙、錯誤收集站的連線字串（DSN 本身含金鑰）。遮罩規則不能只比對 `PASSWORD`

驗收步驟 9 就是在擋這件事，**不可跳過**。
:::

::: {.callout .warn}
**六：`api_logs` 的等級改了，但歷史資料還是 INFO**

改完之後，「等級 = ERROR」這個查詢條件只對**改版之後**的資料有效。診斷包切片時若寫成「撈等級是 ERROR 的」，對舊資料會撈到零筆——而**零筆看起來就像「那段時間沒出錯」**。

處置：切片一律照時間範圍切，等級當排序或標記用，**不當唯一的篩選條件**。`manifest.json` 註明「等級欄自某版起才正確」。
:::

::: {.callout .warn}
**七：匯出端點是一支「把系統內部狀態打包送出」的 API**

見 §5.5 的紅框。三個必守：守門下沉服務層／平台管理員判定是真正的那道門／每次匯出留稽核紀錄。

最後一項容易漏——匯出行為本身就該被稽核，否則「誰把客戶資料帶走了」查不出來。
:::

---

## 9. 與其他需求的關係 {#relations nav="關聯"}

| FR | 關係 |
|---|---|
| **FR-071.0** | 前一棒。錯誤追蹤試用（Sentry SDK 接進 jedi-log／主專案／前端），母卡 CM-1906。本案是它的正式落地 |
| **FR-065** | 落地版 Installer。本案要動 `guidant` 維運指令（新增 `diag` 子命令），安裝包的結構由它定義 |
| **FR-068** | log 轉發（Syslog／GELF）。本案改 log 設定，**有打破它的風險**（見 §8 風險二） |
| **FR-064** | 防竄改與授權鎖定。診斷包裡的授權狀態來自它；遮罩要確保金鑰不進包 |
| **FR-063** | Nuitka 打包。commit hash 的取得路徑（`app_version.py` 的映像標籤來源）與它有關 |
| **FR-110／FR-107** | 守門先例（路由掛 `@jwt_required`、守門下沉 service 層呼叫 `require_platform_admin()`）與選單 migration 先例（`pid=47`），本案照抄不發明 |
| **CM-1939（已結案）** | **背景排程在 gunicorn 多 worker 下只跑一份**——跑在 master 進程（內嵌 gunicorn 的 `load()` 直接交出已建好的 app，worker 不重 import；fork 不帶 APScheduler 執行緒）。設計期一度誤判「每 worker 各起一套、8 份」，CM-1939 本機實跑＋STG 唯讀查 thread 數推翻。**陷阱**：worker 內 `scheduler.running` 回 True 只是被複製的旗標；`worker.age==1` 挑 worker 起排程會在該 worker 重啟後永久失去排程。T-1.9 不需靠冪等擋。留議題：多 pod 部署時才需要「誰跑排程」機制 |
