or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

client-configuration.mderror-tracking.mdevent-tracking.mdexperimental.mdexpress-integration.mdfeature-flags.mdidentification.mdindex.mdsentry-integration.md
tile.json

experimental.mddocs/

Experimental APIs

IMPORTANT: The experimental APIs documented below are not currently accessible in the published package due to missing package.json exports configuration. While the types exist in the codebase, they cannot be imported as documented. Users cannot currently use these APIs.

Experimental APIs for advanced feature flag caching and distributed coordination. These APIs are designed for high-scale production environments where multiple server instances need to share feature flag definitions efficiently.

Note: This documentation is kept for reference but the APIs described are not usable in version 5.13.2.

Capabilities

FlagDefinitionCacheProvider Interface

Provider interface for implementing custom feature flag caching strategies. Use this to control when flag definitions are fetched and how they're cached across multiple workers.

/**
 * @experimental This API is experimental and may change in minor versions.
 *
 * Provider interface for caching feature flag definitions.
 *
 * Implementations control when flag definitions are fetched and how they're
 * cached (Redis, database, filesystem, etc.).
 *
 * All methods may throw errors - the poller catches and logs them gracefully,
 * ensuring cache provider errors never break flag evaluation.
 */
interface FlagDefinitionCacheProvider {
  /**
   * Retrieve cached flag definitions.
   *
   * Called when the poller is refreshing in-memory flag definitions.
   * If this returns undefined (or throws), the poller fetches fresh data
   * from PostHog API if no definitions are in memory.
   *
   * @returns cached definitions if available, undefined if cache is empty
   * @throws if an error occurs while accessing the cache (error will be logged)
   */
  getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> | FlagDefinitionCacheData | undefined;

  /**
   * Determines whether this instance should fetch new flag definitions.
   *
   * Use this to implement distributed coordination (e.g., via distributed locks)
   * to ensure only one instance fetches at a time in a multi-worker setup.
   *
   * @returns true if this instance should fetch, false to skip and read cache
   * @throws if coordination backend is unavailable (error will be logged, fetch continues)
   */
  shouldFetchFlagDefinitions(): Promise<boolean> | boolean;

  /**
   * Called after successfully receiving new flag definitions from PostHog.
   *
   * Store the definitions in your cache backend here. Called only after
   * a successful API response with valid flag data.
   *
   * @param data - The complete flag definition data from PostHog
   * @throws if storage backend is unavailable (error will be logged)
   */
  onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> | void;

  /**
   * Called when the PostHog client shuts down.
   *
   * Release any held locks, close connections, or clean up resources here.
   *
   * @returns Promise that resolves when cleanup is complete, or void for sync cleanup
   */
  shutdown(): Promise<void> | void;
}

FlagDefinitionCacheData Interface

Complete set of feature flag data needed for local evaluation.

/**
 * Represents the complete set of feature flag data needed for local evaluation.
 *
 * This includes flag definitions, group type mappings, and cohort property groups.
 */
interface FlagDefinitionCacheData {
  /** Array of feature flag definitions */
  flags: PostHogFeatureFlag[];
  /** Mapping of group type index to group name */
  groupTypeMapping: Record<string, string>;
  /** Cohort property groups for local evaluation */
  cohorts: Record<string, PropertyGroup>;
}

Import Path

Import experimental APIs from the dedicated experimental module:

import {
  FlagDefinitionCacheProvider,
  FlagDefinitionCacheData
} from 'posthog-node/experimental';

Redis Cache Implementation

Complete example of a Redis-based cache provider with distributed locking:

import { PostHog } from 'posthog-node';
import {
  FlagDefinitionCacheProvider,
  FlagDefinitionCacheData
} from 'posthog-node/experimental';
import Redis from 'ioredis';

class RedisFlagCache implements FlagDefinitionCacheProvider {
  private redis: Redis;
  private teamKey: string;
  private lockTTL: number = 60; // Lock expires after 60 seconds
  private cacheTTL: number = 300; // Cache expires after 5 minutes

  constructor(redis: Redis, teamKey: string) {
    this.redis = redis;
    this.teamKey = teamKey;
  }

