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(notpurchaseCompleteorPURCHASE_COMPLETE)video_watched(notvideoWatchedorvideo-watched)subscription_cancelled(notsubscription_canceled- pick one spelling)button_clicked(notclickorbuttonClick)
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
Recommended Properties
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, notduration) - 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.