FR-133 · 設計文件 v3 · 2026-10-10(D1–D11 全數定案)

Bugsink 隨產品出貨 — 設計定案

裝機全自動建站(三個專案)、只收程式錯誤、前端與 Agent 經同一支轉送端點、診斷包改打 Bugsink API

每套 Guidant AI 自帶一個錯誤追蹤站(Bugsink 2.6.1),裝機人員不輸入任何東西就建好管理員、專案與連線字串。程式錯誤與使用者失誤(4xx)預設全收、送出前遮掉憑證與位置參數;前端與受檢主機 Agent 的錯誤經同一支要驗身分的後端端點轉送,依身分落到各自的專案;stack 內的 Python 行程走內網直送;診斷包改打 Bugsink 自己的讀取 API。本文件把十項決策展開到「照做就能交」的程度,拆成 18 張實作子任務,另有兩輪發版、兩張補缺卡與一張暫緩卡。

D1–D11 全數定案 18 張子任務 跨 4 repo:BE/FE/jedi-python-package/evidence-agent B0 實跑證實 D1/D2;內網 Host 走 ALLOWED_HOSTS 陷阱:nginx 用變數 proxy_pass 時不會剝子路徑 陷阱:event_level 關掉後 worker 的錯誤會整個消失

狀態:設計定案(D1–D11 全數拍板)|日期:2026-10-10|前作:FR-071(錯誤追蹤接線與診斷包) 討論稿(現況缺口實證、Bugsink 事實表、連線面):discussion.html

🔴 這份是設計決策,不是現況。 內文的行號與座標停在 2026-10-09 定稿當時,之後實作改動的結果不回頭改寫這裡——上線後的現況看 FINAL-SPEC.md。

變更紀錄

日期 變更 對應
2026-10-09 初版設計定案(D1–D8),拆 12 張子任務 FR-133 母案
2026-10-09 v2——併入 D9/D10 與實作偏離,18 棒 FR-133 母案 CM-2601
2026-10-10 v3——D11 預設全收、遮罩補位置參數(A4)、升級保留額外主機名(B5)、第二輪發版(R2)、G1 暫緩;§9 補兩項缺口 FR-133 母案 CM-2601、T-E CM-2613

1. 需求背景與端到端流程

產品進客戶 UAT,客戶環境不受我們控制。出錯時要在客戶自己的機器上看得到:錯在哪一行、當下變數值、誰踩的、哪一筆請求。後端送錯誤報告的接線(sentry-sdk 2.71,經 jedi-log 1.3.3 初始化,含遮罩與每分鐘 60 筆節流)已在產品內,缺的是:

  1. 收的站:出貨包不含任何錯誤追蹤站,SENTRY_DSN 預設空。
  2. 篩選:一次打錯密碼在站台變三條,ClientError 八種全部會送,UAT 的正常操作失誤會淹掉真正的 500 並吃光節流額度。
  3. 前端:免登入的 /client-config、/error-tunnel 因資安項 U3-⑤ 已拆,前端 @sentry/vue 一併移除。
  4. 診斷包:infra/support/diag_event_fetcher.py 對著 GlitchTip 式 API 寫,Bugsink 不是那個形狀。

端到端

