# FR-068 系統 Log 轉發（Log Forwarding to Log Server）

> 2026-08-25 需求討論拍板（user＋協調者，對話中定案，未走 HTML 討論稿——範圍單純、決策點僅三個）。
> Notion 母卡：見 FR 登記表。agent log 轉發**本案不做**（user 裁示，未來另案）。

## 給 PM 的三分鐘版 {#pm nav="給 PM"}

> 這節不需要任何技術背景就讀得懂。工程細節從下一節起。

**這案在做什麼**：讓管理者在系統設定頁上填幾個欄位，就把我們系統產生的 log（系統運作
紀錄）自動送到客戶自己的 log 平台去。

**為什麼客戶要這個**：客戶（特別是把系統裝在自己機房的落地版客戶）機房裡通常已經有一套
集中收 log 的平台——ELK、Graylog、rsyslog 這類，所有系統的 log 都往那裡送，統一在一個
地方查。合規客戶更進一步：他們的資安監控平台（SIEM）需要收到「誰登入了、誰改了什麼」
這種**稽核事件**，這是稽核與事故調查的依據。我們的系統如果不送過去，就成了客戶監控網裡
的一個黑洞。

**送的內容分兩條，各自獨立勾選**：

| 條 | 內容 | 誰要看 |
|----|------|--------|
| **應用 log** | 系統運作紀錄、錯誤訊息 | 工程師排錯用 |
| **稽核事件** | 誰登入、誰改了什麼設定 | 合規客戶接 SIEM 真正要的就是這條 |

**影響哪些畫面／操作**：系統管理下新增一頁「Log 轉發設定」——填對方主機位址、埠號、選
用哪種格式送、勾要送哪兩條流，存檔就開始送。**不必改設定檔、不必重啟服務**。旁邊有一顆
「發送測試 log」按鈕，可以當場確認連不連得通。

**拍板了什麼**（決策 D1–D6 的白話版，完整理由見 §2）：

| 題目 | 定案 |
|------|------|
| 用什麼格式送 | Syslog 與 GELF 兩種——這兩種是 rsyslog／Graylog／ELK 三家都吃的最大公約數。**不做各家專屬的對接**（每家的認證方式和版本相容性都是坑，維護成本不成比例） |
| 送哪些內容 | 應用 log 與稽核事件**兩條都做**，畫面上分開勾。只做應用 log 會做完才發現不是合規客戶要的東西 |
| 設定放哪一層 | 系統全域一份（目標客群是一個客戶一套系統）。資料結構預留了「每個租戶各自設定」的空間，未來要開只動畫面和一個小函式 |
| 送不出去怎麼辦 | **絕不能拖垮主系統**——網路斷、對方主機掛了，本機 log 照常寫、系統照常跑，不重試轟炸 |
| 敏感內容要遮嗎 | 第一版不遮（log 是送到客戶自己機房內、信任邊界在客戶內網，手冊會註明）。架構有預留遮罩的掛點 |
| 設定改了要重啟嗎 | 不用，**改完數十秒內自動生效**（實作查證後的精確說法見 §5.2，畫面上會如實顯示這個延遲，不假裝是瞬時） |

**現在到哪了**：母卡 CM-1389 已收案，隨 **v1.17.0** 出貨，188 STG 與 189 POC 都已升級
到位。出貨後四筆修正（含一筆「Graylog 收得到卻讀不懂」的封包格式問題）見
[`README.md`](README.md) 的「實作與設計的差異」節。

**本案不含**：檢測 Agent 那側的 log 轉發（決策者裁示未來另案處理）。

---

## 變更紀錄 {#changelog}

| 日期 | 變更 | 對應 |
|------|------|------|
| 2026-08-25 | 初版設計定案（D1–D6） | 母案 |

## 1. 需求背景與目標 {#why}

客戶（尤其落地版）有集中 log 管理需求：系統 log 要能轉發到客戶自有的 log server（ELK／Graylog／rsyslog 等），在系統 UI 上設定即可生效。合規客群接 SIEM 的核心訴求是**稽核事件**（誰登入、誰改了什麼）進他們的集中平台；應用 log 轉發則服務工程排錯集中化。

## 2. 決策定案 {#decisions}

