Back to blog

Build a URL Shortener: Redirect Engine & Analytics

typescriptnodejsanalyticsbackendpostgresql
Build a URL Shortener: Redirect Engine & Analytics

URL shortener của bạn đã tạo được short code và lưu vào PostgreSQL. Nhưng một short URL mà không biết ai click, click lúc nào, từ đâu — thì nó chỉ là một cái redirect, không phải product. Analytics mới là thứ biến đồ chơi thành công cụ thật sự.

Trong bài này, chúng ta sẽ build redirect engine và click analytics pipeline. Mỗi khi ai đó truy cập short URL, ta redirect họ ngay lập tức, đồng thời record click data ở background — browser, OS, referrer, country, timestamp — mà không làm chậm redirect.

Thời gian: 2–3 giờ
Yêu cầu: Phase 3: Short Code Generation

Những gì ta sẽ build trong bài này:
✅ Redirect engine với configurable 301/302 status code
✅ Click model trong Prisma với proper index
✅ Non-blocking click tracking (fire-and-forget)
✅ User-agent parsing với ua-parser-js
✅ IP geolocation với geoip-lite
✅ Analytics aggregation query (clicks over time, top referrer, device breakdown)
✅ Analytics API endpoint với filtering
✅ Xử lý expired và deactivated URL


Redirect Engine Design

Trước khi code, hãy hiểu redirect flow. Redirect endpoint là đường dẫn quan trọng nhất về performance trong toàn bộ system — đây là endpoint mà user thật sự hit khi click short link.

Hai design decision quan trọng ở đây:

  1. Cache-first lookup — check Redis trước PostgreSQL. Ta sẽ implement Redis caching ở bài tiếp, nhưng design cho nó từ bây giờ.
  2. Non-blocking click recording — redirect response được gửi về browser ngay lập tức. Click data được record bất đồng bộ. Nếu click recording fail, user vẫn nhận được redirect bình thường.

301 vs 302: Chọn Redirect Nào?

Quyết định này ảnh hưởng tất cả — analytics accuracy, SEO, và flexibility. Phân tích kỹ:

Aspect301 (Permanent)302 (Temporary)
Browser cachingCache vô thời hạnKhông cache — hit server mỗi lần
SEO link equityPass ~90-99% cho destinationKhông transfer
Click trackingChỉ click đầu tiên (rồi cache)Mọi click đều được track
Thay đổi destinationBrowser bỏ qua updateLuôn dùng destination hiện tại
Use casePermanent marketing linkAnalytics-focused shortener

Với URL shortener có analytics, 302 là default. Nhưng ta sẽ cho phép configure per URL:

// src/types/url.ts — updated
 
export interface UrlRecord {
  id: string;
  shortCode: string;
  originalUrl: string;
  redirectType: 301 | 302;
  isActive: boolean;
  expiresAt: Date | null;
  createdAt: Date;
  updatedAt: Date;
  clickCount: number;
}

Tại sao configurable? Có người muốn permanent link cho SEO, có người cần analytics. Cho creator chọn per URL là giải pháp linh hoạt nhất.


Prisma Schema Update

Ta cần Click model để lưu analytics data, và update Url model cho redirect configuration và expiration:

// prisma/schema.prisma
 
model Url {
  id           String    @id @default(cuid())
  shortCode    String    @unique @map("short_code")
  originalUrl  String    @map("original_url")
  redirectType Int       @default(302) @map("redirect_type")
  isActive     Boolean   @default(true) @map("is_active")
  expiresAt    DateTime? @map("expires_at")
  clickCount   Int       @default(0) @map("click_count")
  createdAt    DateTime  @default(now()) @map("created_at")
  updatedAt    DateTime  @updatedAt @map("updated_at")
 
  // Relations
  clicks Click[]
 
  @@index([shortCode])
  @@index([isActive, expiresAt])
  @@map("urls")
}
 
model Click {
  id        String   @id @default(cuid())
  urlId     String   @map("url_id")
  timestamp DateTime @default(now())
 
  // Request metadata
  ipAddress String?  @map("ip_address")
  userAgent String?  @map("user_agent")
  referrer  String?
 
  // Parsed user-agent field
  browser        String?
  browserVersion String? @map("browser_version")
  os             String?
  osVersion      String? @map("os_version")
  deviceType     String? @map("device_type")
 
  // Geolocation
  country String?
  city    String?
 
  // Deduplication
  visitorId String? @map("visitor_id")
 
  // Relations
  url Url @relation(fields: [urlId], references: [id], onDelete: Cascade)
 
  @@index([urlId, timestamp])
  @@index([urlId, country])
  @@index([urlId, browser])
  @@index([urlId, deviceType])
  @@index([visitorId, urlId])
  @@map("clicks")
}

Chạy migration:

npx prisma migrate dev --name add-clicks-and-url-fields

Tại Sao Những Index Này?

Mỗi index phục vụ một analytics query cụ thể:

IndexHỗ trợ
[urlId, timestamp]Chart "Clicks over time" — query phổ biến nhất
[urlId, country]Breakdown "Top country"
[urlId, browser]Pie chart "Browser distribution"
[urlId, deviceType]Stats "Mobile vs Desktop"
[visitorId, urlId]Deduplication unique visitor

