PostHog Data Layer Setup Guide | OpsBlu Docs

PostHog Data Layer Setup Guide

Structure your event properties, user properties, and data taxonomy for scalable analytics in PostHog

Overview

A well-structured data layer is the foundation of reliable analytics. It defines what data you collect, how it's formatted, and how different systems interpret it. Unlike traditional tag management systems that require a formal data layer object, PostHog is flexible, but that doesn't mean you should skip planning.

Your data layer strategy determines:

  • What events to track and when
  • What properties to capture with each event
  • How to structure user and group attributes
  • Naming conventions and data types
  • How to maintain consistency across platforms

Event Taxonomy

Defining Your Event Structure

Event naming convention:

[object]_[action]

Examples:

  • purchase_completed (not purchaseComplete or PURCHASE_COMPLETE)
  • video_watched (not videoWatched or video-watched)
  • subscription_cancelled (not subscription_canceled - pick one spelling)
  • button_clicked (not click or buttonClick)

Core business events:

// User lifecycle
posthog.capture('user_signed_up', {
  signup_method: 'email',
  referral_source: 'google_ad',
  signup_page: '/pricing'
});

posthog.capture('user_logged_in', {
  login_method: 'password',
  login_page: '/login'
});

// E-commerce
posthog.capture('product_viewed', {
  product_id: 'SKU_123',
  product_name: 'Wireless Headphones',
  product_category: 'Electronics > Audio',
  price: 199.99,
  currency: 'USD'
});

posthog.capture('product_added_to_cart', {
  product_id: 'SKU_123',
  quantity: 1,
  cart_total: 199.99,
  cart_item_count: 1
});

posthog.capture('purchase_completed', {
  order_id: 'ORDER_12345',
  revenue: 199.99,
  tax: 16.00,
  shipping: 0,
  total: 215.99,
  currency: 'USD',
  payment_method: 'credit_card',
  item_count: 1
});

// Subscription
posthog.capture('subscription_created', {
  plan_name: 'Pro',
  plan_price: 29.99,
  billing_cycle: 'monthly',
  trial_days: 14
});

posthog.capture('subscription_upgraded', {
  old_plan: 'Basic',
  new_plan: 'Pro',
  price_difference: 20.00
});

Property Structure

Event properties best practices:

// Good structure
posthog.capture('video_watched', {
  video_id: 'vid_12345',
  video_title: 'Getting Started Tutorial',
  video_duration_seconds: 180,
  watch_duration_seconds: 165,
  completion_percent: 92,
  quality: '1080p',
  playback_speed: 1.0,
  is_fullscreen: true,
  video_category: 'Tutorial'
});

// Bad structure - inconsistent naming, missing context
posthog.capture('video_watched', {
  id: 'vid_12345',  // Too generic
  Title: 'Getting Started Tutorial',  // Wrong case
  duration: 180,  // Missing unit
  percent: 92,  // Missing context
  'video-quality': '1080p'  // Inconsistent delimiter
});

Use consistent data types:

// Consistent types
{
  revenue: 99.99,              // number
  item_count: 3,               // number
  is_first_purchase: true,     // boolean
  plan_name: 'Pro',            // string
  tags: ['analytics', 'tool'], // array
  signup_date: '2024-01-20'    // ISO date string
}

// Inconsistent types
{
  revenue: '99.99',         // Should be number
  item_count: '3',          // Should be number
  is_first_purchase: 'yes', // Should be boolean
  tags: 'analytics, tool'   // Should be array
}

User Properties

Person Properties Structure

Core user attributes:

posthog.identify('user_123', {
  // Demographics
  email: 'user@example.com',
  name: 'Jane Doe',
  company: 'Acme Corp',

  // Subscription
  plan: 'Pro',
  plan_price: 29.99,
  billing_cycle: 'monthly',
  subscription_status: 'active',

  // Lifecycle
  signup_date: '2024-01-15',
  first_purchase_date: '2024-01-20',
  last_login_date: '2024-03-15',

  // Engagement
  total_logins: 42,
  total_purchases: 5,
  lifetime_value: 149.95,

  // Context
  signup_source: 'google_ad',
  referral_code: 'FRIEND20',
  industry: 'Technology'
});