  async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
    try {
      const cached = await this.redis.get(`posthog:flags:${this.teamKey}`);
      if (cached) {
        console.log('Using cached flag definitions');
        return JSON.parse(cached);
      }
      console.log('No cached flag definitions found');
      return undefined;
    } catch (error) {
      console.error('Error reading from cache:', error);
      return undefined;
    }
  }

  async shouldFetchFlagDefinitions(): Promise<boolean> {
    try {
      // Try to acquire distributed lock using SET NX (set if not exists)
      const lockKey = `posthog:flags:${this.teamKey}:lock`;
      const acquired = await this.redis.set(
        lockKey,
        '1',
        'EX', this.lockTTL,
        'NX'
      );

      if (acquired === 'OK') {
        console.log('Acquired lock, will fetch flag definitions');
        return true;
      } else {
        console.log('Another worker has the lock, will use cache');
        return false;
      }
    } catch (error) {
      console.error('Error acquiring lock:', error);
      // If we can't acquire lock, fetch anyway to be safe
      return true;
    }
  }

  async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
    try {
      // Store flag definitions in cache
      await this.redis.set(
        `posthog:flags:${this.teamKey}`,
        JSON.stringify(data),
        'EX',
        this.cacheTTL
      );

      // Release the lock
      await this.redis.del(`posthog:flags:${this.teamKey}:lock`);

      console.log('Cached flag definitions and released lock');
    } catch (error) {
      console.error('Error caching flag definitions:', error);
      // Try to release lock even if caching failed
      try {
        await this.redis.del(`posthog:flags:${this.teamKey}:lock`);
      } catch (lockError) {
        console.error('Error releasing lock:', lockError);
      }
    }
  }

  async shutdown(): Promise<void> {
    try {
      // Release any locks we might hold
      await this.redis.del(`posthog:flags:${this.teamKey}:lock`);
      console.log('Released locks on shutdown');
    } catch (error) {
      console.error('Error during shutdown:', error);
    }
  }
}

// Usage
const redis = new Redis({
  host: 'localhost',
  port: 6379
});

const cache = new RedisFlagCache(redis, 'your-team-key');

const client = new PostHog('phc_your_api_key', {
  host: 'https://app.posthog.com',
  flagDefinitionCacheProvider: cache
});

// When done
await client.shutdown();
await redis.quit();

Database Cache Implementation

Example using a SQL database for caching flag definitions:

import { PostHog } from 'posthog-node';
import {
  FlagDefinitionCacheProvider,
  FlagDefinitionCacheData
} from 'posthog-node/experimental';
import { Pool } from 'pg';

class PostgresFlagCache implements FlagDefinitionCacheProvider {
  private pool: Pool;
  private teamKey: string;
  private lockTTL: number = 60000; // 60 seconds in milliseconds
  private cacheTTL: number = 300000; // 5 minutes in milliseconds

  constructor(pool: Pool, teamKey: string) {
    this.pool = pool;
    this.teamKey = teamKey;
  }

