Tất cả bài viết

Lead này đến từ đâu?

8 phút đọc

Trong bài này

NowCheckin chạy quảng cáo trên Facebook và Zalo cùng lúc. Bài này kể cách tôi theo dấu một vị khách từ lúc bấm vào quảng cáo đến lúc để lại thông tin, và vì sao tôi tự viết phần tracking thay vì chỉ dán một đoạn script.

Trang mời chủ địa điểm trở thành đối tác của NowCheckin

Chạy quảng cáo ở hai nơi cùng lúc thì sớm muộn cũng có người hỏi một câu rất đơn giản: tuần này có bao nhiêu đối tác đăng ký, và họ đến từ Facebook hay Zalo?

Không có tracking thì câu trả lời là đoán. Có tracking nửa vời thì câu trả lời là một con số sai nhưng trông rất tự tin, còn tệ hơn đoán.

Tracking không phải để có biểu đồ đẹp. Nó để trả lời một câu duy nhất: đồng tiền quảng cáo nào đang mang khách về.

Website không tự biết khách đến từ đâu. Thứ duy nhất nó nhìn thấy là đường link khách vừa bấm, nên mọi thông tin phải nằm sẵn trong link đó.

nowcheckin.io/vi/partnerutm_sourcefacebookutm_mediumcpcutm_campaigndoi_tac_thang_10utm_contentvideo_afbclidIwAR0abc
Một link quảng cáo của NowCheckin, tách thành từng phần. Ô đen là trang khách sẽ mở, các ô vàng là thông tin về nguồn.

Tôi đặt một quy ước cố định cho cả đội chạy quảng cáo, vì chỉ cần một người gõ Facebook và người khác gõ fb là báo cáo tách thành hai dòng.

Tham sốTrả lời câu hỏiGiá trị được phép
utm_sourceKhách đến từ nền tảng nào?facebook, zalo, google
utm_mediumBằng hình thức gì?cpc (trả tiền), oa (tin từ Zalo OA), post (bài đăng thường)
utm_campaignThuộc chiến dịch nào?tên chiến dịch, viết thường, nối bằng gạch dưới
utm_contentMẫu quảng cáo nào?video_a, anh_b

fbclid thì khác: Facebook tự gắn nó vào mọi link khi người dùng bấm. Tôi không tạo ra nó, nhưng tôi giữ nó lại, lát nữa sẽ nói vì sao.

const UTM_KEYS = ['source', 'medium', 'campaign', 'content'];

export function readSource(url: URL) {
  const utm: Record<string, string> = {};
  for (const key of UTM_KEYS) {
    const value = url.searchParams.get('utm_' + key);
    if (value) utm[key] = value;
  }

  return {
    utm,
    fbclid: url.searchParams.get('fbclid') ?? undefined, // Facebook tự gắn, mình chỉ giữ lại
    path: url.pathname // trang đầu tiên khách nhìn thấy
  };
}

Ba cái id, ba câu hỏi khác nhau

Đây là chỗ tôi mất nhiều thời gian nghĩ nhất. "Một vị khách" nghe thì đơn giản, nhưng thật ra có ba thứ cần phân biệt.

Lưu ở trình duyệtNó là gìTrả lời câu hỏi
nc_aidId ẩn danh, sống lâuĐây có phải người hôm qua đã ghé không?
nc_sidId của một phiên truy cậpNhững hành động nào thuộc cùng một lần ghé?
nc_lastThời điểm hoạt động gần nhấtPhiên này còn sống, hay khách đã bỏ đi rồi quay lại?
eventIdId của từng sự kiệnSự kiện này đã được ghi chưa, hay đang bị gửi trùng?

Nếu chỉ có một id, bạn không phân biệt được "một người ghé ba lần" với "ba người ghé một lần". Mà hai trường hợp đó dẫn tới hai quyết định quảng cáo hoàn toàn khác nhau.

