livery model.
1. Define Event Taxonomy & Schema
Events must be versioned, namespaced, and typed. Avoid generic names like button_clicked. Use domain-driven naming: checkout:payment_initiated, onboarding:step_completed. Each event carries a strict contract:
// events/checkout.ts
import { z } from 'zod';
export const PaymentInitiatedSchema = z.object({
event: z.literal('checkout:payment_initiated'),
version: z.literal('1.0.0'),
timestamp: z.number(),
properties: z.object({
cart_value_cents: z.number().positive(),
currency: z.string().length(3),
payment_method: z.enum(['stripe', 'paypal', 'bank_transfer']),
user_id: z.string().uuid(),
session_id: z.string().min(1),
}),
});
export type PaymentInitiatedEvent = z.infer<typeof PaymentInitiatedSchema>;
2. Build a Validated Tracking Runtime
Never fire events without validation. A runtime guard prevents schema drift, strips PII, and batches payloads for network efficiency.
// tracker/analytics.ts
import { z } from 'zod';
import { PaymentInitiatedSchema, PaymentInitiatedEvent } from '../events/checkout';
type AnyEventSchema = z.ZodTypeAny;
type ValidatedEvent<T extends AnyEventSchema> = z.infer<T>;
class AnalyticsTracker {
private queue: Array<{ schema: AnyEventSchema; payload: unknown; retries: number }> = [];
private readonly BATCH_SIZE = 50;
private readonly ENDPOINT = process.env.ANALYTICS_ENDPOINT!;
track<T extends AnyEventSchema>(schema: T, payload: unknown): ValidatedEvent<T> {
const parsed = schema.parse(payload);
this.queue.push({ schema, payload: parsed, retries: 0 });
if (this.queue.length >= this.BATCH_SIZE) this.flush();
return parsed as ValidatedEvent<T>;
}
private async flush(): Promise<void> {
const batch = this.queue.splice(0, this.BATCH_SIZE);
try {
await fetch(this.ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch.map(b => b.payload)),
});
} catch (err) {
// Implement exponential backoff in production
console.error('Analytics flush failed:', err);
this.queue.unshift(...batch);
}
}
}
export const tracker = new AnalyticsTracker();
3. Architect Delivery Boundaries
Client-side tracking captures interaction latency, UI state, and navigation paths. Server-side tracking captures business logic, payment state, and identity resolution. Hybrid delivery prevents double-counting and ensures data integrity.
- Client SDK: Lightweight, validates against UI schemas, batches to edge collector, respects
navigator.doNotTrack and consent flags.
- Server SDK: Runs in request context, enriches events with authenticated user data, writes to internal event bus (Kafka/SQS) before warehouse ingestion.
- Reconciliation Layer: Deduplicates events using
session_id + correlation_id. Server events override client events on conflict.
4. Implement Data Lifecycle & Governance
Events degrade in value over time. Partition by recency and query frequency:
- Hot tier (0β30 days): ClickHouse/BigQuery, low-latency BI access
- Warm tier (30β180 days): Parquet on S3/GCS, scheduled aggregation
- Cold tier (180+ days): Glacier/Archive, compliance-only retention
Attach metadata to every event: environment, app_version, sdk_version, consent_level. This enables cohort filtering, rollback analysis, and automated PII scanning.
Pitfall Guide
1. Tracking Without a Contract
Firing raw JSON objects without schema validation guarantees drift. Properties mutate, types change, and downstream queries break silently.
Best Practice: Enforce Zod/Yup contracts at the SDK boundary. Reject invalid payloads in dev/test, log warnings in prod.
2. Client/Server Context Collision
Both layers tracking the same business action creates double-counting. Client fires on click, server fires on webhook. Dashboards show inflated conversion rates.
Best Practice: Assign ownership. Client tracks UX interactions. Server tracks state transitions. Use a correlation ID to merge in the warehouse.
3. Ignoring Data Residency & PII
Product events frequently leak emails, IP addresses, or internal IDs. GDPR/CCPA audits flag unstructured tracking payloads as high-risk.
Best Practice: Implement a PII scrubber middleware. Hash or drop sensitive fields before queueing. Maintain a data classification matrix per event.
4. No Retention or Tiering Strategy
Storing every event at full fidelity indefinitely bloats storage costs and slows queries. Teams pay for data they never analyze.
Best Practice: Define retention tiers upfront. Aggregate low-value events after 30 days. Archive compliance-only events to cold storage.
5. Skipping Tracking QA
Analytics breaks silently. A missed property or renamed event goes undetected until a quarterly review.
Best Practice: Add contract tests to CI. Mock the analytics endpoint in E2E tests. Validate payload shape against schema in staging.
6. Treating Setup as One-Time Work
Product evolves. Features ship, flows change, metrics shift. Static tracking decays within months.
Best Practice: Version events. Deprecate old schemas with migration windows. Maintain a living event catalog tied to Jira/Linear tickets.
7. Metric-Event Misalignment
Tracking events that don't map to north-star metrics creates noise. Teams optimize for vanity counts instead of actionable signals.
Best Practice: Reverse-engineer from dashboards. Define the metric first, then derive the minimal event set required to calculate it.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Early-stage startup (<10k MAU) | Client-only SDK + lightweight warehouse | Fast iteration, minimal infra, lower initial complexity | Low setup cost, scales poorly past 50k MAU |
| Growth-stage SaaS (10kβ200k MAU) | Hybrid client/server + event bus | Accurate attribution, deduplication, compliance-ready | Moderate infra cost, reduces wasted storage by 60%+ |
| Enterprise/regulated (HIPAA, GDPR) | Server-only + PII gateway + audit logging | Strict data control, legal defensibility, centralized governance | High initial cost, eliminates compliance fines |
| Mobile-first product | Native SDK + offline queue + background sync | Handles connectivity gaps, preserves session continuity | Slightly higher client footprint, improves data completeness |
Configuration Template
// config/analytics.ts
import { z } from 'zod';
export const AnalyticsConfigSchema = z.object({
endpoint: z.string().url(),
batch_size: z.number().int().min(10).max(200).default(50),
flush_interval_ms: z.number().int().min(1000).default(5000),
environment: z.enum(['development', 'staging', 'production']),
consent_required: z.boolean().default(true),
pii_fields: z.array(z.string()).default(['email', 'phone', 'ip_address']),
retention_days: z.object({
hot: z.number().default(30),
warm: z.number().default(180),
cold: z.number().default(730),
}),
});
export type AnalyticsConfig = z.infer<typeof AnalyticsConfigSchema>;
export const defaultConfig: AnalyticsConfig = {
endpoint: process.env.ANALYTICS_ENDPOINT || 'https://collector.yourdomain.com/v1/events',
batch_size: 50,
flush_interval_ms: 5000,
environment: (process.env.NODE_ENV || 'development') as AnalyticsConfig['environment'],
consent_required: true,
pii_fields: ['email', 'phone', 'ip_address'],
retention_days: { hot: 30, warm: 180, cold: 730 },
};
Quick Start Guide
- Install dependencies:
npm install zod @yourcompany/analytics-sdk
- Define your first event schema: Copy the
PaymentInitiatedSchema pattern, adjust properties, export types.
- Initialize the tracker: Import
AnalyticsTracker, pass defaultConfig, and attach to your app entry point.
- Instrument a critical flow: Replace ad-hoc
console.log or third-party pixels with tracker.track(PaymentInitiatedSchema, payload).
- Verify in staging: Open network tab, confirm batched POST to collector, validate payload shape against schema, check warehouse ingestion.