錯誤處理:使用者看一句話,log 看全部
錯誤該記什麼、該顯示什麼、追蹤碼怎麼串起來,以及為什麼堆疊訊息出現在畫面上同時是體驗問題和資安問題。
核心原則
使用者得到一句話,log 得到全部真相。
畫面上出現 TypeError: Cannot read properties of undefined (reading 'map') 有兩個問題:使用者不知道該怎麼辦,而攻擊者剛拿到你的框架、檔案結構,有時還有依賴版本。
1. 分層
throw → handleServerError() → 記完整 log(含 traceId)
→ 回傳 { message, traceId } 給前端
→ +error.svelte 顯示人話 + traceId
實作(hooks.server.ts):
export const handleServerError: HandleServerError = ({ error, event, status, message }) => {
const traceId = crypto.randomUUID().slice(0, 8);
logError('unhandled', error, {
traceId,
status,
route: event.route.id ?? event.url.pathname,
method: event.request.method
});
// 這個物件是瀏覽器唯一看得到的東西
return {
message: status === 404 ? message : 'unexpected',
traceId
};
};
注意回傳的 message 是 'unexpected' 這種代號,不是原始訊息。文案在前端依代號查表,這樣才有辦法翻譯,也不可能不小心洩漏內部字串。
2. 追蹤碼
八碼就夠了(16^8 ≈ 43 億),使用者要能唸出來或截圖給你。
顯示的時候用 user-select: all,點一下就整組選起來:
.trace-id { user-select: all; letter-spacing: 0.16em; }
有了 traceId,客服對話從「你說壞掉是哪裡壞掉」變成「a1f9c204,我查一下」。
3. 結構化 log
一行一個 JSON,這是所有 log 平台都吃的格式:
function emit(level: LogLevel, event: string, context: LogContext = {}) {
console.log(JSON.stringify({
level,
event,
ts: new Date().toISOString(),
...redact(context)
}));
}
console.log(error) 印出一坨物件,看得到錯誤卻看不到「是誰、在哪一條路由、帶了什麼參數」——而那才是能不能重現的關鍵。
log 要遮罩
log 是會被很多人看到的地方。密碼、token、cookie、email 都要遮:
const REDACT = ['password', 'token', 'authorization', 'cookie', 'session', 'apikey', 'email'];
遞迴時要限制深度,否則有人會不小心把 2MB 的回應塞進 log。
4. 前端該顯示什麼
| 情況 | 顯示 | 不要顯示 |
|---|---|---|
| 404 | 「這一頁不在這裡」+ 幾個可能想去的地方 | 路由設定、檔案路徑 |
| 500 | 「我們這邊出了點狀況,已經記錄下來」+ traceId | 堆疊、SQL、服務名稱 |
| 網路斷線 | 「連線好像斷了,檢查一下網路」+ 重試 | Failed to fetch |
| 驗證失敗 | 具體哪一欄、怎麼修 | ValidationError: schema.email |
| 沒有權限 | 「你沒有這個工作區的權限」+ 怎麼申請 | 資源是否存在的線索 |
錯誤文案的三個部分
- 發生什麼(用他們的話)
- 是誰的問題(幾乎都是我們的 —— 這句要說出來)
- 現在可以做什麼(一個具體動作)
✗ 發生錯誤,請稍後再試
✓ 上傳失敗:檔案超過 2GB。請先壓縮,或改用桌面版上傳工具。
5. 什麼時候用 toast、什麼時候用整頁
| 用什麼 | |
|---|---|
| 整頁載入失敗 | 錯誤頁 |
| 某個區塊失敗,其他還能用 | 區塊內的 inline 錯誤 + 重試 |
| 使用者的動作失敗(存檔、上傳) | Toast,而且不自動消失 |
| 表單驗證 | 欄位旁邊 + 頂部總結 |
錯誤 toast 不可以自動消失。 使用者需要處理它,計時器把它拿走等於把問題藏起來。成功 toast 可以自動消失。
6. 錯誤邊界
一個區塊爆炸不應該讓整頁白掉。
- React:
<ErrorBoundary>包住每個獨立區塊 - Svelte:
+error.svelte處理路由層;元件層用<svelte:boundary> - Vue:
onErrorCaptured
邊界的 fallback 要保留版面高度,否則一個區塊掛掉會讓下面的東西全部跳上來。
7. 客戶端的錯誤也要回報
伺服器的錯誤你看得到,瀏覽器裡的看不到。加一個最小的回報端點:
window.addEventListener('error', (event) => report(event.error));
window.addEventListener('unhandledrejection', (event) => report(event.reason));
回報時要節流,否則一個在 render 迴圈裡的錯誤會用幾千個請求打垮你自己的 API。
8. 檢查清單
- 使用者永遠看不到堆疊、檔案路徑、SQL、內部服務名
- 每一個伺服器錯誤都有 traceId,而且畫面上看得到
- log 是單行 JSON,含路由、方法、狀態碼
- log 有遮罩敏感欄位
- 404 與 500 有各自的頁面,文案不同
- 錯誤 toast 不自動消失,而且帶重試
- 每個獨立區塊有錯誤邊界,fallback 保留高度
- 客戶端錯誤有回報且有節流