Update strategies:

// Set properties (overwrites existing)
posthog.people.set({
  plan: 'Enterprise',  // Updates plan
  last_login_date: new Date().toISOString()
});

// Set once (never overwrites)
posthog.people.set_once({
  first_login_date: '2024-01-15',  // Only sets if not already set
  signup_source: 'organic'
});

// Increment numeric values
posthog.people.increment({
  total_logins: 1,
  videos_watched: 1
});

// Append to arrays
posthog.people.append({
  purchase_history: 'ORDER_12345'
});

Group Properties (B2B/SaaS)

For B2B products, track company/organization-level data:

// Associate user with company
posthog.group('company', 'company_id_123');

// Set company properties
posthog.group('company', 'company_id_123', {
  name: 'Acme Corporation',
  plan: 'Enterprise',
  employee_count: 500,
  industry: 'Technology',
  signup_date: '2023-06-01',
  mrr: 5000,
  total_users: 50,
  feature_flags: ['advanced_reports', 'api_access'],
  account_manager: 'john@example.com'
});

// Now all events include company context
posthog.capture('feature_used', {
  feature_name: 'advanced_reports'
});
// Event automatically tagged with company properties

Multiple group types:

// User belongs to company and team
posthog.group('company', 'acme_corp');
posthog.group('team', 'engineering_team');

// Set properties for each group type
posthog.group('company', 'acme_corp', {
  plan: 'Enterprise',
  employee_count: 500
});

posthog.group('team', 'engineering_team', {
  team_size: 15,
  department: 'Engineering'
});

Data Layer Implementation Patterns

Client-Side Data Layer

Define global data layer:

// Initialize data layer
window.dataLayer = window.dataLayer || {
  user: {},
  page: {},
  product: {},
  event: {}
};

// Populate on page load
window.dataLayer.user = {
  id: 'user_123',
  email: 'user@example.com',
  plan: 'Pro',
  signup_date: '2024-01-15'
};

window.dataLayer.page = {
  title: document.title,
  url: window.location.href,
  path: window.location.pathname,
  referrer: document.referrer
};

// Initialize PostHog with data layer
posthog.init('YOUR_API_KEY', {
  loaded: (posthog) => {
    // Identify user
    if (window.dataLayer.user.id) {
      posthog.identify(
        window.dataLayer.user.id,
        window.dataLayer.user
      );
    }

    // Set super properties from page context
    posthog.register({
      page_title: window.dataLayer.page.title,
      page_url: window.dataLayer.page.url
    });
  }
});

// Track events using data layer
function trackEvent(eventName, eventProps = {}) {
  posthog.capture(eventName, {
    ...window.dataLayer.page,
    ...eventProps
  });
}

// Usage
trackEvent('button_clicked', {
  button_name: 'signup_cta',
  button_location: 'hero'
});

Server-Side Data Layer

Node.js example:

const { PostHog } = require('posthog-node');
const posthog = new PostHog('YOUR_API_KEY');

// Middleware to track page views
app.use((req, res, next) => {
  if (req.user) {
    // Track server-side page view with user context
    posthog.capture({
      distinctId: req.user.id,
      event: 'page_viewed',
      properties: {
        page_path: req.path,
        page_url: req.url,
        page_title: req.route?.name,
        user_plan: req.user.plan,
        user_signup_date: req.user.createdAt,
        referrer: req.get('Referrer'),
        user_agent: req.get('User-Agent')
      }
    });
  }
  next();
});

