Bảy chỗ hỏng không kêu khi tự dựng lớp gắn tham số cho link

hecigo20 min read
trackingCloudflare WorkersGA4UTMvận hành

Một cái link /go/ vừa lên production. Từ máy tôi, curl trả về mã mới. Cùng lúc, từ trình duyệt mở đúng cái URL đó, mã trả về là bản cũ 18 phút tuổi. Cùng một đường dẫn, cùng một lần purge, hai kết quả ngược nhau.

Khác biệt duy nhất giữa hai phép đo là điểm biên Cloudflare phục vụ và kiểu nén được yêu cầu: một bên SIN với gzip, một bên HKG với br.

Bài này ghi lại một ngày dựng lớp gắn tham số cho mọi link công bố ra ngoài, và bảy chỗ hỏng tìm thấy trong đó. Điểm chung của cả bảy là thứ làm nó đáng viết ra: không cái nào trả về lỗi. Link vẫn chạy, mã trạng thái vẫn 200, người bấm vẫn tới nơi. Thứ duy nhất mất là câu trả lời cho câu hỏi "link này đem về bao nhiêu", và nó mất trong im lặng.

Dựng cái gì

Trước ngày đó, hecigo.com không có quy ước gắn tham số nào. Mọi link đã đăng đều không truy lại được: một lượt truy cập từ README trên npm, từ một bài LinkedIn, hay từ chữ ký thư đều rơi vào cùng một dòng không tên trong báo cáo.

Cái bất đối xứng đáng nói nằm ở chỗ này: link đăng hôm nay mà không có tham số thì tối vĩnh viễn. Không có cách nào gắn ngược. Một README đã publish lên npm thì không gọi về được.

Thứ dựng ra gồm hai phần, và phần thứ hai mới là phần có giá:

  • Một đường dẫn /go/<bí danh> trả 302 tới trang thật kèm tham số. Trên một site Next.js tĩnh sau Cloudflare Workers, nó là một nhánh thêm vào worker đang phục vụ toàn site, không phải một dịch vụ mới.
  • Một quy ước đặt tên bí danh, và một hàm thuần dịch bí danh thành cặp đích với tham số.

Dịch vụ rút gọn có sẵn thì nhiều, và chúng giải đúng phần dễ. Phần khó không phải chuyển hướng, mà là làm sao để tham số sinh ra đọc được trong công cụ phân tích, và làm sao để một người viết bài không phải nhớ bảng mã.

Bí danh có ba hoặc bốn đoạn:

/go/<đích>-<nguồn>-<đặt chỗ>[-<chiến dịch>]
 
/go/dv-np-readme        README trên npm dẫn sang trang developers
/go/bl-li-post-lab      bài LinkedIn dẫn về blog, thuộc loạt lab notes

Tính được, không phải sinh ra rồi lưu. Đó là điều kiện để một trợ lý viết bài có thể tự dựng link đúng mà không cần gọi API nào.

1. Để người viết tự chọn utm_medium là cách làm rỗng báo cáo kênh

Bản thiết kế đầu của tôi cho phép người viết đặt thẳng utm_medium, với các giá trị nghe rất hợp lý: bio, post, share, print, doc.

Cả năm đều sai, và chúng sai theo cách không báo lỗi.

GA4 gom phiên vào nhóm kênh mặc định bằng cách khớp mẫu cố định trên utm_medium. Tài liệu của Google liệt kê rõ: Organic Social khớp khi medium thuộc tập ('social', 'social-network', 'social-media', 'sm', 'social network', 'social media'), Organic Video khớp biểu thức ^(.*video.*)$, Email khớp email|e-mail|e_mail|e mail, Referral khớp ('referral', 'app', 'link'). Và khi không luật nào khớp, tài liệu ghi rằng "Unassigned is the value Analytics uses when there are no other channel rules that match the event data".

