Docs For AI
Monitoring

Event Tracking

Business event tracking - instrumentation during development, post-release monitoring, trend analysis, and threshold alerting

Event Tracking

Business event tracking (also known as analytics instrumentation or "埋点") captures user behavior and business-critical interactions. This page covers the full lifecycle: adding tracking code during development, monitoring events in production, analyzing trends, and configuring threshold-based alerts.

1. Adding Event Tracking During Development

SDK Design

A well-designed tracking SDK provides a clean API, automatic context enrichment, and reliable delivery.

// lib/tracker.ts
interface TrackEvent {
  event: string;
  properties?: Record<string, string | number | boolean>;
}

interface TrackContext {
  userId?: string;
  sessionId: string;
  page: string;
  timestamp: number;
  device: string;
  appVersion: string;
}

class EventTracker {
  private queue: (TrackEvent & { context: TrackContext })[] = [];
  private flushTimer: ReturnType<typeof setInterval>;
  private sessionId = crypto.randomUUID();

  constructor(
    private endpoint: string,
    private flushInterval = 5000,
    private batchSize = 20
  ) {
    this.flushTimer = setInterval(() => this.flush(), this.flushInterval);

    // Flush on page hide (user navigating away)
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') this.flush();
    });
  }

  track(event: string, properties?: Record<string, string | number | boolean>) {
    this.queue.push({
      event,
      properties,
      context: this.getContext(),
    });

    if (this.queue.length >= this.batchSize) {
      this.flush();
    }
  }

  private getContext(): TrackContext {
    return {
      userId: this.getUserId(),
      sessionId: this.sessionId,
      page: location.pathname,
      timestamp: Date.now(),
      device: /Mobi|Android/i.test(navigator.userAgent) ? 'mobile' : 'desktop',
      appVersion: document.querySelector('meta[name="app-version"]')?.getAttribute('content') ?? 'unknown',
    };
  }

  private getUserId(): string | undefined {
    // Read from your auth context — never track PII directly
    return window.__USER_ID__;
  }

  private flush() {
    if (this.queue.length === 0) return;
    const batch = this.queue.splice(0);

    const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
    navigator.sendBeacon(this.endpoint, blob);
  }

  destroy() {
    clearInterval(this.flushTimer);
    this.flush();
  }
}

// Singleton instance
export const tracker = new EventTracker('/api/events/collect');

React Integration

// hooks/useTrack.ts
import { useCallback, useEffect } from 'react';
import { tracker } from '@/lib/tracker';

// Track a single event
export function useTrack() {
  return useCallback(
    (event: string, properties?: Record<string, string | number | boolean>) => {
      tracker.track(event, properties);
    },
    []
  );
}

// Track page view on mount
export function usePageView(pageName: string) {
  useEffect(() => {
    tracker.track('page_view', { page_name: pageName });
  }, [pageName]);
}
// components/ProductCard.tsx
import { useTrack } from '@/hooks/useTrack';

function ProductCard({ product }: { product: Product }) {
  const track = useTrack();

  return (
    <div
      onClick={() => {
        track('product_click', {
          product_id: product.id,
          product_name: product.name,
          category: product.category,
          price: product.price,
        });
      }}
    >
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
}

Common Event Patterns

Define a clear event taxonomy so that data is consistent and queryable.

// events/definitions.ts

// ── Page Events ────────────────────────────────────────────
tracker.track('page_view', { page_name: 'home' });

// ── User Actions ───────────────────────────────────────────
tracker.track('button_click', { button_id: 'checkout', position: 'header' });
tracker.track('form_submit', { form_name: 'signup', success: true });
tracker.track('search', { query: 'react hooks', results_count: 42 });

// ── Business Events ────────────────────────────────────────
tracker.track('add_to_cart', { product_id: 'sku-123', quantity: 1, price: 29.99 });
tracker.track('purchase', { order_id: 'ord-456', total: 59.98, items_count: 2 });
tracker.track('subscription_start', { plan: 'pro', billing: 'annual' });

// ── Feature Usage ──────────────────────────────────────────
tracker.track('feature_used', { feature: 'dark_mode', action: 'enable' });
tracker.track('experiment_exposure', { experiment: 'new_checkout', variant: 'B' });

// ── Error Context ──────────────────────────────────────────
tracker.track('error', { type: 'api_failure', endpoint: '/api/cart', status: 500 });

Declarative Tracking (Attribute-Based)

For teams that want to track clicks without per-component code, use a global attribute-based approach.

// components/TrackProvider.tsx
'use client';
import { useEffect } from 'react';
import { tracker } from '@/lib/tracker';

export function TrackProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    function handleClick(e: MouseEvent) {
      const el = (e.target as HTMLElement).closest('[data-track]');
      if (!el) return;

      const event = el.getAttribute('data-track')!;
      const propsRaw = el.getAttribute('data-track-props');
      const properties = propsRaw ? JSON.parse(propsRaw) : {};

      tracker.track(event, properties);
    }

    document.addEventListener('click', handleClick, { capture: true });
    return () => document.removeEventListener('click', handleClick, { capture: true });
  }, []);

  return <>{children}</>;
}