// Track business events
app.post('/api/subscriptions', async (req, res) => {
  const subscription = await createSubscription(req.body);

  posthog.capture({
    distinctId: req.user.id,
    event: 'subscription_created',
    properties: {
      plan_name: subscription.plan,
      plan_price: subscription.price,
      billing_cycle: subscription.billing_cycle,
      trial_days: subscription.trial_days,
      payment_method: subscription.payment_method
    }
  });

  res.json(subscription);
});

React Data Layer

Context-based approach:

// AnalyticsContext.js
import { createContext, useContext } from 'react';
import posthog from 'posthog-js';

const AnalyticsContext = createContext();

export function AnalyticsProvider({ children, user }) {
  const track = (event, properties = {}) => {
    posthog.capture(event, {
      // Include user context automatically
      user_plan: user?.plan,
      user_signup_date: user?.signupDate,
      ...properties
    });
  };

  return (
    <AnalyticsContext.Provider value={{ track }}>
      {children}
    </AnalyticsContext.Provider>
  );
}

export const useAnalytics = () => useContext(AnalyticsContext);

// Usage in components
function CheckoutButton() {
  const { track } = useAnalytics();

  const handleClick = () => {
    track('checkout_started', {
      cart_total: 99.99,
      item_count: 3
    });
  };

  return <button
}

Standardized Property Naming

User properties:

{
  email: 'user@example.com',
  name: 'Jane Doe',
  plan: 'Pro',  // or subscription_tier
  signup_date: '2024-01-15',  // ISO 8601 format
  first_purchase_date: '2024-01-20',
  total_logins: 42,
  total_purchases: 5,
  lifetime_value: 299.95
}

E-commerce properties:

{
  product_id: 'SKU_123',
  product_name: 'Wireless Headphones',
  product_category: 'Electronics > Audio',
  price: 199.99,
  currency: 'USD',
  quantity: 1,
  revenue: 199.99,  // Total revenue (price × quantity)
  order_id: 'ORDER_12345',
  payment_method: 'credit_card'
}

Content properties:

{
  content_id: 'article_456',
  content_title: 'Introduction to PostHog',
  content_type: 'blog_post',
  content_category: 'Tutorial',
  author: 'Jane Doe',
  publish_date: '2024-01-15',
  word_count: 2500,
  read_time_seconds: 420
}

Engagement properties:

{
  session_duration_seconds: 320,
  page_views: 5,
  scroll_depth_percent: 85,
  clicks: 12,
  form_submits: 1,
  errors_encountered: 0
}

Data Validation

Validate event structure:

// Event schema validator
function validateEvent(eventName, properties) {
  const schemas = {
    purchase_completed: {
      required: ['order_id', 'revenue', 'currency'],
      optional: ['tax', 'shipping', 'discount'],
      types: {
        order_id: 'string',
        revenue: 'number',
        currency: 'string',
        tax: 'number',
        shipping: 'number'
      }
    },
    subscription_created: {
      required: ['plan_name', 'plan_price', 'billing_cycle'],
      types: {
        plan_name: 'string',
        plan_price: 'number',
        billing_cycle: 'string'
      }
    }
  };

  const schema = schemas[eventName];
  if (!schema) return true;  // No schema defined

  // Check required properties
  for (const prop of schema.required) {
    if (!(prop in properties)) {
      console.error(`Missing required property: ${prop} for event: ${eventName}`);
      return false;
    }
  }

  // Check types
  for (const [prop, expectedType] of Object.entries(schema.types)) {
    if (prop in properties) {
      const actualType = typeof properties[prop];
      if (actualType !== expectedType) {
        console.error(`Invalid type for ${prop}: expected ${expectedType}, got ${actualType}`);
        return false;
      }
    }
  }

  return true;
}

// Use validator before tracking
function trackValidated(eventName, properties) {
  if (validateEvent(eventName, properties)) {
    posthog.capture(eventName, properties);
  }
}

// Usage
trackValidated('purchase_completed', {
  order_id: 'ORDER_123',
  revenue: 99.99,
  currency: 'USD'
});

