FR-071.1 · log 地基/錯誤追蹤 SDK 接線/診斷包雙入口 · 設計文件 · 2026-09-18(D1–D13 全數定案)

客戶機房出錯時,原廠不必到場也查得出原因 — 設計定案

落地版裝在客戶自己的機房,出錯時案發現場全留在那邊。本案分四棒補齊:把 log 地基改對(環境明訂、每一筆帶同一條追蹤編號、5xx 記得下夠資訊、把淹沒稽核事件的垃圾擋掉),接上錯誤追蹤 SDK(可接客戶自有的外部錯誤收集站,出貨不裝站台),做診斷包雙入口(一支指令、一顆按鈕),再在支援頁直接列最近的錯誤讓一線先瞄一眼。驗收條款寫死:故意弄壞一個功能、產包、丟給一個什麼都不知道的 AI,它要能指出錯在哪與怎麼修。

D1–D13 全數定案 4 子需求 跨 3 repo:BE/FE + jedi-common/jedi-log 前一棒 FR-071.0 已完成(試用通過) 陷阱:出貨機沒設 RUN_ENV,實際跑的是 dev 那套 陷阱:稽核事件被 99.9% 垃圾淹沒

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

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

變更紀錄

日期 變更 對應
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. 需求背景與端到端流程

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 設定

討論稿寫「落地版根本沒有 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 庫):

586,498system_logs 總筆數(STG)

586,031event_code 為 -(非稽核事件)

467真正的稽核事件

等級分佈: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。

🔴 所以第一棒的形狀變了

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

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

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

1.3 這一棒要補的洞(修正後)

🔴 洞一:出貨環境用哪一套 logging 設定,沒有人明訂過

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

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

🔴 洞二:追蹤編號全庫零命中

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 只能猜。

🔴 洞三:存取紀錄的等級欄全是 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 欄位清成空字串——請求階段本來存了路徑進去,回應階段把它抹掉。

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

🔴 洞四:稽核事件被 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)。

🔴 洞五:請求標頭與內容原樣印進 log,未遮罩

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

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

1.4 端到端流程

%%{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: 指出根本原因與修法
圖 1 — 出錯當下、匯出、分析三段,追蹤編號串起全鏈

追蹤編號在四個地方出現(這就是「串得起來」的全部機制):

出現在哪 長什麼樣 沒有它會怎樣
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 秒版)

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

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

四棒,①②可平行,③依賴①,④依賴③

第一棒是地基——沒有它,後面打包出來的東西查不到東西。第二棒的錯誤追蹤 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 完成後開工。

🔴 全案的驗收條款(決策者寫死,不可打折)

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

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


3. 決策定案

五項前置定案(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. 現況接入點盤點

下表每一列都經過實檔/實查驗證

行號與筆數為 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. 詳細設計

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 轉發鏈也靠它

🔴 出貨環境必須顯式寫出 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)。

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 那行是它的前導脈絡行):

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

為什麼是「一行 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 且沒有任何錯誤訊息

🔴 匯出端點的本質是「一鍵下載系統的完整現場」

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

  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 已在送出時遮過,這裡是第二道

遮罩要「寧可多遮」,而且遮掉的要留痕跡

遮成 *** 而不是刪掉欄位——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

節流放最後、佔位值不可省

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

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\// 會漏掉。

遮罩在送出前就做完,不依賴站台

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


6. 拆分(4 子需求 + 收口)

依賴鏈: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-5.3 稽核事件品質(CM-1943) 心跳灌 WARNING 進表、登入成功事件沒帶 user——兩者都讓 ERROR 列表與稽核紀錄失去可讀性 主專案 + 相關套件 心跳不再灌表;登入成功事件帶得到登入者

序列摘要:.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 的建議設定

決策者 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. 端到端驗收

前置:找一個「壞得有代表性」的缺陷

不要挑「打錯字導致語法錯誤」那種——那種一看堆疊就知道。挑一個要串三種紀錄才查得出來的:例如在某支服務裡加一個條件,讓「使用者送了特定內容時」才炸。這樣 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 產得出包;包裡有容器狀態與各容器記錄

🔴 步驟 11 的執行紀律

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

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

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

驗收環境打折要當場講明

步驟 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. 風險與陷阱

🔴 一: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 照常有內容(欄位值是 -)。這一步不驗,等於把一顆炸彈交給下一棒。

🔴 二:改 log 設定會打到 FR-068 的轉發鏈

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

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

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

🔴 三:RUN_ENV 的預設值是一條隱形依賴

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

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

四:DBLogHandler 加了 filter,但稽核事件的判準寫錯就是合規缺口

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

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

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

五:遮罩漏一個欄位,就是把客戶的憑證寄給原廠

診斷包會經過:客戶的機器 → 客戶的郵件/隨身碟 → 原廠的機器 → AI 對話。每一跳都是一次外洩機會,而遮罩是唯一的防線(D7 不加密)。

三個最容易漏的地方:

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

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

六:api_logs 的等級改了,但歷史資料還是 INFO

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

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

七:匯出端點是一支「把系統內部狀態打包送出」的 API

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

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


9. 與其他需求的關係

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 部署時才需要「誰跑排程」機制