Lựa Chọn Column

  • ipAddress — lưu để geolocation lookup, nhưng cần xem xét GDPR. Trong production, bạn có thể hash hoặc xóa sau khi lookup xong.
  • visitorId — hash của IP + user-agent. Dùng để đếm unique visitor mà không cần lưu raw IP lâu dài.
  • Parsed field (browser, os, deviceType) — parse một lần khi insert thay vì parse raw user-agent string mỗi lần chạy analytics query. Giúp aggregation query nhanh hơn nhiều.
  • onDelete: Cascade — khi URL bị xóa, click của nó cũng tự động bị xóa.

Install Dependency

npm install ua-parser-js geoip-lite
npm install -D @types/ua-parser-js @types/geoip-lite
  • ua-parser-js — parse user-agent string thành structured data (browser, OS, device)
  • geoip-lite — offline IP-to-country/city lookup dùng MaxMind GeoLite2 database. Không gọi API, không có latency.

Click Tracking Service

Click tracking service là core của analytics pipeline. Nó parse request data và insert click record — và làm điều này mà không block redirect response.

// src/services/clickService.ts
 
import { PrismaClient } from '@prisma/client';
import UAParser from 'ua-parser-js';
import geoip from 'geoip-lite';
import crypto from 'crypto';
 
const prisma = new PrismaClient();
 
export interface ClickData {
  urlId: string;
  ipAddress: string | undefined;
  userAgent: string | undefined;
  referrer: string | undefined;
}
 
export class ClickService {
  /**
   * Record một click event. Method này được thiết kế để gọi
   * fire-and-forget — đừng await nó trong redirect path.
   */
  async recordClick(data: ClickData): Promise<void> {
    try {
      const parsed = this.parseUserAgent(data.userAgent);
      const geo = this.lookupGeolocation(data.ipAddress);
      const visitorId = this.generateVisitorId(data.ipAddress, data.userAgent);
 
      await prisma.click.create({
        data: {
          urlId: data.urlId,
          ipAddress: data.ipAddress,
          userAgent: data.userAgent,
          referrer: this.sanitizeReferrer(data.referrer),
          browser: parsed.browser,
          browserVersion: parsed.browserVersion,
          os: parsed.os,
          osVersion: parsed.osVersion,
          deviceType: parsed.deviceType,
          country: geo.country,
          city: geo.city,
          visitorId,
        },
      });
 
      // Tăng click count denormalized trên URL
      await prisma.url.update({
        where: { id: data.urlId },
        data: { clickCount: { increment: 1 } },
      });
    } catch (error) {
      // Log nhưng không throw — click tracking failure
      // không bao giờ được ảnh hưởng redirect
      console.error('Failed to record click:', error);
    }
  }
 
  private parseUserAgent(userAgent: string | undefined): ParsedUA {
    if (!userAgent) {
      return {
        browser: null,
        browserVersion: null,
        os: null,
        osVersion: null,
        deviceType: 'unknown',
      };
    }
 
    const parser = new UAParser(userAgent);
    const browser = parser.getBrowser();
    const os = parser.getOS();
    const device = parser.getDevice();
 
    return {
      browser: browser.name || null,
      browserVersion: browser.version || null,
      os: os.name || null,
      osVersion: os.version || null,
      deviceType: this.normalizeDeviceType(device.type),
    };
  }
 
  private normalizeDeviceType(type: string | undefined): string {
    // ua-parser-js trả về: console, mobile, tablet, smarttv, wearable, embedded
    // Ta normalize thành: mobile, tablet, desktop, other
    switch (type) {
      case 'mobile':
        return 'mobile';
      case 'tablet':
        return 'tablet';
      case 'smarttv':
      case 'console':
      case 'wearable':
      case 'embedded':
        return 'other';
      default:
        // undefined nghĩa là desktop (ua-parser-js không set type cho desktop)
        return 'desktop';
    }
  }
 
  private lookupGeolocation(ipAddress: string | undefined): GeoResult {
    if (!ipAddress) {
      return { country: null, city: null };
    }
 
    // Bỏ qua localhost/private IP
    if (this.isPrivateIp(ipAddress)) {
      return { country: null, city: null };
    }
 
    const geo = geoip.lookup(ipAddress);
    if (!geo) {
      return { country: null, city: null };
    }
 
    return {
      country: geo.country || null, // ISO 3166-1 alpha-2 (vd: "US", "VN")
      city: geo.city || null,
    };
  }
 
  private isPrivateIp(ip: string): boolean {
    // IPv4 private range
    const privateRanges = [
      /^127\./,         // Loopback
      /^10\./,          // Class A private
      /^172\.(1[6-9]|2[0-9]|3[01])\./, // Class B private
      /^192\.168\./,    // Class C private
      /^::1$/,          // IPv6 loopback
      /^fc00:/,         // IPv6 ULA
      /^fe80:/,         // IPv6 link-local
    ];
 
    return privateRanges.some((range) => range.test(ip));
  }
 
  private generateVisitorId(
    ipAddress: string | undefined,
    userAgent: string | undefined
  ): string | null {
    if (!ipAddress) return null;
 
    // Hash IP + user-agent để tạo pseudonymous visitor ID
    // Cho phép đếm unique visitor mà không cần lưu raw IP
    const input = `${ipAddress}:${userAgent || 'unknown'}`;
    return crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
  }
 
  private sanitizeReferrer(referrer: string | undefined): string | null {
    if (!referrer) return null;
 
    try {
      const url = new URL(referrer);
      // Bỏ query param và fragment để bảo vệ privacy
      return `${url.protocol}//${url.host}${url.pathname}`;
    } catch {
      return null;
    }
  }
}
 