Documentation Template

Maintain event catalog:

# Event Taxonomy

## purchase_completed
**When:** User completes a purchase
**Where:** Checkout confirmation page, server-side on payment success
**Properties:**
- `order_id` (string, required): Unique order identifier
- `revenue` (number, required): Total revenue before tax/shipping
- `tax` (number, optional): Tax amount
- `shipping` (number, optional): Shipping cost
- `total` (number, required): Final amount charged
- `currency` (string, required): ISO 4217 currency code (e.g., 'USD')
- `item_count` (number, required): Number of items purchased
- `payment_method` (string, required): Payment method used
- `is_first_purchase` (boolean, required): Whether this is user's first purchase

**Example:**
```javascript
posthog.capture('purchase_completed', {
  order_id: 'ORDER_12345',
  revenue: 99.99,
  tax: 8.00,
  shipping: 10.00,
  total: 117.99,
  currency: 'USD',
  item_count: 2,
  payment_method: 'credit_card',
  is_first_purchase: true
});

Owner: E-commerce team Last updated: 2024-03-15


## Validation and Testing Procedures

### Step 1: Validate Event Structure

**Create event validation tool:**
```javascript
// event-validator.js
class EventValidator {
  constructor() {
    this.schemas = {};
    this.errors = [];
  }

  // Define schema for an event
  defineSchema(eventName, schema) {
    this.schemas[eventName] = schema;
  }

  // Validate event against schema
  validate(eventName, properties) {
    const schema = this.schemas[eventName];

    if (!schema) {
      console.warn(`No schema defined for event: ${eventName}`);
      return true;
    }

    this.errors = [];

    // Check required properties
    if (schema.required) {
      schema.required.forEach(prop => {
        if (!(prop in properties)) {
          this.errors.push(`Missing required property: ${prop}`);
        }
      });
    }

    // Check property types
    if (schema.types) {
      Object.entries(schema.types).forEach(([prop, expectedType]) => {
        if (prop in properties) {
          const actualType = Array.isArray(properties[prop]) ? 'array' : typeof properties[prop];

          if (actualType !== expectedType) {
            this.errors.push(
              `Invalid type for "${prop}": expected ${expectedType}, got ${actualType}`
            );
          }
        }
      });
    }

    // Check value constraints
    if (schema.constraints) {
      Object.entries(schema.constraints).forEach(([prop, constraint]) => {
        if (prop in properties) {
          const value = properties[prop];

          if (constraint.min !== undefined && value < constraint.min) {
            this.errors.push(`"${prop}" value ${value} is below minimum ${constraint.min}`);
          }

          if (constraint.max !== undefined && value > constraint.max) {
            this.errors.push(`"${prop}" value ${value} exceeds maximum ${constraint.max}`);
          }

          if (constraint.enum && !constraint.enum.includes(value)) {
            this.errors.push(
              `"${prop}" value "${value}" not in allowed values: ${constraint.enum.join(', ')}`
            );
          }

          if (constraint.pattern && !constraint.pattern.test(value)) {
            this.errors.push(`"${prop}" value "${value}" doesn't match pattern ${constraint.pattern}`);
          }
        }
      });
    }

    if (this.errors.length > 0) {
      console.error(`Validation errors for event "${eventName}":`, this.errors);
      return false;
    }

    return true;
  }

  getErrors() {
    return this.errors;
  }
}

// Usage example
const validator = new EventValidator();

// Define purchase_completed schema
validator.defineSchema('purchase_completed', {
  required: ['order_id', 'revenue', 'currency'],
  types: {
    order_id: 'string',
    revenue: 'number',
    tax: 'number',
    shipping: 'number',
    total: 'number',
    currency: 'string',
    item_count: 'number',
    payment_method: 'string',
    is_first_purchase: 'boolean'
  },
  constraints: {
    revenue: { min: 0 },
    currency: { enum: ['USD', 'EUR', 'GBP', 'CAD'] },
    payment_method: { enum: ['credit_card', 'debit_card', 'paypal', 'apple_pay'] }
  }
});