Không hỏi thì không theo dõi

Hộp thoại xin phép dùng cookie trên NowCheckin
Trước khi khách bấm Chấp nhận, không có id ẩn danh nào được tạo và không có sự kiện nào được gửi đi.

Id ẩn danh chỉ được tạo sau khi khách đồng ý. Trước đó, trang vẫn chạy bình thường, chỉ là tôi không biết gì về họ. Tôi chấp nhận mất một phần dữ liệu để đổi lấy việc không phải giải thích với ai rằng vì sao mình theo dõi họ khi họ chưa cho phép.

Một sự kiện trông như thế nào

Đây là thứ trình duyệt thật sự gửi về API, lấy từ một lần ghé trang. Tôi chỉ đổi tên chiến dịch.

{
  "anonymousId": "e9d2fdca-a74e-4d25-8739-fe07dc417f6d",
  "sessionId": "1b7936f4-34b6-4cef-8df5-b0a9a6ee01dd",
  "source": "web",
  "ctx": {
    "path": "/vi",
    "utm": {
      "source": "facebook",
      "medium": "cpc",
      "campaign": "doi_tac_thang_10",
      "content": "video_a"
    },
    "fbclid": "IwAR0abc",
    "device": "desktop",
    "locale": "vi"
  },
  "events": [
    {"eventId": "31150ce6-...", "name": "page_view", "ts": "2026-10-11T07:42:47.027Z", "props": {"path": "/vi"}},
    {"eventId": "bf9d5430-...", "name": "page_view", "ts": "2026-10-11T07:42:53.919Z", "props": {"path": "/vi/explore"}}
  ]
}

Ba điều đáng để ý:

  1. Ngữ cảnh chỉ gửi một lần. Nguồn quảng cáo, thiết bị, ngôn ngữ nằm trong ctx, không lặp lại trong từng sự kiện.
  2. Sự kiện được gom lại. Hai lượt xem trang đi chung một request. Khách bấm nhanh mười lần thì server không phải nhận mười request.
  3. Mỗi sự kiện có id riêng. Mạng chập chờn, request bị gửi lại, server vẫn biết đó là cùng một sự kiện.
const queue: TrackEvent[] = [];

export function track(name: string, props: Record<string, unknown> = {}) {
  if (!hasConsent() || disabledEvents.has(name)) return; // chưa đồng ý, hoặc sự kiện đang bị tắt

  queue.push({eventId: crypto.randomUUID(), name, ts: new Date().toISOString(), props});
  scheduleFlush(); // gom lại rồi gửi một lượt
}

Hành trình của một vị khách

Khi mọi sự kiện đều mang cùng sessionId, xếp chúng theo thời gian là ra một câu chuyện.

  1. 00:00Bấm quảng cáo trên Facebookutm_source=facebook, utm_content=video_a
  2. 00:01Xem trang chủpage_view /vi
  3. 00:07Sang trang khám phápage_view /vi/explore
  4. 00:31Mở một quán phởpage_view /vi/merchant/...
  5. 01:12Bấm Trở thành đối tácchuyển sang /vi/partner
  6. 02:40Gửi form đăng kýlead được ghi kèm nguồn facebook
Một phiên truy cập, đọc từ trên xuống. Thời gian là phút và giây tính từ lúc vào trang.

Nhìn vào đây thấy được những thứ mà con số tổng không cho thấy: khách xem gì trước khi quyết định, và họ bỏ đi ở bước nào.

Từ sự kiện đến câu trả lời

  1. Bấm quảng cáo
  2. Vào trang kèm UTM
  3. Sự kiện gửi về API
  4. Lead gắn với nguồn
Nguồn được đọc ở bước hai và đi theo khách tới tận lúc họ để lại thông tin.

Vì nguồn đã nằm trong ngữ cảnh của mọi sự kiện, câu hỏi ban đầu chỉ còn là một truy vấn.