%%{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 I as install.sh
    participant DB as guidant-db
    participant BS as guidant-bugsink
    participant ENV as guidant.env
    participant BE as 後端三容器
    I->>I: 讀包內 bugsink-admin.secret
    I->>DB: 建 bugsink 庫與帳號(冪等)
    I->>BS: up -d guidant-bugsink(CREATE_SUPERUSER)
    BS->>DB: 自動遷移、建管理員
    I->>BS: 等 /health/ready
    I->>BS: bugsink-manage shell 建 team/project/token
    I->>DB: SQL 讀 project id 與 sentry_key
    I->>ENV: 寫 SENTRY_DSN/SENTRY_PUBLIC_DSN/BUGSINK_API_TOKEN
    I->>BE: up -d 後端
    BE->>BS: 內網送事件
圖 1 — 安裝時序:先起 Bugsink、建專案、寫 DSN,最後才起後端

執行期:後端三容器與證據分類沙箱走 docker 內網直送;已登入的瀏覽器與受檢主機 Agent 只打 443,經後端同一支轉送端點進 Bugsink,依身分落到各自的專案;原廠人員從 443 的 /bugsink/ 子路徑看站台;診斷包從內網打 canonical API,撈三個專案。


2. 分工概述(30 秒版)

整案一句話:每套 Guidant AI 自帶錯誤追蹤站,裝好就能用;後端與前端的程式錯誤自動進站,使用者打錯不進站。

子需求 做什麼 產出 完成怎麼判定
A 過濾與遮罩 4xx 使用者失誤與 logger.error 各一個開關(D11 預設全收);遮罩補「值是 Bearer xxx」與「無名位置參數」 jedi-log 1.3.6、兩個環境變數、worker/socketio 補送 預設下打錯密碼站台有 401 事件、全文找不到輸入的密碼;兩開關設 false 後打錯密碼 0 條、故意 500 仍 1 條
B0 可行性實跑 DEV 實跑 Bugsink 整條鏈,十項驗證 b0-findings.md+可直接進 install.sh 的 shell 片段 十項每項附實際輸出與通過/退路結論;片段在空白環境重跑能重現
B 進出貨包 compose、nginx 子路徑、bugsink 庫、bundle 收 image、密碼烙印、install.sh 首裝與升級建三個專案、維運子命令 新安裝包 乾淨機與 1.26.0 升級機各裝一次:https://<主機>/bugsink/ 管理員登入看得到三個專案,故意 500 一分鐘內出現
C 客戶端接回 瀏覽器(@sentry/vue)與 Agent 經同一支轉送端點送事件;沙箱走只通站台的內網網段 FE 接線、BE 轉送端點(使用者與 Agent 兩種身分)、Agent 設定附帶在註冊與心跳回應、沙箱網段 登入後前端故意 throw 落在 guidant-ai-fe;Agent 故意 raise 落在 guidant-agent 且帶 agent_id;未認證 401;交叉專案 400
D 診斷包 撈事件改打 canonical API(三個專案聯集),收容器 log 名單換成 bugsink 改寫 diag_event_fetcher.py DEV 產一次 500 → 診斷包內有該事件含堆疊、manifest「已取得」;拿掉 token → 寫明未取得原因
R 發版出包 兩輪:首輪上 190/188/189;第二輪帶遮罩修正與預設全收再上三環境、四台 Agent Nexus 上的 jedi-log 1.3.6/jedi-evidence-classification 1.6.5、Agent 1.4.2 包、BE 安裝包(image tag 仍 1.26.0,不進版) 三環境站台可登入、三專案存在、sandbox 1.6.5;四台 Agent log「錯誤回報已啟用」
E 文件 sample、部署文件、客戶手冊、release note、D9 接入規範 五份文件 手冊找得到站台入口、密碼在哪、保留 1 萬筆與 vacuum、改設定用 start

依賴:A、B0 平行 → B(B1/B2 平行 → B3 → B4)→ C、D 平行 → 整包實機驗收 → E。


3. 決策定案(D1–D10)

# 決策 定案 理由 被排除的方案
D1 自動建專案 bugsink-manage shell 跑 Django ORM 建 team/project/API token;DSN 用 SQL 從庫讀 id 與 sentry_key 組出 官方沒有建專案的指令或環境變數;走它自己的資料模型,版本升級時欄位異動由它吸收 直接 SQL INSERT(繞過模型預設值與關聯,升版易壞);要裝機人員進畫面建(裝機人員不具操作能力)
D2 站台位址 443 子路徑 https://<主機>/bugsink/(B0 實測可行,不用 8443) 客戶防火牆常只開 443 一律開 8443(要客戶多開 port)
D3 授權 PolyForm Shield 可接受,不過法務 本產品不是錯誤追蹤產品,不構成競爭 —
D4 前端錯誤 一律經後端轉送端點,要驗身分;DSN 由登入後才拿得到的回應供給;不復活 /client-config//error-tunnel 只依賴 443 與登入狀態,不碰跨網域 瀏覽器直送(要開 CORS 與對外端點);免登入轉送(U3-⑤ 已判為攻擊面)。代價:登入前的前端錯誤收不到
D5 管理員帳密 帳號 admin@guidantai.com;密碼全客戶同一組,build 時從 CI 遮罩變數烙進包內 600 檔;install.sh 讀它建管理員並另存一份到資料目錄 裝機人員不輸入;原廠維護要能直接登入 每台隨機產生(原廠登不進去);install.conf 自帶(裝機人員要經手密碼)
D6 資料庫 stack 內 guidant-db 另開 bugsink 庫與獨立帳號 不多起一顆 Postgres;與產品資料權限互不相通 SQLite(官方不建議放 volume);另起一顆 Postgres(多一份維運)
D7 事件保留 預設每專案 1 萬筆(依重要性挑留),不設天數;提供 guidantai bugsink-vacuum 手動清 UAT 價值在看趨勢;筆數上限已封頂磁碟 設 MAX_EVENT_AGE_DAYS(砍掉趨勢)
D8 告警 第一版不做 先求裝得起來、收得到;客戶 SMTP 與內網可達性不確定 Email/webhook(列下一版)
D9 錯誤追蹤接入規範 元件分兩類,各一條固定路。stack 內網的 Python 行程(api/socketio/worker/證據分類沙箱/未來常駐服務):直連 guidant-bugsink:8000 內網 DSN,compose 給 DSN,啟動只准呼叫 jedi-log 的 init_error_tracking,不准裸 sentry_sdk.init。stack 外經 443 進來的客戶端(瀏覽器前端、evidence-agent、未來 CLI):經後端同一支轉送端點;身分由呼叫端既有認證決定(使用者 JWT 或 Agent 通行證);DSN 在拿到身分的同一個回應裡附帶(使用者:GET /error-tracking/config;Agent:註冊與心跳回應) 新元件只要回答「內網還是外網」就知道照哪條走,不再逐個設計 每個元件各自設計接法
D10 Bugsink 專案劃分 三個專案、同一個 team:guidant-ai(api/socketio/worker/證據分類沙箱)、guidant-ai-fe(瀏覽器)、guidant-agent(所有受檢主機 Agent,tag agent_id 分台)。單人模式一個帳號看三個專案 前端與 Agent 的錯誤量級與類型不同,混在一起淹掉後端 500;Agent 同一錯誤在多台各發一次,分開才看得出是版本問題還是單台問題 一個專案靠 tag 分
沙箱接入 證據分類服務容器(guidant-sandbox) 接。compose 多一個 internal: true 網段 guidant-sandbox-errors,成員只有沙箱與 guidant-bugsink;沙箱從 .env 的 GUIDANT_SENTRY_DSN 拿 DSN,不碰 guidant.env 沙箱刻意不連 DB/Redis/物件儲存;guidant.env 含 DB 與登入金鑰,沙箱一樣都不該拿到 讀 guidant.env;把沙箱接上 default 網段
D11 收件預設 預設全收:ERROR_TRACKING_CAPTURE_CLIENT_ERRORS、ERROR_TRACKING_LOG_EVENTS 預設 true;站台被使用者失誤灌滿時設 false 只收程式錯誤。開關只認 1/true/yes、0/false/no,打錯字回預設 UAT 期間要看得到使用者在哪裡撞牆,比站台乾淨重要;前提是位置參數遮罩(A4)已在,全收才不會把登入密碼送進站台 預設只收程式錯誤(v2 原定案;UAT 看不到 403/404 類卡點)

D5 的已知風險(決策已定,如實記錄)

同一組密碼烙在每一份安裝包裡;任何一台客戶機的安裝目錄外洩,所有客戶的站台都能用這組登入。緩解:站台入口只在客戶內網可達、guidantai bugsink-set-password 可在單台改掉。升級不改既有管理員密碼,舊客戶的密碼由維運人員記錄與現場改。


4. 現況接入點盤點

元件 座標 現況 本案動作 子任務
jedi-log 初始化 jedi_api_log/error_tracking/__init__.py(1.3.3) integrations 只放 FlaskIntegration,logging 整合用 SDK 預設(ERROR 以上變事件) 加 log_events 參數;加 capture_exception 小工具 T-A1
宿主送出前規則 core/plugins/api_log.py _extra_before_send/init_error_tracking 補誰/request_id → 節流 最前面加業務錯誤過濾;讀新變數傳給 jedi-log T-A2
錯誤處理器 jedi-common handler.py ClientError 處理器 logger.error 一次;Exception 處理器帶 exc_info 只讀;作為過濾依據 —
例外家族 jedi_common.handler.exception.ClientError 子類八種(BadRequest/Unauthorized/Forbidden/NotFound/MethodNotAllowed/Conflict/PreconditionFailed/TooManyRequests) 只讀 —
stack docker/production/docker-compose.yml 唯一對外是 guidant-fe 80/443 加 guidant-bugsink(不對外) T-B1
前門 nginx FE repo nginx.onprem.conf /api/1.0、/socket.io、SPA;全站 client_max_body_size 1m;用變數 proxy_pass 搭 resolver 127.0.0.11 加 location /bugsink/ T-B1
出貨包 scripts/build/build_bundle.sh 第 98–117 行 第三方 image 收 tar 並在 PINNED_DIGEST 釘 digest 加 bugsink;收管理員密碼檔 T-B2
CI .gitlab-ci.yml package job 跑 build_bundle.sh 讀遮罩變數 GUIDANT_BUGSINK_ADMIN_PASSWORD T-B2
安裝器 scripts/installer/install.sh .env(compose 用)+guidant.env(後端用);升級「缺了才補」範例 ensure_classifier_env();SYSTEM_URL 存在 guidant.env 加 ensure_bugsink_env、setup_bugsink;三處服務清單加 bugsink T-B3
維運指令 scripts/installer/guidantai GUIDANT_SERVICES 清單、子命令分派與參數個數守門 加 bugsink-vacuum、bugsink-set-password;清單加 bugsink T-B4
前端 FE src/main.js @sentry/vue 已移除 復原,DSN 改由登入後取得、tunnel 指向後端 T-C2
轉送端點 後端(新增) 無 POST /api/1.0/error-tracking/tunnel(使用者 JWT 或 Agent 通行證) T-C1、T-C1b
守門 common/authz/ 有使用者守門與 Agent 守門各一支,無「兩者擇一」 新增 jwt_or_agent_pass_required(common/authz/caller.py) T-C1b
Agent 註冊與心跳 jedi_remote_agent 套件路由,主專案 core/plugins/remote_agent.py 掛 blueprint 回應不帶錯誤追蹤設定;套件的 response_enricher 插槽存在但路由沒呼叫 主專案在 blueprint 疊 after_request 補欄位 T-C3
evidence-agent evidence-agent repo 無錯誤回報 接 sentry-sdk、自寫轉送 transport T-C3
證據分類沙箱 jedi-evidence-classification、compose guidant-sandbox 無錯誤回報;網段只通 worker 套件加初始化與遮罩;compose 加只通站台的網段 T-C4
jedi-log 遮罩 jedi_api_log/error_tracking/masking.py 依欄位名遮(mark_password) 改走 jedi-common mask_secrets_in_text T-A3
守衛測試 test/security/code/test_version_module_no_error_tracking_endpoints.py、test_route_guard_inventory.py、test_security_shape_external_calls.py 擋兩支免登入端點;登入清單;對外呼叫白名單 第一支不動;後兩支各登記一筆 T-C1
診斷包 infra/support/diag_event_fetcher.py;infra/support/collector/host_collector.py KNOWN_SERVICES;staged_collector.py 範例 GlitchTip 式 API 與 GLITCHTIP_* 三變數;收 glitchtip 四容器 log 改 canonical API;收 guidant-bugsink log;移除 GLITCHTIP_* T-D
文件 .env.sample 第 249–267 行;docs/features-site/docs/FR-063-2608-nuitka-packaging/deployment-env.md 第 360 行;FR-132-STATE.md 第 19 行;docs/release_notes/v1.26.0.md §2b 寫「出貨不含站台」、GLITCHTIP_*、restart 全數改寫 T-B4、T-E

5. 詳細設計

5.1 子需求 A:過濾開關

新環境變數(先 grep config/config.py、.env.sample、全 codebase 與 jedi 套件確認無同義名;現有同族為 SENTRY_DSN、ERROR_TRACKING_RATE_LIMIT):

變數 預設 意思
ERROR_TRACKING_CAPTURE_CLIENT_ERRORS true(D11) false 時不把 ClientError 八種送站台
ERROR_TRACKING_LOG_EVENTS true(D11) false 時 ERROR 等級的 log 只當事件的前情紀錄(breadcrumb),不各自成事件

布林解析:1/true/yes 為開、0/false/no 為關,沒設或打錯字回預設值(core/plugins/api_log.py::_env_flag)。

BE core/plugins/api_log.py

_extra_before_send(event, hint) 開頭加:

exc_info = (hint or {}).get("exc_info")
exc = exc_info[1] if exc_info else None
if isinstance(exc, ClientError) and not _CAPTURE_CLIENT_ERRORS:
    return None
  • ClientError 從 jedi_common.handler.exception import。
  • 放在 _attach_actor 之前、_throttle 之前:被濾掉的不該花取脈絡成本,也不該佔節流配額。
  • init_error_tracking() 讀 ERROR_TRACKING_LOG_EVENTS 傳給 jedi-log 的 log_events。

jedi-log init_error_tracking

  • 新參數 log_events: bool = False。False 時 integrations 加 LoggingIntegration(level=logging.INFO, event_level=None);True 時不加(維持 SDK 預設:INFO 以上當前情、ERROR 以上當事件)。
  • 新公開函式 capture_exception(exc=None) -> None:未啟用時什麼都不做、永不拋錯;啟用時呼叫 sentry_sdk.capture_exception。給宿主在「例外被 catch 後只寫 log」的地方補送。
  • 主專案 pin jedi-log[error-tracking]==1.3.6(含下一小節的兩條遮罩規則)。套件發版一律停下等決策者明示。

jedi-log 遮罩:值是 Bearer xxx 也要遮;無名位置參數整個遮

jedi_api_log/error_tracking/masking.py 的 _mask(字串與 JSON 兩處)走 jedi-common 的 mask_secrets_in_text,不自己寫 regex。jedi-log 相依下限 jedi-common>=1.3.6(那支函式從 1.3.6 起才有)。

堆疊變數中名為 args/kwargs,以及任何含字串元素的 tuple/list,整個換成 ***;純數字清單保留。不採「依函式參數名逐位遮」:SDK 送出的 frame 只剩函式名、拿不到簽章,*args 轉手層本身也沒有參數名。代價是除錯時看不到 args 內容。同一規則複製在 Agent 與分類器兩份遮罩(§9)。

🔴 陷阱:只看欄位名的遮罩擋不住「變數名無害、值是 Bearer 密鑰」

mark_password 依欄位名判斷,堆疊區域變數叫 auth_header 這類名字時,值 Bearer <密鑰> 會原樣進站台。jedi-common 的 Bearer 值規則只掛在 log 輸出那條路上,錯誤追蹤這條沒吃到。驗收斷言要寫成「整個事件序列化後找不到原始值」,只檢查 request 區段等於沒驗。

🔴 陷阱:event_level=None 後 worker 與 socketio 的錯誤會整個消失

API 的 500 不受影響:flask-restful 的 handle_error 對每個例外送 got_request_exception,SDK 的 Flask 整合從那裡抓到例外本身。但 worker 跑背景工作、socketio 處理事件時,例外多半被 catch 後只寫 logger.error(..., exc_info=True)——那一行是它們進站台的唯一途徑。關掉後這兩個容器的錯誤在站台上零條,而且沒有任何錯誤訊息。

做法:T-A2 找出 worker 背景工作失敗的 catch 點與 socketio 事件處理的 catch 點,各補一行 jedi_api_log.error_tracking.capture_exception(exc)。驗收必含「故意讓一筆背景工作失敗 → 站台一條」。

驗收(兩開關設 false):打錯密碼登入三次 → 0 條;故意 1/0 的端點打一次 → 1 條,事件的前情紀錄內看得到前面那幾行 logger.error;故意失敗一筆背景工作 → 1 條。預設(全收):打錯一次密碼 → 有 401 事件,全文找不到輸入的密碼。

5.2 子需求 B0:可行性實跑

在 DEV 用本機 Postgres 另開 bugsink 庫,docker run bugsink/bugsink:2.6.1 起站台,依序驗下表十項。每項把指令與實際輸出貼進 docs/features/FR-133-2610-bugsink-bundled-error-tracking/b0-findings.md。

# 驗什麼 怎麼做 通過判準 不通的退路
① 接 Postgres 起得來 DATABASE_URL=postgresql://bugsink:<pw>@<host>:5432/bugsink、SECRET_KEY(≥50 字)、BASE_URL、BEHIND_HTTPS_PROXY=True、USER_REGISTRATION=CB_NOBODY、SINGLE_USER=True、PHONEHOME=false、TIME_ZONE=Asia/Taipei 容器 healthy,curl /health/ready 200,bugsink 庫內有遷移出的表 抄錯誤訊息回報,停
② 管理員 CREATE_SUPERUSER=admin@guidantai.com:<pw> 瀏覽器能登入 改用 shell 的 createsuperuser 等價寫法
③ ORM 建 team/project docker exec -i <c> bugsink-manage shell < 片段;model 的 import 路徑與欄位從 image 內 projects/models.py、teams/models.py 查,寫進 findings 畫面看得到 team「Guidant AI」與 project「guidant-ai」;片段重跑不重複建(以名稱 get_or_create) 無退路(D1 的前提),回報決策者
④ 讀 DSN SQL 讀 project 表的 id 與 sentry_key(實際表名寫進 findings) 組出的 DSN 與畫面上顯示的 DSN 一致 改由 shell 印出
⑤ 建 API token shell 建 token;權限欄位從 image 內 token model 查 token 帶 issues:read、events:read、projects:read 權限欄位不存在=版本不符,回報
⑥ 443 子路徑 DEV 起一個 nginx 照 §5.3 的 location 轉;BASE_URL=https://<host>/bugsink 登入、各頁連結、靜態檔、登出不跑掉;envelope 經子路徑也收得到 退 8443,findings 寫「不通,退 8443」與原因
⑦ sentry-sdk 直送 Python 3.11+sentry-sdk 2.71 用④的 DSN 送一個例外 站台出現該事件 回報
⑧ 內網形狀 DSN 後端容器同網段,DSN 改 http://<key>@<容器名>:8000/<id> 送 站台收到 設 ALLOWED_HOSTS 覆寫(見下方陷阱)
⑨ tunnel 轉送 寫 20 行 Flask 端點收 envelope 原樣 POST 到⑧的內網位址;本機頁面載 @sentry/vue 設 tunnel 指向它 瀏覽器事件進站台 回報
⑩ 讀取 API 與主機名 ⑤的 token 打 /api/canonical/0/issues/?project=<id>、/events/?issue=<id>;查能否以 tag(request_id)篩;再把 BASE_URL 換主機名重啟,用舊 DSN 送一次 列得出 issue 與 event;記錄 tag 篩選可不可行;記錄換主機名後舊 DSN 送不送得進去 tag 不能篩=T-D 改客端過濾

實跑結論(全文與輸出見 b0-findings.md):十項全過,D1、D2 成立。對後續設計有約束的四件:

實證 對設計的影響
子路徑可行:BASE_URL 帶 path 時 Bugsink 自動推出 FORCE_SCRIPT_NAME=/bugsink 與 STATIC_URL=/bugsink/static/,連結、靜態檔、登入登出都帶前綴 nginx 只負責剝前綴;不開 8443
內網 Host 預設被拒:只認 BASE_URL 的主機名,http://guidant-bugsink:8000 送件回 400,SDK 不報錯 compose 設 ALLOWED_HOSTS=<站台主機名>,guidant-bugsink,localhost,127.0.0.1;偽造主機名仍被擋
換站台網址後,舊對外 DSN 被拒、內網 DSN 照常 對外 DSN 與 ALLOWED_HOSTS 每次依站台網址重寫,不能「缺了才補」
讀取 API 不能依 tag 篩 診斷包客端比對 request_id,見 §5.5

另兩件:清舊資料的子命令就是 vacuum,但只在站台設了 MAX_EVENT_AGE_DAYS 時才刪舊事件;changepassword 遇到不合格密碼會重問、stdin 讀完即崩潰,改密碼走 ORM(findings 附錄 B)。

陷阱:Bugsink 依 BASE_URL 驗 Host

沒設 ALLOWED_HOSTS 時,後端從內網送的事件全部被拒,而且送件端沒有任何錯誤訊息。站台換網址時同理,舊網址形狀的 DSN 全部失效。

建站片段 b0-bootstrap-snippet.sh 冪等(以名稱 get_or_create),輸入只有容器名,輸出 KEY=VALUE,檔內不含密碼;scripts/installer/bugsink.sh 的 bugsink_bootstrap_project 與它同源。

5.3 子需求 B:進出貨包

5.3.1 compose 服務 guidant-bugsink

guidant-bugsink:
  image: bugsink/bugsink:2.6.1
  container_name: guidant-bugsink
  restart: unless-stopped
  logging: *guidant-logging
  depends_on: { guidant-db: { condition: service_healthy } }
  environment:
    SECRET_KEY: ${GUIDANT_BUGSINK_SECRET_KEY:?必填——由 installer 產生}
    BASE_URL: ${GUIDANT_BUGSINK_BASE_URL:?必填——由 installer 依站台網址寫入}
    DATABASE_URL: postgresql://bugsink:${GUIDANT_BUGSINK_DB_PASSWORD:?必填}@guidant-db:5432/bugsink
    CREATE_SUPERUSER: ${GUIDANT_BUGSINK_CREATE_SUPERUSER:-}
    BEHIND_HTTPS_PROXY: "True"
    USER_REGISTRATION: CB_NOBODY
    SINGLE_USER: "True"
    PHONEHOME: "false"
    TIME_ZONE: ${TZ:-Asia/Taipei}
    ALLOWED_HOSTS: ${GUIDANT_BUGSINK_ALLOWED_HOSTS:?必填——由 installer 依站台主機名寫入}
  volumes: [guidant-bugsink-data:/data]
  networks: [default, guidant-sandbox-errors]
  expose: ["8000"]
  cap_drop: [ALL]
  security_opt: ["no-new-privileges:true"]
  mem_limit: 1g
  healthcheck:
    test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready', timeout=3)"]
    interval: 15s
    timeout: 5s
    retries: 10
    start_period: 60s
  • 不 publish port。容器內埠固定 8000(image 自帶 HEALTHCHECK 寫死這個埠)。
  • CREATE_SUPERUSER 由 install.sh 只在首次建站那一次以 -e 帶入(不寫進 .env),平時為空;Bugsink 只在零使用者時才用它。
  • cap_drop: ALL、no-new-privileges、mem_limit: 1g 下照常起得來,不必放寬。
  • 四個 GUIDANT_BUGSINK_* 變數都是必填:缺了 compose 直接解析失敗,不會靜默起一個壞站。代價是舊客戶的 .env 會讓新 compose 解析失敗,所以升級時 ensure_bugsink_env 要早於第一個 compose 呼叫(§5.3.5)。
  • networks 一宣告就離開預設網路,所以 default 要一起列(DB 與 guidant-fe 都在 default)。guidant-sandbox-errors 見 §5.4.5。

5.3.2 前門 nginx(FE repo nginx.onprem.conf)

location /bugsink/ {
    set $bugsink_upstream guidant-bugsink;
    rewrite ^/bugsink/(.*)$ /$1 break;
    proxy_pass http://$bugsink_upstream:8000;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    client_max_body_size 20m;
    # Bugsink 自己帶 X-Frame-Options/nosniff;只補它沒帶的,避免同名標頭送兩次
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options        $api_frame_options always;
    add_header X-Content-Type-Options $api_nosniff always;
}

location = /bugsink { return 301 /bugsink/; }

🔴 陷阱:proxy_pass 用變數時不會剝子路徑

這份 conf 為了容器重建後重新解析 IP,proxy_pass 一律用變數。用變數時 nginx 不做 location 前綴替換——proxy_pass http://$x:8000/; 寫了結尾斜線也一樣把 /bugsink/... 原樣送上游,Django 路由對不上全部 404。所以要先 rewrite ... break 剝掉 /bugsink,Bugsink 端由 BASE_URL 的子路徑負責產生帶前綴的連結。

  • 不加產品的 CSP($csp_page 會擋 Bugsink 自己的 script)。安全標頭採 conf 既有的「上游沒帶才補」寫法($api_frame_options 一類的 map 變數):Bugsink 頁面自帶 X-Frame-Options: DENY 與 nosniff,前門無條件再加會送出兩份同名標頭。
  • /bugsink/ 下的 envelope 收件端點(/bugsink/api/<id>/envelope/)對外也開著,這是 Bugsink 本來的形狀;後端與轉送端點都不走它(走內網),所以這裡不另擋。

5.3.3 bugsink 庫與帳號

install.sh 以 docker exec guidant-db psql -U <管理帳號> 冪等執行:

  • bugsink 角色:LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS,密碼 gen_pw 隨機(存 .env 的 GUIDANT_BUGSINK_DB_PASSWORD,與其他 DB 帳號同慣例)。
  • bugsink 資料庫 OWNER bugsink(遷移要建表)、REVOKE ALL ON DATABASE bugsink FROM PUBLIC。
  • 已存在就只對齊密碼(ALTER ROLE ... PASSWORD),不重建。
  • bugsink 角色對 guidant_ai 沒有任何 GRANT;PG16 的 public schema 已無 PUBLIC 建表權,產品資料讀不到也寫不了。
  • 不放進 guidant-db-init:那支 image 的 init/migrate 模式只管產品庫,bugsink 庫的生命週期跟著 install.sh 的建站步驟走,升級補站時不必換 init image。

5.3.4 出貨包與密碼烙印

  • build_bundle.sh 的 IMAGES 加 "bugsink/bugsink:2.6.1|bugsink-2.6.1.tar",PINNED_DIGEST 加對應多架構 index digest(docker buildx imagetools inspect bugsink/bugsink:2.6.1 第三行),照該段註解:先 pull、跑 Trivy/Grype、再填 digest。
  • sbom.sh --images 的掃描清單加 bugsink,弱點放行門檻同其他第三方 image。
  • 新腳本 scripts/build/gen_bugsink_admin_bake.sh <輸出路徑>:讀環境變數 GUIDANT_BUGSINK_ADMIN_PASSWORD,缺、少於 12 字元或含 $ → fail 並提示「CI 要設遮罩變數;本機手跑請 export 後再跑」;寫出單行檔、chmod 600。這道檢查放在出包前置檢查的最前面,缺變數不必等到打包尾端才紅。build_bundle.sh 組裝階段呼叫它寫到 $STAGE/bugsink-admin.secret。
  • .gitlab-ci.yml package job 不必改腳本行,只需專案層設 masked+protected 變數 GUIDANT_BUGSINK_ADMIN_PASSWORD(GitLab 遮罩要求 ≥8 字元且字元集受限,產值時避開 $——那個字元進 .env 會被 compose 插值吃掉)。
  • bundle-info.txt 只寫「含管理員密碼檔」,不印值;after_script 收 artifacts 的路徑不含 bundle 目錄,確認密碼檔不會進 out/。
  • 弱點關卡:bugsink 2.6.1 image 有三條 Critical 上游未修,登記在 scripts/build/vuln-allowlist.txt,到期 2026-12-31——MariaDB 客戶端兩條(image 附帶的程式庫,Bugsink 連的是 Postgres、不載入它)、Python ssl sni_callback 一條(Bugsink 走明文 HTTP 只在內網,TLS 由前門終結)。理由逐條寫在白名單檔;到期未換版即重新擋。

5.3.5 install.sh

函式全部放在新檔 scripts/installer/bugsink.sh,由 install.sh source(install.sh 已近三千行,不往裡塞);build_bundle.sh 把它收進安裝包。接入點:

函式 做什麼 首次安裝接在 升級接在
bugsink_load_env 把 .env 既有的金鑰與 DB 密碼讀回 shell generate_secrets 之後、write_configs 之前 —
ensure_bugsink_env .env 的 GUIDANT_BUGSINK_SECRET_KEY(openssl rand -base64 64 去掉 /+= 取 64 字)、GUIDANT_BUGSINK_DB_PASSWORD(gen_pw)缺了才補;GUIDANT_BUGSINK_BASE_URL 每次依站台網址重寫;GUIDANT_BUGSINK_ALLOWED_HOSTS 每次依站台網址重算後與既有清單合併去重(保留現場補的網域) write_configs 之後 升級流程第一個 compose 呼叫之前(與 ensure_classifier_env、ensure_redis_service_accounts 並列)
setup_bugsink 建庫帳號 → 起容器 → 等 healthy → 建三個專案與 token → 寫 DSN → 存管理員密碼檔 init_database 之後、start_services 之前 upgrade_apply_migrations 之後、upgrade_switch_version 之前
  • 為什麼要 bugsink_load_env:首次安裝的 write_configs 會整份重寫 .env。重跑安裝時不先讀回,SECRET_KEY 會被換掉(站台全員被登出),DB 密碼也會對不上既有的 bugsink 帳號。
  • 為什麼網址與允許主機名每次重算:B0 實證換站台網址後舊主機名被拒,送件端不報錯。允許主機名改為合併而非覆寫:用 IP 安裝、外面再掛網域的客戶,網域不在 SYSTEM_URL 裡,覆寫會在每次升級把它洗掉、人用網域開站台回 400。write_configs 整份重寫 .env,所以 bugsink_load_env 先把舊清單讀進 BUGSINK_ALLOWED_HOSTS_PREV,重寫後才合併得回來。代價:換網址後舊主機名也留在清單(只是多放行一個名字),要清得手改 .env。
  • GUIDANT_BUGSINK_BASE_URL 的來源:首次安裝用 ${SITE_URL}/bugsink;升級讀 guidant.env 的 SYSTEM_URL(write_agent_env 用同一來源)再接 /bugsink。讀不到時寫一個能讓 compose 解析的佔位值,並讓 setup_bugsink 略過建站(不停下問人,產品其餘部分照升)。
  • 建不了站一律只警告不擋安裝:包內沒有密碼檔、讀不到站台網址、stack 內沒有 guidant-db(客戶自備資料庫)三種情形都 warn 後略過。只有建到一半失敗(DB 寫不進、站台起不來、片段出錯)才 fail。
  • 站台只在建站成功時才進啟動與健康等待清單(bugsink_services 依 BUGSINK_READY 決定):沒有庫的站台會反覆重啟,把整個安裝卡在健康檢查逾時。

setup_bugsink 細節:

  1. 讀 ${BUNDLE_DIR}/bugsink-admin.secret;檔案不在 → warn「本包未含錯誤追蹤站管理員密碼,站台不建」並 return(不擋安裝)。
  2. 建庫帳號(§5.3.3)。
  3. 判斷是否首次建站:庫剛建,或 select count(*) from auth_user 為 0 → 首次,以 shell 環境變數 GUIDANT_BUGSINK_CREATE_SUPERUSER=admin@guidantai.com:<密碼> 帶入 dc up -d guidant-bugsink;否則不帶。緊接著的正式啟動不帶它重建容器,容器設定裡不留密碼。
  4. 等 guidant-bugsink healthy,上限 180 秒,逾時 fail(訊息指向 docker logs guidant-bugsink)。
  5. 跑建站片段(冪等,bugsink_bootstrap_project):同一個 team「Guidant AI」下以名稱 get_or_create 三個專案,取得三組 id/key 與一支跨專案唯讀 token(service token:不綁使用者、不綁專案,權限 issues:read/events:read/projects:read)。舊機器只有 guidant-ai 時只補另外兩個。
  6. 寫 DSN(bugsink_write_dsn):
key 值 誰讀
SENTRY_DSN(guidant.env) guidant-ai 專案、內網形狀 http://<key>@guidant-bugsink:8000/<id> 後端三容器送事件;轉送端點取它的主機當送往位址
SENTRY_PUBLIC_DSN(guidant.env) guidant-ai-fe 專案、外部形狀 https://<key>@<主機>[:埠]/bugsink/<id>,每次重寫 GET /error-tracking/config 回給前端;轉送端點比對使用者送的專案
SENTRY_AGENT_DSN(guidant.env) guidant-agent 專案、外部形狀,每次重寫 註冊與心跳回應附給 Agent;轉送端點比對 Agent 送的專案
BUGSINK_API_TOKEN(guidant.env) 步驟 5 的 token 診斷包
GUIDANT_SENTRY_DSN(.env) 同 SENTRY_DSN 的值 compose 帶給證據分類沙箱(§5.4.5)

SENTRY_DSN 已有非空值且不指向本機站台(原廠或客戶接了外部站台)→ 保留不動並 warn;此時 GUIDANT_SENTRY_DSN 給空值(沙箱網段只通本機站台,給外部位址也送不出去且不報錯),BUGSINK_API_TOKEN 有值就不動。 7. 首次建站時把密碼寫到 ${GUIDANT_DATA_DIR}/bugsink-admin.secret(root、600);非首次不寫、不改。 8. chmod 600 guidant.env;ok 一行「錯誤追蹤站就緒:<BASE_URL>」。

服務清單:start_services 與 upgrade_switch_version 的 dc_up 與健康等待迴圈都經 bugsink_services 帶上站台。完成畫面(安裝與升級共用 bugsink_print_summary)加一行站台網址與「密碼存放在 ${GUIDANT_DATA_DIR}/bugsink-admin.secret」(只寫路徑不印值)。

所有密碼不上命令列:寫檔走 awk 讀環境變數、建庫走 psql 的 stdin \set,同機其他帳號從 ps 看不到。

5.3.6 維運子命令(scripts/installer/guidantai)

  • GUIDANT_SERVICES 加 guidant-bugsink(start/stop/status/restart <服務> 自動涵蓋)。
  • 子命令本體放新檔 scripts/installer/guidantai_bugsink.sh,由 guidantai source(guidantai 已超過檔案上限)。連帶:install.sh 要把它裝到 guidantai 同目錄、build_bundle.sh 把它列為必要素材——缺它 guidantai 整個起不來。
  • bugsink-vacuum:不吃參數;docker exec guidant-bugsink bugsink-manage vacuum,印前後事件數。不帶天數參數:站台沒設 MAX_EVENT_AGE_DAYS 時 vacuum 自己略過刪舊事件,行為交給站台設定。
  • bugsink-set-password:不吃參數,需要 TTY,互動 read -s 兩次比對;密碼經 docker exec -i 的 stdin 交給一段 Django ORM 程式,先跑 Django 密碼驗證器、合格才改,不放命令列參數(ps 看得到)。成功後覆寫 ${GUIDANT_DATA_DIR}/bugsink-admin.secret(600)。不用 changepassword:密碼不合格時它會重問,stdin 讀完即崩潰。
  • 兩支加進子命令分派與參數個數守門(照既有 case 的逐命令寫法)、usage。
  • 診斷包的 KNOWN_SERVICES 與 installer 服務清單同步(守衛測試 test_diag_known_services_sync),一併補上 guidant-sandbox、guidant-worker。
  • 順手修文件:FR-132-STATE.md 第 19 行與 docs/release_notes/v1.26.0.md §2b 的 guidantai restart 改 guidantai start(restart 底層是 compose restart,不重讀設定檔)。

5.4 子需求 C:客戶端錯誤接回(前端、Agent、沙箱)

5.4.1 BE 轉送端點

  • 新模組 api/error_tracking/(route)+app/error_tracking/(service)+di_containers wiring,config/app_modules.py 註冊。
  • POST /api/1.0/error-tracking/tunnel,守門 jwt_or_agent_pass_required(common/authz/caller.py),不掛能力點(任何登入者踩到的前端錯誤都要收);在 test_route_guard_inventory.py 的豁免清單登記:「錯誤轉送:登入使用者或 Agent 通行證,各自只能送自己那個專案,大小與次數有上限」。
  • 兩種身分、同一個標頭:使用者 JWT 與 Agent 通行證都放在 Authorization: Bearer。守門先讀 token 未驗章的 typ 欄只用來選路:typ 是 Agent 控制通行證 → 交給 jedi_remote_agent 的 agent_pass_required(驗 RS256 簽章、查 remote_agents 那列與租戶);其他 → verify_jwt_in_request(過期仍回 TOKEN_401002 讓前端換發)。兩條路各自完整驗章,偽造 typ 只會換到另一條路的 401。current_agent_uid() 讓 service 分辨身分,使用者回 None。
  • 這支守門放 common/authz、不放 jedi_iam.authz:jedi-iam 層級比 Agent 套件低,不能往上引用。Agent 套件在函式內延後 import,避免每個引用 common.authz 的模組都連帶載入它。
  • route 只取 request.get_data(cache=False),交給 app service;service 規則:
檢查 結果
呼叫者能送的專案:使用者 → SENTRY_PUBLIC_DSN,Agent → SENTRY_AGENT_DSN;它或 SENTRY_DSN 未設 204,不轉
長度 > 1 MiB 413
第一行 JSON 解析失敗或沒有 dsn 400
dsn 的 project id 或 public key 與呼叫者那個專案不同(不比主機——客戶端拿到的是外部形狀) 400
同一位使用者/同一台 Agent 每 60 秒超過 ERROR_TRACKING_RATE_LIMIT 次 429
通過 POST 到「SENTRY_DSN 的主機+呼叫者專案編號」組出的內網 .../api/<id>/envelope/,Content-Type: application/x-sentry-envelope、timeout=(2,5)、allow_redirects=False、不走環境 proxy;上游失敗只記 log(主機、狀態碼、例外類別),回 204
  • 送往主機永遠取 SENTRY_DSN,專案 key 與編號取呼叫者那支外部 DSN:三個專案在同一個站台,內網位址只有一個;客戶端給的位址一律不採用,否則這支端點就是開放轉發器。
  • 節流按身分:key ratelimit:error-tunnel:user:<user_id> 或 ratelimit:error-tunnel:agent:<agent_uid>,用 common/util/redis_rate_limiter.RedisRateLimiter.acquire。理由:要防的是某個登入者(或被盜的憑證)灌爆站台;全域上限會讓一個人的錯誤風暴擋掉其他人的回報。Redis 掛掉時放行並記一行警告——這層是防濫用不是授權,大小與目的地限制仍在。
  • 錯誤碼新檔 common/code/error_tracking_error_code.py,前綴 ERT_:ERT_400001(envelope 格式錯)、ERT_400002(專案不符)、ERT_413001(太大)、ERT_429001(太頻繁)。FE error-code.json 同步四筆。
  • test_security_shape_external_calls.py 白名單登記這支 requests.post:「部署設定 SENTRY_DSN(裝機寫入,內網);envelope 的 dsn 與呼叫者專案不符即拒,不照請求轉」。
  • test_version_module_no_error_tracking_endpoints.py 不動,必須照樣綠。
  • CSP 不用改:瀏覽器只打同源 /api/1.0/...,大小落在全站 1m 內。

5.4.2 DSN 怎麼交給前端

決策 D4 要求「登入後才拿得到」。登入後前端必打的幾支(/users/me/capabilities、/user/web-menu、/license/status)都屬 jedi 套件,在套件回應塞錯誤追蹤欄位是把宿主關切塞進不相干的套件。故:

  • 在 5.4.1 同一模組加 GET /api/1.0/error-tracking/config(只收使用者 jwt_required,登記豁免清單;Agent 通行證打它回 401),回 {"enabled": bool, "dsn": SENTRY_PUBLIC_DSN, "environment": ENV};SENTRY_DSN 或 SENTRY_PUBLIC_DSN 空 → enabled=false 且不帶 dsn。
  • 不回 release:前端 build 時已把 guidant-ai-fe@<版號>+<commit> 烙進常數 VITE_APP_RELEASE(vite.config.js),前端直接用它,出 image 腳本不用改。

5.4.3 FE 接線

  • package.json 加回 @sentry/vue(版本照 FE 3a6f317f 那次的接法,取當下最新 10.x 修補版)。
  • 邏輯集中在 src/config/errorTracking.js;main.js 只交代 app/router/token 來源。
  • 不在 main.js 開頁就初始化。改為**userStore.loadCapabilities() 成功之後**呼叫 initErrorTracking()——登入、OTP、切租戶、F5 重載都會經過這裡,一處涵蓋;有防重複,換頁不會重新初始化。打 5.4.2 拿設定,enabled=false 就整支不載入 @sentry/vue(動態 import)。
  • Sentry.init:dsn、environment、release: import.meta.env.VITE_APP_RELEASE、tunnel: '/api/1.0/error-tracking/tunnel'、sendDefaultPii: false、browserTracingIntegration({ router })、tracesSampleRate: 0、tracePropagationTargets: [/^\/api\//](trace 標頭照 FR-071 的串接方式,讓前後端事件掛同一條 trace)。事件 tag 帶最近一次 API 回應的 request_id(axios 攔截器記下)。
  • 拿掉 BrowserSession 整合:它每次換頁送一筆 session,登入一次就送 4~5 筆,吃掉轉送端點每人每分鐘的額度,站台也用不到。
  • tunnel 請求要帶 Authorization:SDK 的 tunnel 用 fetch 不經 axios 攔截器,預設不帶 token。自訂 fetch 實作每次送出時才讀 token(換發後拿到新的,不在初始化時快取)。沒 token 或回 401 時回報成 429,讓 SDK 停送 60 秒,不會每個錯誤都再打一次必定失敗的請求。
  • 登出(resetStore,全站唯一清 token 的地方)時 Sentry.close(),避免下一位使用者沿用前一位的 transport。
  • initErrorTracking 整段 try/catch,掛不起來不影響前端。CSP 不用改(只打同源)。

驗收:登入後在 console 執行一段故意 throw 的程式 → 站台 guidant-ai-fe 專案出現該事件,release 為 guidant-ai-fe@<版號>+<commit>、tags 有 request_id、能點到同 trace 的後端事件(若同時觸發);未登入 curl -X POST .../error-tracking/tunnel → 401;登入後送一個 dsn 指向別的 project id 的 envelope → 400;守衛測試全綠。

5.4.4 Agent 錯誤回報

D9 的外網客戶端路:Agent 連不到站台,經同一支轉送端點、帶自己的通行證送進 guidant-agent 專案。

BE:設定附帶在註冊與心跳回應

  • 新檔 core/plugins/_agent_error_tracking.py,core/plugins/remote_agent.py::mount() 在 blueprint 掛上前疊 bp.after_request(...)。只對註冊與心跳兩支的 200 回應,在 data 補 error_tracking: {enabled, environment, dsn};dsn 取 SENTRY_AGENT_DSN,它或 SENTRY_DSN 缺一 → enabled=false 且不帶 dsn(與轉送端點同條件)。
  • 心跳也帶:已註冊的 Agent 升版後啟動時跳過註冊,只靠註冊回應會永遠拿不到設定。
  • 不改套件:套件的 SchemaExtensions.response_enricher 插槽存在,但路由沒有呼叫它;錯誤追蹤是宿主關切,不塞進套件。
  • 補欄位失敗只記 log,絕不讓註冊或心跳失敗(那會讓 Agent 掉線)。

陷阱:比對註冊/心跳要比 Resource 類別,不比 endpoint 名

套件掛守門時會把心跳換成 <名稱>Guarded 子類,endpoint 名跟著變。用名字比對會漏掉心跳,Agent 升版後永遠拿不到設定。

Agent(evidence-agent 1.4.1)

  • 回應的 error_tracking.enabled 為真且有 dsn 才 sentry_sdk.init,否則不載入 SDK;同一 dsn 只初始化一次,通行證與 agent_uid 每次回應都更新。
  • release guidant-agent@<版號>+<commit 前 12 碼>;tag agent_id 用 get_global_scope().set_tag(2.x 的 set_tag 只寫當前執行緒,心跳執行緒設的 tag 其他執行緒的事件看不到)。
  • 自寫轉送 transport:Python SDK 沒有前端的 tunnel 選項。envelope POST 到 <CLOUD_ENDPOINT>/api/1.0/error-tracking/tunnel,帶通行證,TLS 信任與註冊/心跳同一條;envelope 標頭補 dsn(SDK 預設不放,轉送端點靠它比對專案)。背景送、不擋呼叫端。401/403 → 記住被拒的那顆通行證,換新前不再送,不無限重試。送失敗只記 debug,避免離線時錯誤回報自己生出更多事件。
  • 遮罩:Agent 釘的 jedi-common 版本太舊,不能依賴 jedi-log,規則複製進 Agent 並註明來源;另遮 log 訊息內嵌的 JSON、logentry/breadcrumbs、例外訊息三處。遮罩失敗丟事件不送。
  • 出包整包收 sentry_sdk(Nuitka 不會自動收延後載入的 integrations)。
  • 已知限制:註冊完成前拿不到設定,那之前的錯誤不回報。

升級順序:先升雲端(BE 要有轉送端點的雙身分守門與回應補欄位),再升 Agent。Agent 升級後靠心跳拿設定,不需重新註冊。

5.4.5 證據分類沙箱

D9 的內網路的例外形:沙箱刻意不連 DB/Redis/物件儲存,所以多給一個只看得到站台的網段。

  • compose 新網段 guidant-sandbox-errors(internal: true),成員只有 guidant-sandbox 與 guidant-bugsink;沙箱原網段不動。
  • 沙箱加 SENTRY_DSN: ${GUIDANT_SENTRY_DSN:-}(.env,安裝器寫入,與後端同一個 guidant-ai 專案)與 ENV。不讀 guidant.env:那份檔有 DB 與登入金鑰。
  • 事件 tag app=guidant-classifier、release guidant-classifier@<版號>+<commit>。
  • 套件 jedi-evidence-classification 1.6.4 不裝 jedi-log:那會把 Flask、SQLAlchemy 一整串帶進沙箱、加大攻擊面;改用同參數的單一初始化函式,遮罩規則從 jedi-log/jedi-common 複製並註明來源(含 Bearer 值規則)。
  • 拆檔程式炸掉時回 HTTP 500,不讓連線直接斷;後端看到的仍是同一種「沙箱失敗」,站台收得到現場。
  • GUIDANT_SENTRY_DSN 空時服務照常起,錯誤追蹤不啟用。

驗收:從沙箱內打 guidant-db/guidant-redis/guidant-seaweedfs/對外位址都不通,只有 guidant-bugsink:8000 通;沙箱觸發一次錯誤 → 站台 guidant-ai 專案出現 app=guidant-classifier 的事件,全文找不到共享密鑰。

5.5 子需求 D:診斷包改 canonical API

  • infra/support/diag_event_fetcher.py:
    • base URL 由 SENTRY_DSN 推導(scheme+host+port,不含 DSN 的 project 段),沿用 _derive_base_url 的推導部分、拿掉顯式變數分支。
    • token 讀 BUGSINK_API_TOKEN(跨專案唯讀)。專案編號取 SENTRY_DSN、SENTRY_PUBLIC_DSN、SENTRY_AGENT_DSN 三者聯集(空的略過、重複去重),三個專案都撈。對外 DSN 的主機後端不一定連得到,只拿它的編號。
    • 每個專案 GET /api/canonical/0/issues/?project=<id>(依最後發生時間由新到舊)→ 依 last_seen 落在 [since, until] 篩;每個 issue 再取最近事件詳情(含堆疊)。
    • --request-id 有值:讀取 API 不能依 tag 篩(B0 實證,加什麼參數都回全部),所以逐 issue 拉事件詳情、客端比對 tags 的 request_id。為免請求暴增:請求總數上限 80、每個 issue 最多看 10 筆,超過即標「截斷」並寫進 manifest。fetch() 因此多一個 request-id 參數,由 diag_bundle_app_service 帶入。
    • 分頁跟回應的 next,MAX_PAGES/PAGE_SIZE/TIMEOUT_SECONDS 照舊上限。
    • 檔名 ARCNAME 改 events/bugsink-events.json;manifest 摘要欄位改用 Bugsink 的對應欄位(issue id、標題、等級、次數)。
    • 失敗訊息機制不變:沒 DSN、沒 token、連不上、401 各回帶白話原因的 UNAVAILABLE,不讓整包失敗。
  • 移除 GLITCHTIP_BASE_URL/GLITCHTIP_ORG_SLUG/GLITCHTIP_API_TOKEN:先 grep 全 codebase 確認除本檔外無讀者;.env.sample 與 deployment-env.md 同步刪。
  • infra/support/collector/host_collector.py 的 KNOWN_SERVICES 刪 glitchtip 四個、加 guidant-bugsink;staged_collector.py docstring 範例的 guidant-glitchtip 改 guidant-bugsink。
  • ERROR_TRACKING_* 與 BUGSINK_API_TOKEN 名稱含 TOKEN,診斷包遮罩規則已會蓋掉 token 值;照樣在 T-D 驗包內 guidant.env 快照的這一行是遮罩後的值。

驗收:DEV 產一次 500 → RUN_MODE=diag python main.py --since 1h → 包內 events/bugsink-events.json 有該事件含堆疊、manifest 標「已取得」;拿掉 BUGSINK_API_TOKEN 重跑 → manifest 寫「未取得:沒有設定權杖」且其他內容照產;停掉 bugsink 容器重跑 → 「未取得:連不上」。

5.6 子需求 E:文件

文件 寫什麼
spec 頁 docs/spec-site/current/system-admin/error-tracking-site.md(站台行為的現況)
.env.sample 錯誤追蹤段 改寫成「出貨包內建站台,裝機自動填」;ERROR_TRACKING_CAPTURE_CLIENT_ERRORS、ERROR_TRACKING_LOG_EVENTS、SENTRY_PUBLIC_DSN、SENTRY_AGENT_DSN、BUGSINK_API_TOKEN 各一行說明,值留空;刪 GLITCHTIP_*(前五項已隨實作卡補上,T-E 只核對與改寫段首說明)
deployment-env.md 第 360 行那列 同上;另列 .env 的 GUIDANT_BUGSINK_* 四個與 GUIDANT_SENTRY_DSN(用途、由 installer 產生;BASE_URL/ALLOWED_HOSTS 每次依站台網址重寫,手改會被蓋掉)
客戶手冊 docs/user-manual/onprem/ 04-operations.md 加「錯誤追蹤站」一節:入口網址、帳號、密碼存放檔、保留 1 萬筆與 bugsink-vacuum、bugsink-set-password、兩個開關怎麼開、改設定後用 guidantai start 不是 restart、升級或重啟瞬間的事件可能遺失、改主機名後改 guidant.env 的 SYSTEM_URL 再跑一次 install.sh --upgrade(網址、允許主機名與兩組對外 DSN 會一起重寫);Agent 升級不需重新註冊;08-network.md 註明不需額外開 port;01-prerequisites.md 記憶體建議值補 Bugsink 用量
release note 下一版 docs/release_notes/v<X.Y.Z>.md 段落草稿:站台入口、三個專案、升級自動補站、預設不收 4xx、前端錯誤回報恢復(僅登入後)、Agent 錯誤回報(先升雲端再升 Agent)
接入規範 D9 寫成開發者文件(新元件怎麼接錯誤追蹤:內網直連走 jedi-log 的 init、外網經轉送端點),放 docs/claude/ 或架構手冊,讓下一個元件不必再設計
spec-site 查 docs/spec-site/current/ 有無支援/診斷頁,有則補變更紀錄一行

6. 拆分

卡 Notion 做什麼(白話一句) repo 依賴 驗收條件(可親手做) 規模 狀態
T-A1 CM-2602 jedi-log 加「log 只當前情、不另開事件」開關與補送小工具,發 1.3.4 jedi-log 無 套件測試:log_events=False 時 logger.error 不產生事件、仍出現在下一個事件的 breadcrumbs;capture_exception 未啟用時不拋錯。發版前停下等決策者明示 S 已驗收
T-A2 CM-2603 後端預設不送使用者失誤類錯誤,worker/socketio 的錯誤改用補送 BE T-A1 §5.1 驗收四項全過;.env.sample、deployment-env.md 各補兩行 S 已驗收
T-A3 CM-2618 jedi-log 遮罩改走 mask_secrets_in_text,補「值是 Bearer xxx」,發 1.3.5 jedi-log 無 套件測試含「變數名無害、值是 Bearer」案例,突變會紅;站台事件全文 grep 不到密鑰 S 已驗收
T-B0 CM-2604 在 DEV 實跑 Bugsink 整條鏈,產出驗證紀錄與可搬進安裝器的指令片段 BE(docs+草稿) 無 b0-findings.md 十項各附輸出與結論;片段在空白容器重跑一次可重現 M 已驗收
T-B1 CM-2605 stack 加入 Bugsink 服務、前門開 /bugsink/ 子路徑 BE+FE(nginx conf) T-B0 DEV 用 compose 起整組:https://<host>/bugsink/ 可登入、靜態檔不 404;後端內網 DSN 送得進去;docker compose config 無錯;安全標頭各只一份 M 已驗收
T-B2 CM-2606 安裝包收進 Bugsink image(釘 digest)與 build 期烙印的管理員密碼檔 BE T-B0 188 跑 build_bundle.sh:包內有 bugsink-2.6.1.tar 與 600 權限的 bugsink-admin.secret,manifest 含 bugsink;變數缺時 build 直接 fail;out/ 與 log 內 grep 不到密碼值 S 已驗收
T-B3 CM-2607 安裝器首次安裝自動建站寫 DSN,升級時補站 BE T-B1、T-B2 乾淨機首裝:guidant.env 出現 DSN 與 token、資料目錄有密碼檔、站台可登入;同機重跑 --upgrade:金鑰與密碼不變、不重建專案;SENTRY_DSN 預先填外部值 → 保留並出現警告 L 已驗收
T-B3b CM-2617 安裝器建三個專案、寫三組 DSN,診斷包撈三專案 BE T-B3 站台三個專案存在、guidant.env 三組 DSN 的 project id 對得上;舊機器只補缺的專案、既有值不動 S~M 已驗收
T-B4 CM-2608 維運指令加清理與改密碼兩個子命令、服務清單加入 Bugsink、修 v1.26.0 文件的 restart 寫法 BE T-B3 guidantai status 列出 bugsink;bugsink-vacuum 印前後筆數;bugsink-set-password 改密碼後新密碼可登入、ps 看不到密碼;兩份文件 restart → start S 已驗收
T-C1 CM-2609 後端新增登入後才能打的前端錯誤轉送端點與設定端點 BE(+FE error-code.json) T-B1 未登入 401;dsn 不符 400;超過 1 MiB 413;超頻 429;正常 204 且站台收到;守衛測試綠 M 已驗收
T-C1b CM-2614 轉送端點同時收使用者與 Agent 兩種身分,依身分選專案 BE T-C1 兩種身分各實打一次 204;交叉專案兩向 400;未認證 401;偽造 typ 401 S 已驗收
T-C2 CM-2610 前端登入後接回錯誤回報,經後端轉送 FE T-C1 登入後故意 throw → 站台事件帶 guidant-ai-fe@<版號>+<commit> 與 request_id;登出後不再送;enabled=false 時不載入 @sentry/vue M 已驗收
T-C3 CM-2615 evidence-agent 接錯誤回報,經轉送端點送進 guidant-agent;BE 註冊與心跳回應帶設定 BE+evidence-agent T-C1b 註冊後與重啟後(只走心跳)都啟用;故意 raise → guidant-agent 專案出現事件、帶 agent_id、無明文密碼;清空 SENTRY_AGENT_DSN 後不啟用、註冊心跳照常 M 已驗收
T-C4 CM-2616 證據分類沙箱多一個只通站台的網段並接錯誤追蹤 BE+jedi-evidence-classification T-B1 沙箱只連得到 guidant-bugsink;故意錯誤 → guidant-ai 專案出現 app=guidant-classifier 事件、無密鑰 M 已驗收
T-D CM-2611 診斷包改打 Bugsink 的讀取 API、收 Bugsink 容器 log BE T-B0 §5.5 驗收三項;全 codebase grep GLITCHTIP 零命中(docs 歷史檔除外) M 已驗收
T-A4 CM-2620 三份遮罩(jedi-log 1.3.6、Agent 1.4.2、分類器 1.6.5)補「無名位置參數」:args/kwargs 與含字串的清單整個遮 jedi-log+evidence-agent+jedi-evidence-classification 無 三邊各兩條新測試(合成事件、真 SDK 事件),突變轉紅;全收模式打錯密碼,站台事件全文找不到輸入的密碼 S 修正待驗證
T-B5 CM-2621 升級時允許主機名與既有清單合併,保留現場補的網域 BE 無 含額外網域的 .env 跑兩次 ensure_bugsink_env,網域仍在、不重複;新裝只有預設四條 S 修正待驗證
T-R CM-2619 首輪發版(jedi-log 1.3.5、jedi-evidence-classification 1.6.4、evidence-agent 1.4.1)、後端釘版、出包、上 190/188/189 與四台 Agent 全部 上列實作卡 三環境站台可登入、三專案存在;四台 Agent log 出現「錯誤回報已啟用」;Nexus 查得到新版號 L 修正待驗證
T-R2 CM-2623 第二輪發版:jedi-log 1.3.6、jedi-evidence-classification 1.6.5、Agent 1.4.2;後端以 aba953f67(預設全收)出包,190/188/189 與四台 Agent 升級;不進版 全部 T-A4、T-B5 三環境 api log 帶新 commit、sandbox 1.6.5、bugsink healthy;四台 Agent 1.4.2;188/189 打錯密碼的 401 事件全文找不到密碼 L 修正待驗證
T-G1 CM-2622 Agent 升級子命令對 1.1.0 機器靜默升出壞的儲存 evidence-agent — — M 暫緩:Agent 尚未對外出貨,舊版直升不處理,遇到就移除重裝
T-V CM-2612 新安裝包在乾淨機與 1.26.0 升級機各跑一次端到端 BE T-R §7 端到端驗收清單逐項打勾,附截圖或輸出;升級機產品資料筆數升級前後一致 M 待派
T-E CM-2613 設定範本、部署文件、客戶手冊、spec 頁、D9 接入規範(不進版,release note 留到進版時寫) BE T-R2 §5.6 表內每列都改到;手冊四件事(入口、密碼在哪、保留與清理、改設定用 start)找得到 S 修正待驗證
%%{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
  A1["T-A1 jedi-log 開關"] --> A2["T-A2 後端過濾"]
  A3["T-A3 遮罩 Bearer"]
  B0["T-B0 實跑"] --> B1["T-B1 compose+nginx"]
  B0 --> B2["T-B2 出貨包+烙印"]
  B0 --> D["T-D 診斷包"]
  B1 --> B3["T-B3 安裝器"]
  B2 --> B3
  B3 --> B3b["T-B3b 三專案"]
  B3 --> B4["T-B4 維運指令"]
  B1 --> C1["T-C1 轉送端點"]
  C1 --> C2["T-C2 前端"]
  C1 --> C1b["T-C1b 雙身分"]
  C1b --> C3["T-C3 Agent"]
  B1 --> C4["T-C4 沙箱"]
  A2 --> R["T-R 首輪發版"]
  A3 --> R
  B3b --> R
  B4 --> R
  C2 --> R
  C3 --> R
  C4 --> R
  D --> R
  R --> A4["T-A4 位置參數遮罩"]
  R --> B5["T-B5 保留額外主機名"]
  A4 --> R2["T-R2 第二輪發版"]
  B5 --> R2
  R2 --> V["T-V 整包驗收"]
  R2 --> E["T-E 文件"]
圖 2 — 子任務依賴

7. 端到端驗收

用 T-V 出的新安裝包,兩台機各跑一次:乾淨機首裝、1.26.0 機升級。

# 動作 期望
1 裝完(或升完)瀏覽器開 https://<主機>/bugsink/ 用 admin@guidantai.com 與 ${GUIDANT_DATA_DIR}/bugsink-admin.secret 的值登入,看得到 guidant-ai、guidant-ai-fe、guidant-agent 三個專案(升級機:密碼檔若升級前已存在則用原值)
2 產品登入頁打錯密碼一次(預設全收) 站台出現 401 事件,事件全文找不到輸入的密碼
3 呼叫一支故意 500 的端點 一分鐘內 1 條,含堆疊、release guidant-ai@<版號>+<commit>、request_id、user
4 讓一筆背景工作失敗 1 條,來自 worker
5 登入後在瀏覽器故意觸發前端錯誤 guidant-ai-fe 專案 1 條,release guidant-ai-fe@...
5b 受檢主機 Agent 1.4.1 製造一個錯誤 guidant-agent 專案 1 條,帶 agent_id;Agent log 有「錯誤回報已啟用」
5c 讓證據分類沙箱失敗一次 guidant-ai 專案 1 條,tag app=guidant-classifier
5d 在第 3、5b、5c 步的事件全文 grep 密碼、token、Bearer 後的密鑰 零命中
6 登出後 curl -X POST https://<主機>/api/1.0/error-tracking/tunnel 401
7 sudo guidantai diag --since 1h 包內 events/bugsink-events.json 含第 3、5、5b 步三個專案的事件,manifest「已取得」
8 guidant.env 加 ERROR_TRACKING_CAPTURE_CLIENT_ERRORS=false 後 sudo guidantai start,再打錯密碼 站台不再出現 401
9 sudo guidantai bugsink-vacuum 正常結束,印前後筆數
10 升級機:比對升級前後 guidant_ai 主要表筆數與一位使用者能登入 一致;產品功能不受影響
11 主機只開 443 時從另一台機器開站台與產品 都可用

8. 風險與陷阱

🔴 改了 guidant.env 用 restart 不會生效

guidantai restart 是 compose restart,不重讀設定檔;要 guidantai start(up -d 會重建容器)。install.sh 寫完 DSN 之後一律走 up -d;手冊與 release note 一律寫 start。

🔴 內網送事件被 Host/HTTPS 驗證擋下時,後端不會報錯

SDK 送失敗只在自己內部丟棄。T-B1、T-V 的驗收一定要在站台上看到事件,不能只看後端 log 有沒有「已啟用」。

容器重啟會丟掉還沒處理的事件

Bugsink 背景佇列寫在容器內 /tmp 的 SQLite,升級或重啟那幾秒進來的事件會遺失。UAT 可接受,手冊寫明。

CREATE_SUPERUSER 只在零使用者時生效

升級或重跑安裝器不會改密碼;改密碼只能走 bugsink-set-password。

轉送端點不可變成開放轉發器

只比對 project id 與 public key、目的地只取設定值不取 envelope、大小與次數有上限——三項缺一,已登入使用者就能往內網灌任意資料。

預設全收時,站台可能被使用者失誤灌滿

打錯密碼、沒權限、查不到資料都會進站,量大時淹掉真正的 500 並吃掉每分鐘節流額度。此時把 ERROR_TRACKING_CAPTURE_CLIENT_ERRORS 設 false 後 guidantai start。

🔴 全收依賴位置參數遮罩

登入失敗的 401 事件堆疊裡,包裝層 @transaction 的區域變數 args 是 (self, username, password, ...)——沒有欄位名,只看欄位名與 Bearer 樣式的遮罩抓不到。jedi-log 1.3.6、Agent 1.4.2、分類器 1.6.5 起把 args/kwargs 與含字串的無名清單整個遮掉。任何一份遮罩退回舊版,全收就會把密碼送進站台。

客戶自備資料庫的部署

setup_bugsink 以 docker exec guidant-db 建庫;stack 內沒有 guidant-db 時整段 warn 後略過,產品照裝,錯誤追蹤站不建。

改了站台網址沒重跑安裝器,前端與 Agent 的事件靜默消失

對外 DSN 與 ALLOWED_HOSTS 綁站台主機名。只改 SYSTEM_URL 不跑 --upgrade,站台會拒收舊主機名的事件,送件端不報錯。

Agent 的錯誤回報在 Nuitka 編譯後可能整個不送

sentry-sdk 的 integrations 多為延後載入,Nuitka 不一定收得到。出包整包收 sentry_sdk,每次出 Agent image 都要實測一次錯誤回報送得出去——不能只看 log 有「已啟用」。

升級順序:先雲端、後 Agent

Agent 1.4.1 靠雲端回應拿設定。雲端沒升時 Agent 只是不啟用,不會壞;但若遇到要重新註冊,該機註冊時用的 token 在雲端必須仍是啟用狀態,否則卡在 401。

弱點白名單有到期日

bugsink 2.6.1 image 的三條 Critical 白名單到 2026-12-31。到期前沒有換上游新版,出包會被弱點關卡擋下。


9. 已知未做與後續

項目 白話說明 誰接
守門 docstring jwt_or_agent_pass_required 的說明要加一句「兩條路徑都必須自行驗簽」,防止日後有人只靠 typ 判斷身分(reviewer 建議) 下次動 common/authz/caller.py 時順手
CONTROL_TOKEN_TYPE 對外匯出 守門目前從 Agent 套件的內部路徑取這個常數;請 jedi_remote_agent 對外 re-export,免得套件內部重整時守門靜默壞掉(reviewer 建議) 下次發 jedi_remote_agent 時
沙箱不裝 jedi-log 沙箱的初始化與遮罩是 jedi-log/jedi-common 的複製品,兩邊規則要同步維護;不裝的理由是不把 Flask、SQLAlchemy 帶進沙箱 維持現狀;動 jedi-log 遮罩時一併看沙箱與 Agent 兩份複製
Agent 遮罩也是複製品 Agent 釘的 jedi-common 太舊,不能依賴 jedi-log;Agent 升 jedi-common 後可改回依賴 Agent 升 jedi-common 時
告警(D8) 第一版不做 下一版
登入前、註冊前的錯誤 前端登入前、Agent 註冊完成前的錯誤收不到(D4 取捨) 不做
🔴 遮罩三份複製品 後端(jedi-log jedi_api_log/error_tracking/masking.py)、Agent(core/error_tracking/masking.py)、分類器(jedi-evidence-classification docker/error_tracking.py)三份規則相同但各自維護。A4 就是三份同一個盲點要各修一次。改一處必須同步另外兩處並各自補測試與突變;接入規範 docs/claude/error-tracking-integration.md 有清單 結構債,維持現狀;Agent 升 jedi-common 後至少可收掉一份
Agent 升級不清 /tmp 暫存解包 guidant-agent-compose upgrade 成功或失敗都不清解包目錄(每次約 3.7G)。/tmp 是記憶體盤的小磁碟機第二次升級就塞滿,解包失敗時錯誤訊息寫「檔案可能損毀」,實際 sha256 正常——誤導排錯方向(T-R2 在 .166 踩到) 記錄不開卡;下次動 Agent 升級子命令時一併處理
Agent 舊版直升(G1) 1.1.0 以前的機器用舊升級程式直升,儲存憑證不會產生、seaweedfs 不重建 暫緩(Agent 未出貨);遇到就移除重裝

版號註記:jedi-log 1.3.4 曾推上 Nexus,含開關但沒有 Bearer 遮罩;1.3.5 沒有位置參數遮罩。主專案釘 1.3.6,其他 consumer 不應釘 1.3.4/1.3.5——全收模式下 1.3.5 會把登入密碼送進站台。


10. 與其他需求的關係

FR 關係
FR-071 錯誤追蹤接線、遮罩、節流、診斷包都在那裡建立;本案換收件站、改接讀取 API,前端 release 與 trace 串接沿用其做法
FR-132 v1.26.0 上版文件的 restart 寫法由 T-B4 修正
FR-126 資安總報告 前端免登入端點(U3-⑤)已拆,T-C1 在既有守衛測試下以登入後端點接回