Skip to main content

Analytics (PostHog)

CONA uses PostHog for product analytics and user behavior tracking. This guide explains how our PostHog integration works, including user identification, event tracking, and best practices.

Overview

PostHog provides both client-side and server-side analytics tracking, allowing us to understand user behavior across the entire application. Our implementation is designed around CONA’s multi-tenant architecture using the actor/user system.

Integration Architecture

Client-Side Integration

The PostHog client is initialized in the PostHogProvider component:
// apps/webapp/app/providers/post-hog-provider.tsx
export function PostHogProvider({ children }: { children: React.ReactNode }) {
  const { user, isLoading: isUserLoading } = useUser();
  const { actorId, organizationId, organizationName, isLoading: isAuthLoading } = useAuth();

  useEffect(() => {
    posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
      api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
      capture_pageview: false, // Manual pageview capture
      capture_pageleave: true,
    });
  }, []);
  
  // User identification logic...
}

Server-Side Integration

Server-side tracking uses the PostHog Node.js client:
// apps/webapp/app/lib/services/posthog.ts
import { PostHog } from "posthog-node";

export default function PostHogClient() {
  const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
    host: process.env.NEXT_PUBLIC_POSTHOG_HOST!,
    flushAt: 1,
    flushInterval: 0,
  });
  return posthogClient;
}
Note: The server-side PostHog client has different API signatures than the client-side version:
// Client-side API
posthog.identify(userId, { email: "..." });

// Server-side API  
posthog.identify({
  distinctId: userId,
  properties: { email: "..." }
});

Helper Functions

PostHog Tracking Helper

We provide a reusable helper function for consistent event tracking:
// apps/webapp/app/lib/utils/posthog-tracking.ts
import { trackEventWithAuth } from "@/app/lib/utils/posthog-tracking";

// Simple usage with auth context
await trackEventWithAuth(
  userId,
  organizationId,
  organizationName,
  "event_name",
  {
    custom_property: "value",
    // ... other properties
  }
);

// Advanced usage with options
await trackEvent({
  userId,
  eventName: "event_name",
  properties: { custom_property: "value" },
  organizationId,
  organizationName,
  skipIdentification: false, // Set to true if user was recently identified
});
Benefits of using the helper:
  • Automatic user identification with proper fallback logic
  • Consistent organization context
  • Error handling that doesn’t break main operations
  • TypeScript support for better development experience

User Identification System

Migration from actorId to userId (Feb 2026)

Important: We previously used actorId (organization-scoped) as the distinct ID, which created duplicate users in PostHog when the same person belonged to multiple organizations. We have now migrated to using userId (global, stable identifier) as the distinct ID. What changed:
  • ✅ Distinct ID changed from actorId to userId
  • ✅ Added PostHog groups for organization-level tracking
  • ✅ Created migration script to alias existing duplicates
  • ✅ All tracking code updated to use userId
If you need to run the migration:
# Run with your environment
pnpm dlx dotenv-cli -e .env.local -- node scripts/posthog-alias-duplicate-users.mjs

Why We Use User IDs

CONA uses a multi-tenant architecture where:
  • Users represent the actual person/account (global identity)
  • Actors represent the user’s identity within a specific organization
For PostHog analytics, we use the userId as the distinct ID, not the actorId. This ensures:
  • ✅ The same person is tracked consistently across all organizations
  • ✅ No duplicate user entries when switching organizations
  • ✅ Accurate cross-organization user journey tracking
  • ✅ Organization-level analytics via PostHog groups

Identification Flow

  1. Authentication: User logs in via Auth0
  2. Context Loading: App loads user context (userId, organizationId, organizationName)
  3. PostHog Identification: When both user and organization context are available, PostHog:
    • Identifies the user with userId as distinct ID
    • Associates the user with their organization via groups
  4. Event Tracking: All events are tracked using the same userId and organization group
// Client-side identification
posthog.identify(userId, {
  email: user.email,
  name: user.name || user.email,
});

// Set organization as a group
posthog.group("organization", organizationId, {
  name: organizationName,
  slug: organizationSlug,
});

User Properties

Each identified user has the following properties:
PropertySourceDescription
Distinct IDuserIdUnique identifier for the person (global, stable)
EmailAuth0 user profileUser’s email address
NameAuth0 user profileUser’s display name

Organization Tracking via Groups

Organization-level data is tracked using PostHog groups:
PropertyTypeDescription
Group TypeorganizationFixed group type for all organizations
Group KeyorganizationIdUnique identifier for the organization
Group NameorganizationNameDisplay name for the organization
Group SlugorganizationSlugURL-friendly organization identifier
This allows you to:
  • Filter events by organization
  • Track organization-level metrics
  • Understand which organizations are most active
  • Analyze feature adoption per organization
  • Query by organization slug for URL-based filtering

Currently Tracked Events