// Type definition
interface ParsedUA {
  browser: string | null;
  browserVersion: string | null;
  os: string | null;
  osVersion: string | null;
  deviceType: string;
}
 
interface GeoResult {
  country: string | null;
  city: string | null;
}

Những Design Decision Quan Trọng

Fire-and-forget pattern — toàn bộ method recordClick được wrap trong try-catch. Database down, user-agent không parse được, geolocation fail — error được log và nuốt. Redirect vẫn hoạt động bình thường.

Denormalized click count — ta giữ clickCount trên Url model và increment mỗi click. Tránh phải chạy COUNT(*) trên clicks table cho câu hỏi đơn giản "bao nhiêu click?". Trade-off là thêm một write mỗi click, nhưng write này nhanh với index.

Visitor ID hashing — thay vì lưu raw IP để đếm unique visitor, ta hash IP + user-agent thành chuỗi hex 16 ký tự. Vừa có deduplication, vừa không lo privacy khi lưu IP lâu dài.

Referrer sanitization — ta bỏ query parameter và fragment khỏi referrer. Referrer như https://twitter.com/user/status/123?ref=xyz trở thành https://twitter.com/user/status/123. Giảm noise và bảo vệ user privacy.


Updated Redirect Route

Giờ hãy update redirect handler để dùng click tracking service:

// src/routes/redirectRoutes.ts
 
import { Router, Request, Response, NextFunction } from 'express';
import { UrlService } from '../services/urlService';
import { ClickService } from '../services/clickService';
 
const router = Router();
const urlService = new UrlService();
const clickService = new ClickService();
 
// GET /:code — redirect tới original URL
router.get(
  '/:code',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { code } = req.params;
 
      // Resolve short code thành URL record
      const urlRecord = await urlService.resolveUrl(code);
 
      if (!urlRecord) {
        res.status(404).json({ error: 'Short URL not found' });
        return;
      }
 
      // Check URL bị deactivate chưa
      if (!urlRecord.isActive) {
        res.status(410).json({
          error: 'This short URL has been deactivated',
        });
        return;
      }
 
      // Check URL hết hạn chưa
      if (urlRecord.expiresAt && urlRecord.expiresAt < new Date()) {
        res.status(410).json({
          error: 'This short URL has expired',
        });
        return;
      }
 
      // Gửi redirect TRƯỚC — đây là critical path
      res.redirect(urlRecord.redirectType, urlRecord.originalUrl);
 
      // Record click SAU redirect (fire-and-forget)
      // Chạy bất đồng bộ — ta không await
      const clientIp = getClientIp(req);
      clickService.recordClick({
        urlId: urlRecord.id,
        ipAddress: clientIp,
        userAgent: req.headers['user-agent'],
        referrer: req.headers['referer'], // Note: HTTP header viết sai chính tả là "referer"
      });
    } catch (error) {
      next(error);
    }
  }
);
 
/**
 * Extract client IP từ request, xử lý proxy.
 * Trong production sau reverse proxy (Nginx, CloudFlare),
 * IP thật nằm trong X-Forwarded-For hoặc X-Real-IP header.
 */
function getClientIp(req: Request): string | undefined {
  // X-Forwarded-For có thể chứa nhiều IP: client, proxy1, proxy2
  const forwarded = req.headers['x-forwarded-for'];
  if (forwarded) {
    const ips = (Array.isArray(forwarded) ? forwarded[0] : forwarded).split(',');
    return ips[0].trim();
  }
 
  const realIp = req.headers['x-real-ip'];
  if (realIp) {
    return Array.isArray(realIp) ? realIp[0] : realIp;
  }
 
  return req.socket.remoteAddress;
}
 
export default router;

Fire-and-Forget Pattern

Chú ý đoạn này:

// Gửi redirect TRƯỚC
res.redirect(urlRecord.redirectType, urlRecord.originalUrl);
 
// Record click SAU (fire-and-forget)
clickService.recordClick({ ... });

Ta cố ý không await lời gọi recordClick. Redirect response được gửi về browser ngay lập tức. Click recording diễn ra ở background. Nếu fail, user không bao giờ biết.

Đây là trade-off có chủ đích:

ApproachRedirect latencyData guarantee
await recordClick()+5-20ms (DB write)Strong — click luôn được record
Fire-and-forget+0msEventual — có thể mất vài click hiếm

Với URL shortener, redirect speed là vua. Mất 0.01% click data thì chấp nhận được. Làm chậm mọi redirect thì không.

Note: Trong Express, bạn vẫn có thể chạy async code sau res.redirect(). Response đã gửi, nhưng request handler vẫn tiếp tục execute. Node.js event loop sẽ xử lý click insertion khi đến lượt.

Xử Lý Expired và Deactivated URL

Ta trả về 410 Gone (không phải 404) cho URL bị deactivate và expired. Điều này nói cho browser và search engine biết URL từng tồn tại nhưng không còn available — khác với "chưa bao giờ tồn tại."


Updated URL Service

URL service cần update để hỗ trợ redirect flow mới và URL status check:

// src/services/urlService.ts — updated resolveUrl method
 
import { PrismaClient } from '@prisma/client';
 
const prisma = new PrismaClient();
 
export class UrlService {
  /**
   * Resolve short code thành URL record.
   * Trả về full record để redirect handler check
   * status, expiration, và redirect type.
   */
  async resolveUrl(shortCode: string) {
    const url = await prisma.url.findUnique({
      where: { shortCode },
      select: {
        id: true,
        shortCode: true,
        originalUrl: true,
        redirectType: true,
        isActive: true,
        expiresAt: true,
      },
    });
 
    return url;
  }
 
