FR-071.1 · log 地基/錯誤追蹤 SDK 接線/診斷包雙入口 · 設計文件 · 2026-09-18(D1–D13 全數定案)
落地版裝在客戶自己的機房,出錯時案發現場全留在那邊。本案分四棒補齊:把 log 地基改對(環境明訂、每一筆帶同一條追蹤編號、5xx 記得下夠資訊、把淹沒稽核事件的垃圾擋掉),接上錯誤追蹤 SDK(可接客戶自有的外部錯誤收集站,出貨不裝站台),做診斷包雙入口(一支指令、一顆按鈕),再在支援頁直接列最近的錯誤讓一線先瞄一眼。驗收條款寫死:故意弄壞一個功能、產包、丟給一個什麼都不知道的 AI,它要能指出錯在哪與怎麼修。
狀態:設計定案(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 |
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)。
討論稿寫「落地版根本沒有 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)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 那套改對,並且明訂出貨環境用哪一套」:
RUN_ENV,不再靠預設撞對「靠預設撞對」是本案最該收掉的東西——今天撞對了,明天有人把 config_logger.py:28 的預設改成 prod(那看起來完全合理),客戶機房的 log 檔與稽核事件會在沒有任何錯誤訊息的情況下一起消失。
🔴 洞一:出貨環境用哪一套 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 格式裡有兩個看起來像追蹤編號的欄位(config_dev.py:12 的 otelTraceID / otelSpanID),但那是 OpenTelemetry 的欄位、沒有對應的收集器,值恆為 0(custom_formatter.py 明文寫了這個預設值的由來)。
這是整個診斷包的關鍵前提——白話需求原文寫得很清楚:「分析端不必靠時間戳猜對應」。沒有它,包裡的 log、存取紀錄、錯誤事件是三堆對不起來的東西,AI 只能猜。
🔴 洞三:存取紀錄的等級欄全是 INFO,撈不出「出錯的那幾筆」
原因是三處連鎖:
app_mw.py:60 建立紀錄時 level='INFO' 是寫死的UpdateApiLogDTO(jedi-log 套件)根本沒有 level 這個欄位,所以事後也改不了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 的索引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 對話」的整條路徑。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
participant U as 客戶使用者
participant BE as Guidant AI 後端
participant L as log 檔/存取紀錄表
participant GT as 外部錯誤收集站(選配)
participant OP as 客戶管理員/到場工程師
participant HQ as 原廠
participant AI as 乾淨的 AI 對話
U->>BE: 操作某功能
BE->>BE: 產生追蹤編號 req-xxxx
BE->>L: 每一行 log 都帶 req-xxxx
BE->>BE: 炸了(5xx)
BE->>L: 存取紀錄該筆標 ERROR,帶 req-xxxx
BE->>GT: 送錯誤事件(有接站台時:堆疊+變數值+req-xxxx)
BE-->>U: 回錯誤,標頭帶 X-Request-ID: req-xxxx
Note over U,OP: 客戶回報「剛剛按下去就壞了」
alt 一線先瞄一眼
OP->>BE: 系統設定 → 支援 → 看最近 N 筆 ERROR
end
alt 客戶自己來(畫面)
OP->>BE: 系統設定 → 支援 → 選時間範圍 → 匯出
BE-->>OP: 下載 guidant-diag-*.tar.gz
else 到場或 ssh(指令)
OP->>OP: sudo guidant diag --since 2h
end
Note over OP,HQ: 客戶把包傳給原廠(不做自動回傳)
OP->>HQ: 傳檔
HQ->>AI: 只給「包 + 原始碼倉庫」
AI->>AI: 讀 manifest.json 知道每個檔是什麼
AI->>AI: 用 commit hash 把原始碼切到同一版
AI->>AI: 用 req-xxxx 把 log/存取紀錄/錯誤事件串起來
AI-->>HQ: 指出根本原因與修法
追蹤編號在四個地方出現(這就是「串得起來」的全部機制):
| 出現在哪 | 長什麼樣 | 沒有它會怎樣 |
|---|---|---|
| log 每一行 | [req-3f8a2c1b] 某某訊息 |
一個時段有上千行 log,不知道哪幾行屬於出錯那一次 |
| 存取紀錄表一個欄位 | request_id = 'req-3f8a2c1b' |
知道哪一行 log 壞了,但不知道使用者當時送了什麼 |
| 錯誤事件的標籤 | 收集站上該事件有 request_id 標籤 |
站台上看得到堆疊,但對不回那一次請求與那個使用者 |
| 回應標頭 | X-Request-ID: req-3f8a2c1b |
使用者回報問題時沒有東西可以念給客服 |
決策者定調:客戶端的人不碰 Linux、不碰 docker。
| 角色 | 碰得到什麼 | 碰不到什麼 |
|---|---|---|
| 客戶的平台管理員 | 支援頁的 ERROR 列表、支援頁的匯出按鈕(若客戶自備錯誤收集站,另有該站台的瀏覽器介面) | ssh、docker、log 檔 |
| 到場/遠端工程師 | 上述全部 + guidant diag 指令 |
— |
| 原廠分析端 | 收到的診斷包 + 原始碼倉庫 | 客戶的現場 |
檔案 log 是診斷包的原料,不是給客戶看的東西——所以本案不做「log 檢視畫面」,也不把 system_logs 做成一個選單頁(見 §3 D11 的排除理由)。
整案一句話:讓客戶機房裡發生的錯誤留得下完整證據,並且能被打包帶回原廠分析——原廠不必到場、客戶不必會下指令。
給非工程讀者的背景:軟體出錯時,工程師要的東西有三樣——錯在程式的哪一行(堆疊)、當時使用者在做什麼(那一次請求的內容)、當下資料長什麼樣(資料庫的相關資料)。這三樣現在都在客戶的機器裡,而且各自散著、對不起來。本案把它們串成一條線,再做一個「打包帶走」的功能。
四棒,①②可平行,③依賴①,④依賴③
第一棒是地基——沒有它,後面打包出來的東西查不到東西。第二棒的錯誤追蹤 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 拿到它能推進一步嗎」,不能就不放。
五項前置定案(A–E,隨討論稿拍板)+ 十三項本案決策(D1–D13)全部拍板
下列每一項都寫明:定案內容、選它的理由、被排除的方案與排除原因。
| # | 決策 | 定案內容 | 理由 | 被排除 |
|---|---|---|---|---|
| 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 是「日誌聚合」不是「錯誤追蹤」,要的堆疊去重與聚合它不做 | 見理由欄 |
| 項目 | 內容 |
|---|---|
| 定案 | 應用 log 檔保留 30 天、按天輪轉(TimedRotatingFileHandler,when='midnight'、backupCount=30);容器記錄 20MB×5 不動。錯誤事件的保留期由站台方自訂(產品不管) |
| 理由 | 診斷包最常見的用法是「客戶昨天出錯、今天回報」,30 天綽綽有餘。按天輪轉是診斷包切片的前提——按大小輪轉時「昨天下午」可能橫跨三個檔、也可能早被輪掉;按天輪轉時切片就是挑檔案 |
| 被排除 | 維持現況的 1MB × 5(dev 設定,實際生效中)— 總共 5MB,流量稍大時「出事前十分鐘」就已經被輪掉,而那正是最需要的那十分鐘。保留更久(90 天以上) — 客戶機房磁碟規格不一,而 30 天涵蓋了實際的回報週期 |
| 附帶約束 | 保留期要做成設定檔可調(環境變數),因為客戶機房的磁碟大小差異很大。第一棒實作完在 DEV 量一天的實際 log 大小寫進安裝手冊的資源估算——現在拍一個磁碟需求數字等於猜 |
| 項目 | 內容 |
|---|---|
| 定案 | 後端中介層產生(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)。請求帶了上游編號就沿用,沒帶才自己產——這樣前端的錯誤與後端的錯誤在站台上會串成同一條 |
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 兩個條件都成立、分批刪(避免鎖表) |
| 項目 | 內容 |
|---|---|
| 定案 | 不適用——站台不進出貨包(見 D13)。站台的資料庫與 Redis 由架站的一方自理,產品不碰 |
| 項目 | 內容 |
|---|---|
| 定案 | 放主專案 app/support/。遮罩不自己寫,呼叫 jedi-log 的 error_tracking/masking.py |
| 理由 | 判準是「這裡面有多少是產品知識」——答案是幾乎全部:六個服務名、guidant.env 的欄位、schema_migrations、授權狀態、安裝紀錄路徑。抽成套件等於把一份產品知識翻譯成一份設定檔,然後兩邊都要維護 |
| 被排除 | 放 jedi-log 套件 — 與 log 基礎建設同住,理論上其他產品也能用,但要把「包裡有什麼」抽象成設定,而那個設定本身就是全部的內容 |
| 附帶約束 | 唯一該進套件的是遮罩規則——那已經在 masking.py,打包程式直接呼叫,不要另寫一份(兩套遮罩規則是資安缺口的標準長法)。指令版與畫面版必須呼叫同一支打包核心,不可各寫一套 |
| 項目 | 內容 |
|---|---|
| 定案 | 含。拿不到就在 manifest.json 寫明未取得與原因,不安靜跳過 |
| 理由 | 兩個入口必須產出同一種包——不同的話,原廠收到包還要先問「你是用哪種方式產的」。而「拿不到」是常態(多數客戶根本沒接站台,另有權杖過期、網路不通等情形),要接受它但不可安靜:安靜缺席會讓分析端以為「這段時間沒有錯誤事件」,那是完全相反的結論 |
| 被排除 | 不含(畫面版只包 log、環境、資料庫切片) — 兩個入口產出的包內容不同,違反「同一種包」原則。含且拿不到就失敗 — 一個可選項的失敗不該讓整包產不出來 |
| 附帶約束 | 有接站台時後端要存一份讀事件用的權杖(多一份要保管的機密),保管照既有做法(設定檔),不新發明機制。權杖留空時包照樣產得出來,manifest 寫明「未取得,原因:沒有設定權杖」 |
| 項目 | 內容 |
|---|---|
| 定案 | 不加密。靠遮罩保證包裡沒有機密 |
| 理由 | 三點:一、與「客戶可審」直接衝突——那是白話需求明文的隱私要求,客戶要交出去的東西自己看不到本身就是信任問題。二、遮罩才是真正的防線:加密只保護傳輸途中,解開之後內容照樣在原廠的機器上流轉;遮罩沒做好的話加密只是把問題延後。三、落地版客戶多為隔離內網,包的傳遞往往是隨身碟或內部檔案分享,攔截風險本來就低 |
| 被排除 | 用原廠公鑰加密 — 客戶無法自審,且要管金鑰(原廠私鑰外洩就全部解得開)。預設不加密+--encrypt 選配 — 兩條路都要做也都要測,而現在沒有人要求加密 |
| 反悔條件 | 若日後有客戶明確要求(合約有加密傳輸條款),再補選配路徑——那時遮罩管線已成熟,加一層加密是小事 |
| 項目 | 內容 |
|---|---|
| 定案 | 這一棒補,放在②那一批(與 SDK 接線同一批動前端)。格式 guidant-ai-fe@<版號>+<commit>,與後端的 guidant-ai@<版號>+<commit> 對齊 |
| 理由 | 三點:一、成本極小(建置時注入一個變數);二、它與後端是一對——前後端錯誤要在站台上串成同一條,一邊有版本一邊沒有,串起來也看不出是哪一版的組合;三、現在不補就會變成永久狀態,因為沒有人會為了這一件事單獨開一棒 |
| 被排除 | 留到下一棒 — 站台上前端錯誤永遠沒有版本;而「下一棒」在實務上等於「沒有下一棒」 |
| 項目 | 內容 |
|---|---|
| 定案 | 每分鐘 60 筆(平均每秒一筆),環境變數 ERROR_TRACKING_RATE_LIMIT 可調。超過就丟棄,並在應用 log 留一行「因節流丟棄 N 筆」。節流在 SDK 側(產品這邊),與站台是誰的無關 |
| 理由 | 節流的目的是「防止錯誤風暴打死客戶的站台」,不是省流量——風暴本身就是最重要的訊號,所以丟棄時一定要留痕,不然分析端會以為錯誤停了。站台在客戶手上、規格產品不知道,因此上限要保守且可調 |
| 被排除 | 不節流 — 一個迴圈裡的錯誤每秒一百次會打死站台。丟棄但不記錄 — 分析端看到事件停了會判斷成「問題自己好了」 |
| 項目 | 內容 |
|---|---|
| 定案 | 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 小時」。這句話不寫,分析端會把「沒資料」誤判成「沒事發生」 |
| 項目 | 內容 |
|---|---|
| 定案 | 要做。系統設定→支援頁上方顯示「最近 N 筆 ERROR」列表,從 system_logs 撈 level >= ERROR,平台管理員守門,每列含可複製的追蹤編號;與匯出按鈕同一頁 |
| 理由 | 一線現場最常見的動作是「使用者剛說壞了,我看一眼是什麼錯」——出貨不含站台,這個列表就是產品內唯一看得到錯誤的地方。列表看得到就能判斷「這是已知的還是新的」,需要深究時再匯出包。D3 的 filter 讓這張表只剩稽核事件與 ERROR,這個列表因此變得可用(不加 filter 的話要在五十八萬筆裡撈) |
| 被排除 | 另開一個「系統日誌」選單頁 — 那會變成第二套錯誤檢視介面(支援頁、日誌頁),兩套之間的差異會在最需要的時候咬人;而「瀏覽全部 log」的深度檢索本來就該交給診斷包與(客戶自備時的)收集站,產品不自己刻一套去重與聚合 |
| 附帶約束 | 這個列表只讀不寫、只給平台管理員;不提供搜尋與分頁以外的功能(要查得深就匯出診斷包)。守門與匯出端點同一套(見 §5.5) |
事實(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 待補建」寫成一條待辦回報,不自己動手 |
| 項目 | 內容 |
|---|---|
| 定案 | 不進。產品只出錯誤追蹤 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) |
下表每一列都經過實檔/實查驗證
行號與筆數為 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 層」 |
沿用,不新發明 |
三份設定檔(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」。
產生(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 與它並存,不是改它的語意——改語意的話兩處回填會寫錯列,而寫錯列不會報錯。
現況 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 可以直接解析,而且不會被切碎。
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 寫明「未取得,原因:沒有設定權杖」 |
| 方法 | 路徑 | 做什麼 |
|---|---|---|
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_requiredrequire_platform_admin()(軸①,common/authz/platform.py → jedi_iam.authz.platform)🔴 匯出端點的本質是「一鍵下載系統的完整現場」
守門若寫錯,等於開了一個資訊外洩的大門。三個必守:
第三項最容易漏——匯出行為本身就該被稽核,否則「誰把客戶資料帶走了」查不出來。D3 之後 system_logs 正好是乾淨的稽核落點,這筆進那裡。
支援頁版面(系統設定 → 支援,pid=47、sort=40):
| 區塊 | 內容 |
|---|---|
| 上方 | 「最近的系統錯誤」列表:時間/追蹤編號(可複製)/訊息摘要。空的時候寫「最近沒有系統錯誤」而不是空白表格 |
| 下方 | 時間範圍選擇(最近 2 小時/24 小時/7 天)+「匯出診斷包」按鈕 |
| 說明區 | 「這個包裡有什麼、不會有什麼」——客戶要能審自己交出了什麼,這是白話需求明文的隱私要求,不是裝飾 |
一條管線、兩個入口共用(指令版與畫面版各寫一套就是兩套真相的起點)。
| 來源 | 遮什麼 |
|---|---|
| 設定檔 | 鍵名含 PASSWORD/SECRET/TOKEN/KEY/DSN 一律換成 *** |
| 存取紀錄的請求/回應欄 | 既有的 mark_password 打底,另加金鑰型欄位 |
| 應用 log | T-1.5 已在源頭遮罩,這裡是第二道 |
| 錯誤事件 | masking.py 已在送出時遮過,這裡是第二道 |
遮罩要「寧可多遮」,而且遮掉的要留痕跡
遮成 *** 而不是刪掉欄位——AI 看到 "password": "***" 知道「有這個欄位,值被遮了」;看到欄位不見了會以為「客戶沒填」,那是完全不同的判斷。
manifest.json 要列出遮罩規則,讓分析端知道哪些是遮的。
產品出的是接線,不是站台(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 |
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(連堆疊裡的區域變數都掃)。站台是誰的、在哪裡、誰看得到,產品都不知道——因此不能把「站台在內網所以安全」當作遮罩可以打折的理由。
依賴鏈: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 還原後重打一次。
| # | 子任務 | 做什麼 | 動哪些 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 | — |
| # | 子任務 | 做什麼 | 動哪些 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 保留並已完成。
| # | 子任務 | 做什麼 | 動哪些 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 | — |
| # | 子任務 | 做什麼 | 動哪些 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 |
| # | 子任務 | 做什麼 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| 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 全程可平行。
決策者 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 列表 + 匯出診斷包——這兩樣不依賴任何外部站台。
前置:找一個「壞得有代表性」的缺陷
不要挑「打錯字導致語法錯誤」那種——那種一看堆疊就知道。挑一個要串三種紀錄才查得出來的:例如在某支服務裡加一個條件,讓「使用者送了特定內容時」才炸。這樣 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 小時按匯出下載得到 → 登出改用租戶管理員登入,這一頁應該完全不存在;另去操作日誌頁選一個日期區間按匯出,下載的檔應該只有那個區間 |
🔴 一: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驗收步驟 9 就是在擋這件事,不可跳過。
六:api_logs 的等級改了,但歷史資料還是 INFO
改完之後,「等級 = ERROR」這個查詢條件只對改版之後的資料有效。診斷包切片時若寫成「撈等級是 ERROR 的」,對舊資料會撈到零筆——而零筆看起來就像「那段時間沒出錯」。
處置:切片一律照時間範圍切,等級當排序或標記用,不當唯一的篩選條件。manifest.json 註明「等級欄自某版起才正確」。
七:匯出端點是一支「把系統內部狀態打包送出」的 API
見 §5.5 的紅框。三個必守:守門下沉服務層/平台管理員判定是真正的那道門/每次匯出留稽核紀錄。
最後一項容易漏——匯出行為本身就該被稽核,否則「誰把客戶資料帶走了」查不出來。
| 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 部署時才需要「誰跑排程」機制 |