Nghĩa là utm_medium=bio không rơi vào Organic Social. Nó rơi vào (Unassigned), cùng một chỗ với mọi thứ không phân loại được. Link vẫn chạy, phiên vẫn được ghi, chỉ có báo cáo kênh là rỗng dần. Và vì không có gì báo lỗi, chuyện này chỉ lộ ra khi ai đó mở báo cáo kênh sau vài tháng.

Cách sửa là không cho chọn medium. Người viết chọn một đặt chỗ, và bảng tra nở nó thành cặp (medium, content) hợp lệ:

export const PLACE = {
  bio:    { medium: null,       content: 'bio' },
  post:   { medium: 'social',   content: 'post' },
  vid:    { medium: 'video',    content: 'video' },
  share:  { medium: 'social',   content: 'share' },
  mail:   { medium: 'email',    content: 'newsletter' },
  readme: { medium: 'referral', content: 'readme' },
  doc:    { medium: 'document', content: 'doc' },
}

medium: nullbio là chỗ đáng chú ý: cùng một đặt chỗ nhưng kênh khác nhau tuỳ nguồn. Link trong hồ sơ LinkedIn là Organic Social; link trong README trên npm là Referral, vì npm là một trang web thật dẫn người sang.

const medium = place.medium ?? (SOCIAL.has(sCode) ? 'social' : 'referral')

Còn doc thì tôi cố ý để nó rơi vào (Unassigned). GA4 không có nhóm kênh nào cho tài liệu gửi khách, và gán bừa nó vào referral cho báo cáo đẹp lên là nói sai nguồn gốc. Một ô Unassigned đúng thì đọc được; một ô Referral sai thì không.

Câu tự kiểm ở đây: trong hệ của bạn, ai là người gõ giá trị utm_medium vào link, và người đó có bảng nào để tra không?

Bí danh sai thì làm gì? Ý đầu tiên của tôi là chuyển về trang chủ kèm utm_source=badlink, để còn thấy mà sửa.

Ý đó tự mâu thuẫn với chính mục đích của cả lớp này. badlink không phải một nguồn; nó là một sự kiện nội bộ. Đưa nó vào GA4 là tạo ra một dòng nguồn không tồn tại trong thực tế, ngay trong bộ số liệu mà lớp này sinh ra để làm sạch.

Cách làm hiện tại: bí danh sai thì chuyển về trang chủ trần, không một tham số nào. Còn sự kiện "có người bấm một bí danh không tồn tại" được ghi ở nơi khác, một kho đếm riêng không đi qua GA4:

env.GO_CLICKS.writeDataPoint({
  indexes: [khoaLayMau(alias)],
  blobs: [alias, ket_qua, duong_dan, src, med, camp, content, quoc_gia],
  doubles: [1],
})

Nguyên tắc rút ra, và nó rộng hơn chuyện link: sự kiện vận hành và sự kiện kinh doanh không dùng chung một kho. Trộn vào nhau thì kho nào cũng mất khả năng trả lời câu hỏi của nó.

Chỗ này cũng không ghi địa chỉ IP. Trường quốc gia lấy từ dữ liệu mà điểm biên đã suy ra sẵn và đính kèm request. Một dịch vụ chuyển hướng theo mặc định sẽ ghi IP, user agent và referrer; không ghi thì không phát sinh nghĩa vụ xử lý dữ liệu cá nhân, và ở quy mô này gần như không mất gì để đổi lại.

📖

Ba thứ hỏng mà không kêu, cả ba đều do chính công cụ vận hành gây ra

apt báo 0 gói chờ nâng. Gắn Ubuntu Pro xong, cùng máy đó báo 25 bản vá bảo mật. Ghi lại một buổi rà soát ba máy chủ, và ba chỗ hỏng mà không có gì...

3. Facebook nuốt mất nửa bộ tham số, và không báo gì

Phần này xảy ra trên một site WordPress khác chúng tôi vận hành, khi gắn tham số vào nút chia sẻ cuối bài.

Nút chia sẻ do theme dựng, và href của chúng trông như thế này:

