Thiết kế hệ thống analytics cho PDF Viewer: Active Time, Reading Depth và Exit Page nên đo thế nào?
Thiết kế hệ thống analytics cho PDF Viewer: Active Time, Reading Depth và Exit Page nên đo thế nào?
Khi xây một PDF Viewer trên web, phần khó không chỉ nằm ở việc render file PDF.
Nếu sản phẩm cần analytics, chúng ta sẽ nhanh chóng gặp những câu hỏi khó hơn:
- Bao nhiêu người thực sự mở tài liệu?
- Họ đọc tới trang nào?
- “Thời gian đọc” nên tính như thế nào?
- Nếu người dùng nhảy từ trang 1 sang trang 20 thì có được tính là đọc 100% không?
- Có thể xác định chính xác trang thoát hay không?
- Một visitor có giống một người thật không?
Nếu định nghĩa metric không cẩn thận, dashboard có thể trông rất đẹp nhưng lại đưa ra kết luận sai.
Bài viết này sẽ đi qua một thiết kế analytics thực tế cho PDF Viewer trên web, tập trung vào:
- Reading Session
- Active Time
- Reading Depth
- Page Engagement
- Exit Page
- Download
- CTA Click
1. Đừng bắt đầu từ dashboard, hãy bắt đầu từ event model
Một lỗi khá phổ biến là thiết kế dashboard trước.
Ví dụ chúng ta muốn hiển thị:
- Views
- Reading Time
- Completion Rate
Sau đó mới tìm cách thu thập dữ liệu để “đổ vào” các con số đó.
Cách tốt hơn là bắt đầu từ:
Browser thực sự quan sát được những hành vi nào?
Ví dụ:
document_opened
page_viewed
page_visible
document_hidden
document_focused
document_blurred
pdf_downloaded
cta_clicked
session_closed
Sau đó mới xây metric từ những event này.
Cách tiếp cận này khá giống event-based analytics của GA4: thay vì chỉ nghĩ theo pageview, ta mô hình hóa hành vi thành event. Viblo Engineering cũng đã chia sẻ cách dùng mô hình event để phân tích hành vi người dùng trong hệ thống analytics.
2. Session không giống Visitor
Trước tiên cần phân biệt hai khái niệm:
Session
Một lần người dùng mở tài liệu và tương tác trong một khoảng thời gian.
Ví dụ:
10:00 mở PDF
10:05 đóng PDF
Đây có thể được xem là một session.
Nếu cùng người đó quay lại lúc 15:00:
15:00 mở lại PDF
15:03 đóng
đây là session thứ hai.
Visitor
Visitor thường được suy ra từ một browser identifier.
Ví dụ:
visitor_id = crypto.randomUUID()
sau đó lưu vào cookie hoặc localStorage.
Nhưng cần nhớ:
browser identifier != verified human
Một người có thể dùng:
- Chrome trên laptop
- Safari trên iPhone
- Incognito
- một thiết bị khác
và tạo nhiều identifier.
Do đó dashboard nên ghi:
Visitors
hoặc thậm chí rõ hơn:
Browser identifiers
thay vì khẳng định:
Unique people
3. Thiết kế Session ID
Mỗi lần mở PDF, có thể tạo một session ID mới.
Ví dụ:
const sessionId = crypto.randomUUID();
Event gửi về server có thể có dạng:
{
"session_id": "abc-123",
"visitor_id": "visitor-456",
"document_id": "pdf-789",
"event": "document_opened",
"timestamp": 1760342400000
}
Tất cả event phát sinh sau đó nên mang cùng session_id.
Ví dụ:
document_opened
page_viewed
page_viewed
cta_clicked
session_closed
Server có thể reconstruct lại timeline của session.
4. Đừng gọi raw session duration là “Reading Time”
Giả sử:
10:00 mở PDF
10:01 chuyển sang tab khác
10:21 quay lại
10:22 đóng
Nếu tính:
close_time - open_time
kết quả là:
22 phút
Nhưng người dùng chỉ thực sự nhìn PDF khoảng 2 phút.
Do đó raw duration không phải reading time.
5. Active Time nên được tính như thế nào?
Một cách thực tế hơn là chỉ cộng thời gian khi:
document.visibilityState === "visible"
và window đang focus.
Có thể bắt đầu bằng:
let isVisible = document.visibilityState === "visible";
let isFocused = document.hasFocus();
let activeStartedAt = null;
let activeTime = 0;
Sau đó tạo helper:
function shouldBeActive() {
return isVisible && isFocused;
}
Khi trạng thái chuyển từ inactive → active:
function startActiveTimer() {
if (activeStartedAt === null) {
activeStartedAt = Date.now();
}
}
Khi chuyển từ active → inactive:
function stopActiveTimer() {
if (activeStartedAt !== null) {
activeTime += Date.now() - activeStartedAt;
activeStartedAt = null;
}
}
Theo dõi visibility:
document.addEventListener("visibilitychange", () => {
isVisible = document.visibilityState === "visible";
if (shouldBeActive()) {
startActiveTimer();
} else {
stopActiveTimer();
}
});
Theo dõi focus:
window.addEventListener("focus", () => {
isFocused = true;
if (shouldBeActive()) {
startActiveTimer();
}
});
window.addEventListener("blur", () => {
isFocused = false;
stopActiveTimer();
});
Như vậy thời gian user để tab chạy nền sẽ không bị cộng liên tục.
6. Nhưng Active Time vẫn không phải “attention”
Đây là phần quan trọng.
Ngay cả khi tab:
- visible
- focused
browser vẫn không biết user có đang thực sự đọc hay không.
Người dùng có thể:
- nhìn sang màn hình khác
- nói chuyện
- đứng dậy
- nhìn điện thoại
Do đó tên metric tốt hơn là:
Active Time
không phải:
Attention Time
hay:
Actual Reading Time
Metric nên mô tả đúng thứ hệ thống quan sát được.
7. Có nên thêm inactivity timeout?
Có thể.
Ví dụ user mở PDF nhưng không scroll, click, đổi trang hoặc tương tác trong 5 phút.
Có thể xem session là idle.
Ta lưu:
let lastInteractionAt = Date.now();
Reset khi có interaction:
["mousemove", "keydown", "touchstart", "scroll"].forEach(eventName => {
window.addEventListener(eventName, () => {
lastInteractionAt = Date.now();
}, { passive: true });
});
Sau đó:
const IDLE_TIMEOUT = 60_000;
Nếu:
Date.now() - lastInteractionAt > IDLE_TIMEOUT
thì không cộng Active Time nữa.
Tuy nhiên cần cân nhắc.
Một người có thể đang đọc một trang dài mà không tạo interaction.
Nếu timeout quá ngắn, metric lại bị thấp hơn thực tế.
Vì vậy inactivity timeout là product decision, không phải chân lý kỹ thuật.
8. Reading Depth nên định nghĩa là gì?
Giả sử PDF có:
20 pages
User đã xem:
1 → 2 → 3 → 4 → 5
Ta có thể nói:
furthest_page = 5
Reading Depth:
5 / 20 = 25%
Công thức:
const readingDepth =
furthestPage / totalPages * 100;
Điều này khá trực quan.
Nhưng có một vấn đề lớn.
9. Page jump phá vỡ khái niệm completion
Giả sử user:
Page 1
↓
Page 20
Nếu dùng công thức trên:
furthest_page = 20
reading_depth = 100%
Nhưng rõ ràng user không đọc 20 trang.
Do đó Reading Depth nên được mô tả là:
Furthest page reached
không phải:
Percentage fully read
Đây cũng là cách PDF to Link mô tả Reading Depth hiện tại: metric dựa trên furthest page reached và page jumping có thể ảnh hưởng đến curve.
10. Đừng gọi “Reached Last Page” là Completion Rate
Nếu user đến trang cuối, event có thể là:
{
"event": "last_page_reached",
"page": 20
}
Nhưng dashboard nên hiển thị:
Reached Last Page
thay vì:
Completed Reading
Hai khái niệm này không giống nhau.
Đây là một lỗi naming rất dễ khiến team marketing hoặc sales đọc sai dữ liệu.
Một dashboard tốt đôi khi phải “kém hấp dẫn” hơn một chút để chính xác hơn.
11. Muốn metric tốt hơn, hãy track từng page
Thay vì chỉ lưu furthest page, có thể lưu page-level events.
Ví dụ:
{
"session_id": "abc",
"event": "page_viewed",
"page": 4,
"timestamp": 1760342500000
}
Sau đó server có thể tạo:
page 1 → viewed
page 2 → viewed
page 4 → viewed
page 7 → viewed
Điều này cho phép phân biệt:
furthest_page = 7
với:
pages actually observed = [1, 2, 4, 7]
12. Page Engagement nên đo bằng gì?
Một metric hữu ích là active time trên từng trang.
Ví dụ:
Page 1: 18s
Page 2: 23s
Page 3: 19s
Page 4: 71s
Page 4 rõ ràng đáng chú ý.
Nhưng analytics không thể biết lý do.
Page 4 có thể chứa:
- pricing
- chart
- technical detail
- content khó hiểu
Metric chỉ nói:
Có một pattern khác biệt ở page 4.
Không nên kết luận:
User thích page 4 nhất.
13. Track page visibility thay vì chỉ page change
Nếu viewer hiển thị nhiều trang cùng lúc, event:
current_page_changed
có thể chưa đủ.
Có thể dùng IntersectionObserver.
Ví dụ:
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
const page = Number(entry.target.dataset.page);
trackPageVisible(page);
}
});
}, {
threshold: [0.5]
});
Sau đó observe từng page:
document.querySelectorAll("[data-page]").forEach(page => {
observer.observe(page);
});
Điều này phù hợp hơn cho scroll-based PDF reader.
14. Nên định nghĩa khi nào page được xem
Nếu chỉ cần 1 pixel xuất hiện đã tính view thì metric sẽ rất noisy.
Có thể định nghĩa:
intersectionRatio >= 50%
và visible ít nhất:
500 ms
Ví dụ:
const MIN_VISIBLE_TIME = 500;
Page chỉ được ghi nhận khi đủ cả hai điều kiện.
Điều này giảm false positives khi user scroll nhanh.
15. Exit Page là metric khó hơn tưởng tượng
Ta muốn biết:
User rời khỏi document ở page nào?
Nghe đơn giản.
Nhưng web browser không đảm bảo bạn luôn nhận được event khi user rời đi.
Ví dụ:
- browser crash
- mobile app bị kill
- mất mạng
- OS suspend tab
- user tắt máy
Do đó không nên giả định 100% session có exit page.
16. Dùng pagehide thay vì chỉ beforeunload
Một implementation đơn giản:
window.addEventListener("pagehide", () => {
sendExitEvent();
});
Có thể kết hợp:
navigator.sendBeacon()
Ví dụ:
function sendExitEvent() {
const payload = JSON.stringify({
session_id: sessionId,
event: "session_exit",
page: currentPage,
active_time: activeTime
});
navigator.sendBeacon(
"/api/analytics",
payload
);
}
sendBeacon() phù hợp cho analytics cuối session vì browser cố gửi request mà không block navigation.
Nhưng vẫn không có guarantee tuyệt đối.
17. Vì vậy cần có “Exit Page Unknown”
Nếu session có activity nhưng không nhận được exit event:
exit_page = null
Dashboard nên giữ category:
Exit Page Unknown
thay vì tự suy luận:
last_page_viewed = exit_page
Hai thứ này khác nhau.
Last page observed chưa chắc là trang user thật sự rời đi.
18. Download là một event riêng
Download event nên được track rõ ràng.
Ví dụ:
downloadButton.addEventListener("click", () => {
track({
event: "pdf_downloaded",
document_id: documentId,
session_id: sessionId
});
});
Sau khi user download file và đọc offline:
browser analytics không còn thấy behavior đó nữa.
Đây là một tradeoff quan trọng.
19. CTA Click là event có giá trị cao hơn view
Ví dụ page có CTA:
Book a demo
Contact sales
Visit product
Track:
ctaButton.addEventListener("click", () => {
track({
event: "cta_clicked",
cta_id: "book_demo",
session_id: sessionId
});
});
CTA click thường là behavioral signal mạnh hơn document open.
Nhưng vẫn không được hiểu là:
cta_clicked = converted_customer
Nó chỉ chứng minh:
CTA đã được click.
20. Đừng gửi request sau mỗi pixel scroll
Analytics dễ tạo quá nhiều request.
Nếu mỗi event đều POST ngay:
page visible
scroll
focus
blur
interaction
page change
server sẽ nhận rất nhiều traffic không cần thiết.
Một cách tốt hơn là buffer event.
Ví dụ:
const eventQueue = [];
function track(event) {
eventQueue.push({
...event,
ts: Date.now()
});
}
Sau đó batch:
setInterval(() => {
flushEvents();
}, 5000);
21. Batch event trước khi gửi
Ví dụ:
async function flushEvents() {
if (!eventQueue.length) return;
const events = eventQueue.splice(0);
try {
await fetch("/api/analytics/batch", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ events }),
keepalive: true
});
} catch {
eventQueue.unshift(...events);
}
}
Điều này giảm số request đáng kể.
22. Analytics không được làm hỏng PDF Viewer
Một nguyên tắc rất quan trọng:
Analytics should fail open.
Nếu endpoint analytics lỗi:
PDF vẫn phải đọc được.
Không nên:
await analytics.initialize();
renderPdf();
Tốt hơn:
renderPdf();
analytics.initialize().catch(() => {
// ignore analytics failure
});
Core product phải hoạt động độc lập với analytics pipeline.
23. Event schema nên versioned
Khi sản phẩm phát triển, event schema sẽ thay đổi.
Nên thêm:
{
"schema_version": 1,
"event": "page_viewed"
}
Sau này:
{
"schema_version": 2,
"event": "page_viewed"
}
Server hoặc data warehouse có thể xử lý cả hai.
Điều này rất hữu ích khi analytics đã chạy ở production lâu.
24. Một schema đơn giản
Ví dụ event:
{
"schema_version": 1,
"document_id": "doc_123",
"session_id": "session_123",
"visitor_id": "visitor_123",
"event": "page_visible",
"page": 7,
"active_ms": 2340,
"timestamp": 1760342400000
}
Có thể bổ sung:
country
device_type
referrer
utm_source
nếu product thật sự cần.
Không nên collect dữ liệu chỉ vì “có thể collect”.
25. Một dashboard nên hiển thị gì?
Từ event model trên có thể tạo:
Overview
Reading Sessions
Visitors
Active Time
Downloads
CTA Clicks
Reading Depth
Page 1 100%
Page 5 82%
Page 10 59%
Page 15 35%
Page Engagement
Page
Readers
Active Time
Exit Pages
Page 3
Page 7
Page 10
Unknown
Quan trọng nhất là label phải phản ánh đúng dữ liệu.
26. Case thực tế: PDF to Link
Một sản phẩm thực tế dùng mô hình này là PDF to Link.
Sản phẩm publish PDF thành browser reading page và hiện có các nhóm metric như:
- Reading Sessions
- Visitors
- Active Time
- Reading Depth
- Page Heatmap
- Reader Locations
- Exit Pages
- Downloads
- CTA Clicks
Điểm đáng chú ý không phải số lượng metric.
Mà là cách định nghĩa giới hạn:
- Visitor dựa trên browser identifier
- Active Time là visible + focused
- Reading Depth là furthest page reached
- page jumping có thể ảnh hưởng curve
- Reached Last Page không được gọi là completed read
- Exit Page có thể Unknown
Đây là cách tiếp cận khá tốt nếu mục tiêu là tránh “analytics theater”.
27. Raw PDF URL và Reading Page khác nhau
Một direct PDF URL:
https://example.com/file.pdf
rất phù hợp nếu:
- API cần file
- cần direct download
- hệ thống khác cần
.pdf
Nhưng analytics sâu sẽ khó hơn.
Reading page:
https://example.com/page/abc
tạo một web layer để collect event.
Sự khác biệt này được giải thích rõ hơn trong hướng dẫn PDF to URL.
Nếu audience là machine:
raw URL
Nếu audience là human và cần analytics:
reading page
thường hợp lý hơn.
28. Một rule đơn giản khi thiết kế analytics
Trước mỗi metric, hãy hỏi:
Browser thực sự biết điều gì?
Ví dụ:
Browser biết:
Page 10 đã visible.
Browser không biết:
User đã hiểu page 10.
Browser biết:
Tab visible trong 120 giây.
Browser không biết:
User tập trung 120 giây.
Browser biết:
CTA được click.
Browser không biết:
User chắc chắn sẽ mua.
Nếu giữ ranh giới này rõ ràng, dashboard sẽ đáng tin hơn rất nhiều.
Kết luận
Xây analytics cho PDF Viewer không khó nhất ở phần JavaScript.
Phần khó hơn là định nghĩa metric.
Một implementation tốt nên:
- dùng session rõ ràng
- phân biệt session và visitor
- dùng
visibilityState+ focus cho Active Time - xem Reading Depth là furthest page reached
- không gọi last-page reach là completion
- track page-level engagement
- chấp nhận Exit Page Unknown
- batch event
- dùng
sendBeacon()cho exit khi phù hợp - để analytics fail-open
- đặt tên metric thật chính xác
Analytics tốt không cần giả vờ biết mọi thứ.
Nó chỉ cần trả lời chính xác:
“Chúng ta thực sự quan sát được điều gì?”
Và từ đó, product team mới quyết định bước tiếp theo.
All rights reserved