  /**
   * Tạo short URL mới với optional redirect configuration.
   */
  async createUrl(data: {
    originalUrl: string;
    shortCode: string;
    customAlias?: string;
    redirectType?: 301 | 302;
    expiresAt?: Date;
  }) {
    return prisma.url.create({
      data: {
        shortCode: data.shortCode,
        originalUrl: data.originalUrl,
        redirectType: data.redirectType ?? 302,
        expiresAt: data.expiresAt ?? null,
      },
    });
  }
 
  /**
   * Deactivate URL (soft delete).
   * URL vẫn tồn tại trong database nhưng không redirect nữa.
   */
  async deactivateUrl(shortCode: string) {
    return prisma.url.update({
      where: { shortCode },
      data: { isActive: false },
    });
  }
 
  /**
   * Kích hoạt lại URL đã bị deactivate.
   */
  async reactivateUrl(shortCode: string) {
    return prisma.url.update({
      where: { shortCode },
      data: { isActive: true },
    });
  }
}

Tại Sao Dùng select Trong resolveUrl?

Ta chỉ fetch những column cần cho redirect decision. Table Url có thể có thêm column trong tương lai (creator, tag, description). Select chỉ những gì cần thiết giúp query nhanh và response payload nhỏ.


Analytics Service

Giờ đến phần vui — biến raw click data thành insight có ý nghĩa. Analytics service chạy aggregation query trên clicks table:

// src/services/analyticsService.ts
 
import { PrismaClient } from '@prisma/client';
 
const prisma = new PrismaClient();
 
export interface AnalyticsQuery {
  startDate?: Date;
  endDate?: Date;
  groupBy?: 'hour' | 'day' | 'week' | 'month';
}
 
export interface ClicksOverTime {
  period: string;
  clicks: number;
  uniqueVisitors: number;
}
 
export interface TopReferrer {
  referrer: string;
  clicks: number;
}
 
export interface DeviceBreakdown {
  deviceType: string;
  clicks: number;
  percentage: number;
}
 
export interface BrowserBreakdown {
  browser: string;
  clicks: number;
  percentage: number;
}
 
export interface CountryBreakdown {
  country: string;
  clicks: number;
  percentage: number;
}
 
export interface UrlAnalytics {
  totalClicks: number;
  uniqueVisitors: number;
  clicksOverTime: ClicksOverTime[];
  topReferrers: TopReferrer[];
  devices: DeviceBreakdown[];
  browsers: BrowserBreakdown[];
  countries: CountryBreakdown[];
}
 
export class AnalyticsService {
  /**
   * Lấy analytics tổng hợp cho một URL.
   */
  async getUrlAnalytics(
    urlId: string,
    query: AnalyticsQuery = {}
  ): Promise<UrlAnalytics> {
    const { startDate, endDate } = this.getDateRange(query);
 
    // Chạy tất cả query song song để tăng performance
    const [
      totalClicks,
      uniqueVisitors,
      clicksOverTime,
      topReferrers,
      devices,
      browsers,
      countries,
    ] = await Promise.all([
      this.getTotalClicks(urlId, startDate, endDate),
      this.getUniqueVisitors(urlId, startDate, endDate),
      this.getClicksOverTime(urlId, startDate, endDate, query.groupBy || 'day'),
      this.getTopReferrers(urlId, startDate, endDate),
      this.getDeviceBreakdown(urlId, startDate, endDate),
      this.getBrowserBreakdown(urlId, startDate, endDate),
      this.getCountryBreakdown(urlId, startDate, endDate),
    ]);
 
    return {
      totalClicks,
      uniqueVisitors,
      clicksOverTime,
      topReferrers,
      devices,
      browsers,
      countries,
    };
  }
 
  private async getTotalClicks(
    urlId: string,
    startDate: Date,
    endDate: Date
  ): Promise<number> {
    return prisma.click.count({
      where: {
        urlId,
        timestamp: { gte: startDate, lte: endDate },
      },
    });
  }
 
  private async getUniqueVisitors(
    urlId: string,
    startDate: Date,
    endDate: Date
  ): Promise<number> {
    // Đếm distinct visitor ID
    const result = await prisma.click.groupBy({
      by: ['visitorId'],
      where: {
        urlId,
        visitorId: { not: null },
        timestamp: { gte: startDate, lte: endDate },
      },
    });
 
    return result.length;
  }
 
  private async getClicksOverTime(
    urlId: string,
    startDate: Date,
    endDate: Date,
    groupBy: 'hour' | 'day' | 'week' | 'month'
  ): Promise<ClicksOverTime[]> {
    // Dùng raw SQL cho date truncation — Prisma không hỗ trợ
    // date_trunc natively trong groupBy
    const truncFormat = this.getDateTruncFormat(groupBy);
 
    const results: any[] = await prisma.$queryRaw`
      SELECT
        date_trunc(${truncFormat}, timestamp) as period,
        COUNT(*)::int as clicks,
        COUNT(DISTINCT visitor_id)::int as unique_visitors
      FROM clicks
      WHERE url_id = ${urlId}
        AND timestamp >= ${startDate}
        AND timestamp <= ${endDate}
      GROUP BY period
      ORDER BY period ASC
    `;
 
    return results.map((row) => ({
      period: row.period.toISOString(),
      clicks: row.clicks,
      uniqueVisitors: row.unique_visitors,
    }));
  }
 