Usage in any component — no import needed:

<button
  data-track="cta_click"
  data-track-props='{"position":"hero","variant":"primary"}'
>
  Get Started
</button>

Tracking Plan Document

Maintain a tracking plan as the single source of truth for all events.

EventPropertiesTriggerOwner
page_viewpage_namePage mountAuto
product_clickproduct_id, category, priceCard clickProduct team
add_to_cartproduct_id, quantity, priceButton clickCart team
purchaseorder_id, total, items_countCheckout successCheckout team
searchquery, results_countSearch submitSearch team
errortype, endpoint, statusAPI failurePlatform team

2. Post-Release Event Monitoring

Ingestion API

Receive batched events from the browser SDK and write to storage.

// server/event-collector.ts
import { createClient } from '@clickhouse/client';
import { Router, json } from 'express';

const clickhouse = createClient({
  url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123',
  database: 'analytics',
});

let buffer: any[] = [];
const FLUSH_INTERVAL = 5000;
const BATCH_SIZE = 500;

async function flush() {
  if (buffer.length === 0) return;
  const batch = buffer.splice(0);

  await clickhouse.insert({
    table: 'events',
    values: batch,
    format: 'JSONEachRow',
  });
}

setInterval(flush, FLUSH_INTERVAL);

export const eventRouter = Router();

eventRouter.post('/collect', json(), (req, res) => {
  const events: any[] = Array.isArray(req.body) ? req.body : [req.body];

  for (const e of events) {
    buffer.push({
      event: e.event,
      properties: JSON.stringify(e.properties ?? {}),
      user_id: e.context?.userId ?? '',
      session_id: e.context?.sessionId ?? '',
      page: e.context?.page ?? '',
      device: e.context?.device ?? '',
      app_version: e.context?.appVersion ?? '',
      timestamp: new Date(e.context?.timestamp ?? Date.now()).toISOString(),
    });
  }

  if (buffer.length >= BATCH_SIZE) flush().catch(console.error);
  res.status(204).end();
});

Storage Schema (ClickHouse)

CREATE TABLE events (
  event        LowCardinality(String),
  properties   String,           -- JSON string
  user_id      String,
  session_id   String,
  page         String,
  device       LowCardinality(String),
  app_version  LowCardinality(String),
  timestamp    DateTime64(3)
) ENGINE = MergeTree()
  PARTITION BY toYYYYMMDD(timestamp)
  ORDER BY (event, timestamp);

-- Materialized view: event counts per hour
CREATE MATERIALIZED VIEW event_hourly_mv
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMMDD(hour)
ORDER BY (event, page, device, hour)
AS
SELECT
  event,
  page,
  device,
  toStartOfHour(timestamp) AS hour,
  count() AS event_count,
  uniqExact(user_id)  AS unique_users,
  uniqExact(session_id) AS unique_sessions
FROM events
GROUP BY event, page, device, hour;

Verifying Events After Deployment

Run a quick sanity check to confirm events are flowing correctly.

// scripts/verify-events.ts
import { createClient } from '@clickhouse/client';

const clickhouse = createClient({ url: process.env.CLICKHOUSE_URL!, database: 'analytics' });

async function verify() {
  const result = await clickhouse.query({
    query: `
      SELECT
        event,
        count() AS total,
        uniqExact(session_id) AS sessions,
        min(timestamp) AS first_seen,
        max(timestamp) AS last_seen
      FROM events
      WHERE timestamp >= now() - INTERVAL 1 HOUR
      GROUP BY event
      ORDER BY total DESC
    `,
    format: 'JSONEachRow',
  });

  const rows = await result.json();

  console.log('Events in last 1 hour:');
  console.table(rows);

  // Check for expected events
  const expected = ['page_view', 'product_click', 'add_to_cart'];
  const found = new Set(rows.map((r: any) => r.event));
  for (const e of expected) {
    console.log(`  ${found.has(e) ? '✓' : '✗'} ${e}`);
  }
}

verify();

3. Trend Analysis

Trend Query API