// Validate before tracking
const eventProps = {
  order_id: 'ORDER_123',
  revenue: 99.99,
  currency: 'USD',
  item_count: 2,
  payment_method: 'credit_card',
  is_first_purchase: true
};

if (validator.validate('purchase_completed', eventProps)) {
  posthog.capture('purchase_completed', eventProps);
} else {
  console.error('Event validation failed:', validator.getErrors());
}

Step 2: Test Data Layer Integration

Client-side data layer test:

// test-data-layer.js
function testDataLayer() {
  const results = {
    dataLayerExists: !!window.dataLayer,
    posthogLoaded: typeof posthog !== 'undefined',
    userIdentified: false,
    superProperties: {},
    tests: []
  };

  // Test 1: Data layer structure
  if (window.dataLayer) {
    results.tests.push({
      name: 'Data layer structure',
      passed: !!(window.dataLayer.user && window.dataLayer.page),
      message: window.dataLayer.user && window.dataLayer.page
        ? 'Data layer has user and page objects'
        : 'Missing user or page objects'
    });
  }

  // Test 2: PostHog identification
  if (typeof posthog !== 'undefined') {
    results.userIdentified = !!posthog.get_distinct_id();
    results.tests.push({
      name: 'User identified',
      passed: results.userIdentified,
      message: results.userIdentified
        ? `User ID: ${posthog.get_distinct_id()}`
        : 'User not identified'
    });
  }

  // Test 3: Super properties
  if (typeof posthog !== 'undefined') {
    results.superProperties = posthog.get_property('$initial_referrer');
    results.tests.push({
      name: 'Super properties set',
      passed: !!posthog.get_property('$initial_referrer'),
      message: 'Initial referrer tracked'
    });
  }

  // Test 4: Event tracking works
  if (typeof posthog !== 'undefined') {
    try {
      posthog.capture('test_event', { test: true });
      results.tests.push({
        name: 'Event tracking',
        passed: true,
        message: 'Test event captured successfully'
      });
    } catch (e) {
      results.tests.push({
        name: 'Event tracking',
        passed: false,
        message: `Error: ${e.message}`
      });
    }
  }

  console.table(results.tests);
  return results;
}

// Run test
testDataLayer();

Step 3: Verify Property Consistency

Check naming consistency:

// property-consistency-checker.js
class PropertyConsistencyChecker {
  constructor() {
    this.capturedEvents = [];
    this.propertyNames = new Set();
    this.namingIssues = [];
  }

  // Intercept posthog.capture
  startMonitoring() {
    const originalCapture = posthog.capture;

    posthog.capture = (eventName, properties) => {
      // Store event
      this.capturedEvents.push({ eventName, properties, timestamp: Date.now() });

      // Check property names
      if (properties) {
        Object.keys(properties).forEach(prop => {
          this.propertyNames.add(prop);
          this.checkNamingConvention(prop);
        });
      }

      // Call original
      return originalCapture.call(posthog, eventName, properties);
    };

    console.log('Property consistency monitoring started');
  }

  checkNamingConvention(propertyName) {
    // Check for snake_case violations
    if (!/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/.test(propertyName)) {
      // Skip PostHog default properties (start with $)
      if (!propertyName.startsWith('$')) {
        this.namingIssues.push({
          property: propertyName,
          issue: 'Not in snake_case format',
          suggestion: this.toSnakeCase(propertyName)
        });
      }
    }

    // Check for missing units in duration/size properties
    const needsUnit = ['duration', 'size', 'length', 'width', 'height', 'time'];
    needsUnit.forEach(keyword => {
      if (propertyName.includes(keyword) &&
          !propertyName.includes('_seconds') &&
          !propertyName.includes('_ms') &&
          !propertyName.includes('_bytes') &&
          !propertyName.includes('_kb') &&
          !propertyName.includes('_mb') &&
          !propertyName.includes('_px') &&
          !propertyName.includes('_percent')) {
        this.namingIssues.push({
          property: propertyName,
          issue: 'Missing unit specification',
          suggestion: `${propertyName}_seconds or ${propertyName}_ms`
        });
      }
    });
  }

