---
title: "客戶端診斷包與錯誤收集站 — 需求討論稿 (FR-071.1)"
brand: "Guidant AI · **FR-071.1** 遠端可重現分析"
eyebrow: "FR-071.1 · log 地基／GlitchTip 進出貨包／診斷包雙入口 · 需求討論稿 · 2026-09-17（v1 · 待審）"
h1: "系統在客戶機房出錯時，原廠不必到場也查得出原因"
lede: "落地版裝在客戶自己的機房，出錯時案發現場全留在那邊——log 在客戶主機、資料在客戶資料庫、錯誤堆疊隨容器重啟消失。目前唯一動線是派人到場。本案分四棒補齊：**先把 log 地基補起來**（寫成檔案、每一筆帶同一條追蹤編號、5xx 記得下夠資訊），**再把錯誤收集站（GlitchTip）包進安裝包**讓每個客戶自帶一套，**最後做診斷包匯出**——指令一支、畫面一顆按鈕，包好丟回原廠就能重建現場。驗收條款寫死：**故意弄壞一個功能、產包、丟給一個什麼都不知道的 AI，它要能指出錯在哪與怎麼修。**"
chips: [
  {text: "前一棒 FR-071.0 已完成（試用通過）", kind: ok},
  {text: "已定案 5 項（決策者 2026-09-17）", kind: ok},
  {text: "待決策 D1–D10", kind: warn},
  {text: "四棒：log 地基／站台進包／CLI／UI", kind: accent},
  {text: "跨 3 repo：BE／FE ＋ jedi-common／jedi-log", kind: accent},
  {text: "陷阱：出貨機沒設 RUN_ENV 跑的是 dev 那套，request id 全庫零命中", kind: crit}
]
footer: "FR-071.1 · 客戶端診斷包與錯誤收集站 — 需求討論稿 · 2026-09-17 v1 · 前作：FR-071.0 錯誤追蹤試用（母卡 CM-1906，Sentry SDK 接進 jedi-log／主專案／前端，本機 GlitchTip 六項驗證通過）· 原白話需求：[requirement-draft.md](requirement-draft.md)（2026-09-02）· 現況依據：`jedi_common/logger/config_logger.py`、`common/middleware/app_mw.py`、`scripts/installer/guidant`、`docker/production/docker-compose.yml` 實檔查證 ＋ DEV／STG 實查資料筆數 · 定案見 [design.md](design.md)（D1–D12） · 沿革見 LOG 與 git log"
---

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

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

給非工程讀者的背景：軟體出錯時，工程師要的東西有三樣——**錯在程式的哪一行**（堆疊）、**當時使用者在做什麼**（那一次請求的內容）、**當下資料長什麼樣**（資料庫的相關資料）。這三樣現在都在客戶的機器裡，而且**各自散著、對不起來**：log 檔留得太短（幾 MB 就被輪掉）、每一筆之間沒有共同的編號可以串。本案把這三樣串成一條線，再做一個「打包帶走」的功能。

::: {.callout .decided}
**四棒，前兩棒有先後，後兩棒可並行**

第一棒是地基——沒有它，後面打包出來的東西查不到東西。第二棒的錯誤收集站是獨立的一條線，可與第一棒平行開工。
:::

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者親手檢查法） |
|---|---|---|---|
| **① log 地基** | 出貨環境明訂用哪一套 log 設定（現在靠預設撞對）、log 檔改按天輪轉保留 30 天；每一筆 log／每一筆存取紀錄／每一個錯誤事件都帶**同一條追蹤編號**；系統錯誤（5xx）記下是誰、打了哪支、送了什麼；存取紀錄的「等級」欄位開始會標 ERROR（現在全是 INFO）；**把淹沒稽核事件的除錯 log 擋在資料庫外面** | 後端 log 設定 ＋ 中介層 ＋ 錯誤處理 ＋ 一支清理 migration，動到兩支共用套件 | 在 DEV 故意打一支會壞的 API → 到 `log/app.log` 找得到那一筆，行首有一串追蹤編號 → 拿同一串編號去資料庫的存取紀錄表查，查得到同一次請求且等級是 ERROR → 回應的標頭裡也有同一串編號 |
| **② 錯誤收集站進出貨包** | GlitchTip（開源、MIT 授權）打進安裝包，**每個客戶裝一套**，只給原廠管理員用；裝機時自動建好帳號與專案、把連線字串寫進設定檔 | 安裝包新增服務 ＋ 裝機腳本 ＋ 維運指令 | 拿一包全新的安裝包，在一台乾淨的機器裝起來 → 瀏覽器開站台 → 故意讓系統出錯 → **站台上立刻出現那一筆錯誤**，點進去看得到完整堆疊與是誰觸發的 |
| **③ 指令版診斷包** | `guidant diag` 一支指令產出 `guidant-diag-<時間>.tar.gz`，內含環境快照／log 切片／資料庫診斷切片／錯誤事件／說明檔 | `guidant` 維運指令新增子命令 | ssh 進客戶主機打 `sudo guidant diag --since 2h` → 得到一個檔案 → 解開來看，裡面有一份 `manifest.json` 說明每個檔是什麼，**而且翻遍整包找不到任何密碼與金鑰** |
| **④ 畫面版一鍵匯出** | 系統設定底下新增「支援」頁，選時間範圍、按一顆按鈕下載同一種包 | 後端端點 ＋ 前端頁面 | 用平台管理員登入 → 系統設定 → 支援 → 選「最近 2 小時」→ 按匯出 → 瀏覽器下載到同一種包；**用一般租戶管理員登入則完全看不到這一頁** |

依賴：①是地基，②可與①完全平行（兩條不同的線）；③要等①完成（沒有追蹤編號與等級就切不出東西）；④與③共用同一支打包程式，③驗收通過後接著做。

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

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

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

---

## 需求背景：前一棒做完了什麼、這一棒補什麼 {#background nav="背景"}

### 前一棒 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`，註明留空＝完全不啟用 |

本機用 GlitchTip 站台實測六項全過。決策者看過站台效果後拍板往下做，並且**選了積極版**（見下段）。

### 這一棒要補的三個洞（皆經實檔查證）

::: {.callout .crit}
**🔴 洞一：出貨機沒設 `RUN_ENV`，實際跑的是 dev 那套**

出貨的 compose、installer、build 腳本**沒有任何一處設定 `RUN_ENV`**（grep `docker/production/`、`scripts/installer/install.sh`、`scripts/build/*.sh` 零命中；唯一寫到它的是 `.env.sample:63` 的 `RUN_ENV=dev`，那是開發機範本、不隨出貨包走）。而 `config_logger.py:28` 是 `os.getenv("RUN_ENV", "dev")`、`:73` 是 `_CONFIGS.get(RUN_ENV, logging_config_dev)`——**沒設就退 dev**。

所以客戶機房實際生效的是 `logging_config_dev`：**檔案輸出是開的**（`log/app.log` 1MB × 5）、**資料庫輸出是開的**（`DBLogHandler` → `system_logs`）。`config_prod.py` / `config_stg.py` 那兩份把檔案輸出註解掉、無資料庫輸出的設定，**從來沒有在任何機器上生效過**。

問題因此是三件：

- **沒有人明訂過出貨環境用哪一套**——今天能運作純屬預設值恰好是 `dev`。`config_logger.py:28` 的預設改一個字（改成 `prod`，那看起來完全合理），客戶機房的 log 檔與稽核事件會**在沒有任何錯誤訊息的情況下一起消失**
- **1MB × 5 是開發機的取捨**（重開就清），在客戶機房等於「出事前十分鐘的 log 已經被輪掉了」
- **資料庫輸出無條件全收**——每個請求三筆 INSERT，見 D3

所以第一棒要做的是「**把 dev 那套改對，並且明訂出貨環境用哪一套**」，不是「開檔案 log」。
:::

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

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

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

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

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