  async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
    const client = await this.pool.connect();
    try {
      const result = await client.query(
        `SELECT data, updated_at
         FROM posthog_flag_cache
         WHERE team_key = $1
         AND updated_at > NOW() - INTERVAL '${this.cacheTTL} milliseconds'`,
        [this.teamKey]
      );

      if (result.rows.length > 0) {
        console.log('Using cached flag definitions from database');
        return result.rows[0].data;
      }

      console.log('No valid cached flag definitions found');
      return undefined;
    } catch (error) {
      console.error('Error reading from database cache:', error);
      return undefined;
    } finally {
      client.release();
    }
  }

  async shouldFetchFlagDefinitions(): Promise<boolean> {
    const client = await this.pool.connect();
    try {
      // Use PostgreSQL advisory lock for distributed coordination
      const result = await client.query(
        `SELECT pg_try_advisory_lock($1) as acquired`,
        [this.getLockId()]
      );

      const acquired = result.rows[0].acquired;

      if (acquired) {
        console.log('Acquired database lock, will fetch flag definitions');
        return true;
      } else {
        console.log('Another worker has the lock, will use cache');
        return false;
      }
    } catch (error) {
      console.error('Error acquiring database lock:', error);
      return true;
    } finally {
      client.release();
    }
  }

  async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
    const client = await this.pool.connect();
    try {
      // Upsert flag definitions
      await client.query(
        `INSERT INTO posthog_flag_cache (team_key, data, updated_at)
         VALUES ($1, $2, NOW())
         ON CONFLICT (team_key)
         DO UPDATE SET data = $2, updated_at = NOW()`,
        [this.teamKey, data]
      );

      // Release advisory lock
      await client.query(
        `SELECT pg_advisory_unlock($1)`,
        [this.getLockId()]
      );

      console.log('Cached flag definitions in database and released lock');
    } catch (error) {
      console.error('Error caching flag definitions:', error);
      // Try to release lock even if caching failed
      try {
        await client.query(
          `SELECT pg_advisory_unlock($1)`,
          [this.getLockId()]
        );
      } catch (lockError) {
        console.error('Error releasing lock:', lockError);
      }
    } finally {
      client.release();
    }
  }

  async shutdown(): Promise<void> {
    const client = await this.pool.connect();
    try {
      // Release any advisory locks
      await client.query(
        `SELECT pg_advisory_unlock_all()`
      );
      console.log('Released all locks on shutdown');
    } catch (error) {
      console.error('Error during shutdown:', error);
    } finally {
      client.release();
    }
  }

  private getLockId(): number {
    // Convert team key to a numeric lock ID
    let hash = 0;
    for (let i = 0; i < this.teamKey.length; i++) {
      hash = ((hash << 5) - hash) + this.teamKey.charCodeAt(i);
      hash = hash & hash; // Convert to 32bit integer
    }
    return Math.abs(hash);
  }
}

// Database schema
const schema = `
  CREATE TABLE IF NOT EXISTS posthog_flag_cache (
    team_key VARCHAR(255) PRIMARY KEY,
    data JSONB NOT NULL,
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
  );

  CREATE INDEX IF NOT EXISTS idx_posthog_flag_cache_updated_at
  ON posthog_flag_cache(updated_at);
`;

// Usage
const pool = new Pool({
  host: 'localhost',
  port: 5432,
  database: 'myapp',
  user: 'postgres',
  password: 'password'
});

const cache = new PostgresFlagCache(pool, 'your-team-key');

const client = new PostHog('phc_your_api_key', {
  host: 'https://app.posthog.com',
  flagDefinitionCacheProvider: cache
});

// When done
await client.shutdown();
await pool.end();

In-Memory Cache with File Backup

Lightweight implementation using in-memory cache with file system backup:

import { PostHog } from 'posthog-node';
import {
  FlagDefinitionCacheProvider,
  FlagDefinitionCacheData
} from 'posthog-node/experimental';
import fs from 'fs/promises';
import path from 'path';

class FileFlagCache implements FlagDefinitionCacheProvider {
  private teamKey: string;
  private cacheDir: string;
  private cachePath: string;
  private lockPath: string;
  private lockTTL: number = 60000; // 60 seconds
  private cacheTTL: number = 300000; // 5 minutes

  constructor(teamKey: string, cacheDir: string = '/tmp/posthog-cache') {
    this.teamKey = teamKey;
    this.cacheDir = cacheDir;
    this.cachePath = path.join(cacheDir, `flags-${teamKey}.json`);
    this.lockPath = path.join(cacheDir, `flags-${teamKey}.lock`);
  }

