FE Day 30 監控與錯誤追蹤
執行需求:需外部服務帳號。今天是「前端開發實戰:React 與 Next.js 全套」系列的第三十天,我們把視角從「怎麼寫程式」轉到「怎麼知道程式在真實環境跑得怎樣」。昨天我們把 Next.js 部署上線,今天要把「使用者實際碰到什麼問題」這條線補齊。我們會用 Sentry JavaScript SDK(2026 年 3 月主流的版本是 8.x 與 9.x)做錯誤追蹤、用 Performance Monitoring 量效能、用 Source Map 還原壓縮後的堆疊;同時也會示範「沒有外部服務帳號」時的本地替代方案:React 的錯誤邊界、console 增強、本地日誌檔案。讀完之後你會拿到兩條並行的路徑,有 Sentry 帳號就走雲端版、沒有就用本地版,兩者介面接近,之後想搬家也只需要換一個 hook。
引言
前二十九天我們幾乎都在討論「怎麼寫」,但軟體上線之後真正困難的是「怎麼知道它在真實使用者手上跑得怎樣」。一支部署到 Vercel 的 Next.js 應用,可能在百分之九十九的請求都沒事,但剩下的百分之一可能就是某個使用者的舊版瀏覽器在某個特殊時區出了例外;如果沒有監控,你只會在客服信箱收到「我按按鈕沒反應」的抱怨,沒辦法重現、沒辦法定位、也沒辦法量化影響範圍。監控的角色就是「把這些看不見的失敗變成可觀察、可分析、可修補的事件」。
今天介紹的 Sentry 是 2026 年 3 月前端錯誤追蹤的主流選擇。它的 SDK(JavaScript SDK 8.x、9.x 世代)在 React 與 Next.js 都有官方支援,可以自動抓到未處理的例外、unhandled promise rejection、React 元件渲染錯誤;Performance Monitoring 可以量到 LCP(最大內容繪製)、INP(互動到下次繪製)、FID;Session Replay 可以把使用者操作錄成影片重播;Source Map 上傳讓壓縮後的堆疊能對應回原始碼行數。今天我們聚焦在「錯誤追蹤+效能監控+Source Map」三個核心,Session Replay 因為要付費方案才完整,今天先不展開。
沒有 Sentry 帳號也沒關係。今天我們也會實作一個本地的監控層:window.onerror、window.onunhandledrejection、React 的錯誤邊界(error boundary)、把錯誤資訊打到 console 與 localStorage。這層監控雖然沒有雲端儀表板,但對開發與除錯已經非常有用;之後真的要接 Sentry,只要把收集到的錯誤事件多送一份到雲端即可。今天的範例可以單獨跑,不依賴任何外部服務。
原理解念:錯誤、效能、使用者體驗三層訊號
前端監控分成三層訊號,每一層回答的問題不同。第一層是「錯誤訊號」:應用程式拋出未處理的例外,導致畫面破版或功能失效。第二層是「效能訊號」:畫面載入慢、互動卡頓、API 回應慢,使用者還看得到畫面但體驗變差。第三層是「使用者行為訊號」:點了哪個按鈕、停留多久、轉換漏斗在哪一步流失。三層訊號都要收集,但前兩層是工程問題、第三層偏產品分析,今天我們聚焦在前兩層。
Sentry 的設計把這兩層抽象成兩個物件:Sentry.captureException(error) 送錯誤事件、Sentry.startTransaction() 或 React 的 <SentryProfiler> 元件包住效能追蹤。所有事件都有共通的「上下文」(context):誰(user)、在哪(URL)、用什麼瀏覽器(userAgent)、什麼時間(timestamp)、做了什麼(breadcrumb,事件發生前的軌跡)。把這些上下文收集齊,未來除錯時才不會只看到「TypeError」而不知道發生在哪個使用者的哪一步操作。
另一個關鍵觀念是「Source Map」。生產環境的 JavaScript 通常被壓縮(minify)成單行,變數名稱也被改成 a、b、c。沒有 Source Map 還原,看到的錯誤堆疊會指向壓縮檔的第幾行而不是原始碼的語意行,這對除錯幾乎沒幫助。Sentry 在初始化時可以設定「上傳 Source Map 到 Sentry 平台」,部署時 Next.js 透過 @sentry/nextjs 的 withSentryConfig 把 Source Map 自動上傳。這層整合看似簡單,但漏掉會讓整套錯誤追蹤失去意義——你只會知道「有錯」,不知道「錯在哪」。
最後一個觀念是「隱私」。監控收集的上下文包含 email、IP、URL 參數,可能夾帶個資。Sentry 提供 beforeSend hook 讓你在事件送出前做最後過濾,把個資欄位遮罩(mask)掉;本地版本也應該實作同樣的過濾,避免把信用卡號、身分證字號不小心送出去。這條規則在台灣的《個人資料保護法》底下特別重要,後面會示範具體寫法。
完整實作:Sentry 雲端版與本地版雙軌
今天的目標是讓監控可以「切換」:用環境變數 NEXT_PUBLIC_SENTRY_DSN 有沒有值來決定走 Sentry 或本地版本。我們先建立一個抽象層 lib/monitor.ts,把兩種實作藏在同一個介面後面,然後在 React 元件與 Next.js 路由裡呼叫這個介面。這種「介面固定、實作可換」的寫法跟 Day 13 的元件設計原則一致——把它套用到監控層就對了。
第一步,安裝 @sentry/nextjs 與其相依套件。Sentry 官方提供 Next.js 專屬整合:
# 安裝 Sentry Next.js 套件(2026 年 3 月主流版本為 9.x)
pnpm add @sentry/nextjs@^9.0.0
# 註冊 sentry-cli 用來上傳 Source Map(建議裝在 devDependencies)
pnpm add -D @sentry/cli@^2.40.0
裝完之後,我們建立抽象層 lib/monitor.ts:
// lib/monitor.ts
// 把錯誤與效能監控抽象成一個介面,背後可以是 Sentry 或本地實作。
// 2026 年 3 月主流:Sentry JavaScript SDK 9.x,本檔案沿用同一份介面。
export type MonitorContext = {
user?: { id: string; email?: string; role?: string };
url?: string;
extra?: Record<string, unknown>;
};
export interface Monitor {
init(): void;
captureException(error: unknown, context?: MonitorContext): void;
captureMessage(message: string, level?: "info" | "warning" | "error"): void;
startTransaction(name: string): { finish(): void };
setUser(user: MonitorContext["user"] | null): void;
}
// 內部常數:依環境變數決定要走哪一個實作
const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
class NoopMonitor implements Monitor {
init(): void {}
captureException(): void {}
captureMessage(): void {}
startTransaction() {
return { finish(): void {} };
}
setUser(): void {}
}
class SentryMonitor implements Monitor {
private sentry: typeof import("@sentry/nextjs") | null = null;
async init(): Promise<void> {
if (this.sentry) return;
const mod = await import("@sentry/nextjs");
this.sentry = mod;
mod.init({
dsn: SENTRY_DSN,
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0,
beforeSend(event) {
// 把潛在個資欄位遮罩掉(個資法遵循)
if (event.request?.cookies) {
event.request.cookies = "[REDACTED]";
}
if (event.user?.email) {
event.user.email = "[REDACTED]";
}
return event;
},
});
}
captureException(error: unknown, context?: MonitorContext): void {
this.sentry?.captureException(error, { extra: context?.extra, tags: { url: context?.url ?? "" } });
}
captureMessage(message: string, level: "info" | "warning" | "error" = "info"): void {
this.sentry?.captureMessage(message, level);
}
startTransaction(name: string) {
const tx = this.sentry?.startTransaction({ name });
return {
finish(): void {
tx?.finish();
},
};
}
setUser(user: MonitorContext["user"] | null): void {
if (user) {
this.sentry?.setUser({ id: user.id, email: user.email, role: user.role });
} else {
this.sentry?.setUser(null);
}
}
}
class LocalMonitor implements Monitor {
// 把錯誤事件寫進 console 與 localStorage,方便沒有 Sentry 時的除錯
private buffer: Array<{ type: string; payload: unknown; at: string }> = [];
init(): void {
if (typeof window === "undefined") return;
window.addEventListener("error", (event) => {
this.captureException(event.error ?? event.message, { url: location.href });
});
window.addEventListener("unhandledrejection", (event) => {
this.captureException(event.reason, { url: location.href });
});
}
captureException(error: unknown, context?: MonitorContext): void {
const payload = { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, context };
this.push("exception", payload);
if (typeof console !== "undefined") {
console.error("[monitor:exception]", payload);
}
}
captureMessage(message: string, level: "info" | "warning" | "error" = "info"): void {
this.push("message", { message, level });
const fn = level === "error" ? console.error : level === "warning" ? console.warn : console.info;
fn("[monitor:message]", message);
}
startTransaction(name: string) {
const startedAt = performance.now();
return {
finish(): void {
const dur = performance.now() - startedAt;
this.push("transaction", { name, durationMs: Math.round(dur) });
},
};
}
setUser(user: MonitorContext["user"] | null): void {
this.push("user", user);
}
private push(type: string, payload: unknown): void {
const entry = { type, payload, at: new Date().toISOString() };
this.buffer.push(entry);
if (typeof window !== "undefined") {
try {
window.localStorage.setItem("monitor:buffer", JSON.stringify(this.buffer.slice(-50)));
} catch {
// localStorage 可能滿了或被關閉,忽略錯誤不影響主流程
}
}
}
}
export const monitor: Monitor = SENTRY_DSN ? new SentryMonitor() : new LocalMonitor();
這份抽象層有三個關鍵設計。第一,用 interface Monitor 把所有監控操作統一,captureException、captureMessage、startTransaction、setUser 四個方法構成最小可用介面。第二,SentryMonitor 用 dynamic import("@sentry/nextjs") 動態載入,避免在沒有 DSN 的環境也被打包進去;這對「一個專案同時有雲端與本地兩種部署」的情境特別重要。第三,LocalMonitor 把事件寫進 localStorage 的 monitor:buffer 鍵,保留最近 50 筆,這樣即使沒有雲端儀表板,開發者也可以在瀏覽器的 Application 分頁打開來看。
本地版的 init() 註冊了兩個全域監聽:window.error 接同步例外、window.unhandledrejection 接 Promise 拒絕。這兩個事件是「使用者拋出但程式沒接住」的保險,沒有它們很多 bug 會默默消失。注意 LocalMonitor 的 captureException 收到 Error 物件時會把 stack 一起存,這樣就算沒有 Source Map 也能看到堆疊的檔名與行數(雖然是壓縮後的行數,但至少知道是哪個檔出問題)。
接下來實作 React 錯誤邊界(error boundary)。React 19.x 提供函式元件版的錯誤邊界 hook,但官方建議還是寫成類別元件。我們寫一個會把錯誤送到 monitor 的邊界:
// components/ErrorBoundary.tsx
"use client";
import { Component, type ReactNode } from "react";
import { monitor } from "@/lib/monitor";
type Props = {
children: ReactNode;
fallback?: (error: Error, reset: () => void) => ReactNode;
};
type State = { error: Error | null };
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: { componentStack?: string }): void {
monitor.captureException(error, {
url: typeof window === "undefined" ? undefined : window.location.href,
extra: { componentStack: info.componentStack },
});
}
reset = (): void => {
this.setState({ error: null });
};
render(): ReactNode {
if (this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.reset);
}
return (
<div role="alert" className="rounded border border-red-300 bg-red-50 p-4">
<h2 className="text-lg font-semibold text-red-700">發生錯誤</h2>
<p className="mt-2 text-sm text-red-600">{this.state.error.message}</p>
<button
type="button"
onClick={this.reset}
className="mt-3 rounded bg-red-600 px-3 py-1 text-white"
>
重試
</button>
</div>
);
}
return this.props.children;
}
}
這個錯誤邊界有三個重點。getDerivedStateFromError 在渲染階段更新 state,觸發 fallback UI;componentDidCatch 在 commit 階段把錯誤送到 monitor,這兩階段分工是 React 設計的硬規則。fallback 接受函式讓呼叫端決定怎麼顯示錯誤,例如可以做成「重新整理」按鈕、Toast、或整頁錯誤頁。reset 方法把 state 清回 null,讓子樹重新嘗試渲染,這對「網路短暫斷線」這類暫時性錯誤特別有用。
接下來在 Next.js App Router 的根 layout 套上邊界,並且註冊路由層級的錯誤頁:
// app/layout.tsx
import type { ReactNode } from "react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-Hant">
<body>
<ErrorBoundary>{children}</ErrorBoundary>
</body>
</html>
);
}
// app/global-error.tsx
"use client";
import { useEffect } from "react";
import { monitor } from "@/lib/monitor";
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
useEffect(() => {
monitor.captureException(error, { url: typeof window === "undefined" ? undefined : window.location.href });
}, [error]);
return (
<html lang="zh-Hant">
<body>
<h1>系統暫時無法回應</h1>
<button type="button" onClick={reset}>重試</button>
</body>
</html>
);
}
app/layout.tsx 的 ErrorBoundary 包住所有頁面內容,app/global-error.tsx 是 Next.js 對「layout 自己也出錯」這條死角的保險——當錯誤發生在 layout 內部(例如讀取根 metadata 失敗),只有 global-error 才能救。這兩個層級搭配起來,整個應用程式的錯誤覆蓋率就接近百分之百。
Sentry 專屬整合:sentry.client.config.ts
用 @sentry/nextjs 時,Next.js 會期待專案根目錄有兩個設定檔:sentry.client.config.ts 給瀏覽器用、sentry.server.config.ts 給伺服器用。我們把昨天的 monitor.init() 改成自動呼叫它們:
// sentry.client.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.1,
// 在開發環境不要把錯誤送出去,避免干擾本機除錯
enabled: process.env.NODE_ENV === "production",
beforeSend(event) {
if (event.user?.email) event.user.email = "[REDACTED]";
return event;
},
});
// sentry.server.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.05,
enabled: process.env.NODE_ENV === "production",
});
接著調整 next.config.js 讓 Next.js 在 build 時自動上傳 Source Map:
// next.config.js
import { withSentryConfig } from "@sentry/nextjs";
const baseConfig = {
// 你原本的 Next.js 設定
reactStrictMode: true,
};
export default withSentryConfig(baseConfig, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
silent: !process.env.CI,
hideSourceMaps: true,
});
withSentryConfig 會在 build 結束後呼叫 sentry-cli,把 .next/static/chunks 裡的 Source Map 上傳到 Sentry 平台。實務上 SENTRY_AUTH_TOKEN 必須在 CI 或本機的環境變數裡設定;Vercel 用 Project Settings → Environment Variables 設定,build log 會印出上傳結果。這層整合漏掉的話,錯誤追蹤雖然能收到事件,但堆疊會指向壓縮檔,看不出來原始碼行。
效能監控:Web Vitals 與 transaction
錯誤追蹤之外,效能監控是另一個獨立但相關的需求。Google 在 2026 年 3 月把 LCP(最大內容繪製)、INP(互動到下次繪製)、CLS(版面配置位移)列為 Core Web Vitals。Next.js 15 提供 useReportWebVitals hook 把這些指標送出去,我們把它接到 monitor:
// app/layout.tsx(節錄)
"use client";
import { useReportWebVitals } from "next/web-vitals";
import { monitor } from "@/lib/monitor";
export function WebVitalsReporter() {
useReportWebVitals((metric) => {
monitor.captureMessage(`web-vital:${metric.name}=${metric.value}`, "info");
});
return null;
}
這段把每個 Web Vital 指標變成一則訊息送到 monitor。本地版本會寫進 console;Sentry 版本會被 Performance 收集,可以在 Sentry 儀表板用「瀏覽器效能分布」圖表看到 P75 的 LCP、INP 數值。對前端團隊來說,這比看 Lighthouse 報表更即時——Lighthouse 是本機模擬、Sentry 是真實使用者。
沒有外部服務時的替代流程
如果不想註冊 Sentry 或還在評估要不要付費,前面寫的 LocalMonitor 就足夠開發與 staging 環境使用。我們寫一支小工具把 localStorage.monitor:buffer 的事件匯出成 JSON 檔,方便貼進 issue 或寄給同事:
# 本地開發時,把 monitor 事件印到終端機
pnpm dev
# 在瀏覽器 DevTools console 執行:
# JSON.parse(localStorage.getItem("monitor:buffer") || "[]")
# 觀察最近 50 筆錯誤與效能事件
對沒有 Sentry 帳號的讀者,這條路徑可以讓你在不依賴外部服務的前提下,把監控當作「開發紀律」先做起來。當流量長大、需要跨使用者的彙總報表時再升級到 Sentry;介面與程式碼完全不變,只差一個環境變數的設定。
常見錯誤與踩雷
第一個踩雷是「把開發環境的錯誤也送出去」。很多團隊在 sentry.client.config.ts 漏設 enabled: process.env.NODE_ENV === "production",結果本地按 F5 重整瀏覽器也會把一堆 TypeError 送到 Sentry,污染正式環境的資料、也讓自己的 quota 提早用完。務必用 enabled 把開發環境關掉,或者用 Sentry 的 environment 區分 dev / staging / prod。
第二個踩雷是「Source Map 沒上傳」。很多團隊以為接好 SDK 就結束,結果第一次發生錯誤,看到的堆疊是「main.bundle.js:1:1234」這種沒有意義的位置。其實只要在 next.config.js 套上 withSentryConfig,Source Map 就會自動上傳,否則要手動用 sentry-cli 跑一次 sourcemaps upload。Vercel 部署時如果 build log 沒看到 Uploading source maps 那行,幾乎可以確定沒有上傳成功。
第三個踩雷是「個資外洩」。Sentry 預設會把錯誤訊息與堆疊完整送出去,但錯誤訊息裡可能夾帶信用卡號、表單輸入的 email、URL 裡的 token。務必在 beforeSend hook 過濾敏感欄位,並對表單輸入做額外的 mask;本地版本也應該實作相同的過濾,否則開發者打開 localStorage 就會看到完整個資。在台灣的《個人資料保護法》底下這條特別重要,曾有團隊因為 Sentry 事件被搜尋引擎索引而收到罰款。
第四個踩雷是「把所有元件都包進錯誤邊界」。邊界不是越多越好,包太多會讓錯誤被吞掉、看不到原本的錯誤介面。建議策略是「每個有意義的功能區塊一個邊界、整頁一個根邊界」。當一個邊界接住錯誤並顯示 fallback UI 時,其他區塊仍然正常運作——這就是分區隔離的價值;但邊界太多則會把真正的問題埋在巢狀 fallback 後面,反而難除錯。
第五個踩雷是「效能監控 sample rate 設太高」。Sentry 的 transaction 是有成本的,每一個 transaction 都要付費或佔 quota。預設的 tracesSampleRate: 0.1 是合理值(10% 取樣);如果你的流量很高,可以進一步降到 0.01 或 0.05,並在重要的頁面(例如結帳頁)用 tracesSampler 函式把 sample rate 拉到 1.0,兼顧成本與重要頁面的資料完整度。
效能與實務提醒
Sentry 的 bundle size 約 30 KB gzipped,這對大多數 Next.js 應用不會有明顯影響,但如果你很在意 Core Web Vitals,可以用 dynamic import 把 SDK 延遲到互動後再載入:const Sentry = await import("@sentry/nextjs")。這種「延遲載入監控」的模式犧牲一點錯誤捕捉率(使用者離開太快的事件會漏掉),換取較好的 LCP 與 INP。對內部工具來說不必這樣做;對電商或內容網站則可以考慮。
另一個實務提醒是「錯誤事件需要去重」。同一個 bug 可能在短時間內被上千個使用者觸發,如果每個事件都送到 Sentry,quota 一下就用完。Sentry 預設會做事件去重(fingerprint),但對於「同一個 API 短暫壞掉、造成 500 個失敗請求」這種情況,可以用 beforeSend 回傳 null 直接丟棄,或用 Sentry.withScope 自訂 fingerprint 把同一個原因的事件合併。
對於沒有 Sentry 帳號又想看效能趨勢的團隊,本地版本可以擴充成「上傳到自己的後端」:把 LocalMonitor.buffer 用 navigator.sendBeacon 批次送到自家 API,再把資料存進 Postgres、用 Grafana 畫圖表。這條路徑工作量較大,但完全不需要外部服務、也可以掌握所有資料。對 side project 或內部系統來說,這是個經濟實惠的選擇。
最後一個提醒:monitor.captureException 不應該在 render 階段呼叫,只能在事件處理、useEffect、或錯誤邊界的 componentDidCatch 呼叫。原因很直接:render 階段是 React 的純函式階段,不該有 side effect(送網路請求);如果 render 階段自己出錯、又被送出去,會形成「錯誤送出 → 觸發新錯誤 → 再送出」的無止盡迴圈。本地版本目前用 try/catch 包住 console 呼叫,所以即使在 render 階段誤用也不會炸,但生產環境仍應遵循這個慣例。
小結
今天把前端監控的兩個核心——錯誤追蹤與效能監控——用 Sentry 與本地雙軌實作完成。lib/monitor.ts 抽象出統一介面,ErrorBoundary 接住 React 元件錯誤,global-error.tsx 接住 layout 死角的錯誤,useReportWebVitals 把 Core Web Vitals 串到監控管線。withSentryConfig 把 Source Map 上傳自動化,beforeSend 把個資遮罩寫進流程。沒有外部服務帳號時,LocalMonitor 把事件寫進 localStorage,可以在 DevTools 看到。明天 Day 31 開始進入貫穿專案「預約管理系統」:我們要定義前端的設計、路由、與 Web 系列 FastAPI 後端的串接契約,把前三十天學到的 React、Next.js、Tailwind、TypeScript 全部集合起來。
結語
監控不是「上線以後再加」,而是「寫第一支元件時就要設計」。今天把抽象層做好,未來要換工具、改策略、補欄位都不用改業務程式碼。明天,我們會正式開工貫穿專案:定義路由結構、設計系統的視覺語彙、與 Web 系列 Day 35–44 定義的 API 契約對齊,把這個預約管理系統的前端骨架先畫出來。Day 32 會在這個骨架上實際建立 Next.js 專案、Day 33 開始寫清單頁、Day 34 寫預約流程,一路到 Day 45 結束。今天寫的 monitor 介面會被後續 14 天的所有元件用到,是整個專案篇的基礎建設之一。
延伸資源
- Sentry Next.js 官方整合指南(2026 年 3 月,9.x 世代):
https://docs.sentry.io/platforms/javascript/guides/nextjs/ - React 19 錯誤邊界官方說明:
https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary - Next.js 15 Web Vitals 與
useReportWebVitals:https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals - Core Web Vitals 官方指標定義(LCP、INP、CLS):
https://web.dev/vitals/ - 個資法遵循與 Sentry 事件過濾實務:
https://docs.sentry.io/platforms/javascript/configuration/filtering/
留言
張貼留言