<a href="https://www.facebook.com/sharer/sharer.php?u=https://site.com/bai-viet/">
<a href="https://twitter.com/intent/tweet?url=https://site.com/bai-viet/&text=Tiêu đề">

Để ý giá trị của u=: URL nằm đó không được mã hoá. Với link trần thì không sao, vì :/ hợp lệ trong phần query.

Nhưng nối thẳng tham số vào thì hỏng:

sharer.php?u=https://site.com/bai-viet/?utm_source=facebook&utm_medium=social

Facebook đọc chuỗi này thành hai tham số của chính sharer.php: uutm_medium. Cái thứ hai bị bỏ đi vì nó không phải tham số mà sharer.php biết. Kết quả: chia sẻ vẫn chạy, link vẫn đúng trang, chỉ mất utm_mediumutm_content.

Đây là kiểu hỏng tệ nhất trong bảy cái, vì nó hỏng một nửa. Nếu rụng hết tham số thì sẽ có người để ý. Rụng một nửa thì báo cáo vẫn có dòng facebook, chỉ là medium rỗng, và phiên rơi vào Unassigned.

Cách sửa là đừng nối chuỗi, để trình duyệt tự mã hoá:

const href = new URL(a.getAttribute('href'))
const goc = href.searchParams.get('u')          // đọc ra, đã giải mã
href.searchParams.set('u', gan(goc, 'facebook')) // ghi lại, tự mã hoá
a.setAttribute('href', href.toString())

Sau khi sửa, href thô chứa %3F%26 thay cho ?&, nên sharer.php chỉ thấy đúng một tham số u của nó.

Cái bẫy này còn một mặt nữa đáng nói: khi đọc lại để kiểm, công cụ nào cũng giải mã URL cho dễ nhìn, và bản đã giải mã trông y hệt bản hỏng. Muốn biết đúng sai thì phải đọc href thô, hoặc đếm xem tầng ngoài có mấy tham số.

4. Nút được bấm nhiều nhất lại là nút không ai gắn tham số

Lần khảo sát đầu, tôi grep khối nút chia sẻ theo tên miền: twitter, facebook, pinterest. Ra ba nút, và tôi tưởng thế là hết.

Khối đó có sáu nút. Hai cái bị bỏ sót là hai cái duy nhất không có tên miền nào trong href:

<a class="share-button share-envelope" href="mailto:...">
<a class="share-button share-copy" href="#" data-copy-link-url="https://site.com/bai-viet/">

Nút copy giữ địa chỉ trong thuộc tính data-, còn href chỉ là #. Grep theo tên miền không bao giờ thấy nó.

Và đây mới là phần đáng kể: ở Việt Nam, copy rồi dán vào Zalo nhiều khả năng là cách chia sẻ phổ biến nhất, chứ không phải bấm nút mạng xã hội. Mọi lượt truy cập từ đường đó rơi vào direct trong GA4, vì ứng dụng nhắn tin không gửi referrer. Nghĩa là cách chia sẻ được dùng nhiều nhất lại lẫn vào đúng cái xô rác lớn nhất của báo cáo.

Tôi không có số đo cho tỉ trọng đó, vì trước ngày này không có gì để đo. Đó cũng chính là lý do phải gắn tham số trước rồi mới biết.

Chọn giá trị cho nút copy là chỗ phải cẩn thận. Không ai biết người ta sẽ dán link vào đâu, nên gán utm_medium=social là đoán. Tôi dùng utm_source=copy-link với utm_medium=share, chấp nhận rơi vào (Unassigned), đúng lý do đã nói ở mục 1: thứ biết chắc là "qua nút copy của chính mình", và chỉ thế thôi.

Bài học về cách tìm thì rộng hơn bài học về nút: grep theo thứ mình đoán sẽ có thì chỉ tìm ra thứ mình đã biết. Đọc cả khối markup mới thấy hết.

