Implementation Architecture
Patterns for building reliable, maintainable analytics infrastructure.
Good analytics architecture follows the same principles as good software architecture: centralize definitions, handle errors gracefully, separate concerns, and make the right thing the easy thing.
These patterns apply regardless of language or framework. The structure and principles are the same whether you are in TypeScript, Python, Ruby, Go, or anything else.
Core principles
| Principle | Description |
|---|---|
| Centralized definitions | All events defined in one place |
| Queue-based delivery | Reliable, async event processing |
| Backend routing | Frontend events route through backend |
| Debug mode | Log instead of send during development |
| Error resilience | Analytics failures never break the product |
| Bundled context | Identify + Group + Track together |
Directory structure
Organize analytics code in a dedicated directory with clear separation of concerns:
src/analytics/
dispatcher.{js,ts,py} # HTTP transport to provider
consumer.{js,ts,py} # Queue processor (if using queues)
events.{js,ts,py} # Event definitions (THE tracking plan)
utils.{js,ts,py} # Context extraction helpers
README.md # How to add eventsThis structure gives you:
- A single audit point for all events (
eventsfile) - Clear separation between what to track and how to send it
- A discoverable location that new developers can find immediately
Centralized event definitions
This is the most important architectural decision. Events must be defined in a single source of truth, not scattered across the codebase.
The problem
// File A
track("user performed search");
// File B
track("Search Performed");
// File C
track("SEARCH_EXECUTED");Three files, three names, one action. Your analytics now has three separate events that should be one. This is not a discipline failure -- it is a system design failure.
The fix
Define every event as a named function in a centralized file:
// src/analytics/events.js
/**
* Track report creation.
* @category core_value
*/
export const trackReportCreated = async (context) => {
await track(context, "Report_Created");
};
/**
* Track search performed.
* @category feature_usage
*/
export const trackSearchPerformed = async (context) => {
await track(context, "Search_Performed");
};
/**
* Track teammate invitation.
* @category collaboration
*/
export const trackTeammateInvited = async (context) => {
await track(context, "Teammate_Invited");
};Key points:
- Every event is a named, exported function
- The event name string appears exactly once in the codebase
- JSDoc comments describe purpose and category
- All events call a common
track()helper - No inline string events anywhere else
For larger applications, organize event definitions by domain (user events, billing events, feature events) but keep them in a known location within src/analytics/.
Calling events from application code
Application code imports and calls the event functions. It never constructs event name strings.
// In your application code
import { trackReportCreated } from "../analytics/events";
const createReport = async (data) => {
const report = await db.reports.create(data);
trackReportCreated(context).catch(console.error);
return report;
};The dispatcher
The dispatcher handles HTTP communication with your analytics provider. It is the only file that knows about the network.
// src/analytics/dispatcher.js
const dispatch = async (eventType, payload) => {
const apiKey = process.env.ANALYTICS_API_KEY;
const url = `https://in.accoil.com/v1/${eventType}`;
const body = JSON.stringify({
...payload,
api_key: apiKey,
timestamp: Date.now(),
});
// Debug mode: log instead of send
if (process.env.ANALYTICS_DEBUG === "true") {
console.log(`[Analytics Debug] ${eventType}:`, body);
return;
}
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
};
export const handleTrack = async (userId, event, groupContext) => {
await dispatch("events", {
user_id: userId,
event,
context: groupContext,
});
};
export const handleIdentify = async (userId, groupId, traits) => {
await dispatch("users", {
user_id: userId,
group_id: groupId,
traits,
});
};
export const handleGroup = async (groupId, traits) => {
await dispatch("groups", { group_id: groupId, traits });
};Context extraction
Helper functions extract IDs from your application's context. This is where instance-level tracking is configured:
// src/analytics/utils.js
export const getUserId = (context) => {
// Instance-level tracking: use account ID for all users
if (process.env.ANALYTICS_INSTANCE_LEVEL === "true") {
return context.accountId;
}
return context.userId;
};
export const getAccountId = (context) => {
return context.accountId;
};See Cost Optimization for when instance-level tracking makes sense.
Queue-based delivery
For reliability, use a queue instead of direct HTTP calls.
Why queues
| Benefit | Description |
|---|---|
| Retry on failure | Transient errors do not lose events |
| Non-blocking | User actions do not wait for analytics |
| Batching | Combine multiple events for efficiency |
| Backpressure | Handle traffic spikes gracefully |
Pattern
The track helper bundles identify + group + track into a single queued operation:
// src/analytics/events.js
import { Queue } from "your-queue-library";
const analyticsQueue = new Queue({ key: "analytics-queue" });
const track = async (context, eventName, groupId) => {
const userId = getUserId(context);
const accountId = getAccountId(context);
const events = [
{
type: "identify",
userId,
groupId: accountId,
traits: { name: userId },
},
{
type: "group",
groupId: accountId,
traits: { name: accountId },
},
{
type: "track",
userId,
event: eventName,
context: { groupId: groupId || accountId },
},
];
await analyticsQueue.push(events);
};The consumer processes queued events:
// src/analytics/consumer.js
export const processAnalyticsEvent = async (payload) => {
switch (payload.type) {
case "identify":
await handleIdentify(payload.userId, payload.groupId, payload.traits);
break;
case "group":
await handleGroup(payload.groupId, payload.traits);
break;
case "track":
await handleTrack(payload.userId, payload.event, payload.context);
break;
}
};Backend routing
Never send analytics directly from the frontend to the provider.
Why backend routing
| Reason | Description |
|---|---|
| Privacy | API keys stay server-side |
| Compliance | Control what data leaves your system |
| Consistency | Same context extraction logic everywhere |
| Enrichment | Add server-side data to events |
Pattern
The frontend calls a backend endpoint. The backend enriches with server-side context and sends to the provider:
// Frontend -- sends only the event name
import { invoke } from "your-rpc-library";
export const track = async (eventName) => {
try {
await invoke("track-event", { event: eventName });
} catch (error) {
// Never let analytics break the UI
console.error("[Analytics] Failed:", error);
}
};
export const trackSearchPerformed = () => track("Search_Performed");// Backend resolver -- adds context server-side
export const trackEvent = async ({ payload, context }) => {
await track(context, payload.event);
};The frontend never sees the API key, never constructs the full payload, and never communicates directly with the analytics provider.
Debug mode
Essential for development. Log events to the console instead of sending them to the provider:
const dispatch = async (eventType, payload) => {
if (process.env.ANALYTICS_DEBUG === "true") {
console.log(
`[Analytics Debug] ${eventType}:`,
JSON.stringify(payload, null, 2)
);
return;
}
// Actual HTTP call
await fetch(url, { method: "POST", headers, body });
};Environment configuration
# Development
ANALYTICS_API_KEY=dev_key
ANALYTICS_DEBUG=true
# Production
ANALYTICS_API_KEY=prod_key
# ANALYTICS_DEBUG not set or falseDebug mode lets you verify that the right events fire at the right moments without polluting production data. Every developer on the team should be able to see analytics output in their console during development.
Error resilience
Analytics must never break the product. This is non-negotiable.
Frontend: catch and log
export const track = async (eventName) => {
try {
await invoke("track-event", { event: eventName });
} catch (error) {
console.error("[Analytics]", error);
}
};Backend: catch and continue
export const trackReportCreated = async (context) => {
try {
await track(context, "Report_Created");
} catch (error) {
console.error("[Analytics] Failed to track:", error);
}
};Non-blocking on the critical path
Do not await analytics on the user's critical path:
// Wrong: blocks user action on analytics
const createReport = async (data) => {
const report = await db.reports.create(data);
await trackReportCreated(context); // User waits for this
return report;
};
// Right: fire and forget with error handling
const createReport = async (data) => {
const report = await db.reports.create(data);
trackReportCreated(context).catch(console.error); // Non-blocking
return report;
};If analytics is down, slow, or throwing errors, the user should never notice. Use queue-based delivery for additional reliability -- the queue absorbs failures and retries automatically.
Client-side vs server-side identity
How you handle identity depends on where your code runs.
Client-side (browser and mobile)
Client-side SDKs maintain state. Call identify and group once, then subsequent track calls automatically include user context:
// On login -- call once
analytics.identify("usr_123", { email: "jane@example.com" });
analytics.group("acc_456", { name: "Acme Corp" });
// Later -- userId is automatic
analytics.track("Report_Created");
analytics.track("Search_Performed");Re-identify only when user or account traits change.
Server-side (backend)
Server-side SDKs are stateless. You must pass userId on every call:
analytics.track({
userId: "usr_123",
event: "Report_Created",
properties: {},
});For server-side implementations, the bundled context pattern (identify + group + track per operation) ensures no events are orphaned. See the queue-based delivery section above.
Group context on track calls
In B2B products with a group hierarchy, every track call must carry the group ID for the level where the event occurred.
Matching group level to events
The tracking plan assigns each event to a group level. The implementation must carry that through:
| Event | Group level | groupId to use |
|---|---|---|
Task_Completed | project | proj_123 |
Workspace_Settings_Updated | workspace | ws_789 |
Plan_Upgraded | account | acc_456 |
Event functions with group context
/**
* Track task creation (group_level: project)
*/
export const trackTaskCreated = async (context, projectId) => {
await track(context, "Task_Created", projectId);
};
/**
* Track workspace settings change (group_level: workspace)
*/
export const trackWorkspaceSettingsUpdated = async (context, workspaceId) => {
await track(context, "Workspace_Settings_Updated", workspaceId);
};
/**
* Track plan upgrade (group_level: account)
*/
export const trackPlanUpgraded = async (context) => {
const accountId = getAccountId(context);
await track(context, "Plan_Upgraded", accountId);
};Establishing the full hierarchy
Before tracking events against sub-account groups, ensure every level has been established via group() calls:
export const establishGroupHierarchy = async (context) => {
const accountId = getAccountId(context);
const workspaceId = getWorkspaceId(context);
const projectId = getProjectId(context);
// Account (top level)
await analyticsQueue.push({
type: "group",
groupId: accountId,
traits: { name: context.accountName, group_type: "account" },
});
// Workspace (if applicable)
if (workspaceId) {
await analyticsQueue.push({
type: "group",
groupId: workspaceId,
traits: {
name: context.workspaceName,
group_type: "workspace",
parent_group_id: accountId,
},
});
}
// Project (if applicable)
if (projectId) {
await analyticsQueue.push({
type: "group",
groupId: projectId,
traits: {
name: context.projectName,
group_type: "project",
parent_group_id: workspaceId,
},
});
}
};Call establishGroupHierarchy() on login or when the user enters a new group context.
Language-agnostic summary
These patterns apply in any language:
| Pattern | Implementation |
|---|---|
| Centralized definitions | One file with all events as named functions |
| Debug mode | Environment variable check before dispatch |
| Error resilience | Try/catch everywhere, never throw from analytics |
| Queue-based delivery | Use your platform's queue or job system |
| Backend routing | RPC or API call from frontend to backend |
| Context bundling | Identify + Group + Track per operation |