Khi một web app hỏng trên điện thoại của ai đó, log của server không có gì: lỗi xảy ra trong trình duyệt. Cách thường làm là cài một SDK theo dõi lỗi, nhưng với một app chat mã hoá hay một app tài chính cá nhân, câu hỏi khó là bản báo lỗi được phép chứa những gì. Bài này mô tả endpoint báo lỗi nhỏ mà mình đã viết ba lần: cho một app chat mã hoá đầu cuối và một app kế hoạch tài chính (cả hai dùng Fastify), và một app quản lý thời gian (Spring Boot). Bài đi qua những gì trình duyệt gửi đi, vì sao endpoint không cần token, và cách giữ nó rẻ khi bị spam.
Cả ba app đều là local-first hoặc mã hoá, nên dữ liệu người dùng hoặc không bao giờ tới server, hoặc tới dưới dạng bản mã. Một công cụ báo lỗi gửi kèm trạng thái trang, nội dung ô nhập hay URL đầy đủ sẽ âm thầm phá hỏng điều đó. Nguyên tắc thiết kế nói ra thì đơn giản: gửi đủ để tìm và sửa lỗi, và không gửi gì mô tả người dùng.
Hợp đồng: năm trường, mỗi trường một giới hạn cứng
Bản báo lỗi là một schema Zod dùng chung giữa web app và API, nên hai bên thống nhất cùng một bộ giới hạn:
export const clientError = z.object({
kind: z.enum(["render", "error", "rejection", "csp"]),
message: z.string().max(500),
stack: z.string().max(4_000).optional(),
/** The path of the page (no query: it can hold what was typed). */
route: z.string().max(200),
appVersion: z.string().max(40),
});
Mỗi trường có lý do của nó:
| Trường | Vì sao cần | Bỏ đi cái gì |
|---|---|---|
kind |
Bộ xử lý nào bắt được lỗi: màn hình lỗi của React, lỗi không ai bắt, promise bị reject mà không ai xử lý, hoặc tài nguyên bị chặn | — |
message, stack |
Lỗi nằm ở đâu trong code | Cắt còn 500 và 4.000 ký tự |
route |
Trang nào | Chỉ location.pathname, không bao giờ có query hay hash |
appVersion |
Bản build nào, dưới dạng mã commit | — |
Không có user id, device id hay session. Trường route là chỗ dễ sơ ý nhất: ô tìm kiếm, bộ lọc và trang callback OAuth đều đặt dữ liệu người dùng nhập hoặc token vào query string, nên bản báo lỗi chỉ lấy đường dẫn và bỏ phần còn lại.
Phía trình duyệt: bắt lỗi, giới hạn, và mỗi lỗi chỉ gửi một lần
Có bốn nguồn đổ vào bộ báo lỗi: error boundary của React (hiện màn hình lỗi), sự kiện error của window, unhandledrejection, và securitypolicyviolation. Cả bốn đi qua cùng một hàm (rút gọn từ finance-planner):
const MAX_REPORTS = 10;
const sent = new Set<string>();
export const reportError = (kind: ClientError["kind"], error: unknown, extra?: string) => {
const { message, stack } = describe(error);
const key = `${kind} ${message}`;
if (!env.VITE_API_URL || sent.has(key) || sent.size >= MAX_REPORTS) return;
sent.add(key);
void fetch(`${env.VITE_API_URL}/v1/client-errors`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ kind, message: clip(message, 500), /* ... */ route: clip(location.pathname, 200), appVersion }),
keepalive: true,
}).catch(() => undefined);
};
Ba chi tiết quan trọng hơn vẻ ngoài:
- Mỗi lỗi một lần mỗi lần tải trang, tối đa mười lỗi. Nếu không, một vòng render lỗi hay một timer ném lỗi mỗi giây sẽ gửi hàng trăm bản báo giống hệt nhau từ một tab.
keepalivecho request chạy xong cả khi trang đang tải lại hay đang đóng, đúng lúc người dùng bấm nút "Tải lại" trên màn hình lỗi.- Việc báo lỗi không bao giờ tự gây lỗi. Mất mạng, hoặc không cấu hình API, thì lỗi chỉ nằm trong console. Một bộ báo lỗi mà tự ném lỗi còn tệ hơn không có.
Có một loại lỗi được cố ý bỏ qua: một chunk tải lười (lazy load) không còn nữa vì một lần deploy đã thay nó. Đó không phải lỗi code mà là một tab cũ, và app tải lại sang bản mới thay vì báo lỗi, như mình đã kể trong bài về PWA cài trên iPhone và deploy.
Vì sao endpoint không cần token
Gần như mọi route khác trong các API này đều cần access token của Keycloak. Route này thì không, vì những lỗi đáng biết nhất thường xảy ra trước hoặc ngay trong lúc đăng nhập: một trang callback hỏng, một lần làm mới token thất bại, hay một lỗi crash ở màn hình chào của người chưa đăng nhập. Một endpoint đòi session hợp lệ sẽ bỏ sót đúng những lỗi đó.
Cái giá phải trả là ai trên Internet cũng gửi được vào đây. Điều đó chấp nhận được chỉ vì server gần như không làm gì với một bản báo lỗi (rút gọn từ finance-planner, bỏ ngưỡng thật):
app.post("/client-errors", {
schema: { body: clientError, hide: true },
bodyLimit: 16 * 1024,
config: { rateLimit: { max: REPORTS_PER_MINUTE, timeWindow: "1 minute" } },
}, async (req, reply) => {
counter.inc({ kind: req.body.kind });
req.log.warn({ clientError: req.body, userAgent: req.headers["user-agent"]?.slice(0, 300) }, "client error");
return reply.code(204).send();
});
Không có gì được lưu vào database. Bản báo lỗi được kiểm theo schema dùng chung, ghi vào log JSON, và đếm bằng một counter Prometheus gắn nhãn theo kind. hide: true giữ route này ngoài tài liệu OpenAPI sinh tự động, vì nó không phải phần API mà ai nên dựa vào.
Giữ cho một đợt spam vẫn rẻ
Không có xác thực thì việc kiểm soát chi phí phải dựa vào những giới hạn áp dụng trước khi làm bất kỳ việc gì:
- Body tối đa 16 KB. Schema cho phép tổng cộng 4.740 ký tự văn bản; kể cả mỗi ký tự chiếm ba byte trong UTF-8 thì vẫn vừa, nên trên thực tế thứ gì lớn hơn không phải một bản báo thật và bị từ chối ngay khi đang đọc.
- Một giới hạn nhỏ mỗi phút cho mỗi địa chỉ client, cỡ bằng mức trình duyệt tự giới hạn cho mỗi lần tải trang. Một trình duyệt thật không bao giờ cần nhiều hơn.
- Một counter với bốn giá trị nhãn. Nhãn lấy từ enum
kind, không bao giờ lấy từ message, nên spam không tạo được time series mới. Hướng dẫn đặt tên của Prometheus cảnh báo rằng mỗi giá trị nhãn là một time series mới, và các giá trị không giới hạn như user ID làm lượng dữ liệu lưu trữ tăng mạnh.
Rate limit chỉ tốt bằng địa chỉ mà nó dùng làm khoá. Đứng sau reverse proxy, địa chỉ thật của client tới trong một header như CF-Connecting-IP của Cloudflare. Tin header đó từ bất kỳ ai thì kẻ tấn công có thể tự chọn một địa chỉ mới cho mỗi request và không bao giờ chạm giới hạn. Vì vậy nó chỉ được tin khi chính kết nối đến từ một địa chỉ nội bộ, nơi proxy chạy:
static String clientAddress(HttpServletRequest request) {
String peer = request.getRemoteAddr();
String reported = request.getHeader("CF-Connecting-IP");
if (reported != null && isIpLiteral(reported) && isPrivate(peer)) return reported;
return peer == null ? "unknown" : peer;
}
Ở bản Spring, khoá của rate limiter còn có tiền tố riêng, client-errors: cộng địa chỉ, nên một loạt báo lỗi từ một thiết bị không bao giờ ăn vào hạn mức đồng bộ của user đó.
Cùng hợp đồng đó trong Spring Boot
API của app quản lý thời gian dùng Spring Boot, và việc chuyển endpoint sang đó cho thấy phần nào là tính năng của framework, phần nào là quyết định của mình. Fastify cho sẵn kiểm schema, giới hạn body và rate limit dưới dạng tuỳ chọn của route. Trong Spring, controller tự làm từng bước:
- Kiểm rate limit trước, trước khi đọc bất cứ thứ gì, và trả
429kèmRetry-After: 60. - Đọc tối đa 16 KB cộng một byte, và trả
413nếu byte thừa đó tới. - Kiểm theo đúng năm trường và đúng các độ dài như schema phía frontend.
- Đếm bằng Micrometer, ghi log, và trả
204.
Route được mở tường minh trong cấu hình bảo mật (permitAll() riêng cho POST /api/client-errors), nên một thay đổi sau này ở quy tắc mặc định không thể âm thầm khiến nó đòi token trở lại.
Báo cả những gì chính sách bảo mật đã chặn
Loại thứ tư, csp, có mặt vì hai trong số các app đặt Content Security Policy bằng thẻ <meta>, mà thẻ này không mang được report-uri. Trang tự lắng nghe sự kiện securitypolicyviolation và gửi directive bị vi phạm qua cùng endpoint. URL bị chặn được rút gọn còn origin trước khi gửi, vì một URL đầy đủ cũng có thể mang query string như mọi URL khác.
Cách làm này không bảo vệ được gì
- Message của lỗi có thể trích dữ liệu. Lỗi
JSON.parsecủa V8 chứa phần đầu của chuỗi đầu vào:JSON.parse("hello world")ném raUnexpected token 'h', "hello world" is not valid JSON. Nếu code parse thứ người dùng gõ vào, message sẽ mang theo nó. Cắt độ dài chỉ giới hạn lượng bị lộ chứ không ngăn được, nên cách phòng thật sự là không đưa dữ liệu người dùng vào message lỗi trong code của chính mình. - Mẫu (sample) của vi phạm CSP. Trường
samplechứa vài ký tự đầu của đoạn code inline bị chặn. Đó là code chứ không phải dữ liệu người dùng, trừ khi trang dựng code từ dữ liệu người dùng. - Stack đã minify. Stack từ bản production trỏ vào các file chunk có hash trong tên. Đọc được nó cần source map của đúng bản build đó, nên hoặc giữ source map riêng cho mỗi bản phát hành, hoặc build lại từ commit mà
appVersionchỉ ra. - Một kẻ spam quyết tâm. Các giới hạn làm cho spam rẻ chứ không làm nó bất khả. Một botnet có nhiều địa chỉ vẫn đổ được rác vào log; nó không đổ được vào database, vì không có gì được lưu.
Tóm tắt
| Quyết định | Lý do |
|---|---|
| Schema dùng chung với giới hạn độ dài cứng | Hai bên thống nhất một bản báo lỗi được phép là gì |
| Chỉ đường dẫn, không query, không user hay device id | Bản báo mô tả code, không mô tả con người |
Mỗi lỗi một lần, mười lỗi mỗi lần tải trang, keepalive |
Một tab hỏng không spam được, và bản báo không mất khi tải lại |
| Không cần token | Những lỗi quan trọng nhất xảy ra trước khi đăng nhập chạy được |
| Giới hạn body, rate limit, không lưu gì | Một endpoint mở vẫn rẻ khi bị lạm dụng |
| Header của proxy chỉ được tin khi đến từ mạng nội bộ | Không lách được rate limit bằng địa chỉ giả |
Nếu bạn báo lỗi phía client từ một app xử lý dữ liệu nhạy cảm, mình rất muốn được trao đổi xem bạn đã quyết định bỏ ra những gì. Email của mình ở trang chủ.