---
title: "Bugsink 隨產品出貨 — 設計文件 (FR-133)"
brand: "Guidant AI · **FR-133** 錯誤追蹤站隨產品出貨"
eyebrow: "FR-133 · 設計文件 v3 · 2026-10-10（D1–D11 全數定案）"
h1: "Bugsink 隨產品出貨 — 設計定案"
subtitle: "裝機全自動建站（三個專案）、只收程式錯誤、前端與 Agent 經同一支轉送端點、診斷包改打 Bugsink API"
lede: "每套 Guidant AI 自帶一個錯誤追蹤站（Bugsink 2.6.1），**裝機人員不輸入任何東西**就建好管理員、專案與連線字串。程式錯誤與使用者失誤（4xx）預設全收、送出前遮掉憑證與位置參數；前端與受檢主機 Agent 的錯誤經同一支**要驗身分**的後端端點轉送，依身分落到各自的專案；stack 內的 Python 行程走內網直送；診斷包改打 Bugsink 自己的讀取 API。本文件把十項決策展開到「照做就能交」的程度，拆成 18 張實作子任務，另有兩輪發版、兩張補缺卡與一張暫緩卡。"
chips: [
  {text: "D1–D11 全數定案", kind: ok},
  {text: "18 張子任務", kind: accent},
  {text: "跨 4 repo：BE／FE／jedi-python-package／evidence-agent", kind: accent},
  {text: "B0 實跑證實 D1／D2；內網 Host 走 ALLOWED_HOSTS", kind: ok},
  {text: "陷阱：nginx 用變數 proxy_pass 時不會剝子路徑", kind: crit},
  {text: "陷阱：event_level 關掉後 worker 的錯誤會整個消失", kind: crit}
]
footer: "FR-133 · Bugsink 隨產品出貨 — 設計文件 · 2026-10-09 · 討論稿：[discussion.html](discussion.html)（現況缺口、Bugsink 事實表、選項推演）· 前作：[FR-071](../FR-071-2609-support-diagnostic-bundle/design.md) · 沿革見 LOG 與 git log"
---

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

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

## 變更紀錄 {#changelog nav="變更紀錄"}

| 日期 | 變更 | 對應 |
|---|---|---|
| 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. 需求背景與端到端流程 {#background nav="背景"}

產品進客戶 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 不是那個形狀。

### 端到端

```{.mermaid cap="圖 1 — 安裝時序：先起 Bugsink、建專案、寫 DSN，最後才起後端"}
%%{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: 內網送事件
```

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

---

## 2. 分工概述（30 秒版） {#overview nav="概述"}

整案一句話：**每套 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） {#decisions nav="決策定案"}

| # | 決策 | 定案 | 理由 | 被排除的方案 |
|---|---|---|---|---|
| **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 類卡點） |

::: {.callout .warn}
**D5 的已知風險（決策已定，如實記錄）**

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

---

## 4. 現況接入點盤點 {#touchpoints nav="接入點"}

| 元件 | 座標 | 現況 | 本案動作 | 子任務 |
|---|---|---|---|---|
| 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. 詳細設計 {#design nav="詳細設計"}

### 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)` 開頭加：

```python
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）。

::: {.callout .crit}
**🔴 陷阱：只看欄位名的遮罩擋不住「變數名無害、值是 Bearer 密鑰」**

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

::: {.callout .crit}
**🔴 陷阱：`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)`。驗收必含「故意讓一筆背景工作失敗 → 站台一條」。]{.rec}
:::

**驗收（兩開關設 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`](./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）。

::: {.callout .warn}
**陷阱：Bugsink 依 `BASE_URL` 驗 Host**

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

建站片段 [`b0-bootstrap-snippet.sh`](./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`

```yaml
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`）

```nginx
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/; }
```

::: {.callout .crit}
**🔴 陷阱：`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 掉線）。

::: {.callout .warn}
**陷阱：比對註冊／心跳要比 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. 拆分 {#breakdown nav="拆分"}

| 卡 | 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 | 修正待驗證 |

```{.mermaid cap="圖 2 — 子任務依賴"}
%%{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 文件"]
```

---

## 7. 端到端驗收 {#acceptance nav="驗收"}

用 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. 風險與陷阱 {#risks nav="風險"}

::: {.callout .crit}
**🔴 改了 guidant.env 用 `restart` 不會生效**

`guidantai restart` 是 `compose restart`，不重讀設定檔；要 `guidantai start`（`up -d` 會重建容器）。install.sh 寫完 DSN 之後一律走 `up -d`；手冊與 release note 一律寫 `start`。
:::

::: {.callout .crit}
**🔴 內網送事件被 Host／HTTPS 驗證擋下時，後端不會報錯**

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

::: {.callout .warn}
**容器重啟會丟掉還沒處理的事件**

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

::: {.callout .warn}
**`CREATE_SUPERUSER` 只在零使用者時生效**

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

::: {.callout .warn}
**轉送端點不可變成開放轉發器**

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

::: {.callout .warn}
**預設全收時，站台可能被使用者失誤灌滿**

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

::: {.callout .crit}
**🔴 全收依賴位置參數遮罩**

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

::: {.callout .warn}
**客戶自備資料庫的部署**

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

::: {.callout .warn}
**改了站台網址沒重跑安裝器，前端與 Agent 的事件靜默消失**

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

::: {.callout .warn}
**Agent 的錯誤回報在 Nuitka 編譯後可能整個不送**

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

::: {.callout .warn}
**升級順序：先雲端、後 Agent**

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

::: {.callout .warn}
**弱點白名單有到期日**

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

---

## 9. 已知未做與後續 {#followups nav="未做"}

| 項目 | 白話說明 | 誰接 |
|---|---|---|
| 守門 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. 與其他需求的關係 {#relations nav="關聯"}

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