// server/event-query.ts
import { Router } from 'express';

export const eventQueryRouter = Router();

// GET /api/events/trend?event=purchase&start=2026-01-01&end=2026-02-01&granularity=day
eventQueryRouter.get('/trend', async (req, res) => {
  const { event, page, start, end, granularity = 'hour' } = req.query;

  const granularityFn = granularity === 'day' ? 'toStartOfDay' :
                         granularity === 'hour' ? 'toStartOfHour' :
                         'toStartOfFifteenMinutes';

  const conditions = ['1 = 1'];
  const params: Record<string, string> = {};

  if (event) { conditions.push('event = {event:String}'); params.event = event as string; }
  if (page)  { conditions.push('page = {page:String}');   params.page = page as string; }
  if (start) { conditions.push('timestamp >= {start:String}'); params.start = start as string; }
  if (end)   { conditions.push('timestamp <= {end:String}');   params.end = end as string; }

  const query = `
    SELECT
      ${granularityFn}(timestamp) AS time,
      count() AS total,
      uniqExact(user_id) AS unique_users,
      uniqExact(session_id) AS unique_sessions
    FROM events
    WHERE ${conditions.join(' AND ')}
    GROUP BY time
    ORDER BY time
  `;

  const result = await clickhouse.query({ query, query_params: params, format: 'JSONEachRow' });
  res.json(await result.json());
});

// GET /api/events/funnel?steps=page_view,add_to_cart,purchase&start=2026-01-01&end=2026-02-01
eventQueryRouter.get('/funnel', async (req, res) => {
  const steps = (req.query.steps as string).split(',');
  const { start, end } = req.query;

  // Window funnel: count users who completed each sequential step within 30 minutes
  const query = `
    SELECT
      ${steps.map((step, i) =>
        `countIf(level >= ${i + 1}) AS step_${i + 1}_${step}`
      ).join(',\n      ')}
    FROM (
      SELECT
        user_id,
        windowFunnel(1800)(
          timestamp,
          ${steps.map(s => `event = '${s}'`).join(', ')}
        ) AS level
      FROM events
      WHERE timestamp BETWEEN {start:String} AND {end:String}
        AND event IN (${steps.map((_, i) => `{step${i}:String}`).join(', ')})
      GROUP BY user_id
    )
  `;

  const queryParams: Record<string, string> = {
    start: start as string,
    end: end as string,
  };
  steps.forEach((s, i) => { queryParams[`step${i}`] = s; });

  const result = await clickhouse.query({ query, query_params: queryParams, format: 'JSONEachRow' });
  res.json(await result.json());
});

Trend Dashboard Component

// components/EventTrendChart.tsx
'use client';
import { useState, useEffect } from 'react';
import {
  LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
  CartesianGrid, ResponsiveContainer, Legend,
} from 'recharts';

const TIME_RANGES = [
  { label: '24H', value: '24h', granularity: 'hour'  },
  { label: '7D',  value: '7d',  granularity: 'hour'  },
  { label: '30D', value: '30d', granularity: 'day'   },
  { label: '90D', value: '90d', granularity: 'day'   },
];

interface EventTrendChartProps {
  event: string;
  title: string;
}

export function EventTrendChart({ event, title }: EventTrendChartProps) {
  const [range, setRange] = useState('7d');
  const [data, setData] = useState([]);

  const granularity = TIME_RANGES.find(r => r.value === range)?.granularity ?? 'hour';

  useEffect(() => {
    const ms: Record<string, number> = { '24h': 86400000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000 };
    const start = new Date(Date.now() - ms[range]).toISOString();
    const params = new URLSearchParams({ event, start, end: new Date().toISOString(), granularity });

    fetch(`/api/events/trend?${params}`)
      .then(r => r.json())
      .then(setData);
  }, [event, range, granularity]);

  return (
    <div>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
        <h3>{title}</h3>
        <div style={{ display: 'flex', gap: 4 }}>
          {TIME_RANGES.map(r => (
            <button
              key={r.value}
              onClick={() => setRange(r.value)}
              style={{
                padding: '4px 12px', borderRadius: 4, border: '1px solid #ddd', cursor: 'pointer',
                background: range === r.value ? '#0070f3' : '#fff',
                color: range === r.value ? '#fff' : '#333',
              }}
            >
              {r.label}
            </button>
          ))}
        </div>
      </div>
      <ResponsiveContainer width="100%" height={300}>
        <LineChart data={data}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="time" tickFormatter={(t) => new Date(t).toLocaleDateString()} />
          <YAxis />
          <Tooltip labelFormatter={(t) => new Date(t as string).toLocaleString()} />
          <Legend />
          <Line type="monotone" dataKey="total" stroke="#0070f3" name="Events" dot={false} />
          <Line type="monotone" dataKey="unique_users" stroke="#10b981" name="Unique Users" dot={false} />
        </LineChart>
      </ResponsiveContainer>
    </div>
  );
}