SELECT ctx_utm_source        AS source,
       COUNT(DISTINCT anonymous_id) AS leads
FROM   events
WHERE  name = 'partner_signup'
GROUP  BY ctx_utm_source
ORDER  BY leads DESC;
  • Facebook46
  • Zalo31
  • Google18
  • Vào thẳng9
Số lead theo nguồn. Đây là số minh họa để cho thấy hình dạng của báo cáo, không phải số thật của NowCheckin.

Còn một quyết định phải chọn có chủ đích: khách bấm quảng cáo Facebook hôm thứ hai, đến thứ năm quay lại qua tin nhắn Zalo rồi mới đăng ký, vậy lead đó thuộc về ai? Tôi lưu cả lần chạm đầu lẫn lần chạm cuối, vì hai đội trả lời hai câu hỏi khác nhau: lần đầu cho biết kênh nào giới thiệu được khách, lần cuối cho biết kênh nào chốt được.

Tắt một sự kiện mà không cần deploy

Trước khi gửi bất cứ thứ gì, trang hỏi API một câu: có sự kiện nào đang bị tắt không?

{"success": true, "data": {"disabledEvents": []}}

Nghe có vẻ thừa, cho tới ngày một sự kiện bị gắn nhầm vào thứ gì đó chạy mỗi giây và bắt đầu đổ rác vào cơ sở dữ liệu. Lúc đó tôi thêm tên nó vào danh sách ở server là xong, không cần build lại website.

Còn Facebook Pixel thì sao

Cách thông thường là dán đoạn script của Facebook vào trang. Trên NowCheckin, trình duyệt không tải script đó. Thay vào đó, fbclid đi cùng sự kiện về API của tôi, và việc báo lại cho Facebook là chuyện của server.

Tôi chọn vậy vì ba lý do:

  • Trình chặn quảng cáo chặn script của bên thứ ba rất giỏi, nhưng không chặn request tới chính tên miền của mình.
  • Trang nhẹ hơn. Bớt một script bên ngoài là bớt một thứ làm chậm lần tải đầu.
  • Tôi giữ dữ liệu gốc. Facebook nhận những gì tôi quyết định gửi, và tôi vẫn có bản đầy đủ để đối chiếu khi hai bên lệch số.

SEO và tracking đừng đạp chân nhau

Tracking thêm tham số vào URL, và đó chính là thứ SEO ghét. Với Google, /vi/partner và /vi/partner?utm_source=facebook là hai địa chỉ khác nhau có cùng nội dung.

export async function generateMetadata({params}) {
  const {locale} = await params;

  return {
    alternates: {
      canonical: '/' + locale + '/partner', // no utm_*, no fbclid
      languages: {vi: '/vi/partner', en: '/en/partner'}
    }
  };
}

Thẻ canonical nói với bot rằng dù link có đuôi gì thì trang gốc vẫn là một. Cùng với nó là những thứ NowCheckin làm từ đầu: mỗi ngôn ngữ một đường dẫn, hreflang nối hai bản với nhau, và trang địa điểm được render phía server để nội dung có sẵn trong HTML.

Trang chủ NowCheckin bản tiếng Việt
/vi
Trang chủ NowCheckin bản tiếng Anh
/en

Còn đoạn code tracking thì chạy sau khi trang đã hiện xong. Không có số liệu nào đáng để khách phải nhìn màn hình trắng thêm nửa giây.

Nếu làm lại, tôi vẫn giữ ba thứ

  1. Quy ước đặt tên UTM viết ra giấy trước khi chạy quảng cáo đầu tiên. Sửa dữ liệu bẩn về sau tốn gấp nhiều lần.
  2. Tách người, phiên và sự kiện ngay từ đầu. Thêm id về sau nghĩa là dữ liệu cũ không dùng được.
  3. Công tắc tắt sự kiện ở server. Nó rẻ, và ngày cần đến nó thì bạn sẽ rất cần.