| 層級 | 錯誤類型 | 處理目標 | 關鍵技術 |
|---|---|---|---|
| 網路/傳輸層 | DNS 失敗、連線逾時、TCP 重置、SSL 驗證失敗 | 重試、降級、用戶提示 | Fetch AbortController、指數退避重試、Service Worker 離線快取 |
| HTTP 語義層 | 4xx (客戶端錯誤)、5xx (伺服器錯誤)、非預期狀態碼 | 統一錯誤碼映射、業務邏輯分流 | 攔截器統一處理、Problem Details (RFC 7807) 格式 |
| 應用資料層 | JSON 解析失敗、欄位缺失、型別不符、業務規則違反 | 資料驗證、友好提示、上報遙測 | Zod/JSON Schema 驗證、i18n 錯誤訊息、錯誤碼國際化 |
| 執行時層 | JS 例外 (TypeError, ReferenceError)、Promise 未捕獲拒絕、框架生命週期錯誤 | 防止白屏、收集堆疊、自動上報 | window.onerror、unhandledrejection、Error Boundary (React)、Sentry/Rollbar |
class ErrorReporter {
constructor(endpoint = '/api/client-errors', options = {}) {
this.endpoint = endpoint;
this.batch = [];
this.timer = null;
this.maxBatch = options.maxBatch || 10;
this.delay = options.delay || 2000;
this._installGlobalHandlers();
}
_installGlobalHandlers() {
// 同步錯誤
window.addEventListener('error', e => {
if (e.error) this.capture(e.error, {type: 'sync', filename: e.filename, lineno: e.lineno, colno: e.colno});
else this.capture(new Error(e.message), {type: 'sync', filename: e.filename, lineno: e.lineno, colno: e.colno});
return false; // 不阻止預設行為 (讓瀏覽器 Console 仍顯示)
});
// Promise 拒絕
window.addEventListener('unhandledrejection', e => {
const err = e.reason instanceof Error ? e.reason : new Error(String(e.reason));
this.capture(err, {type: 'promise'});
return false;
});
}
capture(error, context = {}) {
const payload = {
message: error.message,
stack: error.stack,
name: error.name,
timestamp: Date.now(),
url: location.href,
userAgent: navigator.userAgent,
context: { ...context, ...this._getExtraContext() }
};
this._queue(payload);
}
_getExtraContext() {
return {
viewport: `${innerWidth}x${innerHeight}`,
online: navigator.onLine,
referrer: document.referrer,
// 可加入用戶 ID、Session ID、版本號等
};
}
_queue(payload) {
this.batch.push(payload);
if (this.batch.length >= this.maxBatch) this._flush();
else if (!this.timer) this.timer = setTimeout(() => this._flush(), this.delay);
}
async _flush() {
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
if (!this.batch.length) return;
const payloads = this.batch.splice(0, this.maxBatch);
try {
await fetch(this.endpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({errors: payloads}),
keepalive: true // 頁面卸載時仍發送
});
} catch (e) {
console.error('錯誤上報失敗', e);
// 可選:存入 localStorage 待下次重試
}
}
}
// 初始化 (盡早執行)
const reporter = new ErrorReporter();
// Axios 範例
axios.interceptors.response.use(
resp => resp,
error => {
const ctx = {
type: 'ajax',
url: error.config?.url,
method: error.config?.method,
status: error.response?.status,
data: error.response?.data
};
if (error.response) {
// 401 統一導向登入、403 提示權限、5xx 顯示系統繁忙
switch (error.response.status) {
case 401: redirectToLogin(); break;
case 403: showToast('權限不足'); break;
default: showToast('系統暫時無法響應,請稍後再試');
}
} else if (error.code === 'ECONNABORTED') {
showToast('請求逾時,請檢查網路');
} else {
showToast('網路異常');
}
reporter.capture(new Error(error.message), ctx);
return Promise.reject(error); // 讓業務碼仍可捕獲
}
);
@RestController
@RequestMapping("/api/client-errors")
public class ClientErrorController {
private static final Logger log = LoggerFactory.getLogger(ClientErrorController.class);
@PostMapping
public ResponseEntity receive(@RequestBody ClientErrorBatch batch) {
batch.getErrors().forEach(e -> {
// 結構化日誌 (ELK / Loki 友好)
log.warn("ClientError: {} | {} | url={} ua={}",
e.getName(), e.getMessage(), e.getUrl(), e.getUserAgent(),
kv("stack", e.getStack()), kv("context", e.getContext()));
// 可依錯誤類型/頻率觸發告警 (Prometheus Alertmanager / PagerDuty)
});
return ResponseEntity.noContent().build();
}
record ClientErrorBatch(List errors) {}
record ClientError(String name, String message, String stack, long timestamp,
String url, String userAgent, Map context) {}
}
sourceMapUrl,伺服器/分析平台自動還原混淆堆疊。hash(name + message + topStackFrame) 分組,避免噪音淹沒核心問題。keepalive),非致命批次延遲,節省頻寬。throw new Error('test') → 後端收到結構化日誌。online: false。unhandledrejection 觸發上報。keepalive: true 確保送達。AJAX 錯誤處理非單點技術,而是全鏈路觀測體系:
keepalive、CORS、壓縮、重試策略。投資完善錯誤處理,等同投資系統可觀測性與用戶信任。