Funnel Visualization

// components/FunnelChart.tsx
'use client';
import { useState, useEffect } from 'react';

interface FunnelStep {
  name: string;
  count: number;
  rate: number;     // % of first step
  dropoff: number;  // % dropped from previous step
}

export function FunnelChart({ steps }: { steps: string[] }) {
  const [data, setData] = useState<FunnelStep[]>([]);

  useEffect(() => {
    const params = new URLSearchParams({
      steps: steps.join(','),
      start: new Date(Date.now() - 7 * 86400000).toISOString(),
      end: new Date().toISOString(),
    });

    fetch(`/api/events/funnel?${params}`)
      .then(r => r.json())
      .then(([row]: any[]) => {
        const values = steps.map((_, i) => row[`step_${i + 1}_${steps[i]}`] as number);
        setData(steps.map((name, i) => ({
          name,
          count: values[i],
          rate: values[0] > 0 ? (values[i] / values[0]) * 100 : 0,
          dropoff: i > 0 && values[i - 1] > 0 ? ((values[i - 1] - values[i]) / values[i - 1]) * 100 : 0,
        })));
      });
  }, [steps]);

  return (
    <div>
      <h3>Conversion Funnel</h3>
      {data.map((step, i) => (
        <div key={step.name} style={{ marginBottom: 8 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
            <span>{i + 1}. {step.name}</span>
            <span>{step.count.toLocaleString()} users ({step.rate.toFixed(1)}%)</span>
          </div>
          <div style={{ height: 32, background: '#e5e7eb', borderRadius: 4, overflow: 'hidden' }}>
            <div style={{
              width: `${step.rate}%`, height: '100%',
              background: `hsl(${210 - i * 30}, 70%, 50%)`,
              borderRadius: 4, transition: 'width 0.5s ease',
            }} />
          </div>
          {step.dropoff > 0 && (
            <div style={{ fontSize: 12, color: '#ef4444', marginTop: 2 }}>
              ↓ {step.dropoff.toFixed(1)}% drop-off
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

4. Threshold Alerting

Configure alerts when business event volumes deviate from expected ranges.

Alert Rule Types

TypeUse CaseExample
Volume dropCritical flow volume drops below thresholdPurchase events < 10/hour
Volume spikeUnexpected surge in eventsError events > 500/5min
Rate changeConversion or success rate dropsCheckout conversion < 2%
AnomalyDeviation from historical patternPage views 50% below same-hour average

Alert Configuration

// server/event-alert-rules.ts
export interface EventAlertRule {
  id: string;
  name: string;
  event: string;
  type: 'volume_below' | 'volume_above' | 'rate_below' | 'anomaly';
  threshold: number;
  windowMinutes: number;
  cooldownMinutes: number;
  severity: 'warning' | 'critical';
  channels: { type: 'slack' | 'webhook' | 'wecom' | 'dingtalk'; target: string }[];
  enabled: boolean;

  // For rate alerts
  numeratorEvent?: string;   // e.g. 'purchase'
  denominatorEvent?: string; // e.g. 'add_to_cart'
}

export const defaultEventAlerts: EventAlertRule[] = [
  {
    id: 'purchase-drop',
    name: 'Purchase volume critically low',
    event: 'purchase',
    type: 'volume_below',
    threshold: 10,             // fewer than 10 purchases/hour
    windowMinutes: 60,
    cooldownMinutes: 60,
    severity: 'critical',
    channels: [{ type: 'slack', target: process.env.SLACK_WEBHOOK_URL! }],
    enabled: true,
  },
  {
    id: 'error-spike',
    name: 'Error event spike',
    event: 'error',
    type: 'volume_above',
    threshold: 500,            // more than 500 errors in 5 minutes
    windowMinutes: 5,
    cooldownMinutes: 30,
    severity: 'critical',
    channels: [{ type: 'slack', target: process.env.SLACK_WEBHOOK_URL! }],
    enabled: true,
  },
  {
    id: 'conversion-drop',
    name: 'Cart-to-purchase conversion low',
    event: 'purchase',
    type: 'rate_below',
    threshold: 2,              // conversion below 2%
    windowMinutes: 60,
    cooldownMinutes: 120,
    severity: 'warning',
    numeratorEvent: 'purchase',
    denominatorEvent: 'add_to_cart',
    channels: [{ type: 'slack', target: process.env.SLACK_WEBHOOK_URL! }],
    enabled: true,
  },
];

Alert Evaluator

// server/event-alert-evaluator.ts
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');

async function evaluateEventAlerts(rules: EventAlertRule[]) {
  for (const rule of rules) {
    if (!rule.enabled) continue;

    let shouldFire = false;
    let currentValue = 0;
    let detail = '';

    switch (rule.type) {
      case 'volume_below': {
        const count = await getEventCount(rule.event, rule.windowMinutes);
        currentValue = count;
        shouldFire = count < rule.threshold;
        detail = `${count} events in ${rule.windowMinutes} min (threshold: ≥${rule.threshold})`;
        break;
      }
      case 'volume_above': {
        const count = await getEventCount(rule.event, rule.windowMinutes);
        currentValue = count;
        shouldFire = count > rule.threshold;
        detail = `${count} events in ${rule.windowMinutes} min (threshold: ≤${rule.threshold})`;
        break;
      }
      case 'rate_below': {
        const [num, den] = await Promise.all([
          getEventCount(rule.numeratorEvent!, rule.windowMinutes),
          getEventCount(rule.denominatorEvent!, rule.windowMinutes),
        ]);
        const rate = den > 0 ? (num / den) * 100 : 0;
        currentValue = rate;
        shouldFire = den >= 30 && rate < rule.threshold; // need enough samples
        detail = `${rate.toFixed(1)}% (${num}/${den}) (threshold: ≥${rule.threshold}%)`;
        break;
      }
    }

    if (shouldFire) {
      const cooldownKey = `event-alert:${rule.id}`;
      const inCooldown = await redis.get(cooldownKey);
      if (inCooldown) continue;

      await redis.setex(cooldownKey, rule.cooldownMinutes * 60, '1');

      for (const channel of rule.channels) {
        await sendNotification(channel, {
          title: `[${rule.severity.toUpperCase()}] ${rule.name}`,
          body: detail,
          event: rule.event,
          value: currentValue,
        });
      }
    }
  }
}

async function getEventCount(event: string, windowMinutes: number): Promise<number> {
  const result = await clickhouse.query({
    query: `SELECT count() AS c FROM events WHERE event = {event:String} AND timestamp >= now() - INTERVAL {w:UInt32} MINUTE`,
    query_params: { event, w: String(windowMinutes) },
    format: 'JSONEachRow',
  });
  const [row] = await result.json<{ c: number }>();
  return row?.c ?? 0;
}

Alert Rule Management UI

// app/dashboard/alerts/page.tsx
export default async function AlertsPage() {
  const rules = await fetch(`${process.env.API_URL}/api/events/alert-rules`).then(r => r.json());
  const history = await fetch(`${process.env.API_URL}/api/events/alert-history`).then(r => r.json());

  return (
    <div>
      <h1>Event Alert Rules</h1>

      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr>
            <th>Enabled</th>
            <th>Name</th>
            <th>Event</th>
            <th>Type</th>
            <th>Threshold</th>
            <th>Window</th>
            <th>Severity</th>
          </tr>
        </thead>
        <tbody>
          {rules.map((rule: any) => (
            <tr key={rule.id}>
              <td>{rule.enabled ? '✓' : '✗'}</td>
              <td>{rule.name}</td>
              <td><code>{rule.event}</code></td>
              <td>{rule.type}</td>
              <td>{rule.threshold}</td>
              <td>{rule.windowMinutes} min</td>
              <td>{rule.severity}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <h2>Recent Alerts</h2>
      <ul>
        {history.map((alert: any, i: number) => (
          <li key={i}>
            [{alert.severity}] {alert.name} — {alert.detail} — {new Date(alert.timestamp).toLocaleString()}
          </li>
        ))}
      </ul>
    </div>
  );
}

Best Practices

Event Tracking Guidelines

  1. Define a tracking plan — document every event name, properties, and owner before implementation
  2. Use consistent naming — pick a convention (e.g. noun_verb) and enforce it in code review
  3. Enrich automatically — SDK should add page, device, session, and timestamp without manual effort
  4. Batch and buffer — never send events one at a time; batch with sendBeacon for reliability
  5. Verify after deploy — run a sanity check script to confirm expected events are flowing
  6. Build funnels — connect sequential events to measure conversion and identify drop-offs
  7. Alert on business anomalies — volume drops in critical events (purchases, signups) need immediate attention
  8. Separate tracking from UI — use hooks or data attributes, not inline tracking calls

On this page