Cùng lượt đó lộ ra nút thư đang trỏ mailto: tới một địa chỉ cố định, tức nó mời người đọc gửi thư cho chủ site chứ không phải chia sẻ bài cho bạn họ, dù đứng giữa hàng nút chia sẻ. Một nút "gửi thư cho chúng tôi" và một nút "chia sẻ qua thư" là hai hành vi khác nhau về người hưởng lợi, và chỉ cái thứ hai sinh ra lượt truy cập để đếm.

Nút LinkedIn tôi thêm vào lấy window.location.href làm địa chỉ chia sẻ. Nghe hợp lý, và nó sai theo cách tự nhân lên.

Người đọc tới trang bằng một link đã có tham số, ví dụ ?utm_source=facebook&utm_medium=social&utm_content=post. Họ bấm chia sẻ sang LinkedIn. Link vừa tạo mang nguyên bộ tham số của họ, không phải của lần chia sẻ mới. Người thứ hai bấm vào, và phiên đó báo cáo về đúng cái chiến dịch không hề sinh ra nó.

Sai số này không đứng yên: mỗi lần chuyển tiếp lại nhân nó lên, và nguồn gốc thật càng lúc càng xa.

Cách sửa là lấy địa chỉ sạch, và trang thường đã tự khai nó:

function diaChiSach(o) {
  const c = document.querySelector('link[rel="canonical"]')
  if (c && c.href) return c.href
  const u = new URL(window.location.href)
  u.search = ''
  u.hash = ''
  return u.toString()
}

Thẻ canonical là địa chỉ mà chính trang tự khai là địa chỉ thật của nó, nên nó là nguồn tốt hơn mọi thuộc tính do theme đặt.

Phép kiểm cho chỗ này phải nạp trang kèm tham số của người khác, nếu không thì bẫy không hiện ra:

const d = new JSDOM(html, {
  url: 'https://site.com/bai-viet/?utm_source=facebook&utm_medium=social',
  runScripts: 'outside-only',
})
d.window.eval(fs.readFileSync('share-links.js', 'utf8'))
d.window.document.dispatchEvent(new d.window.Event('DOMContentLoaded'))

Một chi tiết mất thời gian ở đây: jsdom giữ readyStateloading sau khi dựng xong, nên DOMContentLoaded phải bắn bằng tay. Không bắn thì script không chạy, mọi thứ trông như không đổi gì, và rất dễ tưởng nhầm là code hỏng.

6. 96 byte, không phải 96 ký tự

Kho đếm dùng một trường index làm khoá lấy mẫu. Tôi cắt bí danh cho vừa giới hạn:

indexes: [alias.slice(0, 96)]

Tài liệu Cloudflare ghi giới hạn đó là "Each index must be less than 96 bytes". Byte, không phải ký tự.

Với bí danh hợp lệ thì hai con số bằng nhau, vì bảng mã chỉ có [a-z0-9-]. Nên phép kiểm không thể bắt được. Nhưng đường dẫn không hợp lệ thì mang gì cũng được, và 96 ký tự tiếng Việt là gần 170 byte. Điểm dữ liệu quá cỡ bị bỏ, không báo lỗi.

Hệ quả đi đúng chiều xấu nhất: link hỏng càng lạ thì càng dễ biến mất khỏi bảng đếm, trong khi đó chính là nhóm cần nhìn thấy nhất.

export function khoaLayMau(alias) {
  const enc = new TextEncoder()
  if (enc.encode(alias).length <= 96) return alias
  let s = alias
  while (s.length > 0 && enc.encode(s).length > 96) s = s.slice(0, -1)
  return s
}

Cắt theo ký tự cho tới khi vừa 96 byte, không cắt byte thô, để không xả một code point làm đôi.

Tôi tìm ra chỗ này không phải nhờ kiểm, mà nhờ đọc trang giới hạn để tra thời hạn lưu cho một mục trong chính sách quyền riêng tư. Đó là lần thứ hai trong ngày một thứ hữu ích rơi ra từ việc đọc tài liệu vì một lý do khác.

7. Purge theo URL không chạm tới mọi biến thể

Quay lại hai phép đo ở đầu bài.

