← Blog

Tôi tự host analytics bằng SQLite + Astro middleware — và đây là lý do GA4 không bao giờ quay lại

Một file .db duy nhất, 200 dòng TypeScript, không JS gửi sang client, không banner cookie. Đây là cách tôi đếm visitor cho portfolio này — và lúc nào setup này KHÔNG đủ.

Site bạn đang đọc này có một cái counter “Hôm nay X người ghé” ngay đầu trang chủ. Nó không gọi Google Analytics, không có Plausible / Umami / Fathom embedded, không có một dòng JavaScript bên thứ ba nào. Toàn bộ counter chạy bằng:

  • Một file app.db ~2 MB nằm cạnh app.
  • Một middleware Astro 60 dòng.
  • Một INSERT mỗi pageview hợp lệ.
  • Một SELECT COUNT(DISTINCT ip) mỗi lần render trang chủ.

Hết.

Tôi đã chuyển từ Google Analytics 4 sang setup này 14 tháng trước cho 3 project freelance. Sau 14 tháng, tôi không nhớ một lý do nào để quay lại GA4. Đây là setup, đánh đổi thực tế, và khi nào bạn không nên đi theo hướng này.

Logo SQLite

Vì sao tôi rời GA4

Ba lý do, theo thứ tự độ khó chịu:

1. Banner cookie. GDPR + ePrivacy buộc bạn hỏi consent trước khi load gtag.js. Bạn dán Cookiebot / Termly, mất 3 ngày làm cho banner vừa-đủ-đẹp, và sau đó 70% user bấm “Reject”. Số liệu bạn xem trên GA chỉ là 30% mẫu. Quyết định business dựa trên 30% đó là tự nguyện uống thuốc giả.

2. Performance budget cháy không lý do. gtag.js ~50 KB compressed. Nó load xong rồi gọi 2-3 request về Google. Lighthouse mobile của tôi rớt từ 99 xuống 87 chỉ vì cái script đó. Site portfolio không sống được với 87.

3. Tôi không cần 90% feature của GA4. Tôi không có funnel chuyển đổi, không có e-commerce, không có audience segmentation. Tôi chỉ cần biết: “hôm nay bao nhiêu người đọc bài Chainsaw Man?”. GA4 là Photoshop khi tôi cần một cây bút chì.

Kiến trúc — 3 mảnh ghép

┌──────────────────┐
│   Browser GET    │
│  /blog/foo       │
└────────┬─────────┘
         │ HTTP request

┌──────────────────┐
│ Astro middleware │  ──► trackVisit({ ip, ua, path, ... })
│  (server-side)   │
└────────┬─────────┘


┌──────────────────┐       ┌──────────────────┐
│  visits.ts logic │ ────► │   SQLite (WAL)   │
│  - bot UA filter │       │   visits table   │
│  - 60s dedupe    │       │                  │
│  - day bucketing │       └──────────────────┘
└──────────────────┘

Browser không thấy gì cả. Không request, không cookie, không script. Toàn bộ việc tracking xảy ra bên server, trong cùng cái request render trang.

Phần 1: Schema — 1 bảng, 9 cột