::: {.callout .crit}
**🔴 洞三：十三萬筆存取紀錄，零筆 ERROR**

DEV 實查（2026-09-17）：

::: statgrid
::: stat
[131,595]{.v}[存取紀錄總筆數]{.k}
:::
::: {.stat .crit}
[0]{.v}[其中等級為 ERROR 的]{.k}
:::
::: stat
[768,726]{.v}[系統 log 筆數（DEV；STG 另有 586,498）]{.k}
:::
:::

原因是三處連鎖：

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

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

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

（白話需求早就記了這筆債：「api_logs 錯誤請求 level 全是 INFO 要修」。）
:::

---

## 已定案（決策者 2026-09-17） {#decided nav="已定案"}

::: {.callout .decided}
**以下五項已拍板，本段為記錄，不是待議項。**
:::

| # | 決策 | 內容 | 為什麼 |
|---|---|---|---|
| **A** | **分兩階段做** | FR-071 原草稿四層一次做太重。**.0（錯誤追蹤試用）已完成**；本案 **.1** 是正式落地 | 先花小成本看效果，再決定要不要投入整套。試用結果決策者滿意 |
| **B** | **走積極版：GlitchTip 進出貨包** | GlitchTip（MIT 授權）打進安裝包，**每個客戶一套跟著安裝包走**。站台**只給原廠管理員（ROOT 租戶）用**，**單一專案 `GuidantAI`**、不按環境切 | 客戶機房多為隔離內網，連不到原廠的站台；而且錯誤資料屬於客戶，本來就該留在客戶那邊。單一專案是因為每套安裝就是一個環境，不需要在站台內再分 |
| **C** | **開放註冊預設關** | 出貨的 compose 把 `ENABLE_OPEN_USER_REGISTRATION` 設成關 | 站台如果對外可達，開著註冊等於任何人都能進來看客戶的錯誤堆疊（堆疊裡可能有業務資料） |
| **D** | **POC 試用期暫不接** | POC（189）先不裝錯誤收集站，隨第二階段的安裝包升級一起驗證 | POC 等同正式環境，不在開發期動它（環境異動鐵律） |
| **E** | **Loki/Grafana 與 Sentry 自架排除** | 不評估這兩條路 | Sentry 自架是 FSL 授權（有商業使用限制，不能隨產品出貨）；Loki/Grafana 是「日誌聚合」不是「錯誤追蹤」，要的堆疊聚合與去重它不做 |

### 業界原則（決策者已接受，本案照這個形狀走）

```{.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'}}}%%
flowchart LR
  APP["Guidant AI 應用程式"]

  subgraph O["① 除錯用紀錄（診斷）"]
    O1["寫到螢幕與 log 檔<br/>一行一筆、帶追蹤編號"]
    O2["收集存放交給外部：<br/>docker 收檔＋診斷包切片"]
  end

  subgraph A["② 稽核／操作紀錄（業務）"]
    A1["api_logs 資料表<br/>誰在什麼時候做了什麼"]
    A2["這是產品功能<br/>不是除錯輔助"]
  end

  subgraph E["③ 錯誤事件（現場）"]
    E1["系統炸掉時送 GlitchTip<br/>完整堆疊＋各層變數值"]
    E2["自動去重、自動聚合<br/>同一種錯只佔一列"]
  end

  APP --> O1 --> O2
  APP --> A1 --> A2
  APP --> E1 --> E2
```

三句話講完這張圖：

- **除錯用紀錄**：應用只負責「寫出去」，不負責「收在哪、留多久、怎麼查」——那是 docker 與診斷包的事
- **稽核／操作紀錄**（`api_logs`）：**定位收窄**成「誰做了什麼」的業務紀錄，不再兼差當除錯工具
- **錯誤事件**：交給 GlitchTip，它做的去重與聚合是自己寫寫不出來的（同一個錯發生一萬次，站台上只是一列、次數 10000）

那張**除錯用的資料表 `system_logs`** 一直在寫，而且被除錯 log 淹沒了（STG 實查：586,498 筆中只有 467 筆是稽核事件）。處置見 **D3**：表不退役，改在寫入端加一道 filter。

---

## 端到端流程：從客戶出錯到原廠找出原因 {#flow nav="端到端流程"}