  toSnakeCase(str) {
    return str
      .replace(/([A-Z])/g, '_$1')
      .toLowerCase()
      .replace(/^_/, '')
      .replace(/-/g, '_');
  }

  getReport() {
    return {
      totalEvents: this.capturedEvents.length,
      uniqueProperties: this.propertyNames.size,
      namingIssues: this.namingIssues,
      allProperties: Array.from(this.propertyNames).sort()
    };
  }

  printReport() {
    const report = this.getReport();

    console.log('\n=== Property Consistency Report ===\n');
    console.log(`Total events captured: ${report.totalEvents}`);
    console.log(`Unique properties: ${report.uniqueProperties}\n`);

    if (report.namingIssues.length > 0) {
      console.warn(`Found ${report.namingIssues.length} naming issues:\n`);
      console.table(report.namingIssues);
    } else {
      console.log('No naming issues found!\n');
    }

    console.log('All properties:', report.allProperties);
  }
}

// Usage
const checker = new PropertyConsistencyChecker();
checker.startMonitoring();

// After using your app for a while
setTimeout(() => {
  checker.printReport();
}, 30000); // Check after 30 seconds

Step 4: Validate User Properties

Test user identification:

// test-user-properties.js
async function testUserProperties() {
  console.log('=== Testing User Properties ===\n');

  // Test 1: Identify user
  const userId = 'test_user_' + Date.now();
  const userProps = {
    email: 'test@example.com',
    name: 'Test User',
    plan: 'Pro',
    signup_date: new Date().toISOString()
  };

  posthog.identify(userId, userProps);
  console.log('User identified:', userId);

  // Wait for identification to process
  await new Promise(resolve => setTimeout(resolve, 1000));

  // Test 2: Verify distinct_id
  const distinctId = posthog.get_distinct_id();
  console.log('Distinct ID:', distinctId);
  console.assert(distinctId === userId, 'Distinct ID should match user ID');

  // Test 3: Check person properties via API (requires valid API key)
  // This would need server-side verification in production
  console.log('Person properties set:', userProps);

  // Test 4: Update properties
  posthog.people.set({
    last_login: new Date().toISOString(),
    login_count: 5
  });
  console.log('Properties updated');

  // Test 5: Set once (shouldn't overwrite)
  posthog.people.set_once({
    signup_date: 'should-not-overwrite',
    first_login: new Date().toISOString()
  });
  console.log('Set-once properties applied');

  // Test 6: Increment numeric properties
  posthog.people.increment({
    login_count: 1
  });
  console.log('Incremented login_count');

  console.log('\nAll user property tests completed');
}

// Run test
testUserProperties();

Step 5: Test Group Properties (B2B)

Validate group tracking:

// test-group-properties.js
function testGroupProperties() {
  console.log('=== Testing Group Properties ===\n');

  // Test 1: Set company group
  const companyId = 'company_' + Date.now();
  posthog.group('company', companyId);
  console.log('Company group set:', companyId);

  // Test 2: Set group properties
  const companyProps = {
    name: 'Acme Corp',
    plan: 'Enterprise',
    employee_count: 500,
    industry: 'Technology'
  };

  posthog.group('company', companyId, companyProps);
  console.log('Company properties set:', companyProps);

  // Test 3: Set team group
  const teamId = 'team_engineering';
  posthog.group('team', teamId, {
    name: 'Engineering',
    team_size: 15,
    department: 'Engineering'
  });
  console.log('Team group set:', teamId);

  // Test 4: Capture event (should include group context)
  posthog.capture('test_group_event', {
    feature: 'test'
  });
  console.log('Event captured with group context');

  console.log('\nAll group property tests completed');
  console.log('Check PostHog dashboard to verify groups appear correctly');
}

