posthog-data-handling

SKILL.md

PostHog Data Handling

Overview

Manage analytics data privacy in PostHog. Covers property sanitization before event capture, user opt-out/consent management, data deletion for GDPR compliance, and configuring PostHog's built-in privacy controls.

Prerequisites

  • PostHog project (Cloud or self-hosted)
  • posthog-js and/or posthog-node SDKs
  • Understanding of GDPR data subject rights
  • Privacy policy covering analytics data

Instructions

Step 1: Configure Privacy-Safe Event Capture

import posthog from 'posthog-js';

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
  api_host: 'https://us.i.posthog.com',
  autocapture: false,          // Disable to control what's captured
  capture_pageview: true,
  capture_pageleave: true,
  mask_all_text: false,
  mask_all_element_attributes: false,

  // Sanitize properties before sending
  sanitize_properties: (properties, eventName) => {
    // Remove PII from all events
    delete properties['$ip'];
    delete properties['email'];

    // Redact URLs containing tokens
    if (properties['$current_url']) {
      properties['$current_url'] = properties['$current_url']
        .replace(/token=[^&]+/g, 'token=[REDACTED]')
        .replace(/key=[^&]+/g, 'key=[REDACTED]');
    }

    return properties;
  },

  // Respect Do Not Track
  respect_dnt: true,
  opt_out_capturing_by_default: false,
});

Step 2: Consent-Based Tracking

// Cookie consent integration
function handleConsentChange(consent: {
  analytics: boolean;
  marketing: boolean;
}) {
  if (consent.analytics) {
    posthog.opt_in_capturing();
  } else {
    posthog.opt_out_capturing();
    posthog.reset(); // Clear local data
  }
}

// Check consent before identifying users
function identifyWithConsent(
  userId: string,
  traits: Record<string, any>,
  hasConsent: boolean
) {
  if (!hasConsent) return;

  // Only send non-PII traits
  const safeTraits: Record<string, any> = {
    plan: traits.plan,
    signup_date: traits.signupDate,
    account_type: traits.accountType,
  };

  // Explicitly exclude PII
  // Do NOT send: email, name, phone, address
  posthog.identify(userId, safeTraits);
}

Step 3: GDPR Data Deletion

// Server-side: delete user data for GDPR requests
async function deleteUserData(distinctId: string) {
  const response = await fetch(
    `https://us.i.posthog.com/api/person/${distinctId}/delete/`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.POSTHOG_PERSONAL_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        delete_events: true, // Also delete all events from this user
      }),
    }
  );

  if (!response.ok) {
    throw new Error(`Failed to delete user data: ${response.status}`);
  }

  return { deletedUser: distinctId, status: 'completed' };
}

// Find person by property for deletion lookup
async function findPersonByEmail(email: string) {
  const response = await fetch(
    `https://us.i.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/persons/?properties=[{"key":"email","value":"${email}","type":"person"}]`,
    {
      headers: {
        Authorization: `Bearer ${process.env.POSTHOG_PERSONAL_API_KEY}`,
      },
    }
  );

  const data = await response.json();
  return data.results?.[0]?.distinct_ids?.[0];
}

Step 4: Property Filtering for Exports

// Filter sensitive properties from HogQL exports
async function safeExport(query: string) {
  const BLOCKED_PROPERTIES = ['$ip', 'email', 'phone', 'name', 'address'];

  const response = await fetch(
    `https://us.i.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/query/`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.POSTHOG_PERSONAL_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        query: { kind: 'HogQLQuery', query },
      }),
    }
  );

  const data = await response.json();

  // Strip blocked columns from results
  if (data.columns && data.results) {
    const blockedIndexes = data.columns
      .map((col: string, i: number) => BLOCKED_PROPERTIES.some(b => col.includes(b)) ? i : -1)
      .filter((i: number) => i >= 0);

    data.results = data.results.map((row: any[]) =>
      row.filter((_: any, i: number) => !blockedIndexes.includes(i))
    );
    data.columns = data.columns.filter((_: string, i: number) => !blockedIndexes.includes(i));
  }

  return data;
}

Error Handling

Issue Cause Solution
PII in events Autocapture sending form data Disable autocapture, use manual capture
Consent not respected opt_out not called Check consent state on every page load
Deletion failed Wrong distinct_id Look up person by email first
IP in events Not stripped Use sanitize_properties to remove $ip

Examples

GDPR Subject Access Request

async function handleSAR(email: string) {
  const distinctId = await findPersonByEmail(email);
  if (!distinctId) return { found: false };

  // Export their data (filtered)
  const data = await safeExport(
    `SELECT event, timestamp, properties FROM events WHERE distinct_id = '${distinctId}' LIMIT 1000`  # 1000: 1 second in ms
  );
  return { found: true, events: data.results.length };
}

Resources

Output

  • Configuration files or code changes applied to the project
  • Validation report confirming correct implementation
  • Summary of changes made and their rationale
Weekly Installs
16
GitHub Stars
1.6K
First Seen
Jan 30, 2026
Installed on
codex16
opencode15
antigravity15
kilo15
qwen-code15
github-copilot15