  private async getTopReferrers(
    urlId: string,
    startDate: Date,
    endDate: Date,
    limit: number = 10
  ): Promise<TopReferrer[]> {
    const results = await prisma.click.groupBy({
      by: ['referrer'],
      where: {
        urlId,
        referrer: { not: null },
        timestamp: { gte: startDate, lte: endDate },
      },
      _count: { referrer: true },
      orderBy: { _count: { referrer: 'desc' } },
      take: limit,
    });
 
    return results.map((row) => ({
      referrer: row.referrer || 'Direct',
      clicks: row._count.referrer,
    }));
  }
 
  private async getDeviceBreakdown(
    urlId: string,
    startDate: Date,
    endDate: Date
  ): Promise<DeviceBreakdown[]> {
    const results = await prisma.click.groupBy({
      by: ['deviceType'],
      where: {
        urlId,
        timestamp: { gte: startDate, lte: endDate },
      },
      _count: { deviceType: true },
      orderBy: { _count: { deviceType: 'desc' } },
    });
 
    const total = results.reduce((sum, r) => sum + r._count.deviceType, 0);
 
    return results.map((row) => ({
      deviceType: row.deviceType || 'unknown',
      clicks: row._count.deviceType,
      percentage: total > 0
        ? Math.round((row._count.deviceType / total) * 10000) / 100
        : 0,
    }));
  }
 
  private async getBrowserBreakdown(
    urlId: string,
    startDate: Date,
    endDate: Date
  ): Promise<BrowserBreakdown[]> {
    const results = await prisma.click.groupBy({
      by: ['browser'],
      where: {
        urlId,
        timestamp: { gte: startDate, lte: endDate },
      },
      _count: { browser: true },
      orderBy: { _count: { browser: 'desc' } },
      take: 10,
    });
 
    const total = results.reduce((sum, r) => sum + r._count.browser, 0);
 
    return results.map((row) => ({
      browser: row.browser || 'Unknown',
      clicks: row._count.browser,
      percentage: total > 0
        ? Math.round((row._count.browser / total) * 10000) / 100
        : 0,
    }));
  }
 
  private async getCountryBreakdown(
    urlId: string,
    startDate: Date,
    endDate: Date
  ): Promise<CountryBreakdown[]> {
    const results = await prisma.click.groupBy({
      by: ['country'],
      where: {
        urlId,
        timestamp: { gte: startDate, lte: endDate },
      },
      _count: { country: true },
      orderBy: { _count: { country: 'desc' } },
      take: 20,
    });
 
    const total = results.reduce((sum, r) => sum + r._count.country, 0);
 
    return results.map((row) => ({
      country: row.country || 'Unknown',
      clicks: row._count.country,
      percentage: total > 0
        ? Math.round((row._count.country / total) * 10000) / 100
        : 0,
    }));
  }
 
  private getDateRange(query: AnalyticsQuery): {
    startDate: Date;
    endDate: Date;
  } {
    const endDate = query.endDate || new Date();
    const startDate = query.startDate || new Date(
      endDate.getTime() - 30 * 24 * 60 * 60 * 1000 // Default: 30 ngày gần nhất
    );
 
    return { startDate, endDate };
  }
 
  private getDateTruncFormat(
    groupBy: 'hour' | 'day' | 'week' | 'month'
  ): string {
    const formats = {
      hour: 'hour',
      day: 'day',
      week: 'week',
      month: 'month',
    };
    return formats[groupBy];
  }
}

Tại Sao Chạy Parallel Query?

Method getUrlAnalytics chạy bảy query đồng thời với Promise.all. Mỗi query hit index khác nhau nên không block lẫn nhau. Trên database lạnh với 100K click, tổng thời gian ~50ms thay vì ~350ms nếu chạy tuần tự.

Tại Sao Raw SQL Cho clicksOverTime?

groupBy của Prisma không hỗ trợ date_trunc — một PostgreSQL function để làm tròn timestamp theo hour/day/week/month. Ta dùng $queryRaw cho query cụ thể này, còn lại vẫn dùng Prisma query builder type-safe.

Tính Percentage

Ta tính percentage server-side với hai chữ số thập phân:

Math.round((count / total) * 10000) / 100
// 3456 / 10000 * 10000 = 3456 → / 100 = 34.56%

Tránh floating-point display issue kiểu 34.560000000000002%.


Analytics API Endpoint

// src/routes/analyticsRoutes.ts
 
import { Router, Request, Response, NextFunction } from 'express';
import { AnalyticsService } from '../services/analyticsService';
import { PrismaClient } from '@prisma/client';
 
const router = Router();
const analyticsService = new AnalyticsService();
const prisma = new PrismaClient();
 
// GET /api/urls/:code/stats — summary stats cho URL
router.get(
  '/urls/:code/stats',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const url = await prisma.url.findUnique({
        where: { shortCode: req.params.code },
      });
 
      if (!url) {
        res.status(404).json({ error: 'Short URL not found' });
        return;
      }
 
      const { startDate, endDate, groupBy } = parseQueryParams(req);
 
      const analytics = await analyticsService.getUrlAnalytics(url.id, {
        startDate,
        endDate,
        groupBy,
      });
 
      res.json({
        shortCode: url.shortCode,
        originalUrl: url.originalUrl,
        isActive: url.isActive,
        createdAt: url.createdAt,
        ...analytics,
      });
    } catch (error) {
      next(error);
    }
  }
);
 