// Run test
testGroupProperties();

Step 6: Production Validation

Create monitoring script:

// production-monitoring.js
class DataLayerMonitor {
  constructor() {
    this.metrics = {
      eventsTracked: 0,
      validationErrors: 0,
      typeErrors: 0,
      missingProperties: 0
    };

    this.setupMonitoring();
  }

  setupMonitoring() {
    // Monitor PostHog events
    const originalCapture = posthog.capture;

    posthog.capture = (eventName, properties = {}) => {
      this.metrics.eventsTracked++;

      // Validate property types
      Object.entries(properties).forEach(([key, value]) => {
        if (value === undefined || value === null) {
          this.metrics.missingProperties++;
          console.warn(`Property "${key}" is ${value} in event "${eventName}"`);
        }

        // Check for common type mistakes
        if (key.includes('count') || key.includes('total') || key.includes('price')) {
          if (typeof value === 'string') {
            this.metrics.typeErrors++;
            console.error(`Property "${key}" should be number, got string: "${value}"`);
          }
        }

        if (key.includes('is_') || key.includes('has_')) {
          if (typeof value !== 'boolean') {
            this.metrics.typeErrors++;
            console.error(`Property "${key}" should be boolean, got ${typeof value}`);
          }
        }
      });

      // Call original
      return originalCapture.call(posthog, eventName, properties);
    };
  }

  getMetrics() {
    return {
      ...this.metrics,
      errorRate: (this.metrics.validationErrors / this.metrics.eventsTracked * 100).toFixed(2) + '%'
    };
  }

  logReport() {
    console.log('=== Data Layer Monitor Report ===');
    console.table(this.getMetrics());
  }
}

// Initialize in production
const monitor = new DataLayerMonitor();

// Log report every 5 minutes
setInterval(() => monitor.logReport(), 5 * 60 * 1000);

Troubleshooting

Common Issues and Solutions

Problem Symptoms Root Cause Solution
Properties not appearing in PostHog Events tracked but properties missing in dashboard Property names contain special characters or start with numbers Use snake_case with letters only; avoid special characters except underscore
Inconsistent property types Same property appears as string and number No validation before tracking Implement schema validation; ensure consistent data types across codebase
User properties not updating Changes to user don't reflect in PostHog Using wrong method (capture instead of people.set) Use posthog.people.set() for person properties, not event properties
Events missing required properties Incomplete data in PostHog No validation enforced Add event validation before posthog.capture(); define required properties
Nested objects not tracked Complex object properties not appearing PostHog flattens properties Flatten objects before tracking; use dot notation (e.g., user_address_city)
Date formats inconsistent Dates appear as different formats Multiple date format standards used Standardize on ISO 8601 format (2024-01-15T14:30:00Z)
PII accidentally tracked Sensitive data visible in PostHog No PII filtering implemented Add data scrubbing before tracking; use property blacklist
Property name typos Same property with different spellings Manual string typing Use constants for property names; implement autocomplete
Super properties not working Properties not added to all events register() called after events Call posthog.register() early in initialization
Group properties not showing B2B group data missing Groups not configured in PostHog project Enable group analytics in PostHog project settings first
Arrays not tracking correctly Array values appear as [object Object] Arrays not properly serialized Use JSON.stringify for complex arrays or track as separate properties
Duplicate events Same event tracked multiple times Multiple event listeners or component re-renders Add debouncing; check component lifecycle; use event delegation
Properties too long Property values truncated Exceeding PostHog's property value limit Limit property values to 65,535 characters; summarize long text
Numeric IDs tracked as numbers IDs appear as numbers instead of strings Type coercion Always convert IDs to strings: String(id)
Boolean values tracked as strings "true"/"false" instead of true/false String conversion before tracking Use actual boolean values, not strings
Currency values inconsistent Prices in different currencies not normalized No currency conversion or tagging Always include currency property; store in cents/smallest unit
Timezone issues Event timestamps don't match user's timezone Client timezone vs server timezone Use ISO 8601 with timezone; store in UTC
Events lost on page navigation Events not sent before page unload No event flushing before navigation Use posthog.flush() or capture_pageview: false with manual tracking
Data layer undefined window.dataLayer is undefined Data layer loaded after PostHog init Initialize data layer before PostHog; check script load order
React state causing stale properties Old property values tracked in events Closure capturing stale state Use functional updates or refs for current values