  async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
    try {
      const stats = await fs.stat(this.cachePath);
      const age = Date.now() - stats.mtimeMs;

      if (age < this.cacheTTL) {
        const content = await fs.readFile(this.cachePath, 'utf-8');
        console.log('Using cached flag definitions from file');
        return JSON.parse(content);
      } else {
        console.log('Cached file is too old');
        return undefined;
      }
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
        console.error('Error reading cache file:', error);
      }
      return undefined;
    }
  }

  async shouldFetchFlagDefinitions(): Promise<boolean> {
    try {
      // Ensure cache directory exists
      await fs.mkdir(this.cacheDir, { recursive: true });

      // Check if lock file exists and is recent
      try {
        const stats = await fs.stat(this.lockPath);
        const age = Date.now() - stats.mtimeMs;

        if (age < this.lockTTL) {
          console.log('Lock file exists and is recent, another worker is fetching');
          return false;
        } else {
          console.log('Lock file is stale, removing');
          await fs.unlink(this.lockPath);
        }
      } catch (error) {
        if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
          console.error('Error checking lock file:', error);
        }
      }

      // Try to create lock file
      try {
        await fs.writeFile(this.lockPath, Date.now().toString(), { flag: 'wx' });
        console.log('Acquired lock file, will fetch flag definitions');
        return true;
      } catch (error) {
        if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
          console.log('Another worker created lock file first');
          return false;
        }
        throw error;
      }
    } catch (error) {
      console.error('Error acquiring lock:', error);
      return true;
    }
  }

  async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
    try {
      // Ensure cache directory exists
      await fs.mkdir(this.cacheDir, { recursive: true });

      // Write cache file
      await fs.writeFile(this.cachePath, JSON.stringify(data), 'utf-8');

      // Remove lock file
      try {
        await fs.unlink(this.lockPath);
      } catch (error) {
        if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
          console.error('Error removing lock file:', error);
        }
      }

      console.log('Cached flag definitions to file and released lock');
    } catch (error) {
      console.error('Error caching flag definitions:', error);
    }
  }

  async shutdown(): Promise<void> {
    try {
      // Remove lock file if it exists
      await fs.unlink(this.lockPath);
      console.log('Removed lock file on shutdown');
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
        console.error('Error during shutdown:', error);
      }
    }
  }
}

// Usage
const cache = new FileFlagCache('your-team-key', '/tmp/posthog-cache');

const client = new PostHog('phc_your_api_key', {
  host: 'https://app.posthog.com',
  flagDefinitionCacheProvider: cache
});

// When done
await client.shutdown();

Distributed Coordination Patterns

Pattern 1: Leader Election

One worker fetches, others wait and use cache:

class LeaderElectionCache implements FlagDefinitionCacheProvider {
  private cache: Map<string, any> = new Map();
  private isLeader: boolean = false;

  async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
    return this.cache.get('flags');
  }

  async shouldFetchFlagDefinitions(): Promise<boolean> {
    // Only leader fetches
    return this.isLeader;
  }

  async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
    this.cache.set('flags', data);
    // Broadcast to other workers via pub/sub
    await this.broadcastUpdate(data);
  }

  async shutdown(): Promise<void> {
    this.isLeader = false;
  }

  private async broadcastUpdate(data: FlagDefinitionCacheData): Promise<void> {
    // Implement using Redis pub/sub, ZeroMQ, etc.
  }
}

Pattern 2: Time-Based Coordination

Workers take turns fetching based on time slots:

class TimeBasedCache implements FlagDefinitionCacheProvider {
  private workerId: number;
  private totalWorkers: number;
  private intervalSeconds: number;

  constructor(workerId: number, totalWorkers: number, intervalSeconds: number = 30) {
    this.workerId = workerId;
    this.totalWorkers = totalWorkers;
    this.intervalSeconds = intervalSeconds;
  }

  async shouldFetchFlagDefinitions(): Promise<boolean> {
    // Each worker gets a time slot
    const currentSlot = Math.floor(Date.now() / 1000 / this.intervalSeconds) % this.totalWorkers;
    return currentSlot === this.workerId;
  }

  // ... other methods
}

Pattern 3: Random Backoff

Workers randomly decide to fetch with exponential backoff:

class RandomBackoffCache implements FlagDefinitionCacheProvider {
  private lastFetchAttempt: number = 0;
  private backoffMs: number = 1000;

  async shouldFetchFlagDefinitions(): Promise<boolean> {
    const now = Date.now();
    const timeSinceLastAttempt = now - this.lastFetchAttempt;

    if (timeSinceLastAttempt < this.backoffMs) {
      return false;
    }

    // Random chance to fetch (10%)
    const shouldFetch = Math.random() < 0.1;

    if (shouldFetch) {
      this.lastFetchAttempt = now;
    }

    return shouldFetch;
  }

  async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
    // Reset backoff on success
    this.backoffMs = 1000;
    // ... store data
  }

  // ... other methods
}

Multi-Worker Environments

Kubernetes Deployment

