AccoilAccoil Developer Docs
Best Practices

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

PrincipleDescription
Centralized definitionsAll events defined in one place
Queue-based deliveryReliable, async event processing
Backend routingFrontend events route through backend
Debug modeLog instead of send during development
Error resilienceAnalytics failures never break the product
Bundled contextIdentify + 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 events

This structure gives you:

  • A single audit point for all events (events file)
  • 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

BenefitDescription
Retry on failureTransient errors do not lose events
Non-blockingUser actions do not wait for analytics
BatchingCombine multiple events for efficiency
BackpressureHandle 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

ReasonDescription
PrivacyAPI keys stay server-side
ComplianceControl what data leaves your system
ConsistencySame context extraction logic everywhere
EnrichmentAdd 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 false

Debug 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:

EventGroup levelgroupId to use
Task_Completedprojectproj_123
Workspace_Settings_Updatedworkspacews_789
Plan_Upgradedaccountacc_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:

PatternImplementation
Centralized definitionsOne file with all events as named functions
Debug modeEnvironment variable check before dispatch
Error resilienceTry/catch everywhere, never throw from analytics
Queue-based deliveryUse your platform's queue or job system
Backend routingRPC or API call from frontend to backend
Context bundlingIdentify + Group + Track per operation

On this page