```{.mermaid cap="圖 2 — 出錯當下、匯出、分析三段，追蹤編號串起全鏈"}
%%{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 GlitchTip（同機房）
    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: 系統設定 → 支援 → 選時間範圍 → 匯出
        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 壞了，但不知道使用者當時送了什麼 |
| 錯誤事件的標籤 | GlitchTip 上該事件有 `request_id` 標籤 | 站台上看得到堆疊，但對不回那一次請求與那個使用者 |

再加上**回應標頭** `X-Request-ID`——這一項是給客服用的：使用者回報問題時看得到那串編號，念給客服，客服就能直接定位。

---

## 現況盤點 {#inventory nav="現況盤點"}

::: {.callout .warn}
**下表每一列都經過實檔／實查驗證**（行號與筆數為 2026-09-17 當下）。本案動到的每一處都在這張表上。
:::

| 元件 | 現況 | 本案動作 |
|---|---|---|
| **logging 設定的選擇**<br/>`config_logger.py:28,73` | `os.getenv("RUN_ENV", "dev")`，未知值退 dev；**出貨端沒有任何一處設定它**，故實際跑 dev 那套 | **新增**：compose 與 installer 顯式寫出 `RUN_ENV`（見洞一） |
| **正式／STG log 設定**<br/>`config_prod.py:31-38`、`config_stg.py:31-37` | 檔案輸出整段註解、無資料庫輸出；**從未生效過** | **改**：與實際要用的那套對齊（按天輪轉、格式加追蹤編號欄） |
| **開發環境 log 設定**<br/>`config_dev.py:36-43` | 檔案 1MB×5 ＋ 資料庫輸出（`DBLogHandler`）。**這份就是出貨機實際在跑的那份** | **改**：輪轉改按天 30 天；格式加追蹤編號；資料庫輸出加 filter（見 D3） |
| **log 格式的追蹤欄**<br/>`config_dev.py:12` | 有 `otelTraceID`／`otelSpanID`，但落地版無收集器，**值恆為 `0`**；正式環境格式連這兩欄都沒有 | **取代**：換成真正的追蹤編號 |
| **容器記錄**<br/>`docker-compose.yml:102-112` | json-file，20MB × 5 檔／容器 | **不動**。註解明寫 driver 必須維持 json-file（FR-068 轉發鏈依賴它） |
| **主機 log 目錄**<br/>`docker-compose.yml:147` | `/srv/guidant-ai/log` 已掛進容器 `/app/log`，**目前只有安裝紀錄** | **不動**（掛載已就緒），檔案輸出開啟後自然落在這裡 |
| **追蹤編號**<br/>全庫 grep | **零命中**。最接近的 `g.trace_id`（`app_mw.py:71`）＝存取紀錄流水號，未進 log 格式、未回標頭 | **新增**：中介層產生（位置與格式見 D2） |
| **存取紀錄的等級**<br/>`app_mw.py:57` | `level='INFO'` **寫死**；`UpdateApiLogDTO`（jedi-log）**無 level 欄**；回應階段**不看狀態碼** | **改三處**：DTO 加欄位、回應階段依狀態碼改等級、資料表加追蹤編號欄 |
| **存取紀錄的 message 欄**<br/>`app_mw.py:102` | 請求階段存了路徑，回應階段**清成空字串** | **改**：不清空（路徑是查詢時最常用的線索） |
| **請求內容的遮罩**<br/>`app_mw.py:51`／`:100` | 資料庫那條有過 `mark_password`；**但 `:44-45` 把 Headers（含 Authorization）與 Body 原樣印進 log**，未遮罩 | **改**：兩行都過遮罩（已記在 followup，本案順手收） |
| **5xx 錯誤處理**<br/>`jedi_common/handler/handler.py:33` | 只記 `logger.error(..., exc_info=True)`——**沒有方法、路徑、使用者、請求內容** | **改**：補這四樣，遮罩後一行結構化輸出 |
| **`system_logs` 資料表** | DEV 768,726 筆；**STG 586,498 筆且當天仍在寫**（`event_code='-'` 佔 586,031，真稽核事件僅 467；INFO 585,875／ERROR 503）；主專案**無讀取端點、無畫面** | **不退役，加 filter ＋ 清舊資料**（D3）；另新增支援頁的 ERROR 列表讀它（D11） |
| **錯誤收集接線**<br/>`core/plugins/api_log.py:168-187` | FR-071.0 已完成：帶版號＋commit、環境名、登入者；`SENTRY_DSN` 空就完全不啟用 | **不動接線**，補「把追蹤編號塞進事件標籤」 |
| **前端錯誤收集**<br/>`compliance-manager-fe/src/main.js:155-176` | 已接 `@sentry/vue`；**未注入版本識別**（後端有帶、前端沒帶） | 補版本識別（D8 決定要不要這一棒做） |
| **版本錨點**<br/>`common/util/app_version.py:75` ＋ `GET /api/1.0/version` | `get_version_info()` 回 `{version, commit}`，端點**免認證** | **沿用**——commit hash 是「分析端把原始碼切到同一版」的關鍵 |
| **維運指令**<br/>`scripts/installer/guidant`（1,326 行） | 子命令白名單 `:1274`、參數守門 `:1286-1290`、派送 `:1305-1316`、用法 `:29-40`；`logs` 子命令（`:607-617`）＝ `docker compose logs -f --tail 200`；`credentials` 已移除（不印明文密碼鐵律） | **新增 `diag` 子命令**，四處都要動（白名單／守門／派送／用法） |
| **安裝紀錄**<br/>`/etc/guidant-ai/install.conf` | 記 `GUIDANT_DATA_DIR`（客戶可自訂安裝路徑），維運指令讀它定位 | **沿用**——診斷包要靠它找到資料目錄 |
| **容器組成**<br/>`docker/production/docker-compose.yml` | 六個常駐（db／redis／seaweedfs／api／socketio／fe）＋ 一個一次性（db-init） | **新增** GlitchTip 相關容器（幾個、怎麼接 DB 見 D4） |
| **安裝包的第三方映像**<br/>`scripts/build/build_bundle.sh:84-94` | 已包 `postgres:16`／`redis:7-alpine`／`chrislusf/seaweedfs:3.99` 等，`docker save` 成 tar | **新增** GlitchTip 映像進清單（**非做不可**，見風險三） |
| **既有的「匯出存取紀錄」**<br/>jedi-log `api_log_route.py:45-56` | 平台管理員守門、xlsx 包 zip；**篩選參數沒接上＝全量匯出** | **不動**（那是給人看的報表，不是給 AI 分析的診斷包）；但打包程式可參考它的守門與串流做法 |
| **log 轉發（FR-068）**<br/>jedi-log `forwarding/` | Syslog／GELF 轉發已上線，設定表＋畫面熱生效 | **不動**。紅線：`core/plugins/__init__.py:18` 的 log 設定初始化必須早於轉發鏈，本案改 log 設定時不可打破這個順序 |

---

## ① log 地基 {#foundation nav="① log 地基"}

**一句話**：讓落地版留得下 log，而且每一筆都掛得上同一條線。

### 1-1 正式環境開始寫 log 檔

檔案輸出一直開著（跑的是 dev 那套），要改的是**輪轉方式**。三份設定檔要一起對齊，並由 compose／installer 明訂 `RUN_ENV`。

| 項目 | 開發環境現況 | 正式環境要什麼 | 為什麼 |
|---|---|---|---|
| 輪轉方式 | 按大小（1MB × 5 檔） | **按天** | 診斷包要切「某個時間範圍」。按大小輪轉時，「昨天下午」可能橫跨三個檔、也可能早被輪掉；按天輪轉一天一個檔，切片就是挑檔案 |
| 保留量 | 5 檔 ≈ 5MB（**出貨機現況同此**） | 見 **D1** | 客戶機房磁碟大小不一，保留策略要能調；5MB 在稍有流量時「出事前十分鐘」就已被輪掉 |

**與容器記錄的關係**：兩份都留，用途不同——容器記錄是「服務起不來時看得到的東西」（應用還沒起來就沒有 log 檔），log 檔是「服務跑起來之後的完整紀錄」。容器記錄那 20MB×5 的上限不動。

### 1-2 追蹤編號中介層

每一次進來的請求產生一個編號，並且送到四個地方：

| 送到哪 | 怎麼做 | 註記 |
|---|---|---|
| **log 每一行** | 放進 log 格式，**取代現在恆為 `0` 的 OTel 兩欄** | 位置固定在等級後面，AI 與人都好認 |
| **存取紀錄表** | `api_logs` 新增一欄（migration） | 這張表是跨租戶的系統紀錄，加欄位要照 SQL migration 規範 |
| **錯誤事件** | 塞成 GlitchTip 的標籤 | 站台上可用這個編號搜尋 |
| **回應標頭** | `X-Request-ID` | 使用者看得到、客服念得出來 |

**沿用上游送來的編號**：前端已經接了錯誤追蹤並開啟了追蹤標頭傳遞（`main.js:165-170` 的 `browserTracingIntegration` 與 `tracePropagationTargets`）。若請求帶了上游編號就沿用，沒帶才自己產——這樣前端的錯誤與後端的錯誤在站台上會串成同一條。

### 1-3 5xx 記得下夠資訊

現況 `handler.py:33` 只有一行 `logger.error(訊息, exc_info=True)`。**堆疊有了，但不知道是誰、打了哪支、送了什麼**——而這三樣正是重建現場的必要條件。

補成一行結構化輸出（JSON），包含：追蹤編號／方法／完整路徑／登入者／**遮罩後的請求內容**／錯誤類別。

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

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

堆疊本身仍照原樣多行輸出（那是 Python 的標準格式，工具都認得），JSON 那行是它的**前導脈絡行**。
:::

### 1-4 存取紀錄的等級通道

三處連鎖都要動（缺一等於沒改）：

| 動哪 | 改什麼 | 落在哪個 repo |
|---|---|---|
| `UpdateApiLogDTO` | 加 `level` 欄位 | jedi-log 套件 |
| `app_mw.py` 回應階段 | 依 HTTP 狀態碼決定等級：≥500 → ERROR、4xx → WARNING、其餘 INFO | 主專案 |
| `app_mw.py:102` | `message` 不再清空 | 主專案 |

**既有的歷史資料不回頭改**——歷史資料就是 INFO，這是事實紀錄。診斷包切片時對舊資料一律照時間範圍切。

### 1-5 請求內容遮罩補到 log 那條

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

本案**必須順手收掉**——因為 1-1 開啟了正式環境的 log 檔，等於把這個洞從「開發機」擴大到「客戶機房」，而且診斷包會把那個檔打包帶走。

### 1-6 動到的套件與風險

| repo | 動什麼 | 風險 |
|---|---|---|
| **jedi-common** | `logger/config_prod.py`（開檔案輸出）、`logger/config_dev.py`（格式對齊）、`handler/handler.py`（5xx 補脈絡） | 這是地基套件，**所有吃它的服務都會受影響**。改前要盤點還有誰在用（另見 D3 對 `system_logs` 的處置） |
| **jedi-log** | `UpdateApiLogDTO` 加欄位、`api_logs` 資料表加欄位（migration）、錯誤事件加標籤 | 資料表加欄位屬既有 migration 流程 |
| **主專案** | `common/middleware/app_mw.py`（產編號、遮罩、等級、message）、`core/plugins/api_log.py`（標籤） | `app_mw.py` 是**每一支 API 都會經過**的中介層，改壞是全站級別的影響 |

::: {.callout .crit}
**🔴 `app_mw.py` 與 jedi-common 的改動有先後，不可平行**

log 格式新增追蹤編號欄位（jedi-common）與「誰來填這個欄位」（主專案中介層）是一對。**格式先上、填的人還沒上**，每一行 log 都會在格式化階段找不到欄位而拋錯——而 Python 的 logging **會把 formatter 的錯誤吞掉**，症狀是 `log/app.log` **整段消失、服務照常起、沒有任何錯誤訊息**。

`custom_formatter.py` 的檔頭註解記著同型的坑（OTel 欄位沒 instrument 過就每行拋錯），解法也記在那裡：**formatter 自己補預設值**。本案照做——新欄位在 formatter 端給預設值（如 `-`），這樣兩邊上線順序就不再致命。但仍建議先套件、後主專案。
:::

---

## ② GlitchTip 進出貨包 {#glitchtip nav="② 錯誤收集站"}

**一句話**：每個客戶的安裝包裡自帶一套錯誤收集站，裝完就能用，只給原廠管理員看。

### 2-1 為什麼是 GlitchTip

| 條件 | GlitchTip | Sentry 自架 | Loki/Grafana |
|---|---|---|---|
| 授權可隨產品出貨 | ✅ MIT | ❌ FSL（有商業限制） | ⚠️ AGPL／需另評估 |
| 做的是錯誤聚合去重 | ✅ | ✅ | ❌（是日誌聚合，不做堆疊去重） |
| 資源占用適合裝在客戶機房 | ✅ 輕量 | ❌ 重（多個元件） | ⚠️ 中等 |
| 我們的 SDK 已經接好 | ✅（Sentry SDK 相容） | ✅ | ❌ 要重接 |

決策者已排除後兩者（定案 E）。本段只談 GlitchTip 怎麼進包。

### 2-2 本機試用時查到的事實

| 項目 | 實際值 | 對本案的意義 |
|---|---|---|
| 映像 | `glitchtip/glitchtip` | 要進 `build_bundle.sh` 的映像清單 |
| 自帶元件 | 需要自己的 PostgreSQL 與 Redis | **這是 D4 的核心** |
| 站台埠 | 8100（試用時自訂） | 出貨要選一個不撞既有六服務的埠 |
| 事件保留 | `GLITCHTIP_MAX_EVENT_LIFE_DAYS=90` | 可調，出貨值見 D1 |
| 連線字串 | 綁定專案編號（`http://<key>@<host>:<port>/<專案編號>`） | **裝機時才知道專案編號**，所以連線字串必須裝機時才寫得出來 |
| 堆疊裡的變數值 | SDK 會一併送出，是遮罩最容易漏的地方 | 已在 jedi-log 的 `masking.py` 補上（`_scrub_frames`） |