import { PostHog } from 'posthog-node';
import {
  FlagDefinitionCacheProvider,
  FlagDefinitionCacheData
} from 'posthog-node/experimental';
import Redis from 'ioredis';

// Use Redis for coordination across pods
const redis = new Redis(process.env.REDIS_URL);

const cache = new RedisFlagCache(
  redis,
  process.env.POSTHOG_TEAM_KEY || 'default'
);

const client = new PostHog(process.env.POSTHOG_API_KEY!, {
  host: process.env.POSTHOG_HOST,
  flagDefinitionCacheProvider: cache
});

// Handle graceful shutdown
process.on('SIGTERM', async () => {
  console.log('Received SIGTERM, shutting down gracefully');
  await client.shutdown();
  await redis.quit();
  process.exit(0);
});

AWS Lambda

import { PostHog } from 'posthog-node';
import {
  FlagDefinitionCacheProvider,
  FlagDefinitionCacheData
} from 'posthog-node/experimental';
import { DynamoDB } from 'aws-sdk';

class DynamoDBFlagCache implements FlagDefinitionCacheProvider {
  private dynamodb: DynamoDB.DocumentClient;
  private tableName: string;
  private teamKey: string;

  constructor(tableName: string, teamKey: string) {
    this.dynamodb = new DynamoDB.DocumentClient();
    this.tableName = tableName;
    this.teamKey = teamKey;
  }

  async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
    try {
      const result = await this.dynamodb.get({
        TableName: this.tableName,
        Key: { teamKey: this.teamKey }
      }).promise();

      if (result.Item && result.Item.data) {
        const age = Date.now() - result.Item.updatedAt;
        if (age < 300000) { // 5 minutes
          return result.Item.data;
        }
      }

      return undefined;
    } catch (error) {
      console.error('Error reading from DynamoDB:', error);
      return undefined;
    }
  }

  async shouldFetchFlagDefinitions(): Promise<boolean> {
    // In Lambda, always let first invocation fetch
    // Subsequent invocations will use cache
    try {
      const result = await this.dynamodb.get({
        TableName: this.tableName,
        Key: { teamKey: this.teamKey }
      }).promise();

      return !result.Item || (Date.now() - result.Item.updatedAt > 60000);
    } catch (error) {
      return true;
    }
  }

  async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
    try {
      await this.dynamodb.put({
        TableName: this.tableName,
        Item: {
          teamKey: this.teamKey,
          data: data,
          updatedAt: Date.now()
        }
      }).promise();
    } catch (error) {
      console.error('Error writing to DynamoDB:', error);
    }
  }

  async shutdown(): Promise<void> {
    // No cleanup needed for DynamoDB
  }
}

// Lambda handler
let client: PostHog;

export const handler = async (event: any) => {
  if (!client) {
    const cache = new DynamoDBFlagCache(
      process.env.DYNAMODB_TABLE!,
      process.env.POSTHOG_TEAM_KEY!
    );

    client = new PostHog(process.env.POSTHOG_API_KEY!, {
      flagDefinitionCacheProvider: cache
    });
  }

  // Use feature flags
  const isEnabled = await client.isFeatureEnabled('my-flag', event.userId);

  return {
    statusCode: 200,
    body: JSON.stringify({ enabled: isEnabled })
  };
};

Worker Threads

import { PostHog } from 'posthog-node';
import {
  FlagDefinitionCacheProvider,
  FlagDefinitionCacheData
} from 'posthog-node/experimental';
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';

class SharedMemoryCache implements FlagDefinitionCacheProvider {
  private sharedData: SharedArrayBuffer;
  private lock: Int32Array;

  constructor(sharedBuffer: SharedArrayBuffer) {
    this.sharedData = sharedBuffer;
    this.lock = new Int32Array(sharedBuffer, 0, 1);
  }