Plugin cache đặt tên file bundle JS theo danh sách file nguồn, không theo nội dung. Nội dung đổi, tên file và tham số ?ver= giữ nguyên. Nghĩa là URL cũ và URL mới là một, và việc phát đúng bản phụ thuộc hoàn toàn vào cú purge.

Cú purge đó chạy, và báo thành công. Đây là số đo ngay sau đó, cùng một URL:

shell        PoP SIN   Accept-Encoding: gzip   age=523s    -> bản MỚI
trình duyệt  PoP HKG   Accept-Encoding: br     age=1078s   -> bản CŨ

Cloudflare cache riêng từng biến thể nén và riêng từng điểm biên. Lần purge theo URL đó không chạm tới biến thể br ở HKG. Người dùng ở một số nơi tiếp tục nhận JS cũ, và không có gì trong quy trình phát hành kêu lên.

Gốc rễ là asset có tên không sinh từ nội dung. Tên đó do plugin đặt, không đổi được từ phía mình, nên còn lại hai việc phải làm.

Việc thứ nhất là đo cho đúng. Tôi đã viết một phép kiểm so nội dung bundle với chính nó qua một tham số phá cache, và nó báo xanh trong khi edge đang phát bản cũ: thêm tham số lạ vào URL bundle thì origin trả 404, nên hai vế so sánh đều là trang lỗi và tình cờ khớp nhau. Một đèn xanh không có nghĩa còn tệ hơn không có đèn nào.

Thứ đo được và đáng tin là tuổi bản cache, vì nó trả lời đúng câu cần hỏi: bản ở edge được nạp trước hay sau khi lần phát hành này bắt đầu. Và phải hỏi cả bốn biến thể nén rồi lấy cái già nhất, vì hỏi một cái rồi kết luận chính là cách tôi suýt bỏ qua nó.

for e in 'br' 'gzip' 'identity' 'gzip, deflate, br, zstd'; do
  curl -s -D - -o /dev/null -H "Accept-Encoding: $e" "$URL" |
    awk 'tolower($1)=="age:"{print $2}'
done

Việc thứ hai là khi phát hiện lệch thì leo thang sang purge toàn bộ vùng rồi nạp lại đủ bốn biến thể. Với một site nhỏ và nhịp phát hành thưa, vài phút cache nguội rẻ hơn nhiều so với việc phát nhầm bản cũ cho một phần người dùng.

Câu tự kiểm: trong hệ của bạn, có asset nào mà URL giữ nguyên khi nội dung đổi không? Nếu có, mọi thứ đang phụ thuộc vào một cú purge mà chưa chắc bạn đã kiểm bao giờ.

Ba chỗ tôi tự làm hỏng trong cùng ngày

Bảy mục trên đều là chỗ hỏng của công cụ hoặc của nền tảng. Nếu bài dừng ở đó thì nó là quảng cáo chứ không phải lab note.

Phép kiểm báo xanh mà không kiểm gì. Đã kể ở mục 7. Điều đáng rút ra không phải lỗi so sánh, mà là tôi đã ship một phép kiểm mình chưa chứng minh được là đúng. Nó chạy một lần, in ra chữ OK, và tôi tin nó.

Một file mới không lên server, và quy trình phát hành báo thành công. Script deploy đẩy theo một danh sách cứng. Danh sách đó bắt được trường hợp "tên có trong danh sách mà file không tồn tại", và không bắt trường hợp ngược lại: file có trong repo mà không có tên trong danh sách thì bị bỏ qua, không một dòng nào. Cùng lượt đó lộ ra danh sách của bản staging và bản production đã lệch nhau, trong khi ghi chú đầu file vẫn nói hai bên giống hệt. Bộ kiểm giờ dừng lại và gọi tên file, và mọi file trong thư mục theme phải hoặc nằm trong danh sách đẩy, hoặc nằm trong danh sách loại trừ có tên. Không còn loại thứ ba.