Debug Tools

Property inspector:

// Debug all properties sent to PostHog
function inspectPostHogProperties() {
  const originalCapture = posthog.capture;

  posthog.capture = function(eventName, properties) {
    console.group(`Event: ${eventName}`);
    console.log('Properties:', properties);
    console.table(properties);
    console.groupEnd();

    return originalCapture.call(this, eventName, properties);
  };

  console.log('PostHog property inspector enabled');
}

// Enable in development
if (process.env.NODE_ENV === 'development') {
  inspectPostHogProperties();
}

Schema drift detector:

// Detect when event schemas change
class SchemaDriftDetector {
  constructor() {
    this.knownSchemas = new Map();
  }

  checkDrift(eventName, properties) {
    const currentKeys = Object.keys(properties).sort();
    const knownKeys = this.knownSchemas.get(eventName);

    if (!knownKeys) {
      this.knownSchemas.set(eventName, currentKeys);
      return;
    }

    const added = currentKeys.filter(k => !knownKeys.includes(k));
    const removed = knownKeys.filter(k => !currentKeys.includes(k));

    if (added.length > 0 || removed.length > 0) {
      console.warn(`Schema drift detected for "${eventName}"`);
      if (added.length > 0) console.log('Added properties:', added);
      if (removed.length > 0) console.log('Removed properties:', removed);

      // Update schema
      this.knownSchemas.set(eventName, currentKeys);
    }
  }
}

const driftDetector = new SchemaDriftDetector();

// Use before tracking
posthog.capture = new Proxy(posthog.capture, {
  apply(target, thisArg, args) {
    const [eventName, properties] = args;
    driftDetector.checkDrift(eventName, properties || {});
    return target.apply(thisArg, args);
  }
});

Validation Checklist

Before deploying data layer to production:

  • Event naming convention documented and followed (snake_case)
  • All events have defined schemas with required properties
  • Property types validated before tracking
  • User properties tested and verified in PostHog dashboard
  • Group properties configured (for B2B products)
  • Super properties registered correctly
  • No PII tracked without proper handling
  • Date/time values use ISO 8601 format
  • Currency values include currency code
  • Numeric values tracked as numbers, not strings
  • Boolean values tracked as booleans, not strings
  • Arrays and objects properly serialized
  • Event validation implemented and tested
  • Property consistency checker run successfully
  • No special characters in property names
  • Property names under 50 characters
  • Monitoring and error tracking implemented
  • Documentation created for all events and properties
  • Team trained on data layer conventions

Best Practices

Do:

  • Use snake_case for consistency
  • Include units in property names (duration_seconds, not duration)
  • Use ISO 8601 for dates (2024-01-15T14:30:00Z)
  • Validate event structure before sending
  • Document every event and property
  • Keep property names under 50 characters
  • Use consistent data types

Don't:

  • Mix naming conventions (snake_case, camelCase, PascalCase)
  • Use abbreviations unless universally understood
  • Include PII without anonymization
  • Create redundant properties
  • Use nested objects (keep properties flat)
  • Change property names without versioning

Need help? Check PostHog best practices or troubleshooting guide.