Học từ Facebook #6: ErrorGuard, bắt lỗi theo ngữ cảnh
ErrorGuard của Facebook: guard stack cho biết lỗi xảy ra ở đâu, một pipeline chung cho mọi loại lỗi. Viết lại bằng TypeScript, kèm các bẫy khi lên production.
Stack trace từ production sau khi minify trông như sau:
TypeError: Cannot read properties of undefined (reading 'id')
at a (main.3f9a1c.js:1:48213)
at Array.forEach (<anonymous>)
at c (main.3f9a1c.js:1:51002)
text
Có source map thì bạn biết lỗi ở dòng nào. Nhưng thường bạn vẫn không biết lỗi xảy ra trong ngữ cảnh nào: người dùng đang bấm Like, đang load comment, hay đó là event từ websocket? Ngoài ra còn hai vấn đề khó chịu hơn:
- Một listener lỗi trong vòng
forEachsẽ làm các listener sau nó không bao giờ chạy. Một plugin viết ẩu có thể làm hỏng cả trang. - Lỗi đến từ nhiều cửa khác nhau (
try/catch,window.onerror, promise bị reject, React error boundary), mỗi cửa một format. Server nhận về một mớ không gom nhóm được.
Tip
Tóm tắt
- Bọc các điểm vào (listener, callback của observer, plugin) bằng
guard(fn, { name }): một callback lỗi không chặn những callback còn lại. - Guard lồng nhau tạo guard stack, nên lỗi mang theo ngữ cảnh nghiệp vụ như
Feed.render › LikeButton.onClick. - Mọi cửa lỗi (guard,
window.onerror,unhandledrejection, React) đổ về một hàm chuẩn hoá và một pub/sub, có lịch sử để phát lại cho listener đăng ký muộn. - Guard stack chỉ đúng với code đồng bộ; sau
awaitthì phải truyền ngữ cảnh bằng tay.
Facebook có cả một nhóm module cho chuyện này: ErrorGuard, ErrorGuardState, ErrorPubSub, ErrorNormalizeUtils, ErrorSerializer, ErrorGlobalEventHandler, ErrorUnhandledRejectionHandler, ErrorBoundary.react… (thư mục ErrorGuard/ và ErrorBoundary/ của facebook-codebase). Phiên bản đơn giản hơn của ErrorGuard cũng có trong React Native.
#Bức tranh tổng thể
Trước khi đi vào từng module, đây là đường đi của một lỗi trong hệ thống của Facebook:
flowchart LR A["applyWithGuard / guard"] --> N["normalizeError"] B["window error event"] --> N C["unhandledrejection"] --> N D["ErrorBoundary componentDidCatch"] --> N N --> P["ErrorPubSub.reportNormalizedError"] P --> L1["Console listener"] P --> L2["Logging listener gửi lên server"] P --> H["history 50 lỗi gần nhất"]
Mọi cửa đều đổ về một hàm chuẩn hoá và một pub/sub. Phần còn lại của app chỉ cần đăng ký listener, không cần biết lỗi đến từ đâu.
#Bên trong trình duyệt: lỗi đi đường nào?
Hiểu cơ chế của trình duyệt giúp biết cửa nào bắt được lỗi nào.
Lỗi đồng bộ không ai bắt. Khi một task (callback của setTimeout, một event listener, một script) throw mà không có catch, trình duyệt "report the exception": tạo một ErrorEvent và dispatch lên window. Event có message, filename, lineno, colno và error (giá trị bị throw). Task đó dừng, nhưng event loop vẫn chạy tiếp các task khác.
Mỗi DOM event listener được trình duyệt "guard" sẵn. Nếu bạn addEventListener('click', a) và addEventListener('click', b), a throw thì b vẫn chạy, vì spec yêu cầu báo lỗi của từng listener riêng rồi gọi listener kế tiếp. Event emitter tự viết bằng forEach thì không có cơ chế đó. Đây chính là khoảng trống mà applyWithGuard lấp vào.
"Script error." Nếu lỗi xảy ra trong một script tải từ origin khác (CDN, static.example.com), trình duyệt che hết thông tin để không rò dữ liệu xuyên origin: message = "Script error.", filename = "", lineno = 0, error = null. Muốn thấy lỗi thật thì cần cả hai:
<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>
html
và server CDN trả về header Access-Control-Allow-Origin (ví dụ * hoặc origin của bạn). Thiếu một trong hai là vẫn "Script error.". Vì vậy khi event.error là null, handler của bản viết lại (ở dưới) tạo một lỗi riêng tên OpaqueScriptError (message "Script error.", stack là filename:line:col nếu có). Nhìn tên lỗi trên dashboard là biết ngay phải đi kiểm tra cấu hình CORS, thay vì nhầm thành một lỗi "throw không phải Error".
Promise bị reject mà không ai xử lý. Không đi qua error mà qua unhandledrejection. Trình duyệt kiểm tra sau khi microtask queue trống: promise nào đã reject mà chưa có handler thì bắn event, kèm event.reason (giá trị reject, không nhất thiết là Error) và event.promise. Nếu sau đó có người .catch() muộn, trình duyệt bắn thêm rejectionhandled. Ứng dụng cẩn thận có thể dùng event này để "rút lại" báo cáo.
Lỗi trong React. Lỗi trong render, lifecycle và effect không đi qua try/catch của bạn. React tự bắt rồi tìm error boundary gần nhất:
- Có boundary:
componentDidCatch(error, info)được gọi,info.componentStackcho biết cây component. - Từ React 19,
createRoot(container, { onCaughtError, onUncaughtError, onRecoverableError })cho bạn một chỗ duy nhất để nhận mọi lỗi React, kể cả lỗi đã được boundary bắt. Mặc định, lỗi không có boundary nào bắt được báo quawindow.reportError, nên vẫn tới được handlererrorcủa bạn. Lỗi đã được boundary bắt mặc định chỉ đượcconsole.error. - Lỗi trong event handler của React (
onClick) không đi qua boundary. Nó là lỗi JavaScript bình thường, bay lênwindownhư mọi listener khác.
Ghép lại thành bảng "cửa nào bắt cái gì":
| Tình huống | Cửa bắt được |
|---|---|
Callback bọc guard() throw |
GUARDED (lỗi không lan ra ngoài) |
setTimeout/listener/script throw, không có guard |
window error (GLOBAL) |
| Script cross-origin throw, thiếu CORS | GLOBAL, nhưng chỉ còn "Script error." |
async function throw, không ai await/.catch |
unhandledrejection |
| Component render throw, có boundary | componentDidCatch / onCaughtError (REACT) |
| Component render throw, không có boundary | onUncaughtError → mặc định window.reportError → GLOBAL |
onClick của React throw |
GLOBAL |
#Facebook làm thế nào?
#1. applyWithGuard và guard stack
applyWithGuard(fn, context, args, { name }) gọi hàm trong try/catch. Lỗi bị bắt lại, không văng ra ngoài, nên code gọi (vòng lặp listener, scheduler, observer) vẫn chạy tiếp.
Trước khi gọi, nó pushGuard({ name, deferredSource }) vào ErrorGuardState, một mảng toàn cục. Gọi xong thì popGuard() trong finally. Khi lỗi xảy ra, cloneGuardList() chụp lại danh sách tên guard đang mở và gắn vào lỗi. Vì JavaScript chạy đơn luồng, tại thời điểm throw, stack này phản ánh chính xác chuỗi lời gọi đang diễn ra.
Hai chi tiết nhỏ khi đọc code gốc:
ErrorGuardStatedùngunshift/shift, nên guard trong cùng đứng đầu danh sách. (Bản viết lại ở dưới dùngpush/popđể guard ngoài cùng đứng đầu, đọc như breadcrumb.)- Guard không có tên thì dùng
"func_name:" + fn.name, nếu không có cả tên hàm thì là"<anonymous guard>".
#2. deferredSource: nối ngữ cảnh qua ranh giới bất đồng bộ
Mỗi guard có thể mang theo deferredSource, một Error được truyền vào lúc gọi guard. Cách dùng hợp lý là tạo nó lúc lên lịch callback (ví dụ lúc gọi setTimeout), để giữ lại stack của nơi lên lịch. Khi callback lỗi sau đó, ErrorPubSub gọi findDeferredSource() để tìm guard gần nhất có deferredSource và gắn stack của nó vào lỗi. Nhờ vậy, lỗi trong callback vẫn kèm được câu trả lời cho câu hỏi "ai đã lên lịch callback này?". Đây chính là "async stack trace" tự làm, trước khi DevTools hỗ trợ.
#3. Chuẩn hoá lỗi
getErrorSafe xử lý những thứ "không phải Error" bị throw. Đọc code thấy ba trường hợp:
| Thứ bị throw | Kết quả |
|---|---|
null, undefined, string, number |
Non-object thrown: <giá trị> |
Object không có message kiểu string (throw { code: 1 }) |
Non-error thrown: …, keys: ["code"] |
Error đã bị Object.freeze |
Non-extensible thrown: … (vì không gắn thêm metadata vào được) |
Để ý họ không dùng instanceof Error mà kiểm tra typeof a.message === "string". Error đến từ iframe khác (một "realm" khác) có Error constructor khác, nên instanceof sẽ trả về false sai.
Sau đó ErrorNormalizeUtils.normalizeError đưa mọi thứ về một object phẳng gồm name, message, messageFormat, messageParams, stack, stackFrames, guardList, loggingSource, clientTime, page_time, windowLocationURL, componentStackFrames… Có ba điểm đáng học:
Error.stackTraceLimitđược nâng lên 80 (mặc định của V8 là 10). Với app React nhiều tầng, 10 frame thường chỉ toàn code của React.messageFormattách khỏimessageParams. Lỗi được viết dạngerr("Không tìm thấy story %s", id). Server gom nhóm theomessageFormat, nên 10.000 lỗi với 10.000 id khác nhau vẫn chỉ là một nhóm.- Có
hash=getSimpleHash(name, stack, type, project, loggingSource), một hash 6 ký tự. Đây là "fingerprint" để gom nhóm và dedupe.
#4. Global handlers
ErrorGlobalEventHandler nghe window.addEventListener("error"):
- Có
event.errorthì dùng nó. Không có thì tạo lỗi từevent.message. - Bù
fileName,line,columntừevent.filename,lineno,colnonếu lỗi thiếu. - Gắn
guardList = ["<window.onerror>"]vàloggingSource = "ONERROR". - Lỗi chứa chữ
ResizeObserverđược hạ xuống mứcwarn. Lỗi "ResizeObserver loop…" xuất hiện rất nhiều trên mọi trang web lớn nhưng gần như vô hại.
ErrorUnhandledRejectionHandler nghe unhandledrejection. Nếu reason là object không có name/message thì JSON.stringify nó, gặp object vòng thì chỉ in danh sách key. Cuối cùng nó gọi event.preventDefault() để trình duyệt khỏi in lỗi thêm lần nữa ra console.
#5. ErrorPubSub: chống vòng lặp báo lỗi
Hàm reportNormalizedError có ba chi tiết bảo vệ:
- Cờ re-entrancy. Đang trong lúc báo lỗi mà một listener lại gây ra lỗi khác và gọi báo lỗi tiếp, lần gọi sau bị bỏ qua. Không có cờ này thì một listener gửi log bị lỗi sẽ tạo vòng lặp vô hạn.
- Lịch sử có giới hạn. Giữ khoảng 50 lỗi gần nhất. Khi đầy thì xoá ở giữa, nên vẫn giữ được những lỗi đầu tiên (thường là nguyên nhân gốc) và những lỗi mới nhất.
- Phát lại cho listener đến muộn. Listener đăng ký sau vẫn nhận được các lỗi đã xảy ra trước đó. Lỗi lúc khởi động trang vẫn được gửi đi dù module logging tải sau.
Chi tiết thêm từ code gốc: error boundary và nocatch
Error boundary.
ErrorBoundary.react của Facebook trong componentDidCatch:
- lấy tên module con (
getReactElementDisplayName) và tạomessageFormat = "caught error in module %s (%s)", - gắn
componentStack, đặtloggingSource = "ERROR_BOUNDARY"vàtype = "fatal", - đẩy vào cùng
ErrorPubSub, - có prop
forceResetErrorCount: tăng số này lên là boundary reset và thử render lại.
CometErrorBoundary còn gắn thêm pagelet stack (lỗi xảy ra ở vùng nào của trang: feed, chat, sidebar…) vào metadata.
nocatch.
Khi Env.nocatch bật, guard không bắt lỗi nữa mà để lỗi văng ra. DevTools với "Pause on uncaught exceptions" sẽ dừng đúng chỗ throw thay vì trong catch của guard.
#Viết lại
Bản viết lại giữ khung: guard stack, chuẩn hoá, pub/sub có lịch sử, global handlers và chế độ noCatch. Mình đi qua từng phần.
#Kiểu dữ liệu và chuẩn hoá
src/lib/errorGuard.ts
export interface NormalizedError {
name: string;
message: string;
stack?: string;
source: ErrorSource;
/** guard stack lúc lỗi xảy ra, ngoài cùng trước */
guardList: string[];
/** lỗi gốc có phải Error không (throw "abc" hay throw {} cũng gặp ngoài đời) */
wasErrorObject: boolean;
/** giá trị bị throw ban đầu, giữ nguyên để gửi cho Sentry (còn `cause`, field riêng…) */
original: unknown;
timestamp: number;
}
ts
src/lib/errorGuard.ts
export function normalizeError(
thrown: unknown,
source: ErrorSource,
guardList: string[] = [],
): NormalizedError {
const error = toError(thrown);
return {
name: error.name,
message: error.message,
stack: error.stack,
source,
guardList,
wasErrorObject: isErrorLike(thrown),
original: thrown,
timestamp: Date.now(),
};
}
ts
NormalizedErrorlà "hợp đồng" giữa nơi phát lỗi và nơi nhận lỗi. Bất cứ thứ gì gửi lên server đều phải theo đúng format này.originalgiữ nguyên giá trị bị throw để gửi cho Sentry: tạo lạiErrortừmessage/stacksẽ mấtcausevà các field riêng.toError(trong file đầy đủ) xử lýthrow "abc",throw {},throw null.JSON.stringifycó thể throw (object vòng,BigInt), nên được bọc thêmtry/catchvà fallback vềString(). Kiểm tra "có phải Error không" dùng duck typing như Facebook.wasErrorObjectcho biết "có phải Error thật không". Nếu số lượngthrowkhông phải Error tăng đột ngột, thường là một thư viện bên thứ ba đang làm bậy.
#Pub/sub có lịch sử
src/lib/errorGuard.ts
function callListener(listener: ErrorListener, error: NormalizedError) {
try {
listener(error);
} catch (listenerError) {
// Không báo lại qua pipeline (sẽ lặp vô hạn), nhưng cũng đừng nuốt im lặng:
// tích hợp Sentry hỏng mà không ai biết còn tệ hơn.
console.error('[errorGuard] listener lỗi:', listenerError);
}
}
let reporting = false;
/**
* Đưa một lỗi đã chuẩn hoá vào pipeline. Đặt tên khác `window.reportError` (API chuẩn của
* trình duyệt) để không nhầm lẫn / auto-import nhầm.
*/
export function reportNormalizedError(error: NormalizedError) {
// Chống vòng lặp: listener (vd: gửi log) gây lỗi -> lỗi đó lại được báo -> listener lại lỗi…
if (reporting) return;
reporting = true;
try {
history.push(error);
if (history.length > MAX_HISTORY) history.splice(MAX_HISTORY / 2, 1);
listeners.forEach((listener) => callListener(listener, error));
} finally {
reporting = false;
}
}
/**
* Đăng ký nhận lỗi. Mặc định phát lại các lỗi đã xảy ra trước khi đăng ký,
* để lỗi lúc khởi động không bị mất.
*/
export function onError(listener: ErrorListener, { replay = true }: { replay?: boolean } = {}) {
if (replay) history.forEach((error) => callListener(listener, error));
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
ts
- Cờ
reportingchặn vòng lặp: listener gửi log bị lỗi không được phép báo lỗi tiếp vào chính pipeline. - Listener lỗi không bị nuốt im lặng:
console.errorđể bạn biết tích hợp Sentry đang hỏng, nhưng không đưa lại vào pipeline. - Lịch sử 50 lỗi, đầy thì bỏ phần giữa, giống Facebook.
onErrormặc định phát lại lịch sử cho listener mới, nên lỗi lúc khởi động không mất dù Sentry khởi tạo sau. - Tên hàm là
reportNormalizedError(theo Facebook), cố ý tránh trùng vớiwindow.reportError, API chuẩn của trình duyệt.
#applyWithGuard từng bước
src/lib/errorGuard.ts
export function applyWithGuard<A extends unknown[], R>(
fn: (...args: A) => R,
thisArg: unknown,
args: A,
options: GuardOptions = {},
): R | undefined {
guardStack.push(options.name ?? (fn.name ? `func_name:${fn.name}` : '<anonymous guard>'));
try {
if (errorGuardConfig.noCatch) return fn.apply(thisArg, args);
try {
return fn.apply(thisArg, args);
} catch (thrown) {
const normalized = normalizeError(thrown, 'GUARDED', [...guardStack]);
// onError do người dùng truyền vào cũng có thể ném lỗi; không được để nó phá lời hứa
// "lỗi không lọt ra ngoài guard".
if (options.onError) callListener(options.onError, normalized);
reportNormalizedError(normalized);
return undefined;
}
} finally {
guardStack.pop();
}
}
export function guard<A extends unknown[], R>(fn: (...args: A) => R, options: GuardOptions = {}) {
return function (this: unknown, ...args: A) {
return applyWithGuard(fn, this, args, options);
};
}
ts
Theo dõi một lời gọi lồng nhau renderFeed() → onLike() (giống nút "Guard lồng nhau" trong demo):
sequenceDiagram participant Caller as Code gọi participant G1 as guard Feed.render participant G2 as guard LikeButton.onClick participant S as guardStack participant P as listeners Caller->>G1: renderFeed G1->>S: push Feed.render G1->>G2: onLike G2->>S: push LikeButton.onClick G2->>G2: fn throws Error G2->>S: copy stack thành guardList G2->>P: báo lỗi kèm guardList 2 phần tử G2->>S: pop trong finally G2-->>G1: trả về undefined G1->>S: pop trong finally G1-->>Caller: chạy tiếp bình thường
- Tên guard được
pushtrướctry, nên dù hàm throw hay không,finallyluônpopđúng một lần. Nếu đặtpopở cuốitry, một lỗi sẽ làm stack "rò" vĩnh viễn và mọi lỗi sau đó mang guard list sai. - Lỗi bị bắt ở guard trong cùng.
[...guardStack]chụp lại toàn bộ chuỗi["Feed.render", "LikeButton.onClick"]trước khifinallypop. - Guard ngoài không thấy lỗi nào: với nó,
onLike()chỉ trả vềundefined. Vì vậy kiểu trả về làR | undefined, và code gọi phải xử lý trường hợp không có kết quả. - Nhánh
noCatchvẫn push/pop, đểinGuard()vẫn đúng khi debug. options.onErrorcũng được gọi quacallListener: nếu chính nó throw, lỗi vẫn không lọt ra ngoài guard.
#Global handlers
src/lib/errorGuard.ts
/** Bắt cả lỗi lọt ra ngoài mọi guard. Gọi một lần lúc khởi động app. */
export function installGlobalErrorHandlers() {
const onWindowError = (event: ErrorEvent) => {
if (event.error == null) {
// Lỗi từ script khác origin thiếu `crossorigin` + header CORS: trình duyệt giấu hết
// chi tiết, chỉ còn message "Script error.". Báo đúng bản chất thay vì "Non-error thrown".
const error = new Error(event.message || 'Script error.');
error.name = 'OpaqueScriptError';
error.stack = `${event.filename || '(unknown)'}:${event.lineno}:${event.colno}`;
reportNormalizedError(normalizeError(error, 'GLOBAL'));
return;
}
reportNormalizedError(normalizeError(event.error, 'GLOBAL'));
};
const onRejection = (event: PromiseRejectionEvent) => {
reportNormalizedError(normalizeError(event.reason, 'UNHANDLED_REJECTION'));
};
window.addEventListener('error', onWindowError);
window.addEventListener('unhandledrejection', onRejection);
return () => {
window.removeEventListener('error', onWindowError);
window.removeEventListener('unhandledrejection', onRejection);
};
}
ts
Khác bản gốc: guard stack ngoài cùng đứng đầu; không có deferredSource, messageFormat/messageParams hay hash; lỗi ResizeObserver không được hạ mức; unhandledrejection không gọi preventDefault() (lỗi vẫn hiện trong console, tiện khi debug).
Xem toàn bộ src/lib/errorGuard.ts
src/lib/errorGuard.ts
/**
* Bắt lỗi theo "ngữ cảnh" (ý tưởng từ ErrorGuard / ErrorPubSub / ErrorNormalizeUtils của Facebook).
*
* - `guard(fn, { name })` bọc callback: lỗi bị bắt, chuẩn hoá và gửi tới các listener,
* code gọi callback vẫn chạy tiếp.
* - Guard lồng nhau tạo thành "guard stack", nên khi lỗi ta biết nó xảy ra ở đâu:
* ["Feed.onEvent", "LikeButton.onClick"] thay vì chỉ một stack trace đã minify.
* - Lỗi lọt ra ngoài (window.onerror, unhandledrejection) cũng đi qua cùng một pipeline.
*/
export type ErrorSource = 'GUARDED' | 'GLOBAL' | 'UNHANDLED_REJECTION' | 'REACT';
export interface NormalizedError {
name: string;
message: string;
stack?: string;
source: ErrorSource;
/** guard stack lúc lỗi xảy ra, ngoài cùng trước */
guardList: string[];
/** lỗi gốc có phải Error không (throw "abc" hay throw {} cũng gặp ngoài đời) */
wasErrorObject: boolean;
/** giá trị bị throw ban đầu, giữ nguyên để gửi cho Sentry (còn `cause`, field riêng…) */
original: unknown;
timestamp: number;
}
type ErrorListener = (error: NormalizedError) => void;
const guardStack: string[] = [];
const listeners = new Set<ErrorListener>();
// Lịch sử lỗi gần nhất để phát lại cho listener đăng ký muộn (vd: Sentry khởi tạo sau khi
// app đã chạy được một lúc). Đầy thì bỏ phần giữa: giữ lỗi đầu phiên (thường là nguyên nhân
// gốc) và lỗi mới nhất — giống ErrorPubSub của Facebook.
const MAX_HISTORY = 50;
const history: NormalizedError[] = [];
export const errorGuardConfig = {
/**
* true = không bắt lỗi, để lỗi văng ra cho DevTools dừng đúng chỗ (FB gọi là `nocatch`).
* Hữu ích khi debug local.
*/
noCatch: false,
};
/**
* Kiểm tra "giống Error" bằng duck typing thay vì instanceof: Error tạo trong iframe khác
* (realm khác) có prototype khác, `instanceof Error` sẽ trả về false.
*/
function isErrorLike(value: unknown): value is Error {
return (
value instanceof Error ||
(typeof value === 'object' &&
value !== null &&
typeof (value as Error).message === 'string' &&
typeof (value as Error).name === 'string')
);
}
/** Biến mọi thứ bị `throw` thành Error để luôn có name/message/stack. */
export function toError(thrown: unknown): Error {
if (isErrorLike(thrown)) return thrown;
let message: string;
try {
message = typeof thrown === 'string' ? thrown : JSON.stringify(thrown);
} catch {
message = String(thrown);
}
const error = new Error(`Non-error thrown: ${message}`);
error.name = 'NonError';
return error;
}
export function normalizeError(
thrown: unknown,
source: ErrorSource,
guardList: string[] = [],
): NormalizedError {
const error = toError(thrown);
return {
name: error.name,
message: error.message,
stack: error.stack,
source,
guardList,
wasErrorObject: isErrorLike(thrown),
original: thrown,
timestamp: Date.now(),
};
}
function callListener(listener: ErrorListener, error: NormalizedError) {
try {
listener(error);
} catch (listenerError) {
// Không báo lại qua pipeline (sẽ lặp vô hạn), nhưng cũng đừng nuốt im lặng:
// tích hợp Sentry hỏng mà không ai biết còn tệ hơn.
console.error('[errorGuard] listener lỗi:', listenerError);
}
}
let reporting = false;
/**
* Đưa một lỗi đã chuẩn hoá vào pipeline. Đặt tên khác `window.reportError` (API chuẩn của
* trình duyệt) để không nhầm lẫn / auto-import nhầm.
*/
export function reportNormalizedError(error: NormalizedError) {
// Chống vòng lặp: listener (vd: gửi log) gây lỗi -> lỗi đó lại được báo -> listener lại lỗi…
if (reporting) return;
reporting = true;
try {
history.push(error);
if (history.length > MAX_HISTORY) history.splice(MAX_HISTORY / 2, 1);
listeners.forEach((listener) => callListener(listener, error));
} finally {
reporting = false;
}
}
/**
* Đăng ký nhận lỗi. Mặc định phát lại các lỗi đã xảy ra trước khi đăng ký,
* để lỗi lúc khởi động không bị mất.
*/
export function onError(listener: ErrorListener, { replay = true }: { replay?: boolean } = {}) {
if (replay) history.forEach((error) => callListener(listener, error));
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function inGuard() {
return guardStack.length > 0;
}
export interface GuardOptions {
name?: string;
onError?: (error: NormalizedError) => void;
}
export function applyWithGuard<A extends unknown[], R>(
fn: (...args: A) => R,
thisArg: unknown,
args: A,
options: GuardOptions = {},
): R | undefined {
guardStack.push(options.name ?? (fn.name ? `func_name:${fn.name}` : '<anonymous guard>'));
try {
if (errorGuardConfig.noCatch) return fn.apply(thisArg, args);
try {
return fn.apply(thisArg, args);
} catch (thrown) {
const normalized = normalizeError(thrown, 'GUARDED', [...guardStack]);
// onError do người dùng truyền vào cũng có thể ném lỗi; không được để nó phá lời hứa
// "lỗi không lọt ra ngoài guard".
if (options.onError) callListener(options.onError, normalized);
reportNormalizedError(normalized);
return undefined;
}
} finally {
guardStack.pop();
}
}
export function guard<A extends unknown[], R>(fn: (...args: A) => R, options: GuardOptions = {}) {
return function (this: unknown, ...args: A) {
return applyWithGuard(fn, this, args, options);
};
}
/** Bắt cả lỗi lọt ra ngoài mọi guard. Gọi một lần lúc khởi động app. */
export function installGlobalErrorHandlers() {
const onWindowError = (event: ErrorEvent) => {
if (event.error == null) {
// Lỗi từ script khác origin thiếu `crossorigin` + header CORS: trình duyệt giấu hết
// chi tiết, chỉ còn message "Script error.". Báo đúng bản chất thay vì "Non-error thrown".
const error = new Error(event.message || 'Script error.');
error.name = 'OpaqueScriptError';
error.stack = `${event.filename || '(unknown)'}:${event.lineno}:${event.colno}`;
reportNormalizedError(normalizeError(error, 'GLOBAL'));
return;
}
reportNormalizedError(normalizeError(event.error, 'GLOBAL'));
};
const onRejection = (event: PromiseRejectionEvent) => {
reportNormalizedError(normalizeError(event.reason, 'UNHANDLED_REJECTION'));
};
window.addEventListener('error', onWindowError);
window.addEventListener('unhandledrejection', onRejection);
return () => {
window.removeEventListener('error', onWindowError);
window.removeEventListener('unhandledrejection', onRejection);
};
}
ts
#Demo
- Emit không guard / có guard: một event emitter có 3 listener, listener thứ 2 bị lỗi. Không có guard thì listener 3 không chạy (
[1]). Có guard thì listener 1 và 3 vẫn chạy ([1, 3]), lỗi của listener 2 được báo kèm tên guard. - Guard lồng nhau: log hiện đầy đủ
Feed.render › LikeButton.onClick. - throw "string", Promise reject, Component lỗi: đều đi chung pipeline, khác nhau ở
source.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Tại sao không làm cách khác?
"Sao không try/catch ngay tại chỗ?" Bạn vẫn nên làm vậy khi biết cách xử lý lỗi (retry, hiện thông báo). Guard dành cho chỗ không biết làm gì ngoài báo cáo. Nếu mỗi chỗ tự try/catch rồi tự gửi lên Sentry, sẽ có mười format, mười cách gắn context và mười chỗ quên.
"Sao không chỉ dùng window.onerror?" Vì nó đến quá muộn: listener thứ 3 trong ví dụ trên đã không được chạy. Nó cũng mất ngữ cảnh: chỉ có stack trace, không có "đang làm gì". Và nó không thấy lỗi bị thư viện nuốt hay promise bị reject.
"Sao không bọc guard mọi hàm?" Vì nuốt lỗi ở mọi nơi sẽ che bug: code chạy tiếp với state sai, lỗi thứ hai xảy ra ở chỗ không liên quan. Facebook đặt guard ở điểm vào (entry point), nơi "ai đó bên ngoài gọi vào code của mình": callback của scheduler, của IntersectionObserver (xem bài #1), lời gọi vào storage trong Banzai (bài #7), listener của event bus. Bên trong logic nghiệp vụ, cứ để lỗi bay tự nhiên tới guard gần nhất.
"Chi phí hiệu năng?" try/catch trong các engine hiện đại gần như miễn phí khi không có lỗi. Chi phí thật là closure của guard() và thao tác push/pop mảng, rất nhỏ so với một event handler. Đừng bọc guard trong vòng lặp nóng (mỗi frame hàng nghìn lần). Hãy bọc một lần ở ngoài vòng lặp.
#Edge case và cạm bẫy
Bão lỗi (error storm). Một lỗi trong requestAnimationFrame hoặc trong handler scroll có thể throw 60 lần mỗi giây trên mọi người dùng. Không chặn thì bạn tự DDoS server log và cháy quota Sentry trong vài phút. Cần ít nhất:
- Dedupe theo fingerprint (giống
hashcủa Facebook): cùngname + message + vài frame đầu của stackthì chỉ gửi lần đầu, các lần sau chỉ tăng bộ đếm. - Giới hạn tốc độ: ví dụ tối đa 10 lỗi mỗi phút mỗi tab.
- Sampling cho lỗi ồn ào nhưng vô hại (giống cách Facebook hạ
ResizeObserverxuốngwarn).
const seen = new Map<string, number>();
onError((error) => {
const key = `${error.name}|${error.message}|${error.stack?.split('\n')[1] ?? ''}`;
const count = (seen.get(key) ?? 0) + 1;
seen.set(key, count);
if (count > 1) return; // lần đầu mới gửi; có thể gửi kèm count khi flush
sendToServer(error);
});
ts
Message chứa dữ liệu thay đổi. "Không tìm thấy user 12345" làm mỗi lỗi một nhóm riêng. Học Facebook: tách messageFormat và messageParams, hoặc ít nhất thay số và UUID bằng placeholder trước khi tính fingerprint.
PII trong lỗi. message của lỗi validate có thể chứa email, số điện thoại. URL có thể chứa token. Lọc trước khi gửi (Sentry có beforeSend cho việc này).
Warning
Guard stack không đi qua await.
const loadMore = guard(async () => {
await fetchPage(); // ← guard đã pop ngay khi hàm async trả về Promise
throw new Error('x'); // lỗi này thành unhandledrejection, guardList rỗng
}, { name: 'Feed.loadMore' });
ts
Hàm async trả về Promise ngay lập tức, nên finally pop guard trước khi phần sau await kịp chạy. Lỗi sau await cũng không bị catch đồng bộ của guard bắt; nó biến thành promise bị reject. Cách xử lý:
- Bắt lỗi trong chính hàm async và truyền ngữ cảnh tường minh:
catch (e) { reportNormalizedError(normalizeError(e, 'GUARDED', ['Feed.loadMore'])) }. - Viết thêm
guardAsyncgắn.catch()vào promise trả về, kèm guard list chụp lúc bắt đầu. - Tương lai: đề xuất AsyncContext của TC39 cho phép một giá trị "đi theo" qua
await,setTimeout, promise, giốngAsyncLocalStoragecủa Node.js.
instanceof Error sai với lỗi từ iframe. Error tạo trong một iframe có Error constructor riêng, nên instanceof Error ở trang cha trả về false. Bản viết lại làm giống Facebook: hàm isErrorLike kiểm tra bằng duck typing (typeof message === 'string' && typeof name === 'string').
Đăng ký listener muộn. Nhờ lịch sử, lỗi xảy ra trước khi Sentry khởi tạo vẫn được phát lại. Nhưng lịch sử chỉ có 50 lỗi; nếu app khởi động ồn ào, hãy đăng ký listener chính càng sớm càng tốt.
Đừng gửi lỗi bằng chính đường bị lỗi. Nếu lỗi là "mất mạng", gửi báo cáo ngay sẽ lại lỗi. Đưa vào batch logger (bài #7) để được retry và gửi bằng sendBeacon khi rời trang.
#Dùng trong dự án
Nối pipeline này với Sentry (hoặc API của bạn) ở một chỗ duy nhất:
errors.ts
installGlobalErrorHandlers();
onError((error) => {
// gửi nguyên giá trị bị throw: Sentry giữ được stack gốc, `cause` và các field riêng
Sentry.captureException(error.wasErrorObject ? error.original : new Error(error.message), {
tags: { source: error.source },
extra: { guardList: error.guardList },
});
});
errorGuardConfig.noCatch = import.meta.env.DEV && localStorage.debugNoCatch === '1';
ts
Với React 19, nối lỗi React vào cùng pipeline ngay ở createRoot. Error boundary vẫn cần để hiện UI fallback, nhưng việc báo cáo thì gom về một chỗ, khỏi phải viết componentDidCatch ở từng boundary:
main.tsx
createRoot(document.getElementById('root')!, {
// lỗi đã được một error boundary bắt (UI fallback đã hiện)
onCaughtError: (error) => reportNormalizedError(normalizeError(error, 'REACT', ['ErrorBoundary'])),
// lỗi không boundary nào bắt: cả cây bị unmount
onUncaughtError: (error) => reportNormalizedError(normalizeError(error, 'REACT', ['root'])),
}).render(<App />);
tsx
Tham số thứ hai của hai callback này có componentStack. Nếu cần, hãy thêm một field riêng vào NormalizedError để gửi kèm, giống componentStackFrames của Facebook.
Những chỗ nên bọc guard:
- Event emitter / pub-sub do bạn tự viết: mỗi listener một guard.
- Callback đưa cho thư viện bên thứ ba (map, chart, editor) mà bạn không kiểm soát try/catch của họ.
- Plugin / extension / widget do người khác viết chạy trong app của bạn.
- Callback của observer (IntersectionObserver, ResizeObserver, MutationObserver) dùng chung cho nhiều component.
#So với Sentry SDK
Sentry SDK cho trình duyệt cũng làm một phần việc này: integration browserApiErrors (tên cũ là TryCatch) tự bọc callback của setTimeout, setInterval, requestAnimationFrame và addEventListener để bắt lỗi kèm thông tin "lỗi trong callback nào". Ngoài ra còn breadcrumbs: dòng thời gian các thao tác (click, request, console) trước khi lỗi xảy ra. Guard stack bổ sung thứ Sentry không tự đoán được: tên nghiệp vụ của ngữ cảnh (Feed.render › LikeButton.onClick). Hai thứ dùng chung rất tốt: guard lo phần ngữ cảnh, Sentry lo gom nhóm, source map và cảnh báo.
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
errorGuard.test.ts
import { describe, expect, it, vi } from 'vitest';
import { guard, inGuard, normalizeError, onError, reportNormalizedError } from './errorGuard';
describe('guard', () => {
it('bắt lỗi, ghi lại guard stack lồng nhau và không để lỗi lan ra ngoài', () => {
const seen = vi.fn();
const off = onError(seen, { replay: false });
const inner = guard(() => {
throw new Error('boom');
}, { name: 'Inner' });
const outer = guard(() => inner(), { name: 'Outer' });
expect(() => outer()).not.toThrow();
expect(seen).toHaveBeenCalledWith(
expect.objectContaining({ message: 'boom', source: 'GUARDED', guardList: ['Outer', 'Inner'] }),
);
expect(inGuard()).toBe(false); // stack đã được dọn sạch
off();
});
it('chuẩn hoá thứ bị throw không phải Error', () => {
const seen = vi.fn();
const off = onError(seen, { replay: false });
guard(() => {
throw { code: 42 };
})();
expect(seen.mock.calls[0][0]).toMatchObject({ name: 'NonError', wasErrorObject: false });
off();
});
it('phát lại lỗi cũ cho listener đăng ký muộn', () => {
reportNormalizedError(normalizeError(new Error('lúc khởi động'), 'GLOBAL'));
const late = vi.fn();
const off = onError(late);
expect(late).toHaveBeenCalledWith(expect.objectContaining({ message: 'lúc khởi động' }));
off();
});
});
ts
Test đầu tiên kiểm tra ba tính chất quan trọng nhất: lỗi không lan ra ngoài, guard list đúng thứ tự, và stack được dọn sạch sau lỗi. Lưu ý { replay: false }: lịch sử là toàn cục, nên không tắt replay thì test sau nhận cả lỗi của test trước.
Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Phần 1
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh (bài này)
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