// GET /api/urls/:code/clicks — raw click event (có phân trang)
router.get(
  '/urls/:code/clicks',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const url = await prisma.url.findUnique({
        where: { shortCode: req.params.code },
      });
 
      if (!url) {
        res.status(404).json({ error: 'Short URL not found' });
        return;
      }
 
      const page = parseInt(req.query.page as string) || 1;
      const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
      const offset = (page - 1) * limit;
 
      const [clicks, total] = await Promise.all([
        prisma.click.findMany({
          where: { urlId: url.id },
          orderBy: { timestamp: 'desc' },
          skip: offset,
          take: limit,
          select: {
            id: true,
            timestamp: true,
            referrer: true,
            browser: true,
            browserVersion: true,
            os: true,
            osVersion: true,
            deviceType: true,
            country: true,
            city: true,
          },
        }),
        prisma.click.count({ where: { urlId: url.id } }),
      ]);
 
      res.json({
        clicks,
        pagination: {
          page,
          limit,
          total,
          totalPages: Math.ceil(total / limit),
          hasMore: offset + limit < total,
        },
      });
    } catch (error) {
      next(error);
    }
  }
);
 
// Helper để parse analytics query parameter
function parseQueryParams(req: Request) {
  const startDate = req.query.start
    ? new Date(req.query.start as string)
    : undefined;
  const endDate = req.query.end
    ? new Date(req.query.end as string)
    : undefined;
  const groupBy = (req.query.groupBy as string) || 'day';
 
  // Validate groupBy
  const validGroupBy = ['hour', 'day', 'week', 'month'];
  if (!validGroupBy.includes(groupBy)) {
    throw new Error(
      `Invalid groupBy value. Must be one of: ${validGroupBy.join(', ')}`
    );
  }
 
  return {
    startDate,
    endDate,
    groupBy: groupBy as 'hour' | 'day' | 'week' | 'month',
  };
}
 
export default router;

Đăng Ký Analytics Route

Update app.ts để mount analytics route:

// src/app.ts — thêm analytics route
 
import analyticsRoutes from './routes/analyticsRoutes';
 
// ... middleware hiện tại ...
 
// API route
app.use('/api', urlRoutes);
app.use('/api', analyticsRoutes);  // Thêm dòng này
 
// Redirect catch-all (phải ở cuối)
app.use(redirectRoutes);

Pagination Design

Endpoint /clicks dùng offset-based pagination:

const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);

Những quyết định chính:

  • Default 50, max 100 — ngăn client request 10,000 row một lúc
  • Loại bỏ data nhạy cảm — không có ipAddress hay visitorId trong response
  • Count song song — findMany và count chạy đồng thời
  • hasMore flag — cho client biết còn page nào nữa không mà không cần tính total page

Với URL có hàng triệu click, bạn nên chuyển sang cursor-based pagination dùng field id. Nhưng offset pagination hoạt động tốt cho đến ~100K row.


Click Deduplication

Không phải mọi click đều nên đếm là unique visitor. Nếu ai đó refresh page 10 lần, đó là 10 click nhưng 1 visitor. Ta xử lý ở hai level:

Level 1: Visitor ID (lúc insert)

Ta đã generate visitorId từ IP + user-agent:

private generateVisitorId(
  ipAddress: string | undefined,
  userAgent: string | undefined
): string | null {
  if (!ipAddress) return null;
 
  const input = `${ipAddress}:${userAgent || 'unknown'}`;
  return crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
}

Điều này không ngăn duplicate insert — ta vẫn record mọi click. Nhưng visitorId cho phép đếm distinct visitor trong analytics query:

COUNT(DISTINCT visitor_id) as unique_visitors

Level 2: Time-windowed Deduplication (tùy chọn)

Để deduplication chặt hơn, bạn có thể skip recording click từ cùng visitor trong một khoảng thời gian:

// Trong ClickService.recordClick() — optional deduplication
 
async recordClick(data: ClickData): Promise<void> {
  try {
    const visitorId = this.generateVisitorId(data.ipAddress, data.userAgent);
 
    // Optional: skip nếu cùng visitor click trong 5 phút gần nhất
    if (visitorId) {
      const recentClick = await prisma.click.findFirst({
        where: {
          urlId: data.urlId,
          visitorId,
          timestamp: {
            gte: new Date(Date.now() - 5 * 60 * 1000), // 5 phút
          },
        },
      });
 
      if (recentClick) {
        return; // Skip duplicate click
      }
    }
 
    // ... phần còn lại của recordClick logic
  } catch (error) {
    console.error('Failed to record click:', error);
  }
}

Trade-off: thêm một database read vào click recording path. Với hầu hết URL shortener, Level 1 (count distinct lúc query) là đủ. Chỉ thêm Level 2 nếu bạn cần giảm storage cho URL viral nhận hàng triệu click liên tục.


Extract Client IP Qua Proxy

Lấy real client IP phức tạp hơn req.ip. Trong production, app nằm sau Nginx, CloudFlare, hoặc load balancer. IP của proxy là thứ Express thấy — không phải IP của user.

Mỗi proxy thêm IP trước đó vào X-Forwarded-For:

X-Forwarded-For: 203.0.113.42, 172.67.x.x, 10.0.0.1

IP đầu tiên là real client. Nhưng cẩn thận: X-Forwarded-For có thể bị spoof. Trong production, configure Express trust proxy:

// src/app.ts — trust proxy configuration
 
const app = express();
 
