Product Analytics and Event Tracking
- analytics
- event-tracking
- nestjs
- postgresql
- redis
- bullmq
- saas
- systemdesign
In Designing the SaaS Dashboard we built the surface that shows customers their own usage: charts, tables, and summary cards backed by real queries against their own data. None of that works without a reliable stream of events underneath it. Before showing a customer "here's how your team used the product this month," you need to have been recording that usage correctly since day one, which is far easier to solve early than to retrofit later.
This is part of the Full Stack SaaS Masterclass, a build-it-for-real series taking a multi-tenant SaaS from an empty folder to production. Module 3 covers the features that turn a working app into a product people renew, and analytics sits quietly underneath most of them: the dashboard reads from it, sales asks for it, and a customer success manager eventually builds a renewal conversation around a chart shipped months earlier.
This article covers the engineering of event tracking: schema design, ingestion, and the pipeline that turns a click into a row you can query. It skips the tour of analytics vendor dashboards, and it deliberately avoids building a general-purpose analytics platform before a single customer asks a hard question about their usage.
Why product analytics is a different problem than application logging
Most teams already have logs. Every request hits a logger, every error gets captured, and there's probably a Sentry or CloudWatch dashboard somewhere. It's tempting to treat product analytics as "just more logging," and that's the mistake that produces analytics data nobody trusts a year later.
Application logs answer "what happened in the system." Product analytics answers "what did a specific user, in a specific organization, do." Those are different questions with different requirements:
- Logs are transient, consumed mostly by engineers debugging an incident. Analytics events are durable business records, queried by product managers and support teams, and sometimes the billing source of truth.
- Logs can be lossy under load without anyone noticing. A dropped analytics event means a customer's usage report is wrong, and wrong usage numbers erode trust fast.
- Log schemas evolve freely; nothing downstream depends on field names staying stable. Analytics event schemas need the discipline of a public API, since dashboards and cohort queries are written against specific field names.
Event tracking deserves its own pipeline, separate from the logging stack, even a thin one at first. Piggybacking analytics on console.log works only until someone asks a real product question of the data.
Designing an event schema you won't regret
The single highest-leverage decision here is the shape of an event, because changing it later means reconciling old and new data forever. A reasonable baseline that most SaaS products converge on looks like this:
interface AnalyticsEvent {
eventId: string; // idempotency key, generated client or server side
eventName: string; // 'invoice.created', not 'Invoice Created' or 'createInvoice'
organizationId: string;
userId: string | null; // null for system-triggered events
timestamp: string; // ISO 8601, set at capture time, not ingestion time
properties: Record<string, unknown>; // event-specific payload
context: {
source: 'web' | 'mobile' | 'api' | 'worker';
ipAddress?: string;
userAgent?: string;
};
}
A few decisions here are worth calling out explicitly because they're easy to get wrong on the first pass:
Event naming. Pick one convention (resource.action, past tense: invoice.created, member.invited) and enforce it, since inconsistent naming is the most common reason event data becomes unqueryable. A shared constants file beats trusting every engineer to remember it.
Timestamp discipline. Capture the timestamp when the event happens, not when it's ingested. Network retries and queue backlog can make ingestion time lag capture time by seconds or, during an incident, much longer. Storing only ingestion time makes time-series charts subtly wrong during exactly the periods when accuracy matters most.
Idempotency by design. Any event that can be retried, and on an unreliable network almost every event can be, needs a client-generated eventId so the backend can deduplicate. Relying on the network to never send a duplicate is a bet you will lose.
Organization scoping from the start. Every event needs organizationId, even for actions that feel user-centric, because the dashboard and any future analytics feature will be sliced by tenant. Retrofitting tenant scoping onto years of un-scoped events is a miserable migration.
Capturing events without doubling your frontend's complexity
There are two places to fire an event: the client, or the server. Both have a role, and conflating them is where a lot of teams end up with duplicate or missing data.
Client-side tracking captures intent and UI interaction: a button click, a page view, a form abandoned halfway through. It's the only place you can observe some things, like a settings modal opened and closed without saving. The tradeoff is that client-side events are unreliable by nature: ad blockers, browser extensions, and flaky connections drop them silently, with no way to know how many were lost.
Server-side tracking captures state changes: a record created, a subscription upgraded, a webhook processed. These events are far more trustworthy because they originate from the same code path that performed the action, so there's no gap between "we think it happened" and "it happened." A reasonable default is to track anything that changes data or triggers a billable action on the server, and reserve client-side tracking for UI-only interactions the server can't see.
A small NestJS interceptor makes server-side capture close to free for anything already flowing through your API:
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { AnalyticsService } from './analytics.service';
interface TrackedRequest {
organizationId: string;
user?: { id: string };
}
@Injectable()
export class TrackEventInterceptor implements NestInterceptor {
constructor(
private readonly analytics: AnalyticsService,
private readonly eventName: string,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<TrackedRequest>();
return next.handle().pipe(
tap((result) => {
void this.analytics.track({
eventName: this.eventName,
organizationId: request.organizationId,
userId: request.user?.id ?? null,
properties: { resourceId: (result as { id?: string })?.id },
context: { source: 'api' },
});
}),
);
}
}
Firing the event with void inside tap matters: tracking should never block or fail the request it's attached to.
Building the ingestion pipeline
The ingestion endpoint is deceptively simple to write and easy to get wrong under load. Its core requirements are validation, deduplication, and getting the write off the request's critical path.
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import { IsArray, IsIn, IsISO8601, IsObject, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import Redis from 'ioredis';
class EventContextDto {
@IsIn(['web', 'mobile', 'api', 'worker'])
source: 'web' | 'mobile' | 'api' | 'worker';
@IsString()
@IsOptional()
userAgent?: string;
}
class TrackEventDto {
@IsUUID()
eventId: string;
@IsString()
eventName: string;
@IsUUID()
organizationId: string;
@IsUUID()
@IsOptional()
userId?: string;
@IsISO8601()
timestamp: string;
@IsObject()
properties: Record<string, unknown>;
@ValidateNested()
@Type(() => EventContextDto)
context: EventContextDto;
}
class TrackEventsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => TrackEventDto)
events: TrackEventDto[];
}
@Controller('events')
export class EventsController {
constructor(
@InjectQueue('analytics-events') private readonly queue: Queue,
private readonly redis: Redis,
) {}
@Post()
@HttpCode(202)
async track(@Body() dto: TrackEventsDto): Promise<{ accepted: number }> {
const fresh: TrackEventDto[] = [];
for (const event of dto.events) {
const dedupeKey = `analytics:dedupe:${event.eventId}`;
const wasNew = await this.redis.set(dedupeKey, '1', 'EX', 60 * 60 * 24, 'NX');
if (wasNew === 'OK') {
fresh.push(event);
}
}
if (fresh.length > 0) {
await this.queue.add('ingest', { events: fresh }, { removeOnComplete: true });
}
return { accepted: fresh.length };
}
}
The endpoint accepts a batch, checks each event against Redis with SET ... NX for cheap, race-safe deduplication, and hands anything new off to a BullMQ queue instead of writing to PostgreSQL inline. That buys two things. The HTTP response comes back fast regardless of database load, and a burst of events, say a customer bulk-importing thousands of records, doesn't turn into thousands of synchronous inserts competing with application traffic. The worker consuming that queue can then batch inserts into PostgreSQL, which is much cheaper than one insert per event once volume climbs.
A straightforward events table with monthly partitioning keeps queries fast as the table grows, without needing a specialized analytics database on day one:
create table events (
event_id uuid not null,
event_name text not null,
organization_id uuid not null,
user_id uuid,
occurred_at timestamptz not null,
properties jsonb not null default '{}',
context jsonb not null default '{}',
inserted_at timestamptz not null default now(),
primary key (event_id, occurred_at)
) partition by range (occurred_at);
create index idx_events_org_time on events (organization_id, occurred_at desc);
create index idx_events_name_time on events (event_name, occurred_at desc);
Partitioning by occurred_at lets old partitions be dropped or archived cheaply and keeps index sizes bounded, which matters once dashboard charts are querying months of history.
Build vs buy: when a vendor beats your own pipeline
Is any of this worth building instead of just dropping in PostHog, Segment, or Mixpanel and calling it done? Fair question. Both answers are defensible, depending on where the data lives and who consumes it.
A vendor tool wins when the primary need is product-led growth analytics: funnels, session replay, feature flags, and a UI the product team can use without engineering involvement. Building a competing internal tool for that job is a poor use of engineering time. Owning the pipeline earns its cost when analytics data needs to sit next to operational data, feed customer-facing dashboards, or serve as an input to billing. If a customer's dashboard shows API call counts that need to reconcile exactly with an invoice line item, routing that through a third-party vendor's API on the critical path adds a dependency that isn't needed. Plenty of SaaS products run both: a vendor tool for internal growth analytics, and a thin owned pipeline like the one above for anything customer-facing or billing-adjacent.
Production pitfalls worth planning for
A handful of failure modes show up repeatedly once event tracking is live in production, and they're worth designing against before they happen.
Double-firing on retries. Client-side SDKs and mobile apps retry failed requests aggressively, and without the idempotency key described earlier, every retry becomes a duplicate event that inflates counts and corrupts any dashboard built on top of it.
Schema drift with no enforcement. Once more than one service fires events, someone will eventually send properties.userId where everyone else sends properties.user_id, or introduce an event name that's almost, but not quite, the existing convention. A shared package exporting typed event constructors, or a schema registry validated in CI, catches this before it reaches production.
PII ending up in properties. It's easy for a well-meaning engineer to log an email address or a support ticket's text into an analytics event's free-form properties field, since nothing stops it at the type level. Treat the payload with the same review scrutiny as any other place PII could leak, and strip or hash sensitive fields before persisting.
Analytics writes competing with product traffic. If ingestion writes directly to the primary PostgreSQL instance without a queue in front, a traffic spike will slow down the same database serving customer requests. The queue exists specifically to decouple those two concerns.
Key takeaways
- Product analytics events are durable business records, not debug logs, and deserve their own pipeline and schema discipline.
- Design the event schema (naming, timestamps, idempotency key, organization scoping) before the first tracking call, since changing it later means reconciling old and new data forever.
- Prefer server-side tracking for anything that changes data or affects billing; reserve client-side tracking for UI-only interactions the server can't observe.
- Decouple ingestion from the primary database with a queue, so a burst of events never competes with customer-facing traffic for the same connection pool.
- Reach for a vendor tool for product-led growth and funnels; keep a thin owned pipeline for anything that reconciles with customer-facing dashboards or invoices.
- Treat idempotency, PII handling, and schema consistency as production requirements from the first event, not cleanup work for later.
Frequently asked questions
Should I track analytics events on the client or the server?
Default to server-side tracking for anything that changes data or affects billing, since it doesn't depend on the client's network reliability. Reserve client-side tracking for interactions the server can't observe, like a modal opened and closed without a save.
How do I prevent duplicate analytics events from retries?
Generate an idempotency key at capture time and check it against a fast store like Redis with `SET ... NX` before persisting. A retry of the same event fails the uniqueness check and gets dropped instead of double-counted.
Do I need a dedicated analytics database like ClickHouse from the start?
No. A well-indexed, partitioned table in the PostgreSQL instance already in use comfortably handles event volumes for a long time. Reach for a columnar analytics store once query latency becomes a measured problem, not before.
Should product analytics events go through the same pipeline as application logs?
Keep them separate. Logs are transient and consumed mostly during incidents, while analytics events are durable records that dashboards and billing depend on, with different reliability guarantees and schema discipline.
When does it make sense to use a third-party analytics tool instead of building one?
When the primary consumer is the product or growth team and the main need is funnels, session replay, or feature flag experiments. Build a pipeline when the data needs to feed customer-facing dashboards or reconcile with billing.
How should analytics properties handle sensitive or personally identifiable data?
Treat the properties payload as a place PII can leak just as easily as any API response. Strip, hash, or omit fields like email addresses or free-text content before the event is persisted.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.