### 2-3 服務組成（待 D4 定案）

GlitchTip 標準的自架組成是三個角色：**網站**（收事件＋畫面）、**背景工作**（處理事件、清理過期）、**一次性遷移**（建資料表）。後者與現有的 `guidant-db-init` 同型（跑完就結束）。

```{.mermaid cap="圖 3 — GlitchTip 兩種接法：共用既有資料庫 vs 自帶一套（D4 待決）"}
%%{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'}}}%%
flowchart TB
  subgraph EXIST["現有六服務（不動）"]
    API["guidant-api"]
    DB["guidant-db<br/>PostgreSQL 16"]
    RD["guidant-redis"]
    FE2["guidant-fe"]
  end

  subgraph NEW["本案新增"]
    W["guidant-glitchtip<br/>站台＋收事件"]
    WK["guidant-glitchtip-worker<br/>背景處理與清理"]
    MG["guidant-glitchtip-migrate<br/>一次性建表"]
  end

  A4["方案 A：共用 guidant-db<br/>另開一個資料庫"]
  B4["方案 B：自帶一套 DB 與 Redis"]

  API -->|"送錯誤事件"| W
  W --- WK
  MG -.->|"裝機時跑一次"| W
  W -.-> A4
  W -.-> B4
  A4 -.-> DB
  A4 -.-> RD
```

### 2-4 裝機時要自動完成的四件事

客戶不會去 GlitchTip 的畫面上手動建帳號、建組織、建專案、複製連線字串——**那條動線一旦需要人工，落地版就等於沒有這個功能**。安裝腳本必須自動做完：

| 步驟 | 怎麼做 | 待驗 |
|---|---|---|
| ① 產金鑰 | 隨機產 `SECRET_KEY` 寫進設定檔（比照既有的密碼產生做法） | — |
| ② 建管理員帳號 | GlitchTip 的管理指令支援非互動建立超級使用者 | 需實測非互動參數 |
| ③ 建組織與專案 `GuidantAI` | 兩條路：跑管理指令，或呼叫它的 REST API（`POST /api/0/teams/{組織}/{團隊}/projects/`，需要一組有寫入權限的權杖） | **待驗**：權杖本身要先在畫面上建，若無法用指令產生則只能走管理指令那條 |
| ④ 把連線字串寫回設定檔 | 取得專案的連線字串，寫進 `guidant.env` 的 `SENTRY_DSN` 與前端的對應變數 | 連線字串要從建好的專案讀出來 |

::: {.callout .warn}
**🔴 ③ 這一步是本階段最大的未知**

GlitchTip 的 REST API 認證用「使用者在個人設定頁產生的權杖」——**那本身是一個畫面操作**。要做到全自動，得確認它的管理指令（`manage.py`）能不能直接建組織／專案／權杖。

本稿把它標為**待驗**，開卡前必須實測。若真的不行，退路是「裝機時建好帳號，專案由原廠人員第一次上線時點三下建好」——那會讓 `SENTRY_DSN` 變成一個安裝後的手動步驟，**可接受但要寫進安裝手冊**。

判準：不要為了「全自動」硬幹（自己去資料庫塞資料是最糟的解），但也不要默默留一個「要人做卻沒人知道要做」的步驟——後者的症狀是**錯誤收集站裝了卻永遠沒有資料，而且沒有人會發現**。
:::

### 2-5 維運指令要補的

| 子命令 | 做什麼 | 註記 |
|---|---|---|
| 站台密碼輪換 | 比照既有的 `rotate-credentials` | **不印明文**——既有的 `credentials` 子命令就是因為印明文而被移除，新指令不可重蹈 |
| `status` 納入新服務 | 服務清單（`guidant:293`）加進去 | 否則 `status` 看不到它、`restart` 也指不到它 |
| `logs` 納入新服務 | 同上（`logs` 吃同一份清單） | — |

### 2-6 與安裝包的關係

::: {.callout .crit}
**🔴 第三方映像必須包進出貨包**

`build_bundle.sh:84-94` 已經把 `postgres:16`、`redis:7-alpine`、`chrislusf/seaweedfs:3.99` 用 `docker save` 打進包裡。GlitchTip 的映像**必須比照辦理**。

這不是理論風險——2026-08-22 驗收 T-6.6 時，「乾淨 docker 機」是在一台早就有 SeaweedFS 映像的主機上模擬的，於是「交付包沒把第三方映像包進去、封閉網路裝不起來」這個真缺口被驗收環境掩蓋，直到決策者提問才暴露。

**本案的驗收必須在真正沒有這顆映像的機器上做**，或至少先 `docker rmi` 掉再測。
:::

資源估算也要算進去（記憶體與磁碟），列為 D9 的一部分。

---

## ③ 指令版診斷包 `guidant diag` {#cli nav="③ 指令版"}

**一句話**：一支指令把現場包成一個檔案。

### 3-1 指令形狀

```
sudo guidant diag [--since <時間>] [--request-id <編號>] [--out <路徑>]
```

| 參數 | 意思 | 預設 |
|---|---|---|
| `--since` | 往回抓多久（`2h`／`24h`／`7d`） | `2h` |
| `--request-id` | 只抓某一次請求相關的東西 | 無（抓整個時間範圍） |
| `--out` | 輸出到哪 | 當前目錄 |