// Trust proxy đầu tiên (Nginx/load balancer)
// Làm cho req.ip trả về real client IP
app.set('trust proxy', 1);

Với trust proxy được set, Express parse X-Forwarded-For và req.ip trả về đúng client IP. Số 1 nghĩa là "trust một tầng proxy." Nếu bạn đứng sau CloudFlare rồi Nginx (hai proxy), dùng 2.


Updated Folder Structure

Đây là project sau bài này:

url-shortener/
├── src/
│   ├── config/
│   │   └── env.ts
│   ├── middleware/
│   │   ├── errorHandler.ts
│   │   └── validateRequest.ts
│   ├── routes/
│   │   ├── urlRoutes.ts
│   │   ├── redirectRoutes.ts       # Updated: click tracking
│   │   └── analyticsRoutes.ts      # MỚI: analytics endpoint
│   ├── services/
│   │   ├── urlService.ts           # Updated: resolve + deactivate
│   │   ├── clickService.ts         # MỚI: click recording
│   │   └── analyticsService.ts     # MỚI: aggregation query
│   ├── types/
│   │   └── url.ts                  # Updated: field mới
│   ├── utils/
│   │   └── shortCode.ts
│   ├── app.ts                      # Updated: analytics route
│   └── index.ts
├── prisma/
│   └── schema.prisma               # Updated: Click model
├── .env
├── package.json
└── tsconfig.json

API Summary

MethodEndpointMô tảStatus Code
POST/api/shortenRút gọn URL201, 400, 409, 500
GET/api/urls/:codeLấy thông tin URL200, 404
GET/api/urls/:code/statsLấy analytics summary200, 404
GET/api/urls/:code/clicksLấy click event (phân trang)200, 404
GET/:codeRedirect tới original URL301/302, 404, 410
GET/healthHealth check200

Test Thử

Start development server:

npm run dev

Test 1: Shorten URL với Redirect Config

curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://github.com/expressjs/express",
    "redirectType": 302,
    "expiresAt": "2026-12-31T23:59:59Z"
  }'
{
  "shortCode": "aBc1X9z",
  "shortUrl": "http://localhost:3000/aBc1X9z",
  "originalUrl": "https://github.com/expressjs/express",
  "redirectType": 302,
  "expiresAt": "2026-12-31T23:59:59.000Z",
  "createdAt": "2026-03-21T10:00:00.000Z"
}

Test 2: Click Short URL (nhiều lần)

# Click từ nhiều "browser" khác nhau bằng cách thay đổi user-agent
curl -v -L http://localhost:3000/aBc1X9z \
  -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0" \
  -H "Referer: https://twitter.com/post/123"
 
curl -v -L http://localhost:3000/aBc1X9z \
  -H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Safari/605.1" \
  -H "Referer: https://facebook.com/share"
 
curl -v -L http://localhost:3000/aBc1X9z \
  -H "User-Agent: Mozilla/5.0 (Linux; Android 14) Chrome/120.0 Mobile"

Test 3: Xem Analytics Summary

curl http://localhost:3000/api/urls/aBc1X9z/stats
{
  "shortCode": "aBc1X9z",
  "originalUrl": "https://github.com/expressjs/express",
  "isActive": true,
  "createdAt": "2026-03-21T10:00:00.000Z",
  "totalClicks": 3,
  "uniqueVisitors": 3,
  "clicksOverTime": [
    {
      "period": "2026-03-21T00:00:00.000Z",
      "clicks": 3,
      "uniqueVisitors": 3
    }
  ],
  "topReferrers": [
    { "referrer": "https://twitter.com/post/123", "clicks": 1 },
    { "referrer": "https://facebook.com/share", "clicks": 1 }
  ],
  "devices": [
    { "deviceType": "desktop", "clicks": 1, "percentage": 33.33 },
    { "deviceType": "mobile", "clicks": 2, "percentage": 66.67 }
  ],
  "browsers": [
    { "browser": "Chrome", "clicks": 2, "percentage": 66.67 },
    { "browser": "Mobile Safari", "clicks": 1, "percentage": 33.33 }
  ],
  "countries": []
}

Test 4: Analytics Với Date Filtering

# 7 ngày gần nhất, group theo giờ
curl "http://localhost:3000/api/urls/aBc1X9z/stats?start=2026-03-14&end=2026-03-21&groupBy=hour"

Test 5: Xem Raw Click Event