  async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
    // Read from shared memory
    const view = new Uint8Array(this.sharedData, 4);
    const data = Buffer.from(view).toString('utf-8');
    if (data) {
      return JSON.parse(data);
    }
    return undefined;
  }

  async shouldFetchFlagDefinitions(): Promise<boolean> {
    // Try to acquire lock using atomic operations
    const acquired = Atomics.compareExchange(this.lock, 0, 0, 1) === 0;
    return acquired;
  }

  async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
    // Write to shared memory
    const json = JSON.stringify(data);
    const buffer = Buffer.from(json, 'utf-8');
    const view = new Uint8Array(this.sharedData, 4, buffer.length);
    view.set(buffer);

    // Release lock
    Atomics.store(this.lock, 0, 0);
  }

  async shutdown(): Promise<void> {
    // Release lock if held
    Atomics.store(this.lock, 0, 0);
  }
}

if (isMainThread) {
  // Main thread creates shared buffer
  const sharedBuffer = new SharedArrayBuffer(1024 * 1024); // 1MB

  for (let i = 0; i < 4; i++) {
    new Worker(__filename, {
      workerData: { sharedBuffer }
    });
  }
} else {
  // Worker thread uses shared cache
  const cache = new SharedMemoryCache(workerData.sharedBuffer);

  const client = new PostHog(process.env.POSTHOG_API_KEY!, {
    flagDefinitionCacheProvider: cache
  });
}

Best Practices

1. Always Implement Error Handling

Cache providers must never throw uncaught errors:

// Good: Handle all errors
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
  try {
    const data = await this.redis.get('flags');
    return data ? JSON.parse(data) : undefined;
  } catch (error) {
    console.error('Cache error:', error);
    return undefined; // Graceful fallback
  }
}

// Bad: Let errors propagate
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
  const data = await this.redis.get('flags'); // May throw
  return JSON.parse(data); // May throw
}

2. Use Appropriate TTLs

Balance freshness with API load:

// Good: Reasonable TTLs
const LOCK_TTL = 60; // 1 minute - lock expires if worker crashes
const CACHE_TTL = 300; // 5 minutes - fresh enough for most use cases

// Bad: Too short (high API load)
const LOCK_TTL = 5; // 5 seconds - too much lock contention
const CACHE_TTL = 10; // 10 seconds - too many fetches

// Bad: Too long (stale data)
const LOCK_TTL = 3600; // 1 hour - worker crash holds lock too long
const CACHE_TTL = 86400; // 24 hours - flag changes take too long to propagate

3. Always Release Locks

Release locks in finally blocks or on shutdown:

// Good: Always release
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
  try {
    await this.redis.set('flags', JSON.stringify(data));
  } finally {
    await this.redis.del('lock'); // Always release
  }
}

// Good: Release on shutdown
async shutdown(): Promise<void> {
  try {
    await this.redis.del('lock');
  } catch (error) {
    console.error('Error releasing lock:', error);
  }
}

4. Handle Stale Locks

Implement lock expiration to prevent deadlocks:

// Good: Locks expire automatically
await this.redis.set('lock', '1', 'EX', 60, 'NX'); // Expires in 60 seconds

// Good: Check lock age before respecting it
const lockAge = await this.getLockAge('lock');
if (lockAge > 60000) {
  await this.redis.del('lock'); // Remove stale lock
  return true; // Can fetch
}

5. Monitor Cache Performance

Track cache hit rates and fetch patterns:

class MonitoredCache implements FlagDefinitionCacheProvider {
  private hits: number = 0;
  private misses: number = 0;
  private fetches: number = 0;

  async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
    const data = await this.getFromCache();
    if (data) {
      this.hits++;
    } else {
      this.misses++;
    }
    return data;
  }

  async shouldFetchFlagDefinitions(): Promise<boolean> {
    const should = await this.acquireLock();
    if (should) {
      this.fetches++;
    }
    return should;
  }

  getMetrics() {
    return {
      hits: this.hits,
      misses: this.misses,
      fetches: this.fetches,
      hitRate: this.hits / (this.hits + this.misses)
    };
  }
}

6. Test Cache Failures

Verify your implementation handles cache failures gracefully:

describe('RedisFlagCache', () => {
  it('should handle Redis connection failure', async () => {
    const cache = new RedisFlagCache(disconnectedRedis, 'test');

    // Should not throw, should return undefined
    const data = await cache.getFlagDefinitions();
    expect(data).toBeUndefined();
  });

  it('should continue flag evaluation if cache fails', async () => {
    const cache = new RedisFlagCache(disconnectedRedis, 'test');
    const client = new PostHog('test_key', {
      flagDefinitionCacheProvider: cache
    });

    // Should fall back to direct API fetch
    const enabled = await client.isFeatureEnabled('flag', 'user');
    expect(enabled).toBeDefined();
  });
});

