Học từ Facebook #7: Banzai, gom log theo lô và gửi bằng sendBeacon
Banzai, hệ thống logging phía client của Facebook: gom event theo lô, hai mức ưu tiên, retry có backoff, sendBeacon khi rời trang. Viết lại bằng TypeScript.
Mỗi lần người dùng cuộn feed, Facebook ghi lại hàng chục event: bài nào hiện, hiện bao lâu (bài #2), video chạy mấy giây, có lỗi gì (bài #6), thời gian render… Mỗi event một request thì mạng và pin điện thoại không chịu nổi.
Hệ thống lo việc này tên là Banzai.
Tip
Tóm tắt
- Đừng gửi mỗi event một request: gom vào hàng đợi, gửi theo lô; hai mức ưu tiên
vital(gửi sau ~1 giây) vàbasic(chờ lâu hơn). - Server lỗi thì đưa event về đầu hàng đợi, giãn lịch gửi bằng backoff + jitter; lỗi 4xx thì bỏ.
- Tab chuyển sang
hiddenlà sự kiện cuối cùng chắc chắn xảy ra trên mobile: lúc đó gửi phần còn lại bằngsendBeacon. - Banzai còn lưu hàng đợi vào localStorage và chỉ khôi phục cho đúng user; bản viết lại để phần đó ở mục mở rộng.
#Vì sao không gửi mỗi event một request?
Nghe hiển nhiên, nhưng con số cụ thể giúp hiểu vì sao Banzai được thiết kế như vậy:
- Overhead cố định. Mỗi request mang theo header, cookie, có khi là cả TLS handshake nếu kết nối bị đóng. Với cookie của một trang lớn, phần overhead này dễ dàng vượt 1KB, trong khi một event click chỉ vài chục byte.
- Radio trên điện thoại. Mỗi lần gửi, modem 4G/5G phải chuyển sang trạng thái năng lượng cao và giữ ở đó vài giây sau khi gửi xong. Gửi 30 request rải rác trong một phút tốn pin hơn nhiều so với gửi 3 request mỗi request 10 event.
- Phía server. Gom lô giảm số request đi hàng chục lần, với cùng một lượng dữ liệu.
- Tranh tài nguyên với UI. Mỗi request là việc trên main thread (serialize, mở kết nối) và chiếm một slot kết nối. Log không được phép làm chậm việc tải ảnh hay API của feed.
#Bên trong trình duyệt: sendBeacon, keepalive và Page Lifecycle
#navigator.sendBeacon(url, data)
- Chỉ POST, không đặt được header tuỳ ý, không đọc được response. Giá trị trả về
truechỉ có nghĩa là trình duyệt đã nhận vào hàng đợi, không có nghĩa là server đã nhận. - Request được gửi kèm cookie và vẫn tiếp tục sau khi trang đóng. Trình duyệt chịu trách nhiệm gửi nó đi.
- Có giới hạn tổng dung lượng cho các request keepalive đang chờ gửi, khoảng 64KB trong các trình duyệt phổ biến. Vượt quá thì
sendBeacontrả vềfalse. Vì vậy phải chia lô và kiểm tra giá trị trả về. Có lẽ không phải ngẫu nhiên màBATCH_SIZE_LIMITcủa Banzai là 64.000 (suy đoán). Lưu ý con số đó là số ký tự JSON, không phải byte: chữ tiếng Việt có dấu chiếm 2–3 byte khi mã hoá UTF-8. - Content-Type lấy từ kiểu dữ liệu: string →
text/plain;charset=UTF-8,FormData→multipart/form-data,URLSearchParams→application/x-www-form-urlencoded,Blob→blob.type. - Ba loại đầu là kiểu "CORS-safelisted", không cần preflight khi gửi sang origin khác.
Blobkiểuapplication/jsonthì cần preflight, màsendBeaconkhông làm preflight được, nên request sang origin khác bị chặn. Banzai gửiFormDatanên không gặp chuyện này.
#fetch(url, { keepalive: true })
Là "sendBeacon có thêm tuỳ chọn": chọn được method, header, credentials. Nhưng mọi request keepalive (kể cả sendBeacon) dùng chung hạn mức ~64KB đang bay. Một lô lớn gửi bằng keepalive mà chưa xong đúng lúc tab bị ẩn có thể làm sendBeacon ngay sau đó trả về false.
#Page Lifecycle: tại sao là visibilitychange?
Một trang đi qua các trạng thái active → passive → hidden → frozen → (discarded | terminated). Trên mobile, khi người dùng chuyển sang app khác, trang đi tới hidden và sau đó hệ điều hành có thể giết luôn tiến trình mà không có beforeunload, unload hay pagehide nào được bắn. visibilitychange sang hidden là sự kiện cuối cùng được đảm bảo có thể xảy ra.
Còn unload thì vừa không đáng tin, vừa có hại: trang có listener unload thường không được đưa vào bfcache (back/forward cache), nên bấm Back sẽ phải tải lại toàn bộ thay vì hiện ngay lập tức. Chrome đang dần ngừng hỗ trợ unload. pagehide thì an toàn với bfcache (event.persisted === true nghĩa là trang đang vào bfcache chứ không bị huỷ).
#Facebook làm thế nào?
#1. Một "post" là một tuple
BanzaiUtils.wrapData biến mỗi lần Banzai.post(route, data, options) thành một mảng gọn:
[route, data, timestamp, retryCount, size] + __meta: { appID, userID, webSessionId, status, retry, priority }
text
Dùng tuple thay cho object giúp JSON gửi đi ngắn hơn, vì không lặp lại tên key ở mỗi event. size (độ dài JSON của data) được tính ngay lúc post, để sau đó chia lô không phải stringify lại.
#2. Ưu tiên được suy ra từ độ trễ
Không có tham số priority riêng. post() nhận options.delay, và nếu delay > VITAL_WAIT thì event là basic, ngược lại là vital. Các hằng số trong BanzaiConsts:
| Hằng số | Giá trị | Ý nghĩa |
|---|---|---|
VITAL_WAIT |
1 giây | thời gian chờ mặc định của event vital |
BASIC_WAIT_COMET |
2 giây | thời gian chờ mặc định của event basic trên Comet |
BASIC_WAIT |
60 giây | thời gian chờ basic ở bản cũ |
BATCH_SIZE_LIMIT |
64.000 | kích thước tối đa một lần gửi (tính bằng độ dài JSON) |
EXPIRY |
24 giờ | event cũ hơn thì bỏ, không gửi nữa |
STORAGE_FLUSH_INTERVAL |
12 giờ | chu kỳ dọn event tồn trong localStorage |
Mỗi mức ưu tiên có một lịch gửi riêng. _schedule chỉ hẹn lại giờ nếu deadline mới sớm hơn deadline đang có. Trong cùng một mức, một post có delay ngắn đến sau sẽ kéo lịch gửi sớm lên, còn post có delay dài hơn không bao giờ đẩy lịch lùi lại. Nhờ vậy, một luồng event liên tục không thể làm lô bị hoãn mãi.
Lịch gửi cũng không dùng setTimeout trần. Vital dùng setTimeoutCometLoggingPriWithFallback, basic dùng setTimeoutCometSpeculativeWithFallback: cả hai chạy qua scheduler ưu tiên của Comet, nên việc log nhường chỗ cho render và xử lý input.
Ngoài ra còn vài chế độ đặc biệt:
signal: true: gửi ngay trong một request riêng, không chờ lô.batch: gộp nhiều post cùng một khoá trongBATCH_TIMEOUT(10 giây) thành một.BanzaiLazyQueue: các lệnh post gọi trước khi Banzai tải xong được xếp hàng, rồi phát lại khi Banzai sẵn sàng. Code khác không phải chờ module logging.
#3. Chia "wad" theo người dùng và 64KB
Khi tới giờ gửi, BanzaiUtils.filterPost duyệt hàng đợi và gom post thành các wad (gói dữ liệu), mỗi wad ứng với một bộ webSessionId + userID + appID + có nén hay không. Mỗi lần gửi dừng lại khi tổng size chạm BATCH_SIZE_LIMIT (64KB). Phần còn lại chờ chuyến sau. Post đang bay được đánh dấu POST_INFLIGHT để không bị gửi trùng. Post cũ hơn 24 giờ bị bỏ.
Payload là FormData gồm ts (giờ client lúc gửi) và q (JSON của các wad). Trước khi gửi bằng XHR, wad được nén deflate bất đồng bộ (compressWadAsync) để không chặn main thread.
#4. Gửi, retry và nối với NetworkStatus
BanzaiAdapterComet.send POST tới /ajax/bz:
- Status 200 là thành công: gọi
NetworkStatus.reportSuccess(). Status khác:reportError(). Đây chính là module network status ở bài #5. Logging tự trở thành một nguồn tín hiệu về chất lượng mạng. - Nếu
NetworkStatusđang báo offline, request không được gửi mà chờ tới khi có sự kiện online. - Gửi lỗi thì
BanzaiUtils.retryPostđặt post vềPOST_READY, tăngretryCount, và chỉ đưa lại vào hàng đợi nếu status trong khoảng 400–599 (kể cả 4xx). Sau đó toàn bộ hàng đợi được lưu xuống localStorage.
#5. Tab bị ẩn và rời trang
Đây là phần tinh tế nhất:
- Tab chuyển sang hidden: thử gửi mọi thứ bằng
sendBeacon, sau đó_store()lưu phần còn lại vào localStorage. - Tab visible trở lại: thử beacon phần còn lại, rồi
_restore()từ localStorage. beforeunload: beacon cả vital lẫn basic._unload: huỷ các XHR đang bay, đưa post của chúng về hàng đợi với một mã giả 444 (nằm trong khoảng 400–599 nên được retry), beacon lại lần cuối rồi lưu storage.- Beacon bị từ chối: post được trả lại hàng đợi và một bộ đếm
send_via_beacon_failuređược log.
#6. BanzaiStorage: không mất log khi tab chết
- Lưu vào localStorage với key
bz:<webSessionId>.<timestamp>. Không lưu những bộ đếm nội bộ đã gửi rồi. - Khi restore, lấy khoá
WebStorageMutex("banzai")trước. Nếu người dùng mở 5 tab, chỉ một tab lấy log tồn đọng ra gửi, tránh gửi trùng 5 lần. - Chỉ restore post của đúng user hiện tại (hoặc khi chưa đăng nhập). Đăng xuất rồi đăng nhập tài khoản khác thì log của tài khoản cũ không bị gửi kèm danh tính của tài khoản mới. Đây là yêu cầu về quyền riêng tư, không chỉ là tính đúng đắn.
- Không chạy trong iframe. Khi ở trong iframe cùng domain,
post()được chuyển thẳng lên Banzai củawindow.top, để cả trang chỉ có một hàng đợi. - Mọi lời gọi vào storage đều bọc trong
ErrorGuard(bài #6): localStorage đầy hoặc bị chặn trong chế độ ẩn danh cũng không làm hỏng việc logging.
#7. Tự đo chính mình
Banzai đếm messages_sent, messages_received, total_messages_* và gửi lên ODS (hệ thống metric nội bộ, route categorized_ods). So sánh số đã post với số server nhận được, họ biết bao nhiêu phần trăm log bị thất lạc. Hệ thống đo lường cũng cần được đo.
Vòng đời một post:
stateDiagram-v2 [*] --> READY: post vào hàng đợi READY --> INFLIGHT: tới giờ gửi, vào một wad tối đa 64KB INFLIGHT --> SENT: HTTP 200 INFLIGHT --> READY: HTTP 400 đến 599, retryCount tăng INFLIGHT --> [*]: lỗi khác, bị bỏ READY --> STORED: tab hidden, lưu localStorage STORED --> READY: tab visible hoặc lần tải sau, cùng user READY --> [*]: quá 24 giờ, hết hạn SENT --> [*]
Nguồn trong facebook-codebase: thư mục 24.Banzai/, 00 BanzaiComet/; các module phụ BanzaiConsts, BanzaiUtils, BanzaiStorage nằm trong các file bundle lớn như 31.js.
#Viết lại
Bản viết lại giữ những ý cốt lõi: hai mức ưu tiên, gửi ngay khi lô đầy, retry có giới hạn, beacon khi tab ẩn. Bỏ bớt phần nén, mutex nhiều tab và localStorage (bàn ở mục mở rộng). Thêm một thứ Banzai không làm ở tầng này: backoff khi server lỗi liên tục.
#Transport: tách "gửi" khỏi "logic gom lô"
src/lib/batchLogger.ts
export interface Transport {
/** gửi bình thường, reject nếu lỗi */
send(url: string, body: string): Promise<void>;
/** gửi lúc trang sắp đóng, trả về false nếu trình duyệt từ chối */
beacon(url: string, body: string): boolean;
}
export class HttpError extends Error {
constructor(public status: number) {
super(`HTTP ${status}`);
}
}
/** 4xx (trừ 408 timeout, 429 rate limit) là lỗi do chính request: gửi lại cũng vô ích. */
function isRetryable(error: unknown) {
if (!(error instanceof HttpError)) return true; // lỗi mạng, 5xx…
return error.status >= 500 || error.status === 408 || error.status === 429;
}
// Các request keepalive (kể cả sendBeacon) dùng CHUNG một hạn mức ~64KB đang bay.
// Chỉ bật keepalive cho lô nhỏ, để một lô lớn đang gửi dở không làm beacon lúc ẩn tab bị từ chối.
const KEEPALIVE_BUDGET = 16_000;
export const browserTransport: Transport = {
async send(url, body) {
const response = await fetch(url, {
method: 'POST',
body,
// Cùng origin thì application/json không sao. Khác origin thì JSON cần preflight CORS;
// khi đó nên gửi text/plain (server vẫn JSON.parse được) giống nhánh beacon bên dưới.
headers: { 'Content-Type': 'application/json' },
// keepalive: request vẫn hoàn thành nếu người dùng chuyển trang giữa chừng
keepalive: body.length < KEEPALIVE_BUDGET,
});
if (!response.ok) throw new HttpError(response.status);
},
beacon(url, body) {
// text/plain là content-type "CORS-safelisted": gửi sang origin khác không cần preflight.
// application/json thì cần preflight, mà sendBeacon không làm preflight được -> bị chặn.
return navigator.sendBeacon?.(url, new Blob([body], { type: 'text/plain' })) ?? false;
},
};
ts
Logic gom lô không biết fetch hay sendBeacon là gì, chỉ biết transport.send() và transport.beacon(). Nhờ vậy demo thay transport giả để in log ra màn hình, và test thay bằng vi.fn().
Về Content-Type: beacon gửi Blob kiểu text/plain để không cần preflight. send thường gửi application/json, ổn khi endpoint cùng origin. Nếu endpoint khác origin, request JSON sẽ có preflight (và với keepalive, preflight từng không được hỗ trợ ở một số trình duyệt), nên tốt nhất dùng text/plain cho cả hai rồi JSON.parse ở server.
keepalive chỉ bật cho lô dưới ~16KB, để một lô lớn đang bay không chiếm hết hạn mức chung với sendBeacon.
#Ghi log, lịch gửi và backoff
src/lib/batchLogger.ts
log(event: string, data?: Record<string, unknown>, priority: Priority = 'basic') {
queues[priority].push({ id: createId(), event, data, time: Date.now(), retries: 0 });
notify();
// Lô đầy thì gửi ngay, NHƯNG chỉ khi server đang khoẻ. Đang có lỗi (backoff) mà vẫn
// gửi ngay mỗi khi đủ lô thì backoff vô nghĩa đúng lúc lưu lượng cao nhất.
if (queues[priority].length >= maxBatchSize && failures[priority] === 0) {
void flushQueue(priority);
} else {
schedule(priority);
}
},
ts
src/lib/batchLogger.ts
function schedule(priority: Priority) {
if (timers[priority] != null) return; // đã hẹn rồi, event mới đi chung chuyến
const failed = failures[priority];
// 1 lần lỗi -> chờ gấp đôi, 2 lần -> gấp 4… tối đa gấp 32.
// Có lỗi thì thêm jitter ±25% để hàng triệu tab không cùng gửi lại vào một thời điểm.
const backoff = 2 ** Math.min(failed, 5);
const jitter = failed > 0 ? 0.75 + Math.random() * 0.5 : 1;
const delay = delays[priority] * backoff * jitter;
timers[priority] = setTimeout(() => {
timers[priority] = undefined;
void flushQueue(priority);
}, delay);
}
async function flushQueue(priority: Priority) {
clearTimeout(timers[priority]);
timers[priority] = undefined;
const batch = queues[priority].splice(0, maxBatchSize);
if (batch.length === 0) return;
notify();
try {
// sentAt: server so với giờ của nó để bù lệch đồng hồ cho `time` của từng event
await transport.send(endpoint, JSON.stringify({ priority, sentAt: Date.now(), events: batch }));
failures[priority] = 0;
} catch (error) {
if (!isRetryable(error)) {
notify();
return; // bỏ lô này
}
failures[priority]++;
const retry = batch
.map((e) => ({ ...e, retries: e.retries + 1 }))
.filter((e) => e.retries <= maxRetries);
// đưa lại lên đầu hàng đợi để giữ đúng thứ tự thời gian
queues[priority].unshift(...retry);
notify();
}
if (queues[priority].length > 0) schedule(priority);
}
ts
Theo một event đi qua code:
log()đẩy event vào đúng hàng đợi. Nếu hàng đợi đã đạtmaxBatchSizevà server đang khoẻ (failures === 0) thì gọi thẳngflushQueue(); ngược lại gọischedule(). Điều kiện "server đang khoẻ" quan trọng: không có nó, đúng lúc lưu lượng cao nhất và server đang lỗi, mỗi khi đủ lô lại gửi ngay, bỏ qua backoff.schedule()thấy đã có timer thì không làm gì: event mới "đi chung chuyến" với timer đang có. Đây là bản đơn giản hoá của luật "chỉ hẹn sớm hơn" trong Banzai.- Tới giờ,
flushQueue()cắt tối đamaxBatchSizeevent ra khỏi hàng đợi (splice) rồi gửi. Event đang bay không còn nằm trong hàng đợi, nên một lần flush khác chạy song song không gửi trùng chúng. Đây cũng là vai trò của trạng tháiPOST_INFLIGHTở Banzai. - Gửi thành công:
failuresvề 0. - Gửi lỗi không retry được (4xx trừ 408/429): bỏ lô.
- Gửi lỗi còn lại:
failurestăng, mỗi event tăngretries, event vượtmaxRetriesbị bỏ, phần còn lại đượcunshiftvề đầu hàng đợi để giữ thứ tự thời gian. - Còn event thì hẹn lần sau với độ trễ
delays[priority] × 2^min(failures, 5), nhân thêm jitter ±25% khi đang lỗi.
Với cấu hình mặc định, khi server sập (chưa tính jitter):
| Lần lỗi liên tiếp | Chờ (basic) | Chờ (vital) |
|---|---|---|
| 0 | 5 giây | 1 giây |
| 1 | 10 giây | 2 giây |
| 2 | 20 giây | 4 giây |
| 3 | 40 giây | 8 giây |
| 4 | 80 giây | 16 giây |
| 5 trở lên | 160 giây | 32 giây |
Một event được thử tối đa maxRetries + 1 = 4 lần rồi bị bỏ: log không đáng để giữ mãi trong bộ nhớ.
Tại sao backoff quan trọng? Khi server log sập, mọi tab của mọi người dùng cùng retry. Không có backoff, ngay khi server vừa sống lại nó bị dội một cơn sóng request dồn ứ và lại sập ("thundering herd"). Backoff giãn tải ra, còn jitter giúp các client không retry cùng một mili-giây.
Khác bản gốc: Banzai retry mọi status 400–599 (kể cả 4xx) và không có backoff ở tầng này; bản viết lại bỏ 4xx (trừ 408/429) và có backoff + jitter. Không nén, không lưu localStorage, chia lô theo số event chứ không theo kích thước.
#Rời trang
src/lib/batchLogger.ts
/** Gửi tất cả ngay bằng sendBeacon, dùng khi trang sắp bị đóng. */
function flushWithBeacon() {
(['vital', 'basic'] as const).forEach((priority) => {
clearTimeout(timers[priority]);
timers[priority] = undefined;
while (queues[priority].length > 0) {
const batch = queues[priority].splice(0, maxBatchSize);
const ok = transport.beacon(
endpoint,
JSON.stringify({ priority, sentAt: Date.now(), events: batch }),
);
if (!ok) {
// Beacon bị từ chối (thường do payload > 64KB): trả lại hàng đợi.
// Bản production có thể lưu tạm vào localStorage để lần sau gửi tiếp.
queues[priority].unshift(...batch);
break;
}
}
});
notify();
}
// `visibilitychange -> hidden` là tín hiệu đáng tin nhất trên mobile
// (beforeunload/unload thường không chạy khi người dùng vuốt tắt app).
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flushWithBeacon();
};
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('pagehide', flushWithBeacon);
ts
sequenceDiagram participant U as Người dùng participant P as Trang participant L as batchLogger participant B as Trình duyệt participant S as Server U->>P: chuyển app hoặc đóng tab P->>L: visibilitychange hidden L->>B: sendBeacon lô vital L->>B: sendBeacon lô basic B-->>L: true nghĩa là đã xếp hàng, chưa phải đã gửi Note over P: trang có thể bị đóng băng hoặc huỷ ngay sau đó B->>S: POST /log khi có thể, kể cả sau khi trang đã đóng
Bản viết lại nghe cả visibilitychange và pagehide. Hai sự kiện có thể cùng bắn, nhưng lần thứ hai thấy hàng đợi trống nên không gửi gì.
Xem toàn bộ src/lib/batchLogger.ts
src/lib/batchLogger.ts
/**
* Client logging gom theo lô (ý tưởng từ Banzai - hệ thống logging phía client của Facebook).
*
* - Không gửi 1 request cho mỗi event: gom lại, gửi theo lô.
* - 2 mức ưu tiên: `vital` (lỗi, thanh toán…) gửi nhanh, `basic` (click, view…) chờ lâu hơn.
* - Lô đầy thì gửi ngay, không chờ hẹn giờ.
* - Gửi lỗi thì đưa lại vào hàng đợi, quá số lần thử thì bỏ.
* - Tab bị ẩn / rời trang thì gửi hết bằng navigator.sendBeacon (request không bị huỷ khi trang đóng).
*/
export type Priority = 'vital' | 'basic';
export interface LogEvent {
/** id duy nhất để server bỏ event trùng (beacon + retry có thể gửi một event 2 lần) */
id: string;
event: string;
data?: Record<string, unknown>;
time: number;
retries: number;
}
export interface Transport {
/** gửi bình thường, reject nếu lỗi */
send(url: string, body: string): Promise<void>;
/** gửi lúc trang sắp đóng, trả về false nếu trình duyệt từ chối */
beacon(url: string, body: string): boolean;
}
export class HttpError extends Error {
constructor(public status: number) {
super(`HTTP ${status}`);
}
}
/** 4xx (trừ 408 timeout, 429 rate limit) là lỗi do chính request: gửi lại cũng vô ích. */
function isRetryable(error: unknown) {
if (!(error instanceof HttpError)) return true; // lỗi mạng, 5xx…
return error.status >= 500 || error.status === 408 || error.status === 429;
}
// Các request keepalive (kể cả sendBeacon) dùng CHUNG một hạn mức ~64KB đang bay.
// Chỉ bật keepalive cho lô nhỏ, để một lô lớn đang gửi dở không làm beacon lúc ẩn tab bị từ chối.
const KEEPALIVE_BUDGET = 16_000;
export const browserTransport: Transport = {
async send(url, body) {
const response = await fetch(url, {
method: 'POST',
body,
// Cùng origin thì application/json không sao. Khác origin thì JSON cần preflight CORS;
// khi đó nên gửi text/plain (server vẫn JSON.parse được) giống nhánh beacon bên dưới.
headers: { 'Content-Type': 'application/json' },
// keepalive: request vẫn hoàn thành nếu người dùng chuyển trang giữa chừng
keepalive: body.length < KEEPALIVE_BUDGET,
});
if (!response.ok) throw new HttpError(response.status);
},
beacon(url, body) {
// text/plain là content-type "CORS-safelisted": gửi sang origin khác không cần preflight.
// application/json thì cần preflight, mà sendBeacon không làm preflight được -> bị chặn.
return navigator.sendBeacon?.(url, new Blob([body], { type: 'text/plain' })) ?? false;
},
};
const createId = () =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
export interface BatchLoggerOptions {
endpoint: string;
transport?: Transport;
/** thời gian chờ trước khi gửi, theo mức ưu tiên */
delays?: Record<Priority, number>;
maxBatchSize?: number;
maxRetries?: number;
}
export function createBatchLogger({
endpoint,
transport = browserTransport,
delays = { vital: 1000, basic: 5000 },
maxBatchSize = 50,
maxRetries = 3,
}: BatchLoggerOptions) {
const queues: Record<Priority, LogEvent[]> = { vital: [], basic: [] };
const timers: Partial<Record<Priority, ReturnType<typeof setTimeout>>> = {};
// số lần gửi lỗi liên tiếp, để giãn dần lịch gửi khi server đang có vấn đề
const failures: Record<Priority, number> = { vital: 0, basic: 0 };
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
function schedule(priority: Priority) {
if (timers[priority] != null) return; // đã hẹn rồi, event mới đi chung chuyến
const failed = failures[priority];
// 1 lần lỗi -> chờ gấp đôi, 2 lần -> gấp 4… tối đa gấp 32.
// Có lỗi thì thêm jitter ±25% để hàng triệu tab không cùng gửi lại vào một thời điểm.
const backoff = 2 ** Math.min(failed, 5);
const jitter = failed > 0 ? 0.75 + Math.random() * 0.5 : 1;
const delay = delays[priority] * backoff * jitter;
timers[priority] = setTimeout(() => {
timers[priority] = undefined;
void flushQueue(priority);
}, delay);
}
async function flushQueue(priority: Priority) {
clearTimeout(timers[priority]);
timers[priority] = undefined;
const batch = queues[priority].splice(0, maxBatchSize);
if (batch.length === 0) return;
notify();
try {
// sentAt: server so với giờ của nó để bù lệch đồng hồ cho `time` của từng event
await transport.send(endpoint, JSON.stringify({ priority, sentAt: Date.now(), events: batch }));
failures[priority] = 0;
} catch (error) {
if (!isRetryable(error)) {
notify();
return; // bỏ lô này
}
failures[priority]++;
const retry = batch
.map((e) => ({ ...e, retries: e.retries + 1 }))
.filter((e) => e.retries <= maxRetries);
// đưa lại lên đầu hàng đợi để giữ đúng thứ tự thời gian
queues[priority].unshift(...retry);
notify();
}
if (queues[priority].length > 0) schedule(priority);
}
/** Gửi tất cả ngay bằng sendBeacon, dùng khi trang sắp bị đóng. */
function flushWithBeacon() {
(['vital', 'basic'] as const).forEach((priority) => {
clearTimeout(timers[priority]);
timers[priority] = undefined;
while (queues[priority].length > 0) {
const batch = queues[priority].splice(0, maxBatchSize);
const ok = transport.beacon(
endpoint,
JSON.stringify({ priority, sentAt: Date.now(), events: batch }),
);
if (!ok) {
// Beacon bị từ chối (thường do payload > 64KB): trả lại hàng đợi.
// Bản production có thể lưu tạm vào localStorage để lần sau gửi tiếp.
queues[priority].unshift(...batch);
break;
}
}
});
notify();
}
// `visibilitychange -> hidden` là tín hiệu đáng tin nhất trên mobile
// (beforeunload/unload thường không chạy khi người dùng vuốt tắt app).
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flushWithBeacon();
};
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('pagehide', flushWithBeacon);
return {
log(event: string, data?: Record<string, unknown>, priority: Priority = 'basic') {
queues[priority].push({ id: createId(), event, data, time: Date.now(), retries: 0 });
notify();
// Lô đầy thì gửi ngay, NHƯNG chỉ khi server đang khoẻ. Đang có lỗi (backoff) mà vẫn
// gửi ngay mỗi khi đủ lô thì backoff vô nghĩa đúng lúc lưu lượng cao nhất.
if (queues[priority].length >= maxBatchSize && failures[priority] === 0) {
void flushQueue(priority);
} else {
schedule(priority);
}
},
flush: () => Promise.all([flushQueue('vital'), flushQueue('basic')]),
flushWithBeacon,
getQueueSizes: () => ({ vital: queues.vital.length, basic: queues.basic.length }),
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
destroy() {
clearTimeout(timers.vital);
clearTimeout(timers.basic);
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('pagehide', flushWithBeacon);
},
};
}
export type BatchLogger = ReturnType<typeof createBatchLogger>;
ts
#Demo
Transport trong demo chỉ in ra log thay vì gửi thật. Thử:
- Bấm Log click vài lần: 5 giây sau mới có một request.
- Log lỗi thanh toán (vital): chỉ chờ 1 giây.
- Spam 45 event: đủ 20 event là gửi ngay một lô, không chờ.
- Bật Server lỗi rồi log: event được xếp lại hàng đợi, các lần thử sau cách nhau xa dần (backoff).
- Giả lập rời trang hoặc chuyển sang tab khác: phần còn lại đi bằng
sendBeacon.
▶ 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?
"Gửi ngay khi có event, cho realtime?" Analytics hiếm khi cần realtime tới từng giây. Thứ thực sự cần nhanh (lỗi thanh toán, crash) thì đã có mức vital với độ trễ 1 giây. Nếu cần gửi ngay tức thì, làm như signal: true của Banzai: một request riêng cho đúng event đó.
"Dùng requestIdleCallback để gửi lúc rảnh?" Trên trang luôn bận (feed đang cuộn, video đang chạy), idle callback có thể không bao giờ chạy. Nếu dùng thì phải có timeout. Banzai dùng scheduler có mức ưu tiên kèm fallback setTimeout, cùng một ý.
"Chỉ gửi lúc beforeunload, gom cả phiên vào một request?" Mất sạch log khi tab bị hệ điều hành giết, phiên dài thì vượt 64KB, và chặn bfcache như đã nói.
"Gửi lỗi thì retry ngay?" Server đang quá tải mà retry ngay chỉ làm nó chết lâu hơn. Phải có backoff.
"Retry mọi lỗi?" Đây là chỗ bản viết lại khác Banzai: Banzai retry cả 4xx. Bản viết lại thì không. 400 Bad Request (payload sai) hay 413 (quá lớn) thì retry bao nhiêu lần kết quả cũng vậy. Bản viết lại có HttpError mang theo status và hàm isRetryable: lỗi mạng, 5xx, 408 và 429 thì retry với backoff, còn 4xx khác thì bỏ lô đó luôn. Muốn kỹ hơn nữa thì đọc header Retry-After của 429/503 để biết khi nào gửi lại.
#Edge case và cạm bẫy
Gửi trùng. Request có thể đã tới server nhưng client không nhận được response (mất mạng đúng lúc trả về). Client retry, server nhận hai lần. Vì vậy mỗi event trong bản viết lại có id duy nhất (crypto.randomUUID(), fallback sang chuỗi ngẫu nhiên ở trình duyệt cũ) để server dedupe. Với nhiều tab, mượn ý mutex của BanzaiStorage (Web Locks API: navigator.locks.request('logger', …)), để chỉ một tab gửi log tồn đọng.
Lệch giờ client (clock skew). Date.now() trên máy người dùng có thể sai vài phút, thậm chí vài năm. Banzai gửi kèm ts = giờ client lúc gửi. Server lấy giờ server lúc nhận − ts làm độ lệch rồi cộng vào thời gian của mọi event trong lô. Bản viết lại làm tương tự: body của mỗi lô (cả fetch lẫn beacon) có sentAt: Date.now(), server tự tính độ lệch.
Thứ tự event. Retry, nhiều tab, beacon và fetch chạy song song đều có thể làm event đến server không theo thứ tự. Đừng giả định thứ tự đến là thứ tự xảy ra. Hãy gửi kèm thời gian và một số thứ tự tăng dần theo phiên để server tự sắp xếp.
PII. Log là nơi dữ liệu cá nhân rò rỉ nhiều nhất: nội dung ô input, email trong URL, token trong query string. Lọc ở client (danh sách key cho phép thay vì danh sách cấm) và ở server. Banzai còn gắn userID vào từng post và từ chối restore log của user khác. Nếu bạn thêm lưu localStorage, hãy làm tương tự.
Log phình to. Ai đó log nguyên object story 30KB vào mỗi impression. Banzai cảnh báo khi route quá dài hoặc data vượt DATA_LIMIT. Hãy thêm một kiểm tra tương tự ở môi trường dev.
#Mở rộng: lưu localStorage như Banzai
Phần còn thiếu lớn nhất so với Banzai là lưu hàng đợi khi tab ẩn. Phác thảo:
const STORAGE_KEY = 'logger:pending';
function store() {
const pending = [...queues.vital, ...queues.basic];
if (pending.length === 0) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId: currentUserId(), pending }));
} catch {
// hết quota hoặc localStorage bị chặn: chấp nhận mất log, không được throw
}
}
function restore() {
navigator.locks.request('logger:restore', { ifAvailable: true }, async (lock) => {
if (!lock) return; // tab khác đang restore
const raw = localStorage.getItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
if (!raw) return;
const { userId, pending } = JSON.parse(raw);
if (userId !== currentUserId()) return; // không gửi log của user khác
const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
for (const e of pending) if (e.time > dayAgo) queues.basic.push(e);
});
}
ts
Gọi store() sau flushWithBeacon() khi tab hidden (cho phần beacon bị từ chối), và restore() khi khởi động hoặc khi tab visible trở lại.
#Dùng trong dự án
logger.ts
export const logger = createBatchLogger({ endpoint: '/api/log' });
logger.log('page_view', { path: location.pathname });
logger.log('checkout_error', { code }, 'vital');
ts
Phía server, endpoint /api/log nên trả về ngay (202 Accepted) rồi đẩy vào queue (Kafka, SQS…), không xử lý đồng bộ. Đây cũng là cách các SDK analytics lớn (Segment, Amplitude, PostHog…) vận hành. Nếu bạn đang tự viết tracking, đừng gửi mỗi event một request.
#Kiểm thử
Logic phụ thuộc thời gian nên dùng fake timers, và vì code đụng tới document/window nên chạy trong môi trường jsdom.
Ví dụ test (bản phác, không có trong repo)
batchLogger.test.ts
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest';
import { createBatchLogger } from './batchLogger';
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('gom event và chỉ gửi khi tới hạn', async () => {
vi.useFakeTimers();
const send = vi.fn().mockResolvedValue(undefined);
const logger = createBatchLogger({ endpoint: '/log', transport: { send, beacon: vi.fn(() => true) } });
logger.log('a');
logger.log('b');
await vi.advanceTimersByTimeAsync(4999);
expect(send).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(send).toHaveBeenCalledTimes(1);
expect(JSON.parse(send.mock.calls[0][1]).events).toHaveLength(2);
logger.destroy();
});
it('server lỗi thì giãn lịch gửi gấp đôi', async () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0.5); // jitter = 1, để thời gian chờ cố định
const send = vi.fn().mockRejectedValue(new Error('503'));
const logger = createBatchLogger({ endpoint: '/log', transport: { send, beacon: vi.fn(() => true) } });
logger.log('a', {}, 'vital');
await vi.advanceTimersByTimeAsync(1000); // lần 1: lỗi
expect(send).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1999); // chưa tới 2 giây
expect(send).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1); // lần 2 sau đúng 2 giây
expect(send).toHaveBeenCalledTimes(2);
logger.destroy();
});
ts
Kiểm thử phần beacon: gọi document.dispatchEvent(new Event('visibilitychange')) sau khi mock document.visibilityState thành 'hidden', rồi kiểm tra beacon được gọi với đúng các event còn trong hàng đợi.
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
- #7 Banzai: gom log theo lô + sendBeacon (bài này)
- #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