**要動 `guidant` 的四個地方**（缺一個就靜默壞掉）：

| 位置 | 改什麼 | 漏掉的症狀 |
|---|---|---|
| `:1274` 子命令白名單 | 加 `diag` | 打 `guidant diag` 直接被當成未知子命令 |
| `:1286-1290` 參數守門 | `diag` 的參數個數規則 | 多帶的參數被靜默忽略（既有註解明寫這是要防的失敗模式） |
| `:1305-1316` 派送 | 加一行呼叫 | 白名單過了但沒有實作 |
| `:29-40` 用法說明 | 加一行 | `guidant --help` 看不到這個功能，等於沒做 |

### 3-2 包裡有什麼

```
guidant-diag-20260917-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           ← 存取紀錄時間範圍切片（含追蹤編號、等級）
│   ├── row-counts.csv         ← 各表筆數（判斷資料規模用）
│   └── schema-notes.md        ← 上述每個檔的欄位說明
└── events/
    └── glitchtip-events.json  ← 錯誤事件（時間範圍內）
```

**`manifest.json` 是分析端的入口**——白話需求原文寫「manifest.json 開路：目錄結構／每檔說明／時間窗／錯誤事件清單，分析端照 manifest 讀起」。欄位至少要有：

| 欄位 | 為什麼需要 |
|---|---|
| 產包時間、時間範圍 | 分析端知道「看得到多久以前」 |
| 版號 ＋ commit hash | 把原始碼切到同一版，行號才對得上 |
| 每個檔一行說明 | AI 不必猜 `api_logs.csv` 是什麼 |
| 遮罩了哪些欄位 | 客戶可以審「帶走了什麼」，AI 也知道某欄是 `***` 不是真的空 |
| 錯誤事件清單（編號＋標題） | 不必解整包就知道有哪些錯 |
| 每個檔的雜湊 | 傳輸過程有沒有損壞，一比就知道 |

### 3-3 各項資料怎麼取

| 資料 | 取法 | 註記 |
|---|---|---|
| 版號＋commit | `curl` 打 `/api/1.0/version`（免認證） | 服務掛了就退回讀映像標籤 |
| 容器狀態 | `docker ps` / `docker stats --no-stream` | — |
| 容器記錄 | `docker logs --since <時間> <服務名>` | 六個服務各一份 |
| 應用 log | 直接從主機的 `/srv/guidant-ai/log` 切 | **依賴 ①**。按天輪轉後就是挑檔案 |
| 存取紀錄 | `docker exec guidant-db psql ... \copy (SELECT ... WHERE act_time > ...) TO ... CSV` | 帶時間條件，不整表 dump |
| 各表筆數 | 一句 SQL 掃 `information_schema` | 只有數字，沒有資料內容 |
| 設定檔 | 讀 `guidant.env` 過遮罩 | 見下方遮罩管線 |
| 改版水位 | `SELECT * FROM schema_migrations` | 判斷「這台有沒有漏套某個改版」 |
| 錯誤事件 | 呼叫 GlitchTip 的 API 撈時間範圍內的事件 | **待驗**：它沒有專門的匯出端點，得用列表端點分頁撈；認證用 `Authorization: Bearer <權杖>`，權杖要有讀事件的權限 |

### 3-4 遮罩管線

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

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

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

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

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

### 3-5 大小控制

診斷包要能用郵件或即時通訊軟體傳（客戶機房多半沒有檔案傳輸管道）。上限見 **D10**；超過時的處理：log 與存取紀錄從**最舊的**開始砍，並在 `manifest.json` 註明「因大小限制截斷，實際時間範圍是 X 到 Y」——**截斷了不講**是最糟的，分析端會以為那段時間真的沒事。

---

## ④ 畫面版一鍵匯出 {#ui nav="④ 畫面版"}

**一句話**：客戶自己點兩下就能產出同一種包，不必會下指令。這是「減少到場」的關鍵那一步。

| 項目 | 內容 |
|---|---|
| 位置 | 系統設定 → **支援**（新頁） |
| 誰看得到 | 平台管理員（ROOT 租戶）。**一般租戶管理員完全看不到這一頁** |
| 畫面上有什麼 | 時間範圍選擇（最近 2 小時／24 小時／7 天）＋ 一顆「匯出診斷包」按鈕 ＋ 一段說明「這個包裡有什麼、不會有什麼」 |
| 後端 | 一支 `POST` 端點，回傳串流的 `tar.gz` |
| 守門 | 路由掛登入檢查，**守門下沉到服務層**呼叫平台管理員判定（照 FR-110／FR-107 的既有做法） |

::: {.callout .warn}
**打包核心只能有一份實作**

指令版與畫面版產出的**必須是同一種包**——不同的話，原廠收到包還要先問「你是用哪種方式產的」，而兩邊的差異會在最需要的時候咬人。

做法是：**打包邏輯寫成一支可被兩邊呼叫的程式**，指令版呼叫它，畫面版的端點也呼叫它。放哪個 repo 見 **D5**。
:::

畫面上那段「這個包裡有什麼」的說明不是裝飾——**客戶要能審「我把什麼交出去了」**，這是白話需求明文寫的隱私要求。

---

## 待決策 {#pending nav="待決策"}

::: {.callout .decided}
**✅ 已全數拍板：D1–D12**

本段是**推演過程**（各選項與代價），**定案一律以 [design.md §3](design.md) 為準**——本段的建議欄不等於定案。
:::

::: {.callout .pending}
**D1 — log 與事件各保留多久？**

三個保留期要一起定（它們一起決定客戶機房要準備多少磁碟）：

| 項目 | 現況 | 建議 |
|---|---|---|
| 應用 log 檔 | 1MB × 5 檔（dev 那套，出貨機實際在跑） | **保留 30 天**、按天輪轉 |
| 容器記錄 | 20MB × 5 檔／容器（六服務約 600MB） | **不動** |
| 錯誤事件 | 試用時 90 天 | **保留 90 天**（沿用 GlitchTip 預設） |

[**建議**：log 30 天、事件 90 天，兩者都做成設定檔可調。理由：診斷包最常見的用法是「客戶昨天出錯、今天回報」，30 天綽綽有餘；而錯誤事件的價值是「這個錯是不是一直在發生」，那需要更長的區間才看得出趨勢，且事件經過去重後量遠小於 log。磁碟估算：一天的 log 量取決於流量，建議第一棒實作完在 DEV 量一天的實際大小再回頭定數字——**現在拍一個數字等於猜**。]{.rec}
:::

::: {.callout .pending}
**D2 — 追蹤編號在哪裡產生、長什麼樣？**

**產生位置**：

| 選項 | 做法 | 代價 |
|---|---|---|
| **A** | 前端網頁伺服器（nginx）產生，後端沿用 | 靜態檔案的請求也會有編號（沒必要）；而且背景排程的工作沒有經過 nginx，永遠沒有編號 |
| **B** | 後端中介層產生（`app_mw.py` 的請求前階段） | 背景排程仍需另外處理，但那本來就是另一條路徑 |

**格式**：

| 選項 | 樣子 | 代價 |
|---|---|---|
| **甲** | 純 UUID：`3f8a2c1b-...-9d4e` | 36 個字，log 每一行都佔位置；人眼難比對 |
| **乙** | 短碼：`req-3f8a2c1b`（12 字） | 碰撞機率極低（同一秒內要撞上 16 的 8 次方分之一），但理論上會撞 |
| **丙** | 沿用 OTel 的 trace id 格式（32 個十六進位字元） | 未來若真的接了收集器可以無縫對上；但現在沒有收集器，格式意義是空的 |