7. Use Compression for Large Caches

Compress flag definitions to reduce memory and network usage:

import zlib from 'zlib';
import { promisify } from 'util';

const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);

class CompressedRedisCache implements FlagDefinitionCacheProvider {
  async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
    const json = JSON.stringify(data);
    const compressed = await gzip(json);
    await this.redis.set('flags', compressed, 'EX', 300);
  }

  async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
    const compressed = await this.redis.getBuffer('flags');
    if (compressed) {
      const json = await gunzip(compressed);
      return JSON.parse(json.toString());
    }
    return undefined;
  }

  // ... other methods
}

8. Document Cache Behavior

Document TTLs, coordination strategy, and failure modes:

/**
 * Redis-based cache provider for feature flag definitions.
 *
 * Coordination: Distributed lock using Redis SET NX
 * Cache TTL: 5 minutes
 * Lock TTL: 60 seconds
 *
 * Failure modes:
 * - Redis unavailable: Falls back to direct API fetch
 * - Lock timeout: Lock expires automatically after 60s
 * - Stale cache: Expires after 5 minutes
 */
class RedisFlagCache implements FlagDefinitionCacheProvider {
  // ...
}

Troubleshooting

Cache Not Being Used

If the cache isn't being used:

  1. Verify the cache provider is passed to PostHog:
const client = new PostHog('key', {
  flagDefinitionCacheProvider: cache // Must be set
});
  1. Check getFlagDefinitions() returns data:
const data = await cache.getFlagDefinitions();
console.log('Cache data:', data); // Should not be undefined
  1. Verify cache is populated:
// Manually trigger a fetch
const flags = await client.getAllFlags('test-user');
// Now check cache
const cached = await cache.getFlagDefinitions();
console.log('Cached:', cached); // Should have data

Multiple Workers Fetching

If multiple workers are fetching simultaneously:

  1. Check shouldFetchFlagDefinitions() coordination:
// Test lock acquisition
const worker1 = await cache.shouldFetchFlagDefinitions();
const worker2 = await cache.shouldFetchFlagDefinitions();
console.log('Worker 1:', worker1); // Should be true
console.log('Worker 2:', worker2); // Should be false
  1. Verify lock release:
await cache.onFlagDefinitionsReceived(data);
// Lock should be released now
const canFetch = await cache.shouldFetchFlagDefinitions();
console.log('Can fetch:', canFetch); // Should be true eventually
  1. Check for stale locks:
// Ensure locks expire
setTimeout(async () => {
  const canFetch = await cache.shouldFetchFlagDefinitions();
  console.log('After TTL:', canFetch); // Should be true
}, LOCK_TTL + 1000);

Cache Not Updating

If cached flags become stale:

  1. Check cache TTL:
// Verify cache expiration
const oldData = await cache.getFlagDefinitions();
// Wait for TTL
await sleep(CACHE_TTL + 1000);
const newData = await cache.getFlagDefinitions();
console.log('Expired:', newData === undefined); // Should be true
  1. Verify onFlagDefinitionsReceived() is called:
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
  console.log('Caching new definitions:', data);
  await this.store(data);
}
  1. Check for storage errors:
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
  try {
    await this.store(data);
  } catch (error) {
    console.error('Failed to cache:', error); // Check for errors
    throw error; // Re-throw to see in logs
  }
}

Memory Leaks

If memory usage grows over time:

  1. Ensure shutdown() cleans up:
async shutdown(): Promise<void> {
  await this.redis.quit(); // Close connections
  this.cache.clear(); // Clear in-memory data
}
  1. Limit cache size:
class LimitedCache implements FlagDefinitionCacheProvider {
  private maxSize = 1024 * 1024; // 1MB

  async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
    const json = JSON.stringify(data);
    if (Buffer.byteLength(json) > this.maxSize) {
      console.warn('Cache data exceeds max size, skipping');
      return;
    }
    await this.store(data);
  }
}