CREATE TABLE visits (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  ip         TEXT NOT NULL,
  ua         TEXT,
  path       TEXT,
  referrer   TEXT,
  lang       TEXT,
  country    TEXT,
  day        TEXT NOT NULL,         -- YYYY-MM-DD theo Asia/Ho_Chi_Minh
  created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

CREATE INDEX idx_visits_day     ON visits(day);
CREATE INDEX idx_visits_ip_day  ON visits(ip, day);
CREATE INDEX idx_visits_created ON visits(created_at DESC);

Vài ý đồ:

  • dayTEXT YYYY-MM-DD, không phải INTEGER timestamp. Tôi GROUP BY day cả ngày — string equality nhanh hơn date arithmetic, và tôi đỡ phải tính timezone mỗi câu query. Bucketing xảy ra lúc insert, theo timezone Việt Nam:

    function todayVN(): string {
      return new Date().toLocaleDateString('en-CA', {
        timeZone: 'Asia/Ho_Chi_Minh',
      });
    }
    

    'en-CA' cho format YYYY-MM-DD. Trick này dùng được ở mọi runtime hỗ trợ Intl.DateTimeFormat.

  • IP lưu thô, không hash. GDPR nói IP là PII — đúng. Nhưng đây là first-party log trên server tôi sở hữu, không gửi đi đâu, lưu cho mục đích forensics khi có abuse (spam form, scrape API). Tôi chmod 600 app.db và không expose qua bất cứ API public nào.

  • Không có user_id, không có session_id. Cả hai đều cần cookie hoặc fingerprint — dính GDPR ngay lập tức. “Distinct IP per day” là proxy đủ tốt cho “người” với traffic của tôi.

Phần 2: Middleware — 30 dòng

Logo Astro — framework hybrid SSR mà site này đang chạy

src/middleware.ts:

import { defineMiddleware } from 'astro:middleware';
import { trackVisit } from '@/lib/visits';

export const onRequest = defineMiddleware(async (ctx, next) => {
  if (ctx.request.method === 'GET') {
    const headers = ctx.request.headers;
    let ip: string | null = null;
    try {
      ip = ctx.clientAddress || null;
    } catch {
      ip = null;
    }
    if (!ip) {
      const fwd = headers.get('x-forwarded-for');
      if (fwd) ip = fwd.split(',')[0].trim();
      else ip = headers.get('x-real-ip') || headers.get('cf-connecting-ip');
    }
    trackVisit({
      ip,
      ua: headers.get('user-agent'),
      path: new URL(ctx.request.url).pathname,
      referrer: headers.get('referer'),
      lang: ctx.locals.lang ?? null,
      country:
        headers.get('cf-ipcountry') ||
        headers.get('x-vercel-ip-country') ||
        null,
    });
  }
  return next();
});

Điểm tinh tế:

  • ctx.clientAddress có thể throw trên prerendered routes (Astro hybrid: trang static không có per-request address). Try-catch bọc lại.
  • Fallback chain cho IP: clientAddressx-forwarded-for[0]x-real-ipcf-connecting-ip. Nginx local thì cái đầu đủ; đặt sau Cloudflare thì cái cuối là chuẩn.
  • Country lấy từ Cloudflare / Vercel header — họ tag sẵn theo edge location. Tôi không gọi MaxMind, không lưu IP để tự geolocate.

Phần 3: Filter — 3 lớp lọc trước khi insert

export function trackVisit(v: TrackInput): void {
  try {
    if (!v.ip) return;
    if (isBotUA(v.ua)) return;
    if (!isTrackablePath(v.path)) return;
    if (!shouldRecord(v.ip, v.path)) return;

    insertStmt.run(v.ip, v.ua, v.path, v.referrer, v.lang, v.country, todayVN());
  } catch {
    // analytics phải never throw
  }
}

Bot UA filter:

const BOT_UA = /bot|crawler|spider|slurp|googlebot|yandex|baidu|duckduck|
                facebookexternalhit|twitterbot|whatsapp|telegrambot|
                linkedinbot|pingdom|uptimerobot|ahrefs|semrush|mj12|petalbot|
                applebot|headless|monitor|fetch|preview/i;

Một regex là đủ với tôi. List trên cover ~95% bot tôi thấy trong access log nginx. Bot xịn (Googlebot Search Indexing) tự nhận trong UA — không có cách đẹp nào để chúng “lừa” cái regex này trừ khi cố tình.

Path filter:

function isTrackablePath(p: string): boolean {
  if (p.startsWith('/api/')) return false;
  if (p.startsWith('/admin')) return false;
  if (p.startsWith('/og/')) return false;
  if (p.startsWith('/_astro/')) return false;
  if (p === '/rss.xml') return false;
  // Bỏ qua mọi file có extension (asset)
  const last = p.split('/').pop() || '';
  if (last.includes('.')) return false;
  return true;
}

Tôi chỉ tracking trang HTML thực, không tính API call hay asset request.

60s dedupe:

const recent = new Map<string, number>();
const DEDUPE_WINDOW_MS = 60_000;

function shouldRecord(ip: string, path: string): boolean {
  const key = ip + '|' + path;
  const now = Date.now();
  const last = recent.get(key);
  if (last !== undefined && now - last < DEDUPE_WINDOW_MS) return false;
  recent.set(key, now);
  // Lazy evict nếu Map > 5000 entries
  if (recent.size > 5000) {
    const cutoff = now - DEDUPE_WINDOW_MS * 5;
    for (const [k, v] of recent) if (v < cutoff) recent.delete(k);
  }
  return true;
}

In-memory Map. Một cú F5 (reload) không double-count. Một user mở 5 tab cùng URL trong 60s cũng chỉ tính 1. Reset khi container restart — chấp nhận được, dedupe chỉ là noise reduction.

Phần 4: Đọc số liệu — COUNT(DISTINCT ip)

Trên trang chủ, cái counter “Hôm nay X người ghé”:

const todayUniqueStmt = db.prepare(
  'SELECT COUNT(DISTINCT ip) AS c FROM visits WHERE day = ?'
);

export function publicTodayStats() {
  const day = todayVN();
  return {
    today: todayUniqueStmt.get(day)?.c ?? 0,
    pageviewsToday: todayPageviewsStmt.get(day)?.c ?? 0,
    totalUnique: totalUniqueStmt.get()?.c ?? 0,
    totalPageviews: totalPageviewsStmt.get()?.c ?? 0,
  };
}

db.prepare chỉ chạy 1 lần lúc module load — sau đó .get(day) chỉ là binding + execute. SQLite trả về < 1ms với index idx_visits_day cho ~50k hàng. Tôi gọi nó SSR mỗi request trang chủ và Lighthouse vẫn 100.

Trang admin có nhiều câu phức tạp hơn (top paths, top countries, daily series 14 ngày) — cũng đều < 5ms với data 6 tháng.

Đánh đổi — kể cả thứ KHÔNG có

Sau 14 tháng, đây là những thứ tôi không có với setup này, và tôi xếp theo “có cần không”:

Feature GA cóTôi có cần?Thay thế
Real-time visitorsKhôngtail -f log
Funnel chuyển đổiKhông (portfolio)
User flow / session replayKhông
Audience segmentationKhông
E-commerce eventsCó (cho 1 freelance project)Bảng events riêng
Heatmap clickHơi muốnMicrosoft Clarity miễn phí (chấp nhận banner)
Báo cáo email tự độngCó thểCron + script gửi top_paths
Multi-domain trackingKhông
Goals + custom eventsHơi muốnThêm cột event_name

Heatmap là cái duy nhất tôi đôi khi nhớ. Khi cần — tôi tạm bật Microsoft Clarity 1 tuần, xem heatmap, tắt đi. 1 tuần data đủ ra insight, không phải nuôi banner cả năm.

Khi nào KHÔNG nên tự host SQLite analytics

Honest take. Đừng làm theo nếu:

  • Bạn cần share dashboard cho client / sếp. SQLite + custom UI = bạn phải tự build trang admin. Plausible / Umami có cloud version, share link 1 cú click.
  • Site có > 1M pageview/tháng. SQLite vẫn xử lý được, nhưng COUNT(DISTINCT) trên 10M hàng bắt đầu chậm. Ở quy mô đó cân nhắc PostgreSQL hoặc ClickHouse, hoặc đơn giản dùng Plausible self-host.
  • Bạn cần geolocation chính xác hơn country. Không có gì free thay được MaxMind GeoIP database. Cloudflare header chỉ có country, không có city.
  • Bạn deploy lên Vercel / Netlify / Cloudflare Pages. Serverless không có disk persistent — SQLite không sống được. Cần Turso / Cloudflare D1 (SQLite-compatible nhưng có quirks) hoặc đổi hẳn sang Postgres.

Setup này hợp với: Astro / Next / Laravel chạy trên VPS có disk. Site < 500k pageview / tháng. Owner build được 200 dòng TypeScript khi cần.

Số liệu thật — site này

Tôi đang chạy setup này cho nhatdote.io.vn. Sau 8 tháng:

  • File app.db: 2.3 MB (kèm bảng donations, bug_reports, coffee_clicks, visits, issue_comments).
  • Bảng visits: ~47k hàng, ~180/ngày trung bình.
  • Lighthouse mobile: 100/100 mọi metric (so với 87 lúc còn GA4).
  • Backup: cp app.db backup-$(date +%F).db mỗi 3 giờ qua cron, 1 file 2 MB. 7 ngày = 14 MB. Một năm trượt window = ~17 GB nếu giữ tất cả — tôi rotate 30 ngày.
  • Cost: $0 (VPS đã có sẵn cho app, SQLite không tốn gì thêm).

GA4 cho cùng site sẽ tốn $0 nhưng:

  • Một banner cookie tôi không muốn
  • 50 KB JS tải về cho mỗi visitor
  • Một interface tôi phải đăng nhập để xem
  • Số liệu sai 70% vì user bấm reject

Lời cuối

GA4 là sản phẩm built cho enterprise marketing team đo ROI campaign $100k. Nếu bạn không phải team đó — bạn đang dùng dao mổ trâu để cắt rau.

200 dòng TypeScript + 1 file SQLite + 1 middleware là toàn bộ analytics stack tôi cần cho portfolio và 3 freelance project. Privacy-first không phải vì luật — vì ít khi tôi cần biết hơn “hôm nay bao nhiêu người đọc”. SELECT COUNT(DISTINCT ip) FROM visits WHERE day = '2026-05-09' trả lời câu đó nhanh hơn GA4 dashboard load.

Đôi khi câu trả lời đúng là ít hơn, không phải nhiều hơn.

Nguồn