Một công cụ kiểm báo sai ngay lần chạy đầu. Bộ kiểm bí danh tôi viết để bắt lỗi lúc đang soạn bài đã báo dv-np-readme là sai, trong khi đó là bí danh hợp lệ. Nó thử cả hai bảng mã của hai site, mà np chỉ có trong bảng của site này. Cách sửa không phải chép bảng sang, vì bản sao thứ ba của một bảng là bản sẽ lệch đầu tiên; mỗi bên có một bộ kiểm đọc bảng của chính nó.

Ý phản đối mạnh nhất với chính bài này

Ý tôi thấy khó cãi nhất: bảy chỗ này phần lớn là hệ quả của lựa chọn tự dựng. Một dịch vụ rút gọn thương mại đã xử lý sẵn phần mã hoá URL, phần cache, phần bảng mã. Vậy bài này có thật sự nói về một lớp vấn đề chung, hay chỉ là ghi lại cái giá của việc tự làm?

Tôi nghiêng về vế thứ hai nhiều hơn tôi muốn thừa nhận, với ba trong bảy mục. Mục 3, 6 và 7 sẽ không xảy ra nếu dùng dịch vụ có sẵn.

Bốn mục còn lại thì không. Mục 1 về utm_medium là vấn đề của quy ước, không phải của công cụ: một dịch vụ rút gọn vẫn để người viết gõ medium tự do. Mục 2, 4 và 5 nằm ở phía trang web của mình, ngoài tầm với của bất kỳ dịch vụ nào. Nói cách khác, phần dễ thuê ngoài được là phần chuyển hướng; phần quyết định số liệu có đọc được hay không thì không thuê được.

Còn một ý phản đối nữa tôi không có số liệu để cãi: cả lớp này có thể là thứ chưa đáng làm ở quy mô hiện tại. Trước ngày đó chúng tôi không đo được gì, nên cũng không biết mình đã bỏ lỡ bao nhiêu, và vì thế không tính được nó đáng giá bao nhiêu. Phải gắn trước rồi mới trả lời được, và đó là một dạng đặt cược chứ không phải một phép tính.

🚀

Thấy bài này dùng được? Theo dõi hecigo trên Zalo OA để nhận bài kỹ thuật mới, hoặc nhắn cho chúng tôi nếu hai hệ thống của bạn đang phải nói chuyện với nhau và có gì đó hỏng ở khoảng giữa.

📖

Đọc tiếp: Đổi host và bốn hành vi nền tảng đang làm hộ mà bạn không sở hữu

Chuyển hecigo.com sang một host khác. Adapter mất mười lăm dòng. Bốn hành vi nền tảng vẫn làm hộ thì mỗi cái hỏng một kiểu, và không cái nào làm...

Còn chưa xong

Bí danh hiện chỉ trỏ được tới các trang trong một bảng mã tĩnh. Một bài blog cụ thể thì chưa có bí danh riêng, nên khi đăng bài lên mạng xã hội vẫn phải chọn giữa link dài có tham số và link ngắn không đo được. Đó là phần kế tiếp, và nó cần một kho tra cứu chứ không phải một bảng biên dịch sẵn.

Một quan sát trong lúc dựng, và tôi đưa ra đây vì nó ngược với thứ tôi tưởng: phần tốn thời gian nhất không phải viết bộ chuyển hướng, mà là chốt bảng quy ước. Bộ chuyển hướng khoảng 150 dòng và viết trong một buổi. Bảng quy ước thì mỗi ô đều là một quyết định phải sống chung nhiều năm, vì link đã phát hành không sửa được.

Câu tôi chưa trả lời được, và cũng đang muốn biết: cả bảy chỗ trong bài đều lộ ra vì có người ngồi đo tay, và bốn trong số đó chỉ lộ ra khi đo từ hai phía cùng lúc, một phía trong mạng và một phía ngoài. Vậy loại hỏng "mã trạng thái 200 nhưng nội dung sai" thì giám sát tự động được tới đâu, hay nó vốn chỉ bắt được bằng cách đo song song hai điểm nhìn?

Nguồn tham khảo

Related Articles