curl "http://localhost:3000/api/urls/aBc1X9z/clicks?page=1&limit=10"
{
  "clicks": [
    {
      "id": "cm...",
      "timestamp": "2026-03-21T10:02:00.000Z",
      "referrer": "https://facebook.com/share",
      "browser": "Mobile Safari",
      "browserVersion": "605.1",
      "os": "iOS",
      "osVersion": "17.0",
      "deviceType": "mobile",
      "country": null,
      "city": null
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 10,
    "total": 3,
    "totalPages": 1,
    "hasMore": false
  }
}

Test 6: Deactivated URL

# Deactivate URL (bạn sẽ thêm endpoint này)
curl -X PATCH http://localhost:3000/api/urls/aBc1X9z \
  -H "Content-Type: application/json" \
  -d '{"isActive": false}'
 
# Thử truy cập
curl -v http://localhost:3000/aBc1X9z
< HTTP/1.1 410 Gone
{"error": "This short URL has been deactivated"}

Cân Nhắc Về Performance

Click Recording Latency

Approach fire-and-forget của ta thêm zero latency vào redirect. Nhưng nếu URL trở nên viral (10K click/giây), bạn sẽ làm ngập database bằng INSERT statement.

Giải pháp cho traffic cao (ta sẽ khám phá ở bài sau):

ApproachThroughputComplexity
Direct INSERT (hiện tại)~1K click/giâyThấp
Batch INSERT (buffer + flush)~10K click/giâyTrung bình
Message queue (Redis/Kafka)~100K+ click/giâyCao

Với batch approach, bạn buffer click trong memory và flush mỗi N giây:

// Concept — batch click recording
class BatchClickService {
  private buffer: ClickData[] = [];
  private readonly BATCH_SIZE = 100;
  private readonly FLUSH_INTERVAL = 5000; // 5 giây
 
  constructor() {
    setInterval(() => this.flush(), this.FLUSH_INTERVAL);
  }
 
  add(click: ClickData): void {
    this.buffer.push(click);
    if (this.buffer.length >= this.BATCH_SIZE) {
      this.flush();
    }
  }
 
  private async flush(): Promise<void> {
    if (this.buffer.length === 0) return;
 
    const batch = this.buffer.splice(0);
    try {
      await prisma.click.createMany({ data: batch });
    } catch (error) {
      console.error(`Failed to flush ${batch.length} clicks:`, error);
    }
  }
}

Ta sẽ giữ approach đơn giản bây giờ. Premature optimization là gốc rễ của mọi điều xấu — và hầu hết URL shortener không bao giờ đạt 1K click/giây.

Index Maintenance

Mỗi click INSERT update năm index trên clicks table. Với write volume rất cao, index maintenance trở thành bottleneck. Các chiến lược:

  1. Ít index hơn — bỏ index cho query bạn ít chạy
  2. Partial index — chỉ index data gần đây (vd: 90 ngày gần nhất)
  3. Materialized view — tính trước aggregation hàng ngày
  4. Read replica — chạy analytics query trên replica, không phải primary

Hiện tại, năm index là ổn. PostgreSQL xử lý tốt cho hàng triệu row.


Geolocation Deep Dive

geoip-lite dùng MaxMind GeoLite2 database, được bundle sẵn trong Node.js module. Nghĩa là:

Ưu điểm:

  • Không gọi API — mọi lookup đều local
  • Nhanh — pure memory lookup, ~0.5ms mỗi IP
  • Không rate limit hay API key

Nhược điểm:

  • Database tĩnh — bundle lúc npm install
  • Độ chính xác khác nhau — city-level ~60-70%, country-level ~99%
  • Thêm ~60MB vào node_modules

Cho production, cân nhắc MaxMind commercial GeoIP2 database hoặc API service như ipinfo.io cho accuracy cao hơn. Nhưng geoip-lite là hoàn hảo để bắt đầu.

Privacy Note

IP geolocation có liên quan đến GDPR. Cân nhắc:

  • Chỉ lưu country (không city) để giảm PII
  • Hash hoặc xóa IP address sau geolocation lookup
  • Thêm data retention policy (xóa click cũ hơn N tháng)
  • Ghi chú geolocation trong privacy policy

Những Lỗi Thường Gặp

1. Block Redirect Bằng Click Recording

// SAI — redirect đợi click recording
const originalUrl = await urlService.resolveAndTrack(code);
await clickService.recordClick({ ... });  // Block!
res.redirect(302, originalUrl);
 
// ĐÚNG — redirect trước, record sau
res.redirect(302, originalUrl);
clickService.recordClick({ ... });  // Fire-and-forget

2. Không Xử Lý Proxy IP

// SAI — lấy IP proxy, không phải IP user
const ip = req.socket.remoteAddress;
 
// ĐÚNG — check forwarded header
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
  || req.headers['x-real-ip']
  || req.socket.remoteAddress;

3. Lộ Raw IP Trong Analytics API

// SAI — lộ PII
select: { ipAddress: true, ... }
 
// ĐÚNG — loại bỏ field nhạy cảm
select: {
  id: true,
  timestamp: true,
  browser: true,
  // ipAddress cố ý bị loại
}

4. Dùng 301 Làm Default

301 redirect bị browser cache mãi mãi. Khi browser cache 301 rồi, user không bao giờ hit server nữa — kể cả khi bạn đổi destination URL. Bắt đầu với 302 và chỉ chuyển sang 301 khi bạn chắc chắn destination là permanent.

5. Không Phân Trang Click Endpoint

// SAI — trả về tất cả click (có thể hàng triệu)
const clicks = await prisma.click.findMany({ where: { urlId } });
 
// ĐÚNG — phân trang với max limit
const limit = Math.min(parseInt(req.query.limit) || 50, 100);

Tiếp Theo?

Redirect engine hoạt động, click analytics đang record, và ta có thể query insight từ data. Nhưng có performance gap: mỗi redirect đều hit PostgreSQL. Với shortener traffic cao, đó là quá chậm.

Trong Post #5: Caching with Redis, ta sẽ thêm caching layer phục vụ redirect dưới 1ms:

  • Redis làm URL lookup cache
  • Cache invalidation strategy
  • Cache-aside pattern implementation
  • Rate limiting với Redis
  • Performance benchmark: có cache vs không cache

Redirect flow ta thiết kế trong bài này đã có placeholder "Cache" trong sequence diagram. Bài tiếp theo, ta sẽ lấp đầy nó.


Series: Build a URL Shortener
Previous: Phase 3: Short Code Generation
Next: Phase 5: Caching with Redis

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.