Organization Management

  • organization_onboarding_completed - When organization setup is complete

App Store & Installation

  • app_installed - When an app is installed from the app store (one-time event)

Integration Management (Business Logic)

  • integration_created - When a new integration is created and configured
  • integration_activated - When an integration is turned on (the important toggle)
  • integration_deactivated - When an integration is turned off (the important toggle)
  • integration_deleted - When an integration is completely removed

Event Separation Logic

We separate App Store events (installation) from Integration events (business usage):
  • App Installation: One-time event when installing from app store
  • Integration Usage: Ongoing business events when users actually use/configure the integration
This prevents double-tracking and focuses on the events that matter for business analytics.

Automatic Events

  • Pageviews: Manually captured with full URL including search params
  • Page Leave: Automatically captured when users navigate away

Event Tracking Examples

Custom Events

Server actions can track custom events using the helper function:
// Example from app installation
await trackEventWithAuth(
  userId,
  organizationId,
  organizationName,
  "app_installed",
  {
    app_id,
    app_name: installation.app?.name || "Unknown App",
    app_slug: installation.app?.slug || "unknown",
    app_type: installation.app?.app_type || "UNKNOWN",
    initial_status: status,
    has_config: !!config,
    config_keys: config ? Object.keys(config) : [],
  }
);

Event Properties

Always include these properties for consistency:
  • organization_id: For organization-specific analysis
  • organization_name: Human-readable organization identifier
  • Context-specific data relevant to the event

Best Practices

1. Use the Helper Function

Always use trackEventWithAuth or trackEvent instead of calling PostHog directly:
// ✅ Correct - uses helper with consistent identification
await trackEventWithAuth(userId, organizationId, organizationName, "event_name", properties);

// ❌ Incorrect - manual PostHog calls lack consistency
const posthog = PostHogClient();
posthog.capture({ distinctId: userId, event: "event_name" });

2. Include Organization Context

Always include organization information in events:
// ✅ Good - includes organization context via helper
await trackEventWithAuth(
  userId,
  organizationId,
  organizationName,
  "document_created",
  {
    document_type: "invoice",
    // ... other properties
  }
);

3. Event Naming Convention

Use consistent event naming:
  • Use snake_case for event names
  • Include the action and object: document_created, user_invited, payment_processed
  • Be specific but not overly verbose

4. Property Naming

  • Use snake_case for property names
  • Include units in property names: amount_cents, duration_seconds
  • Use consistent property names across similar events

5. Error Handling

The helper functions include error handling that won’t break your main operations:
// Tracking errors are logged but don't throw exceptions
await trackEventWithAuth(userId, organizationId, organizationName, "event_name", properties);
// Main operation continues even if tracking fails

Privacy and Compliance

Data Collection

  • Only collect necessary data for product improvement
  • Respect user privacy settings
  • Follow GDPR and other applicable regulations

Personal Data

  • Email addresses are collected for user identification
  • No sensitive business data should be sent to PostHog
  • Use hashed or anonymized identifiers when possible

Troubleshooting

Common Issues

  1. Events not appearing: Check that userId is available before sending events
  2. Duplicate users: Ensure consistent use of userId across client and server (this was fixed in the migration from actorId)
  3. Missing organization context: Verify that organizationId is included in event properties
  4. Events without user information: This happens when server-side events are fired before client-side identification. Always call posthog.identify() in server actions before posthog.capture()

Timing Issues

Problem: Server-side events fired during onboarding or user creation might not have proper user identification. Solution: Always identify users server-side before capturing events:
// ❌ Problem - event might be orphaned
posthog.capture({
  distinctId: userId,
  event: "organization_onboarding_completed",
  // ... properties
});

// ✅ Solution - use helper function which handles identification
await trackEventWithAuth(userId, organizationId, organizationName, "organization_onboarding_completed", properties);

Adding New Event Tracking

1. Use the Helper Function

import { trackEventWithAuth } from "@/app/lib/utils/posthog-tracking";

// In your server action
export async function yourAction() {
  try {
    const { organizationId, organizationName, userId } = await requireAuth();
    
    // Your business logic here
    const result = await someBusinessOperation();
    
    // Track the event
    await trackEventWithAuth(
      userId,
      organizationId,
      organizationName,
      "your_event_name",
      {
        // Event-specific properties
        property_name: "value",
        has_something: !!result.something,
        count: result.items.length,
      }
    );
    
    return { success: true, data: result };
  } catch (error) {
    // Handle error...
  }
}

2. Event Naming Guidelines

  • Use descriptive, consistent names
  • Follow the pattern: {object}_{action} (e.g., document_created, integration_activated)
  • Use snake_case for all event names and properties

3. Property Guidelines

  • Include relevant context (IDs, names, counts, boolean flags)
  • Use consistent property naming across similar events
  • Include organization context (handled automatically by helper)
  • Add timestamps when relevant (handled automatically by helper)