| # | 決策 | 定案內容 | 理由／被排除方案 |
|---|------|----------|------------------|
| **D1** | 協定 | **Syslog（RFC 5424，UDP/TCP 可選）＋ GELF（UDP/TCP）**兩種 | Syslog 是三家（rsyslog/Graylog/ELK via Logstash syslog input）最大公約數，Python stdlib `SysLogHandler` 現成；GELF 給 Graylog 原生結構化欄位保真。**排除**各家專屬 API 對接（Elastic API 認證/版本相容坑多；syslog 通吃零依賴）。ELK 對接以手冊附 Logstash 配置範例達成 |
| **D2** | 轉發範圍 | **應用 log ＋ 稽核事件兩者都轉，UI 分開勾選** | GRC 客戶接 SIEM 十之八九要的是稽核事件；只做 app log 會做完發現不是客戶要的。兩流各自獨立開關 |
| **D3** | 設定層級 | **系統層（全域一份）**，資料結構預留租戶層擴充 | 目標客群偏落地版（一客戶一套系統），全域一份最簡。**預留**：設定表帶 `tenant_id` 欄可空（NULL＝全域），讀取邏輯「租戶層有值優先、fallback 全域」的形狀先做成 helper 單點，未來開租戶層只動 UI 與該 helper |
| **D4** | 失敗語意 | **轉發失敗絕不拖垮主服務**：UDP fire-and-forget；TCP 非阻塞＋斷線靜默降級（本地 log 照寫＋本地記一條轉發失敗告警，不重試風暴）。**佇列緩衝**：handler 掛在 `QueueHandler`/`QueueListener` 後面（stdlib），主執行緒零阻塞 | log 轉發是旁路，任何網路抖動都不可影響 API 延遲 |
| **D5** | 敏感內容 | **第一版不過濾**（客戶自有 log server，信任邊界在客戶內網，手冊註明）。**架構預留 pipeline 掛點**：轉發 handler 前的 filter 鏈天然支援；UI 預留「敏感資料遮罩」開關位置（disabled 標示即將推出）。實作時內建預設規則組（IPv4/email/token 形狀 regex 遮罩），**不做自訂規則編輯器**（過度設計） | |
| **D6** | 生效方式 | 設定變更**熱生效**（重讀設定、卸舊掛新 handler，不重啟服務）＋ UI「發送測試 log」按鈕驗連通 | 改個 log 位址要重啟服務不可接受 |

## 3. 詳細設計 {#design}

### 3.1 設定資料

新表 `config.log_forwarding_settings`（系統層單列起步）：

| 欄位 | 說明 |
|------|------|
| `tenant_id` BIGINT NULL | **NULL＝全域**（D3 預留；第一版只有 NULL 一列） |
| `enabled` BOOLEAN | 總開關 |
| `protocol` VARCHAR | `syslog` / `gelf` |
| `transport` VARCHAR | `udp` / `tcp` |
| `host` / `port` | log server 座標 |
| `forward_app_log` BOOLEAN | D2 流一：應用 log |
| `forward_audit_events` BOOLEAN | D2 流二：稽核事件 |
| `masking_enabled` BOOLEAN DEFAULT FALSE | D5 預留（第一版 UI disabled） |
| 審計四欄 | 慣例 |

### 3.2 BE 掛載

- 啟動時讀設定 → enabled 則組 handler 鏈：`QueueHandler` → `QueueListener` → `SysLogHandler`（或 GELF handler，GELF UDP 百行內自寫或極輕依賴，實作棒查證後定）。
- **兩條流的源頭**：app log＝掛在既有 root/app logger；稽核事件＝掛在稽核事件寫入點（`reference_audit_event_logging_pattern` 既有埋點鏈），以 structured 欄位出（GELF 時 event_type/actor/target 成獨立欄位，syslog 時 JSON in message）。
- 熱生效：settings 更新 API 成功後觸發 reload（單進程直接重掛；gunicorn 多 worker 走既有的跨 worker 訊號機制——實作棒查證現況後定，沒有就 fallback「下次 worker 重生生效＋UI 註明」，不為此蓋新輪子）。
- 測試按鈕：`POST .../log-forwarding/test` 發一條固定測試訊息，回傳送出成功與否（UDP 只能驗送出不驗到達，UI 文案如實說）。

### 3.3 FE

系統管理群新設定頁（或併入既有整合設定頁——實作棒看現有選單結構定）：表單（開關/協定/傳輸/host/port/兩流勾選）＋測試按鈕＋遮罩開關（disabled）。權限比照同群其他系統管理頁。

### 3.4 手冊

三家對接範例各一段：rsyslog（收 UDP 514 範例 conf）、Graylog（GELF input 建法）、ELK（Logstash syslog input pipeline 範例）。

## 4. 拆分 {#split}