[**建議：B ＋ 乙**。產生位置選後端是因為「有請求進來就有編號」這件事該由應用自己保證，不該依賴部署架構（客戶可能用自己的反向代理，那時 nginx 那層根本不存在）。格式選短碼是因為**人要念得出來**——客服流程是「請問您畫面上那串編號是什麼」，36 個字沒人念得完。碰撞在診斷用途上無害（我們是拿它串同一個時間窗內的紀錄，不是當主鍵）。
背景排程（同步工作、續約排程等）另外處理：每次排程執行產一個編號，形式 `job-<排程名>-<短碼>`，這樣排程出錯時也串得起來。]{.rec}
:::

::: {.callout .pending}
**D3 — `system_logs` 的除錯 log 怎麼處置？**

這張表混住兩種東西：**除錯用的 log**（誰在哪一行印了什麼）與**稽核事件**（誰核准了哪一關，`event_code` 有值那些）。後者是產品功能、是合規交付的一部分，**絕不可退役**；前者 `log/app.log` 有同樣內容。

**實況盤點（實查）**：

| 面向 | 現況 |
|---|---|
| 有沒有在寫 | **有**。STG 586,498 筆、當天仍在寫；DEV 768,726 筆 |
| 兩種東西的比例 | `event_code='-'`（除錯 log）586,031 vs 真稽核事件 **467**，比例 **1,255 : 1** |
| 等級分佈 | INFO 585,875／ERROR 503／WARNING 126 |
| 寫進來的是什麼 | 每個請求（含健康檢查）固定三筆：`API Request`／`Request Headers`／`Request Body`。**標頭含未遮罩的 `Authorization`** |
| 有沒有讀取端點 | **沒有**。套件內有讀取服務（`system_log_service.get_system_log_list`），但主專案**沒有任何路由暴露它** |
| 有沒有畫面 | **沒有**（前端 grep 零命中） |
| 誰在寫 | jedi-common 的 `DBLogHandler`，掛在 `config_dev.py` 的各棵 logger 上 |
| 稽核事件那條路 | `event_code` 欄位是落點（`common/enum/event_code.py`、`stage_advance_service.py:215`、`remote_agent.py:95`），**有三支守衛測試盯著它不可掉出** |

三個具體代價：查稽核事件要在五十八萬筆裡撈（該表沒有 `event_code` 索引）／每請求三筆 INSERT 是持續的資料庫寫入負載／憑證明文進 DB。

[**定案：表不退役，改在寫入端加 filter。**
`DBLogHandler.emit` 前置判斷：**`event_code` 有值 或 `levelno >= ERROR`** 才寫，INFO 級的除錯 log 只走檔案與螢幕；另出一支一次性清理 migration 清掉既有的 `event_code='-' AND level='INFO'`（加防呆：先印將刪筆數、兩條件都成立才刪、分批）。
這樣同時解掉三件事：稽核事件不再被淹沒、每請求三筆的資料庫寫入負載消失、未遮罩的 `Request Headers` 不再進 DB。ERROR 放行是因為出錯那幾筆的查詢價值高而量極小（STG 共 503 筆），且它讓 **D11 的支援頁 ERROR 列表**變得可用。
**排除整張表退役**：兩種東西混住，一刀切會連稽核一起帶走，三支守衛測試會當場擋下。
**排除維持現狀**：垃圾持續累積，憑證明文持續進 DB。
**排除只清資料不加 filter**：清完馬上又長回來。
**連帶新增子任務**：出貨 compose 與 installer 明訂 `RUN_ENV`——現況靠預設撞對，預設改一個字就會讓 log 檔與稽核事件一起靜默消失。]{.rec}
:::

::: {.callout .pending}
**D4 — GlitchTip 的資料庫：共用既有的，還是自帶一套？**

| 選項 | 做法 | 代價 |
|---|---|---|
| **A** | 共用 `guidant-db`，在裡面另開一個資料庫 `glitchtip`；Redis 也共用（用不同的編號區隔） | 省一組容器與一份記憶體。但**錯誤資料與業務資料同一個資料庫實例**——業務資料庫壓力大時錯誤收集跟著受影響，反之亦然；備份還原時兩者綁在一起；而且 GlitchTip 會在同一個實例裡建自己的一整套表 |
| **B** | 自帶一套 PostgreSQL 與 Redis 容器 | 多兩個容器、多一份記憶體（約數百 MB）、安裝包多一顆映像（若版本與現有的不同）。但**完全隔離**——錯誤收集站掛了不影響本體，本體資料庫還原不會動到錯誤資料 |

[**建議：B（自帶一套）**，但**共用同一顆 `postgres:16` 映像**（安裝包已有，不必多帶）。
理由是**故障隔離的方向**：錯誤收集站存在的意義就是「本體出問題時它還活著、看得到現場」。共用資料庫的話，**最需要它的時候（資料庫出問題時）它剛好也躺了**——這不是理論，資料庫連線耗盡、磁碟寫滿都會同時打死兩邊。
另一個實務理由：客戶的資料庫備份還原是常見操作（升級前備份）。共用的話，還原業務資料庫會把錯誤歷史一起還原回去，時間軸錯亂。
代價（多兩個容器）在客戶機房的規格下可接受，但**要把資源估算寫進安裝手冊**（見 D9）。]{.rec}
:::

::: {.callout .pending}
**D5 — 打包程式放主專案還是 jedi-log？**

指令版與畫面版共用同一支打包程式（見 ④），問題是它住哪。

| 選項 | 做法 | 代價 |
|---|---|---|
| **A** | 放主專案（如 `app/support/` 或 `common/diagnostics/`） | 綁死 Guidant AI。但本來就綁死——包的內容（六個服務名、`guidant.env` 的欄位、`schema_migrations`、授權狀態）全是這個產品的知識 |
| **B** | 放 jedi-log 套件 | 與 log 基礎建設同住，理論上其他產品也能用。但要把「包裡有什麼」抽象成設定，而那個設定本身就是全部的內容 |

[**建議：A（主專案）**。判準是「這裡面有多少是產品知識」——答案是幾乎全部：服務清單、設定檔欄位、資料表名、授權機制、安裝紀錄路徑。抽成套件等於把一份產品知識翻譯成一份設定檔，然後兩邊都要維護。
**唯一該進套件的是遮罩規則**——那已經在 jedi-log 的 `masking.py`，打包程式直接呼叫它，不要另寫一份（兩套遮罩規則是資安缺口的標準長法）。]{.rec}
:::

::: {.callout .pending}
**D6 — 畫面版匯出的包，要不要含錯誤事件？**

指令版可以呼叫 GlitchTip 的 API 撈事件（人在主機上，權杖拿得到）。畫面版呢？

| 選項 | 做法 | 代價 |
|---|---|---|
| **A** | 含。後端服務自己拿權杖去撈 | 後端要存一份 GlitchTip 的權杖（多一份要保管的機密）；而且後端與站台的網路要通 |
| **B** | 不含。畫面版只包 log、環境、資料庫切片 | 兩個入口產出的包內容不同——違反「同一種包」原則 |
| **C** | 含，但拿不到就跳過並在說明檔註明 | 兩邊仍是同一種包，只是某一項可能缺 |

[**建議：C**。A 的「後端存權杖」其實不可避免（否則畫面版就是殘廢的），但要接受「拿不到」是正常狀況——客戶可能關掉了錯誤收集站、權杖可能過期、網路可能不通。
關鍵是**缺了要講**：`manifest.json` 裡該項標成「未取得，原因：連線失敗」，而不是安靜地不放那個檔。安靜缺席會讓分析端以為「這段時間沒有錯誤事件」，那是完全相反的結論。
權杖的保管照既有做法（加密落庫或設定檔），不新發明機制。]{.rec}
:::

::: {.callout .pending}
**D7 — 診斷包要不要用原廠公鑰加密？**

白話需求把這列為「選配，brainstorm 談」。

