FR-071.1 · log 地基/GlitchTip 進出貨包/診斷包雙入口 · 需求討論稿 · 2026-09-17(v1 · 待審)
落地版裝在客戶自己的機房,出錯時案發現場全留在那邊——log 在客戶主機、資料在客戶資料庫、錯誤堆疊隨容器重啟消失。目前唯一動線是派人到場。本案分四棒補齊:先把 log 地基補起來(寫成檔案、每一筆帶同一條追蹤編號、5xx 記得下夠資訊),再把錯誤收集站(GlitchTip)包進安裝包讓每個客戶自帶一套,最後做診斷包匯出——指令一支、畫面一顆按鈕,包好丟回原廠就能重建現場。驗收條款寫死:故意弄壞一個功能、產包、丟給一個什麼都不知道的 AI,它要能指出錯在哪與怎麼修。
整案一句話:讓客戶機房裡發生的錯誤留得下完整證據,並且能被打包帶回原廠分析——原廠不必到場、客戶不必會下指令。
給非工程讀者的背景:軟體出錯時,工程師要的東西有三樣——錯在程式的哪一行(堆疊)、當時使用者在做什麼(那一次請求的內容)、當下資料長什麼樣(資料庫的相關資料)。這三樣現在都在客戶的機器裡,而且各自散著、對不起來:log 檔留得太短(幾 MB 就被輪掉)、每一筆之間沒有共同的編號可以串。本案把這三樣串成一條線,再做一個「打包帶走」的功能。
四棒,前兩棒有先後,後兩棒可並行
第一棒是地基——沒有它,後面打包出來的東西查不到東西。第二棒的錯誤收集站是獨立的一條線,可與第一棒平行開工。
| 階段 | 做什麼 | 產出 | 完成怎麼判定(決策者親手檢查法) |
|---|---|---|---|
| ① 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 小時」→ 按匯出 → 瀏覽器下載到同一種包;用一般租戶管理員登入則完全看不到這一頁 |
依賴:①是地基,②可與①完全平行(兩條不同的線);③要等①完成(沒有追蹤編號與等級就切不出東西);④與③共用同一支打包程式,③驗收通過後接著做。
🔴 全案的驗收條款(決策者寫死,不可打折)
在 DEV 故意弄壞一個功能 → 產出診斷包 → 把包丟進一個乾淨的 AI 對話(只給它這個包+原始碼倉庫,沒有資料庫、沒有現場、沒有人講解)→ 它要能指出根本原因與修法。
過不了這一關就不算完成。這條條款同時是設計的指北針:包裡每一樣東西都要能回答「AI 拿到它能推進一步嗎」,不能就不放。
母卡 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 站台實測六項全過。決策者看過站台效果後拍板往下做,並且選了積極版(見下段)。
🔴 洞一:出貨機沒設 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 檔與稽核事件會在沒有任何錯誤訊息的情況下一起消失所以第一棒要做的是「把 dev 那套改對,並且明訂出貨環境用哪一套」,不是「開檔案 log」。
🔴 洞二:追蹤編號全庫零命中
grep 全庫找 request id / correlation id 相關概念,零命中。現況最接近的是 common/middleware/app_mw.py:71 的 g.trace_id,但它是存取紀錄表的流水號(api_logs.id),而且:
log 格式裡倒是有兩個看起來像追蹤編號的欄位(config_dev.py:12 的 otelTraceID / otelSpanID),但那是 OpenTelemetry 的欄位、落地版沒有對應的收集器,值恆為 0(custom_formatter.py 明文寫了這個預設值的由來)。正式環境的 log 格式(config_prod.py:11)連這兩欄都沒有。
這是整個診斷包的關鍵前提——白話需求原文寫得很清楚:「分析端不必靠時間戳猜對應」。沒有它,包裡的 log、存取紀錄、錯誤事件是三堆對不起來的東西,AI 只能猜。
🔴 洞三:十三萬筆存取紀錄,零筆 ERROR
DEV 實查(2026-09-17):
131,595存取紀錄總筆數
0其中等級為 ERROR 的
768,726系統 log 筆數(DEV;STG 另有 586,498)
原因是三處連鎖:
app_mw.py:57 建立紀錄時 level='INFO' 是寫死的UpdateApiLogDTO(jedi-log 套件)根本沒有 level 這個欄位,所以事後也改不了app_mw.py:81-110)不看 HTTP 狀態碼,500 跟 200 走同一條路連帶後果:app_mw.py:102 還把 message 欄位清成空字串——請求階段本來存了路徑進去,回應階段把它抹掉。
症狀:要在存取紀錄裡撈「出錯的那幾筆」撈不出來,只能靠時間範圍全撈再自己翻。診斷包若照這樣切,等於把整段時間的紀錄原封不動裝進去。
(白話需求早就記了這筆債:「api_logs 錯誤請求 level 全是 INFO 要修」。)
以下五項已拍板,本段為記錄,不是待議項。
| # | 決策 | 內容 | 為什麼 |
|---|---|---|---|
| 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 是「日誌聚合」不是「錯誤追蹤」,要的堆疊聚合與去重它不做 |
%%{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
三句話講完這張圖:
api_logs):定位收窄成「誰做了什麼」的業務紀錄,不再兼差當除錯工具那張除錯用的資料表 system_logs 一直在寫,而且被除錯 log 淹沒了(STG 實查:586,498 筆中只有 467 筆是稽核事件)。處置見 D3:表不退役,改在寫入端加一道 filter。
%%{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——這一項是給客服用的:使用者回報問題時看得到那串編號,念給客服,客服就能直接定位。
下表每一列都經過實檔/實查驗證(行號與筆數為 2026-09-17 當下)。本案動到的每一處都在這張表上。
| 元件 | 現況 | 本案動作 |
|---|---|---|
logging 設定的選擇config_logger.py:28,73 |
os.getenv("RUN_ENV", "dev"),未知值退 dev;出貨端沒有任何一處設定它,故實際跑 dev 那套 |
新增:compose 與 installer 顯式寫出 RUN_ENV(見洞一) |
正式/STG log 設定config_prod.py:31-38、config_stg.py:31-37 |
檔案輸出整段註解、無資料庫輸出;從未生效過 | 改:與實際要用的那套對齊(按天輪轉、格式加追蹤編號欄) |
開發環境 log 設定config_dev.py:36-43 |
檔案 1MB×5 + 資料庫輸出(DBLogHandler)。這份就是出貨機實際在跑的那份 |
改:輪轉改按天 30 天;格式加追蹤編號;資料庫輸出加 filter(見 D3) |
log 格式的追蹤欄config_dev.py:12 |
有 otelTraceID/otelSpanID,但落地版無收集器,值恆為 0;正式環境格式連這兩欄都沒有 |
取代:換成真正的追蹤編號 |
容器記錄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,目前只有安裝紀錄 |
不動(掛載已就緒),檔案輸出開啟後自然落在這裡 |
| 追蹤編號 全庫 grep |
零命中。最接近的 g.trace_id(app_mw.py:71)=存取紀錄流水號,未進 log 格式、未回標頭 |
新增:中介層產生(位置與格式見 D2) |
存取紀錄的等級app_mw.py:57 |
level='INFO' 寫死;UpdateApiLogDTO(jedi-log)無 level 欄;回應階段不看狀態碼 |
改三處:DTO 加欄位、回應階段依狀態碼改等級、資料表加追蹤編號欄 |
存取紀錄的 message 欄app_mw.py:102 |
請求階段存了路徑,回應階段清成空字串 | 改:不清空(路徑是查詢時最常用的線索) |
請求內容的遮罩app_mw.py:51/:100 |
資料庫那條有過 mark_password;但 :44-45 把 Headers(含 Authorization)與 Body 原樣印進 log,未遮罩 |
改:兩行都過遮罩(已記在 followup,本案順手收) |
5xx 錯誤處理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) |
錯誤收集接線core/plugins/api_log.py:168-187 |
FR-071.0 已完成:帶版號+commit、環境名、登入者;SENTRY_DSN 空就完全不啟用 |
不動接線,補「把追蹤編號塞進事件標籤」 |
前端錯誤收集compliance-manager-fe/src/main.js:155-176 |
已接 @sentry/vue;未注入版本識別(後端有帶、前端沒帶) |
補版本識別(D8 決定要不要這一棒做) |
版本錨點common/util/app_version.py:75 + GET /api/1.0/version |
get_version_info() 回 {version, commit},端點免認證 |
沿用——commit hash 是「分析端把原始碼切到同一版」的關鍵 |
維運指令scripts/installer/guidant(1,326 行) |
子命令白名單 :1274、參數守門 :1286-1290、派送 :1305-1316、用法 :29-40;logs 子命令(:607-617)= docker compose logs -f --tail 200;credentials 已移除(不印明文密碼鐵律) |
新增 diag 子命令,四處都要動(白名單/守門/派送/用法) |
安裝紀錄/etc/guidant-ai/install.conf |
記 GUIDANT_DATA_DIR(客戶可自訂安裝路徑),維運指令讀它定位 |
沿用——診斷包要靠它找到資料目錄 |
容器組成docker/production/docker-compose.yml |
六個常駐(db/redis/seaweedfs/api/socketio/fe)+ 一個一次性(db-init) | 新增 GlitchTip 相關容器(幾個、怎麼接 DB 見 D4) |
安裝包的第三方映像scripts/build/build_bundle.sh:84-94 |
已包 postgres:16/redis:7-alpine/chrislusf/seaweedfs:3.99 等,docker save 成 tar |
新增 GlitchTip 映像進清單(非做不可,見風險三) |
| **既有的「匯出存取紀錄」 jedi-log api_log_route.py:45-56 |
平台管理員守門、xlsx 包 zip;篩選參數沒接上=全量匯出** | 不動(那是給人看的報表,不是給 AI 分析的診斷包);但打包程式可參考它的守門與串流做法 |
| **log 轉發(FR-068) jedi-log forwarding/ |
Syslog/GELF 轉發已上線,設定表+畫面熱生效 | 不動**。紅線:core/plugins/__init__.py:18 的 log 設定初始化必須早於轉發鏈,本案改 log 設定時不可打破這個順序 |
一句話:讓落地版留得下 log,而且每一筆都掛得上同一條線。
檔案輸出一直開著(跑的是 dev 那套),要改的是輪轉方式。三份設定檔要一起對齊,並由 compose/installer 明訂 RUN_ENV。
| 項目 | 開發環境現況 | 正式環境要什麼 | 為什麼 |
|---|---|---|---|
| 輪轉方式 | 按大小(1MB × 5 檔) | 按天 | 診斷包要切「某個時間範圍」。按大小輪轉時,「昨天下午」可能橫跨三個檔、也可能早被輪掉;按天輪轉一天一個檔,切片就是挑檔案 |
| 保留量 | 5 檔 ≈ 5MB(出貨機現況同此) | 見 D1 | 客戶機房磁碟大小不一,保留策略要能調;5MB 在稍有流量時「出事前十分鐘」就已被輪掉 |
與容器記錄的關係:兩份都留,用途不同——容器記錄是「服務起不來時看得到的東西」(應用還沒起來就沒有 log 檔),log 檔是「服務跑起來之後的完整紀錄」。容器記錄那 20MB×5 的上限不動。
每一次進來的請求產生一個編號,並且送到四個地方:
| 送到哪 | 怎麼做 | 註記 |
|---|---|---|
| log 每一行 | 放進 log 格式,取代現在恆為 0 的 OTel 兩欄 |
位置固定在等級後面,AI 與人都好認 |
| 存取紀錄表 | api_logs 新增一欄(migration) |
這張表是跨租戶的系統紀錄,加欄位要照 SQL migration 規範 |
| 錯誤事件 | 塞成 GlitchTip 的標籤 | 站台上可用這個編號搜尋 |
| 回應標頭 | X-Request-ID |
使用者看得到、客服念得出來 |
沿用上游送來的編號:前端已經接了錯誤追蹤並開啟了追蹤標頭傳遞(main.js:165-170 的 browserTracingIntegration 與 tracePropagationTargets)。若請求帶了上游編號就沿用,沒帶才自己產——這樣前端的錯誤與後端的錯誤在站台上會串成同一條。
現況 handler.py:33 只有一行 logger.error(訊息, exc_info=True)。堆疊有了,但不知道是誰、打了哪支、送了什麼——而這三樣正是重建現場的必要條件。
補成一行結構化輸出(JSON),包含:追蹤編號/方法/完整路徑/登入者/遮罩後的請求內容/錯誤類別。
為什麼是「一行 JSON」而不是印成多行
出錯時人最想看的是「一次看完整件事」,但 log 是一行一筆的串流——多行輸出在高併發下會被其他請求的 log 插進來切碎。一行 JSON 則是:人可以直接讀(欄位名都看得懂),AI 可以直接解析,而且不會被切碎。
堆疊本身仍照原樣多行輸出(那是 Python 的標準格式,工具都認得),JSON 那行是它的前導脈絡行。
三處連鎖都要動(缺一等於沒改):
| 動哪 | 改什麼 | 落在哪個 repo |
|---|---|---|
UpdateApiLogDTO |
加 level 欄位 |
jedi-log 套件 |
app_mw.py 回應階段 |
依 HTTP 狀態碼決定等級:≥500 → ERROR、4xx → WARNING、其餘 INFO | 主專案 |
app_mw.py:102 |
message 不再清空 |
主專案 |
既有的歷史資料不回頭改——歷史資料就是 INFO,這是事實紀錄。診斷包切片時對舊資料一律照時間範圍切。
app_mw.py:44-45 把請求標頭(含 Authorization)與請求內容原樣印進 log。那兩行的註解自己就寫了這件事:「只遮資料庫那條,憑證照樣明文躺在 log/app.log 裡,而看 log 的人比查資料庫的人多」。
本案必須順手收掉——因為 1-1 開啟了正式環境的 log 檔,等於把這個洞從「開發機」擴大到「客戶機房」,而且診斷包會把那個檔打包帶走。
| 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 都會經過的中介層,改壞是全站級別的影響 |
🔴 app_mw.py 與 jedi-common 的改動有先後,不可平行
log 格式新增追蹤編號欄位(jedi-common)與「誰來填這個欄位」(主專案中介層)是一對。格式先上、填的人還沒上,每一行 log 都會在格式化階段找不到欄位而拋錯——而 Python 的 logging 會把 formatter 的錯誤吞掉,症狀是 log/app.log 整段消失、服務照常起、沒有任何錯誤訊息。
custom_formatter.py 的檔頭註解記著同型的坑(OTel 欄位沒 instrument 過就每行拋錯),解法也記在那裡:formatter 自己補預設值。本案照做——新欄位在 formatter 端給預設值(如 -),這樣兩邊上線順序就不再致命。但仍建議先套件、後主專案。
一句話:每個客戶的安裝包裡自帶一套錯誤收集站,裝完就能用,只給原廠管理員看。
| 條件 | GlitchTip | Sentry 自架 | Loki/Grafana |
|---|---|---|---|
| 授權可隨產品出貨 | ✅ MIT | ❌ FSL(有商業限制) | ⚠️ AGPL/需另評估 |
| 做的是錯誤聚合去重 | ✅ | ✅ | ❌(是日誌聚合,不做堆疊去重) |
| 資源占用適合裝在客戶機房 | ✅ 輕量 | ❌ 重(多個元件) | ⚠️ 中等 |
| 我們的 SDK 已經接好 | ✅(Sentry SDK 相容) | ✅ | ❌ 要重接 |
決策者已排除後兩者(定案 E)。本段只談 GlitchTip 怎麼進包。
| 項目 | 實際值 | 對本案的意義 |
|---|---|---|
| 映像 | 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) |
GlitchTip 標準的自架組成是三個角色:網站(收事件+畫面)、背景工作(處理事件、清理過期)、一次性遷移(建資料表)。後者與現有的 guidant-db-init 同型(跑完就結束)。
%%{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
客戶不會去 GlitchTip 的畫面上手動建帳號、建組織、建專案、複製連線字串——那條動線一旦需要人工,落地版就等於沒有這個功能。安裝腳本必須自動做完:
| 步驟 | 怎麼做 | 待驗 |
|---|---|---|
| ① 產金鑰 | 隨機產 SECRET_KEY 寫進設定檔(比照既有的密碼產生做法) |
— |
| ② 建管理員帳號 | GlitchTip 的管理指令支援非互動建立超級使用者 | 需實測非互動參數 |
③ 建組織與專案 GuidantAI |
兩條路:跑管理指令,或呼叫它的 REST API(POST /api/0/teams/{組織}/{團隊}/projects/,需要一組有寫入權限的權杖) |
待驗:權杖本身要先在畫面上建,若無法用指令產生則只能走管理指令那條 |
| ④ 把連線字串寫回設定檔 | 取得專案的連線字串,寫進 guidant.env 的 SENTRY_DSN 與前端的對應變數 |
連線字串要從建好的專案讀出來 |
🔴 ③ 這一步是本階段最大的未知
GlitchTip 的 REST API 認證用「使用者在個人設定頁產生的權杖」——那本身是一個畫面操作。要做到全自動,得確認它的管理指令(manage.py)能不能直接建組織/專案/權杖。
本稿把它標為待驗,開卡前必須實測。若真的不行,退路是「裝機時建好帳號,專案由原廠人員第一次上線時點三下建好」——那會讓 SENTRY_DSN 變成一個安裝後的手動步驟,可接受但要寫進安裝手冊。
判準:不要為了「全自動」硬幹(自己去資料庫塞資料是最糟的解),但也不要默默留一個「要人做卻沒人知道要做」的步驟——後者的症狀是錯誤收集站裝了卻永遠沒有資料,而且沒有人會發現。
| 子命令 | 做什麼 | 註記 |
|---|---|---|
| 站台密碼輪換 | 比照既有的 rotate-credentials |
不印明文——既有的 credentials 子命令就是因為印明文而被移除,新指令不可重蹈 |
status 納入新服務 |
服務清單(guidant:293)加進去 |
否則 status 看不到它、restart 也指不到它 |
logs 納入新服務 |
同上(logs 吃同一份清單) |
— |
🔴 第三方映像必須包進出貨包
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一句話:一支指令把現場包成一個檔案。
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 看不到這個功能,等於沒做 |
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 也知道某欄是 *** 不是真的空 |
| 錯誤事件清單(編號+標題) | 不必解整包就知道有哪些錯 |
| 每個檔的雜湊 | 傳輸過程有沒有損壞,一比就知道 |
| 資料 | 取法 | 註記 |
|---|---|---|
| 版號+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 <權杖>,權杖要有讀事件的權限 |
一條管線、兩個入口共用(指令版與畫面版不可各寫一套——那是兩套真相的起點)。
| 來源 | 遮什麼 |
|---|---|
| 設定檔 | 值的鍵名含 PASSWORD/SECRET/TOKEN/KEY/DSN 一律換成 *** |
| 存取紀錄的請求/回應欄 | 已有的 mark_password 打底,另加金鑰型欄位 |
| 應用 log | ①-5 已在源頭遮罩,這裡是第二道 |
| 錯誤事件 | jedi-log 的 masking.py 已在送出時遮過,這裡是第二道 |
遮罩要「寧可多遮」,而且遮掉的要留痕跡
遮成 *** 而不是刪掉欄位——AI 看到 "password": "***" 知道「有這個欄位,值被遮了」;看到欄位不見了會以為「客戶沒填」,那是完全不同的判斷。
manifest.json 要列出遮罩規則,讓分析端知道哪些是遮的。
診斷包要能用郵件或即時通訊軟體傳(客戶機房多半沒有檔案傳輸管道)。上限見 D10;超過時的處理:log 與存取紀錄從最舊的開始砍,並在 manifest.json 註明「因大小限制截斷,實際時間範圍是 X 到 Y」——截斷了不講是最糟的,分析端會以為那段時間真的沒事。
一句話:客戶自己點兩下就能產出同一種包,不必會下指令。這是「減少到場」的關鍵那一步。
| 項目 | 內容 |
|---|---|
| 位置 | 系統設定 → 支援(新頁) |
| 誰看得到 | 平台管理員(ROOT 租戶)。一般租戶管理員完全看不到這一頁 |
| 畫面上有什麼 | 時間範圍選擇(最近 2 小時/24 小時/7 天)+ 一顆「匯出診斷包」按鈕 + 一段說明「這個包裡有什麼、不會有什麼」 |
| 後端 | 一支 POST 端點,回傳串流的 tar.gz |
| 守門 | 路由掛登入檢查,守門下沉到服務層呼叫平台管理員判定(照 FR-110/FR-107 的既有做法) |
打包核心只能有一份實作
指令版與畫面版產出的必須是同一種包——不同的話,原廠收到包還要先問「你是用哪種方式產的」,而兩邊的差異會在最需要的時候咬人。
做法是:打包邏輯寫成一支可被兩邊呼叫的程式,指令版呼叫它,畫面版的端點也呼叫它。放哪個 repo 見 D5。
畫面上那段「這個包裡有什麼」的說明不是裝飾——客戶要能審「我把什麼交出去了」,這是白話需求明文寫的隱私要求。
✅ 已全數拍板:D1–D12
本段是推演過程(各選項與代價),定案一律以 design.md §3 為準——本段的建議欄不等於定案。
D1 — log 與事件各保留多久?
三個保留期要一起定(它們一起決定客戶機房要準備多少磁碟):
| 項目 | 現況 | 建議 |
|---|---|---|
| 應用 log 檔 | 1MB × 5 檔(dev 那套,出貨機實際在跑) | 保留 30 天、按天輪轉 |
| 容器記錄 | 20MB × 5 檔/容器(六服務約 600MB) | 不動 |
| 錯誤事件 | 試用時 90 天 | 保留 90 天(沿用 GlitchTip 預設) |
建議:log 30 天、事件 90 天,兩者都做成設定檔可調。理由:診斷包最常見的用法是「客戶昨天出錯、今天回報」,30 天綽綽有餘;而錯誤事件的價值是「這個錯是不是一直在發生」,那需要更長的區間才看得出趨勢,且事件經過去重後量遠小於 log。磁碟估算:一天的 log 量取決於流量,建議第一棒實作完在 DEV 量一天的實際大小再回頭定數字——現在拍一個數字等於猜。
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-<排程名>-<短碼>,這樣排程出錯時也串得起來。
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 檔與稽核事件一起靜默消失。
D4 — GlitchTip 的資料庫:共用既有的,還是自帶一套?
| 選項 | 做法 | 代價 |
|---|---|---|
| A | 共用 guidant-db,在裡面另開一個資料庫 glitchtip;Redis 也共用(用不同的編號區隔) |
省一組容器與一份記憶體。但錯誤資料與業務資料同一個資料庫實例——業務資料庫壓力大時錯誤收集跟著受影響,反之亦然;備份還原時兩者綁在一起;而且 GlitchTip 會在同一個實例裡建自己的一整套表 |
| B | 自帶一套 PostgreSQL 與 Redis 容器 | 多兩個容器、多一份記憶體(約數百 MB)、安裝包多一顆映像(若版本與現有的不同)。但完全隔離——錯誤收集站掛了不影響本體,本體資料庫還原不會動到錯誤資料 |
建議:B(自帶一套),但共用同一顆 postgres:16 映像(安裝包已有,不必多帶)。 理由是故障隔離的方向:錯誤收集站存在的意義就是「本體出問題時它還活著、看得到現場」。共用資料庫的話,最需要它的時候(資料庫出問題時)它剛好也躺了——這不是理論,資料庫連線耗盡、磁碟寫滿都會同時打死兩邊。 另一個實務理由:客戶的資料庫備份還原是常見操作(升級前備份)。共用的話,還原業務資料庫會把錯誤歷史一起還原回去,時間軸錯亂。 代價(多兩個容器)在客戶機房的規格下可接受,但要把資源估算寫進安裝手冊(見 D9)。
D5 — 打包程式放主專案還是 jedi-log?
指令版與畫面版共用同一支打包程式(見 ④),問題是它住哪。
| 選項 | 做法 | 代價 |
|---|---|---|
| A | 放主專案(如 app/support/ 或 common/diagnostics/) |
綁死 Guidant AI。但本來就綁死——包的內容(六個服務名、guidant.env 的欄位、schema_migrations、授權狀態)全是這個產品的知識 |
| B | 放 jedi-log 套件 | 與 log 基礎建設同住,理論上其他產品也能用。但要把「包裡有什麼」抽象成設定,而那個設定本身就是全部的內容 |
建議:A(主專案)。判準是「這裡面有多少是產品知識」——答案是幾乎全部:服務清單、設定檔欄位、資料表名、授權機制、安裝紀錄路徑。抽成套件等於把一份產品知識翻譯成一份設定檔,然後兩邊都要維護。 唯一該進套件的是遮罩規則——那已經在 jedi-log 的 masking.py,打包程式直接呼叫它,不要另寫一份(兩套遮罩規則是資安缺口的標準長法)。
D6 — 畫面版匯出的包,要不要含錯誤事件?
指令版可以呼叫 GlitchTip 的 API 撈事件(人在主機上,權杖拿得到)。畫面版呢?
| 選項 | 做法 | 代價 |
|---|---|---|
| A | 含。後端服務自己拿權杖去撈 | 後端要存一份 GlitchTip 的權杖(多一份要保管的機密);而且後端與站台的網路要通 |
| B | 不含。畫面版只包 log、環境、資料庫切片 | 兩個入口產出的包內容不同——違反「同一種包」原則 |
| C | 含,但拿不到就跳過並在說明檔註明 | 兩邊仍是同一種包,只是某一項可能缺 |
建議:C。A 的「後端存權杖」其實不可避免(否則畫面版就是殘廢的),但要接受「拿不到」是正常狀況——客戶可能關掉了錯誤收集站、權杖可能過期、網路可能不通。 關鍵是缺了要講:manifest.json 裡該項標成「未取得,原因:連線失敗」,而不是安靜地不放那個檔。安靜缺席會讓分析端以為「這段時間沒有錯誤事件」,那是完全相反的結論。 權杖的保管照既有做法(加密落庫或設定檔),不新發明機制。
D7 — 診斷包要不要用原廠公鑰加密?
白話需求把這列為「選配,brainstorm 談」。
| 選項 | 做法 | 代價 |
|---|---|---|
| A | 加密。客戶產包後只有原廠解得開 | 客戶無法自己審包裡有什麼——而「客戶可審」是白話需求明文的隱私要求,兩者直接衝突。另外要管金鑰(原廠私鑰外洩就全部解得開) |
| B | 不加密,靠遮罩保證包裡沒有機密 | 包在傳輸途中若被攔截,內容是可讀的(但已遮罩) |
| C | 預設不加密,--encrypt 選配 |
兩者都有,但要做兩條路 |
建議:B(這一棒不做加密)。三個理由: 一、與「客戶可審」直接衝突——那是明文寫進白話需求的要求,而且是合理的:客戶要交出去的東西自己看不到,本身就是信任問題。 二、遮罩才是真正的防線。加密只保護傳輸途中,解開之後內容照樣在原廠的機器上流轉;如果遮罩沒做好,加密只是把問題延後。把力氣放在遮罩管線上更划算。 三、落地版客戶多為隔離內網,包的傳遞往往是隨身碟或內部檔案分享,攔截風險本來就低。 反悔條件:若日後有客戶明確要求(合約有加密傳輸條款),再補 C 的選配路徑——那時遮罩管線已經成熟,加一層加密是小事。
D8 — 前端的版本識別要不要這一棒補?
後端送錯誤事件時帶了「版號+commit hash」(api_log.py:180-182),前端沒有(main.js:161-172 只帶環境名)。後果是站台上前端的錯誤對不回某一版程式碼。
| 選項 | 代價 |
|---|---|
| A 這一棒補 | 動 FE 的建置流程(版本識別要在建置時注入),約半天 |
| B 留到下一棒 | 站台上前端錯誤暫時沒有版本,但後端的有,而本案驗收條款講的是後端的功能缺陷 |
建議:A(這一棒補)。三個理由:一、成本極小(建置時注入一個變數);二、它與後端是一對——前後端錯誤要在站台上串成同一條,一邊有版本一邊沒有,串起來也看不出是哪一版的組合;三、現在不補,就會變成「前端錯誤永遠沒有版本」的長期狀態,因為沒有人會為了這一件事單獨開一棒。 放在②那一棒做(與 GlitchTip 進包同一批動前端)。
D9 — 錯誤事件的節流值要設多少?
某個錯誤在迴圈裡每秒發生一百次時,會往站台送一百個事件。GlitchTip 會去重(同一種錯聚成一列),但網路與站台的處理量仍然是實際消耗,而客戶機房的規格有限。
| 要定的值 | 說明 | 建議 |
|---|---|---|
| 每分鐘事件上限 | 超過就丟棄並記一行 | 每分鐘 60 筆(等於平均每秒一筆) |
| 站台記憶體上限 | 容器的資源限制 | 待實測——本機試用沒有量過 |
| 站台磁碟成長率 | 90 天保留下的總量 | 待實測 |
建議:先設每分鐘 60 筆,站台資源限制等②那一棒實際裝起來跑一天再定。 判準:節流的目的是「防止錯誤風暴打死站台」,不是省流量——風暴本身就是最重要的訊號,所以丟棄時一定要在應用 log 留一行「因節流丟棄 N 筆」,不然分析端會以為錯誤停了。 資源數字現在拍等於猜,而猜錯的方向若是低估,客戶機房會在最不該出事的時候磁碟寫滿(連帶打死業務資料庫,那是既有註解記著的失敗模式)。實測一天再定。
D10 — 診斷包大小上限?
| 選項 | 上限 | 代價 |
|---|---|---|
| A | 50 MB | 大多數郵件系統的附件上限(25MB)之上,即時通訊軟體大多可過。但 log 量大的客戶會被截掉很多 |
| B | 200 MB | 涵蓋大部分情況,但要靠檔案傳輸服務送 |
| C | 不設上限,超過時警告 | 可能產出數 GB 的檔案,客戶傳不出去 |
建議:A(50 MB)+ 兩件配套。 配套一:超過時從最舊的開始砍,而不是直接失敗——有總比沒有好。 配套二:截斷的事實要進說明檔,白話寫「原本要抓 24 小時,因大小限制實際只有最近 6 小時」。這句話不寫,分析端會把「沒資料」誤判成「沒事發生」。 指令版另外提供 --no-limit 給到場工程師用(人在現場,包不必傳輸)。 理由選 A 不選 B:這個功能的價值全在「客戶自己傳得出來」。傳不出去的包等於沒產。
本段是草案。開卡一律照 design.md §6 的正式拆分表(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。
驗收條款展開成可親手做的步驟。每一步都在 DEV 做(環境異動鐵律:不碰 STG/POC)。
前置:找一個「壞得有代表性」的缺陷
不要挑「打錯字導致語法錯誤」那種——那種一看堆疊就知道。挑一個要串三種紀錄才查得出來的:例如在某支服務裡加一個條件,讓「使用者送了特定內容時」才炸。這樣 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 映像的機器上裝一次安裝包 | 裝得起來,站台開得起來,故意出錯時站台收得到 |
🔴 步驟 9 的執行紀律
「乾淨的 AI 對話」是字面意思——不可以是本案的實作者去問(他知道埋了什麼),也不可以在提示裡透露任何線索(「你看看 XX 服務有沒有問題」就毀了)。
正確做法:把包與倉庫連結交給一個不知道埋了什麼的人(或另開一個沒有本案脈絡的對話),提示就一句「這是客戶回報的問題,請分析」。
若沒過,不要調整提示重來——那是在騙自己。要回頭看「包裡少了什麼讓它推不下去」,補完再測。這一條是全案存在的理由,打折就等於整案白做。
驗收環境打折要當場講明
步驟 10 若因手上沒有乾淨機器而用「先 docker rmi 掉映像」模擬,回報時要寫明這是模擬——T-6.6 的教訓就是驗收環境掩蓋了真缺口(那台「乾淨機」剛好早就有第三方映像)。
🔴 一:log 格式先上、填的人沒上 → log 整段消失且無錯誤訊息
Python 的 logging 會吞掉 formatter 拋出的錯誤。新增格式欄位而沒有人填,每一行 log 都在格式化階段拋錯被吞,症狀是 log/app.log 整段沒有內容、服務照常起、沒有任何錯誤訊息。
custom_formatter.py 的檔頭註解記著同型的坑(OTel 欄位),解法也在那裡:formatter 自己補預設值。T-1.1 必須照做。
驗收方式:只套 T-1.1、不套 T-1.2,確認 log 照常有內容(欄位值是 -)。這一步不驗,等於把一顆炸彈交給下一棒。
🔴 二:改 log 設定會打到 FR-068 的轉發鏈
core/plugins/__init__.py:18 有一條紅線:log 設定的初始化必須早於轉發鏈掛載。本案動的正是 log 設定。
順序被打破的症狀:轉發鏈掛不上去,Syslog/GELF 轉發安靜地停止工作——而客戶那邊可能正靠它把 log 送到自己的資安平台,停了不會有人立刻發現。
驗收方式:改完後實際驗一次轉發(設一個轉發目標,確認收得到),不是看程式碼順序對不對。
🔴 三:第三方映像沒進包 → 封閉網路裝不起來,而驗收環境看不出來
見 ②-6。build_bundle.sh:84-94 的映像清單少一顆,症狀是客戶機房(沒有外網)安裝時卡在拉映像,而在任何有網路或已有該映像的機器上測都正常。
必驗:在確定沒有該映像的機器上裝一次(或先 docker rmi)。
四:錯誤收集站的開放註冊沒關 → 任何人都看得到客戶的錯誤堆疊
堆疊裡有各層變數值——可能包含業務資料、可能包含使用者輸入。站台若對外可達且開著註冊,等於把這些攤開。
定案 C 已寫死預設關,但要驗:裝完之後實際去站台的註冊頁看,應該是關的。設定值寫對了但沒生效(版本差異、變數名不同)是常見的靜默失敗——GlitchTip 不同版本用過 ENABLE_OPEN_USER_REGISTRATION 與 ENABLE_USER_REGISTRATION 兩個名字。
五:遮罩漏一個欄位,就是把客戶的憑證寄給原廠
診斷包會經過:客戶的機器 → 客戶的郵件/隨身碟 → 原廠的機器 → AI 對話。每一跳都是一次外洩機會,而遮罩是唯一的防線(D7 建議不加密)。
三個最容易漏的地方:
masking.py 處理(_scrub_frames),但打包程式要確認有呼叫到Authorization——目前 app_mw.py:44 原樣印進 log,T-1.5 要收掉PASSWORD驗收步驟 7 就是在擋這件事,不可跳過。
六:api_logs 的等級改了,但歷史資料還是 INFO
改完之後,「等級 = ERROR」這個查詢條件只對改版之後的資料有效。診斷包切片時若寫成「撈等級是 ERROR 的」,對舊資料會撈到零筆——而零筆看起來就像「那段時間沒出錯」。
處置:切片一律照時間範圍切,等級當排序或標記用,不當唯一的篩選條件。manifest.json 註明「等級欄自某版起才正確」。
七:稽核事件被 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 的真值判斷下行為不同。判準太嚴會讓稽核事件掉出去(守衛測試會擋,這是好的),太鬆則等於沒做。
八:診斷包的匯出端點是一支「把系統內部狀態打包送出」的 API
這支端點的本質是「一鍵下載系統的完整現場」。守門若寫錯,等於開了一個資訊外洩的大門。
三個必守:守門下沉到服務層(照 FR-110/FR-107 的做法,不只掛在路由)/平台管理員判定是真正的那道門(能力點擋不住租戶管理員)/每一次匯出都要留稽核紀錄(誰在什麼時候匯出了什麼時間範圍)。
最後一項容易漏——匯出行為本身就該被稽核,否則「誰把客戶資料帶走了」查不出來。
| 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 的映像標籤來源)與它有關 |