| # | 子任務 | Repo | 規模 |
|---|--------|------|------|
| T-1 | BE：設定表 migration＋CRUD API＋handler 鏈掛載（syslog+gelf、queue、熱生效、測試端點）＋稽核事件流接點 | BE | M |
| T-2 | FE：設定頁＋測試按鈕＋i18n | FE | S~M |
| T-3 | 手冊三家對接範例＋SPEC 頁 | docs | S |

依賴：T-1 → T-2；T-3 收尾做。agent log 轉發不在本案（未來另案，屆時 agent 端沿同一設定下發形狀）。

## 5. 實作回寫 {#impl-writeback}

> 2026-08-29（CM-1429）補記。§3 有三處寫「實作棒查證後定」，實作當時都查證並做了
> 決定，但結論只留在 `common/log_forwarding/` 的檔頭註解裡，沒回到設計稿。本節把它們
> 收進來，內容取自那兩支檔頭（實作當下寫的一手判斷，非事後追述）。
>
> **§1–§4 維持 2026-08-25 拍板當下的原樣不動**——定案稿是快照，差異另記，與
> [`README.md`](README.md) 的「實作與設計的差異」節同一原則。出貨後的四筆行為修正記在
> README 那節，本節只管「設計時懸而未決、實作查證後定案」的三處。

### 5.1 GELF handler：自寫，不引套件（§3.2 第一處）

候選 `pygelf` 與 `graypy` **都不在本專案依賴內**（`pyproject.toml` 無、venv 內
`find_spec` 皆 None），引入等於新增一個第三方依賴。而 GELF 1.1 的線上格式極小——固定
8 個欄位的 JSON，UDP 走 gzip、TCP 走 NUL 結尾，規格全文一頁，自寫約百行。

換掉的成本是：

- **落地版打包**：新依賴要進 Nuitka 收集清單、進離線安裝包、進授權盤點（graypy BSD、
  pygelf MIT 都合規，但每多一個就多一列要維護）。
- **行為可控**：兩個套件的失敗語意都不是我們要的——預設會讓 socket 例外往上冒或自行
  重試，而 **D4 要求「斷線靜默降級、不重試風暴」**。用套件反而要再包一層去壓制它。

順帶定案：**不做 GELF UDP chunking**。規格允許把大訊息切成多個 chunk，但本專案的 log
是單行文字加少量欄位，gzip 後遠小於單一 datagram 上限；真超長時寧可截斷（8 KiB，尾端
標 `…[truncated]`）也不引入 chunk 重組——收不齊的 chunk 在對端是碎片垃圾，比截斷更難查。

落點：`common/log_forwarding/gelf_handler.py`。

### 5.2 多 worker 熱生效：**推翻 D6 的前提**，改 per-worker watcher（§3.2 第二處）

§3.2 原本假設「走既有的跨 worker 訊號機制」。**查證結果是本專案根本沒有這種機制**：全
codebase grep 不到 SIGHUP handler、不到 Redis pub/sub 的訂閱端（redis 只被當 KV 用）、
不到 worker 登記表（`register_gunicorn_master` 只記 master pid 供 tamper 終止，是單向
的）。gunicorn 有 `--reload`，但那是重啟整個 worker，代價遠大於重掛一個 handler。

照 §3.2「沒有就 fallback，不為此蓋新輪子」——但 fallback 不必差到「下次 worker 重生才
生效」。定案：**每個 worker 各起一條 daemon watcher 執行緒**，週期（預設 30s，
`LOG_FORWARDING_WATCH_INTERVAL`）重讀設定，指紋有變才重掛。

- 效果上仍滿足 D6「不必重啟服務」，代價是最長 30s 的生效延遲。
- 不需要任何跨進程協調——每個 worker 各自從 DB 這個共同真相來源讀，天然一致。
- **API response 帶 `applies_within_seconds` 明示這個延遲，不假裝瞬時**（D6 說「熱生
  效」，實際是「數十秒內生效」，UI 必須如實說）。
- 輪詢成本：一次 `SELECT` × worker 數 ÷ 30s，以預設 4 workers 計約每秒 0.13 次查詢。

落點：`common/log_forwarding/forwarder.py`。

### 5.3 FE：新開頁，不併入既有設定頁（§3.3）

定案 **新開頁 `/system/log-forwarding`**（`src/views/log-forwarding/LogForwardingForm.vue`），
選單掛在系統管理群下（`ui_routes` sort 35，migration
`scripts/sql/2026-08-27-fr068-2-log-forwarding-menu-route.sql`），權限走
`log-forwarding.{read,update}` 兩個能力點，比照同群其他系統管理頁。