| 選項 | 做法 | 代價 |
|---|---|---|
| **A** | 加密。客戶產包後只有原廠解得開 | 客戶**無法自己審包裡有什麼**——而「客戶可審」是白話需求明文的隱私要求，兩者直接衝突。另外要管金鑰（原廠私鑰外洩就全部解得開） |
| **B** | 不加密，靠遮罩保證包裡沒有機密 | 包在傳輸途中若被攔截，內容是可讀的（但已遮罩） |
| **C** | 預設不加密，`--encrypt` 選配 | 兩者都有，但要做兩條路 |

[**建議：B（這一棒不做加密）**。三個理由：
一、**與「客戶可審」直接衝突**——那是明文寫進白話需求的要求，而且是合理的：客戶要交出去的東西自己看不到，本身就是信任問題。
二、**遮罩才是真正的防線**。加密只保護傳輸途中，解開之後內容照樣在原廠的機器上流轉；如果遮罩沒做好，加密只是把問題延後。把力氣放在遮罩管線上更划算。
三、**落地版客戶多為隔離內網**，包的傳遞往往是隨身碟或內部檔案分享，攔截風險本來就低。
反悔條件：若日後有客戶明確要求（合約有加密傳輸條款），再補 C 的選配路徑——那時遮罩管線已經成熟，加一層加密是小事。]{.rec}
:::

::: {.callout .pending}
**D8 — 前端的版本識別要不要這一棒補？**

後端送錯誤事件時帶了「版號＋commit hash」（`api_log.py:180-182`），**前端沒有**（`main.js:161-172` 只帶環境名）。後果是站台上前端的錯誤對不回某一版程式碼。

| 選項 | 代價 |
|---|---|
| **A** 這一棒補 | 動 FE 的建置流程（版本識別要在建置時注入），約半天 |
| **B** 留到下一棒 | 站台上前端錯誤暫時沒有版本，但**後端的有**，而本案驗收條款講的是後端的功能缺陷 |

[**建議：A（這一棒補）**。三個理由：一、成本極小（建置時注入一個變數）；二、它與後端是**一對**——前後端錯誤要在站台上串成同一條，一邊有版本一邊沒有，串起來也看不出是哪一版的組合；三、**現在不補，就會變成「前端錯誤永遠沒有版本」的長期狀態**，因為沒有人會為了這一件事單獨開一棒。
放在②那一棒做（與 GlitchTip 進包同一批動前端）。]{.rec}
:::

::: {.callout .pending}
**D9 — 錯誤事件的節流值要設多少？**

某個錯誤在迴圈裡每秒發生一百次時，會往站台送一百個事件。GlitchTip 會去重（同一種錯聚成一列），但**網路與站台的處理量仍然是實際消耗**，而客戶機房的規格有限。

| 要定的值 | 說明 | 建議 |
|---|---|---|
| 每分鐘事件上限 | 超過就丟棄並記一行 | **每分鐘 60 筆**（等於平均每秒一筆） |
| 站台記憶體上限 | 容器的資源限制 | **待實測**——本機試用沒有量過 |
| 站台磁碟成長率 | 90 天保留下的總量 | **待實測** |

[**建議：先設每分鐘 60 筆，站台資源限制等②那一棒實際裝起來跑一天再定。**
判準：節流的目的是「防止錯誤風暴打死站台」，不是省流量——**風暴本身就是最重要的訊號**，所以丟棄時一定要在應用 log 留一行「因節流丟棄 N 筆」，不然分析端會以為錯誤停了。
資源數字**現在拍等於猜**，而猜錯的方向若是低估，客戶機房會在最不該出事的時候磁碟寫滿（連帶打死業務資料庫，那是既有註解記著的失敗模式）。實測一天再定。]{.rec}
:::

::: {.callout .pending}
**D10 — 診斷包大小上限？**

| 選項 | 上限 | 代價 |
|---|---|---|
| **A** | 50 MB | 大多數郵件系統的附件上限（25MB）之上，即時通訊軟體大多可過。但 log 量大的客戶會被截掉很多 |
| **B** | 200 MB | 涵蓋大部分情況，但要靠檔案傳輸服務送 |
| **C** | 不設上限，超過時警告 | 可能產出數 GB 的檔案，客戶傳不出去 |

[**建議：A（50 MB）＋ 兩件配套**。
配套一：**超過時從最舊的開始砍**，而不是直接失敗——有總比沒有好。
配套二：**截斷的事實要進說明檔**，白話寫「原本要抓 24 小時，因大小限制實際只有最近 6 小時」。這句話不寫，分析端會把「沒資料」誤判成「沒事發生」。
指令版另外提供 `--no-limit` 給到場工程師用（人在現場，包不必傳輸）。
理由選 A 不選 B：這個功能的價值全在「客戶自己傳得出來」。傳不出去的包等於沒產。]{.rec}
:::

---

## 拆分草案 {#breakdown nav="拆分草案"}

::: {.callout .warn}
**本段是草案。開卡一律照 [design.md §6](design.md) 的正式拆分表**（4 子需求 × 24 子任務），本段留作對照。
:::

| 子需求 | 子任務 | 做什麼 | 驗收條件 | 動哪些 repo | 依賴／可否平行 |
|---|---|---|---|---|---|
| **.1 log 地基** | T-1.1 | log 格式加追蹤編號欄（formatter 端給預設值）＋ 正式環境開啟檔案輸出（按天輪轉） | 正式環境設定跑起來，`log/app.log` 有內容且每行有欄位（值先是 `-`） | jedi-common | — |
| | T-1.2 | 追蹤編號中介層：產生／填進 log／回應標頭；沿用上游編號 | 打一支 API，回應標頭有編號，log 那幾行是同一串 | 主專案（`app_mw.py`） | T-1.1（格式要先有欄位） |
| | T-1.3 | `api_logs` 加追蹤編號欄（migration）＋ DTO 加 level ＋ 依狀態碼決定等級 ＋ message 不清空 | 打一支會 500 的 API，資料表那筆等級是 ERROR、有編號、message 是路徑 | jedi-log ＋ 主專案 | T-1.2 |
| | T-1.4 | 5xx 補脈絡（方法／路徑／使用者／遮罩後請求內容，一行 JSON） | 故意炸一支，log 找得到那一行 JSON 且無明文憑證 | jedi-common | T-1.2 |
| | T-1.5 | `app_mw.py:44-45` 的標頭與請求內容過遮罩 | log 裡 grep `Authorization` 找不到 token 明文 | 主專案 | 可與 T-1.3／1.4 平行 |
| | T-1.6 | 追蹤編號塞進錯誤事件標籤 | 站台上該事件有 `request_id` 標籤，值與 log 一致 | 主專案（`api_log.py`） | T-1.2 ＋ ②裝起來 |
| | T-1.7（D3 定案後） | 開發環境拆掉「除錯 log 寫資料庫」那條；稽核事件路徑不動 | 開發環境跑一輪，`system_logs` 不再長除錯 log；三支守衛測試仍綠 | jedi-common | D3 |
| **.2 錯誤收集站進包** | T-2.1 | compose 新增 GlitchTip 相關服務（組成依 D4）＋ 資源限制 | `docker compose up` 起得來，瀏覽器開得到站台 | 主專案（compose） | 可與 .1 完全平行 |
| | T-2.2 | 安裝腳本自動化四件事（金鑰／帳號／專案／連線字串回寫） | 全新機器跑 `install.sh`，裝完 `guidant.env` 的 `SENTRY_DSN` 已有值 | 主專案（installer） | T-2.1 ＋ **2-4 的待驗先做完** |
| | T-2.3 | 映像進出貨包 ＋ 服務清單納入維運指令（status／logs／restart） | **在沒有該映像的機器上**裝得起來；`guidant status` 看得到新服務 | 主專案（build／installer） | T-2.1 |
| | T-2.4 | 站台密碼輪換子命令（不印明文） | 輪換後舊密碼登不進、新密碼可以；終端無明文輸出 | 主專案（installer） | T-2.2 |
| | T-2.5 | 前端版本識別注入（D8） | 前端故意炸一個，站台上該事件的版本欄有值且含 commit | FE | 可與 T-2.1 平行 |
| **.3 指令版** | T-3.1 | 打包核心：目錄結構、`manifest.json`、遮罩管線、大小上限 | 直接呼叫打包核心產出一個包，解開結構正確 | 主專案 | T-1.3（要有等級與編號才切得出東西） |
| | T-3.2 | `guidant diag` 子命令（白名單／守門／派送／用法四處） | `guidant --help` 看得到；`guidant diag --since 2h` 產出包；參數帶錯會被擋 | 主專案（installer） | T-3.1 |
| | T-3.3 | 錯誤事件撈取（GlitchTip API，含「拿不到就註明」） | 站台有事件時包裡有；關掉站台時包裡註明未取得 | 主專案 | T-3.1 ＋ .2 |
| **.4 畫面版** | T-4.1 | 匯出端點（守門下沉服務層、串流回傳）＋ 呼叫同一支打包核心 | 平台管理員打得通、租戶管理員 403 | 主專案 | T-3.1 |
| | T-4.2 | 「支援」頁面 ＋ 選單登記 migration | 平台管理員看得到並下載得到；租戶管理員看不到這一頁 | FE ＋ 主專案（migration） | T-4.1 |
| | T-4.3 | 畫面上「包裡有什麼」的說明文案 | 客戶讀得懂自己交出了什麼 | FE | T-4.2 |
| **.5 收口** | T-5.1 | 端到端驗收（下節） | 見下節 | — | 全部 |

