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.
| Event | Properties | Trigger | Owner |
|---|---|---|---|
page_view | page_name | Page mount | Auto |
product_click | product_id, category, price | Card click | Product team |
add_to_cart | product_id, quantity, price | Button click | Cart team |
purchase | order_id, total, items_count | Checkout success | Checkout team |
search | query, results_count | Search submit | Search team |
error | type, endpoint, status | API failure | Platform 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
| Type | Use Case | Example |
|---|---|---|
| Volume drop | Critical flow volume drops below threshold | Purchase events < 10/hour |
| Volume spike | Unexpected surge in events | Error events > 500/5min |
| Rate change | Conversion or success rate drops | Checkout conversion < 2% |
| Anomaly | Deviation from historical pattern | Page 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
- Define a tracking plan — document every event name, properties, and owner before implementation
- Use consistent naming — pick a convention (e.g.
noun_verb) and enforce it in code review - Enrich automatically — SDK should add page, device, session, and timestamp without manual effort
- Batch and buffer — never send events one at a time; batch with
sendBeaconfor reliability - Verify after deploy — run a sanity check script to confirm expected events are flowing
- Build funnels — connect sequential events to measure conversion and identify drop-offs
- Alert on business anomalies — volume drops in critical events (purchases, signups) need immediate attention
- Separate tracking from UI — use hooks or data attributes, not inline tracking calls