**序列關係摘要**：`.1` 內部是一條鏈（T-1.1 → 1.2 → 1.3/1.4）；`.2` 與 `.1` 完全平行；`.3` 要等 `.1` 到 T-1.3；`.4` 要等 `.3` 的 T-3.1。

---

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

驗收條款展開成可親手做的步驟。**每一步都在 DEV 做**（環境異動鐵律：不碰 STG／POC）。

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

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

| # | 步驟 | 通過的標準 |
|---|---|---|
| 1 | 在 DEV 埋一個上述的缺陷，重啟服務 | — |
| 2 | 從畫面操作觸發它 | 畫面回錯誤，**回應標頭有 `X-Request-ID`** |
| 3 | `grep` 那串編號查 `log/app.log` | 找得到完整堆疊 ＋ 那一行 JSON 脈絡（方法／路徑／使用者／請求內容） |
| 4 | 拿同一串編號查 `api_logs` | 查得到那一筆，**等級是 ERROR**，`message` 是路徑，請求內容欄有使用者送的東西 |
| 5 | 開 GlitchTip 站台 | 該錯誤已出現，點進去有堆疊、有登入者、**有 `request_id` 標籤且值一致** |
| 6 | `sudo guidant diag --since 1h` | 產出 `.tar.gz`；解開後結構符合設計；`manifest.json` 每個檔都有說明 |
| 7 | **翻遍整包 grep 密碼／權杖／金鑰** | **一個明文都找不到**（含 `Authorization` 標頭、`guidant.env` 的各項密鑰） |
| 8 | 從畫面匯出同時間範圍的包 | 與步驟 6 的包**結構與內容一致**（除了時間戳與事件那項可能的差異） |
| 9 | **開一個全新的 AI 對話**，只給它：這個包 ＋ 原始碼倉庫連結。不給任何提示 | **它能指出根本原因（哪一支、哪一行、什麼條件觸發）與修法** |
| 10 | 在一台**沒有 GlitchTip 映像**的機器上裝一次安裝包 | 裝得起來，站台開得起來，故意出錯時站台收得到 |

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

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

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

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

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

步驟 10 若因手上沒有乾淨機器而用「先 `docker rmi` 掉映像」模擬，**回報時要寫明這是模擬**——T-6.6 的教訓就是驗收環境掩蓋了真缺口（那台「乾淨機」剛好早就有第三方映像）。
:::

---

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

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

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

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

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

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

`core/plugins/__init__.py:18` 有一條紅線：log 設定的初始化**必須早於**轉發鏈掛載。本案動的正是 log 設定。

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

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

::: {.callout .crit}
**🔴 三：第三方映像沒進包 → 封閉網路裝不起來，而驗收環境看不出來**

見 ②-6。`build_bundle.sh:84-94` 的映像清單少一顆，症狀是客戶機房（沒有外網）安裝時卡在拉映像，**而在任何有網路或已有該映像的機器上測都正常**。

必驗：在確定沒有該映像的機器上裝一次（或先 `docker rmi`）。
:::

::: {.callout .warn}
**四：錯誤收集站的開放註冊沒關 → 任何人都看得到客戶的錯誤堆疊**

堆疊裡有各層變數值——可能包含業務資料、可能包含使用者輸入。站台若對外可達且開著註冊，等於把這些攤開。

定案 C 已寫死預設關，但**要驗**：裝完之後實際去站台的註冊頁看，應該是關的。設定值寫對了但沒生效（版本差異、變數名不同）是常見的靜默失敗——GlitchTip 不同版本用過 `ENABLE_OPEN_USER_REGISTRATION` 與 `ENABLE_USER_REGISTRATION` 兩個名字。
:::

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

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

三個最容易漏的地方：

- **堆疊裡的區域變數**——已在 jedi-log 的 `masking.py` 處理（`_scrub_frames`），但打包程式要確認有呼叫到
- **請求標頭的 `Authorization`**——目前 `app_mw.py:44` 原樣印進 log，T-1.5 要收掉
- **設定檔裡不叫「password」的機密**——例如授權權杖、加密鑰匙。遮罩規則不能只比對 `PASSWORD`

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

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

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

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

::: {.callout .warn}
**七：稽核事件被 99.9% 的除錯垃圾淹沒**

STG `system_logs` 有 586,498 筆、當天仍在寫，稽核事件與除錯 log 的比例是 **467 : 586,031**。

這張表同時是「誰在哪一行印了什麼」與「誰核准了哪一關」的落點，有三支守衛測試盯著後者不可掉出。**整張表退役會連稽核一起帶走**，所以定案是加 filter（D3）。

三個具體代價：查稽核事件要在五十八萬筆裡撈（而該表沒有 `event_code` 索引）／每個請求三筆 INSERT（含健康檢查）是持續的資料庫寫入負載／`Request Headers` 整份進 DB 且 `Authorization` 未遮罩（已在冊：`followup_be_logs_request_body_plaintext_credentials`）。

**加 filter 時的陷阱**：`event_code` 的空值形式是字串 `'-'` 還是 `None` 要**先查清楚**，兩者在 Python 的真值判斷下行為不同。判準太嚴會讓稽核事件掉出去（守衛測試會擋，這是好的），太鬆則等於沒做。
:::

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

這支端點的本質是「一鍵下載系統的完整現場」。守門若寫錯，等於開了一個資訊外洩的大門。

三個必守：**守門下沉到服務層**（照 FR-110／FR-107 的做法，不只掛在路由）／**平台管理員判定是真正的那道門**（能力點擋不住租戶管理員）／**每一次匯出都要留稽核紀錄**（誰在什麼時候匯出了什麼時間範圍）。

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

---

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

| FR | 關係 |
|---|---|
| **FR-071.0** | 前一棒。錯誤追蹤試用（Sentry SDK 接進 jedi-log／主專案／前端），母卡 CM-1906。本案是它的正式落地 |
| **FR-065** | 落地版 Installer。本案要動 `guidant` 維運指令與 `install.sh`，安裝包的結構由它定義 |
| **FR-068** | log 轉發（Syslog／GELF）。本案改 log 設定，**有打破它的風險**（見風險二） |
| **FR-064** | 防竄改與授權鎖定。診斷包裡的授權狀態來自它；遮罩要確保金鑰不進包 |
| **FR-063** | Nuitka 打包。commit hash 的取得路徑（`app_version.py` 的映像標籤來源）與它有關 |
