-
Notifications
You must be signed in to change notification settings - Fork 41
feat(posthog): add Redis-backed flag definition cache for local evaluation #3384
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| /** | ||
| * Redis-backed cache for PostHog feature flag definitions. | ||
| * | ||
| * In a multi-instance deployment (e.g. multiple Next.js server processes), we want | ||
| * only ONE instance to poll PostHog for flag updates while all instances share the | ||
| * cached results, avoiding redundant API calls. | ||
| * | ||
| * This uses a distributed lock pattern: | ||
| * - One instance acquires the lock and fetches flag definitions | ||
| * - Others skip fetching and read from the shared Redis cache | ||
| * - The lock is released after storing data so the next poll cycle re-elects | ||
| * - If the fetching instance crashes, the lock TTL ensures another takes over | ||
| * | ||
| * When Redis is unavailable, all instances fall through to PostHog API calls | ||
| * (the same behaviour as before this cache was added). | ||
| * | ||
| * Reference: https://posthog.com/docs/feature-flags/local-evaluation/distributed-environments | ||
| */ | ||
|
|
||
| import type { | ||
| FlagDefinitionCacheProvider, | ||
| FlagDefinitionCacheData, | ||
| } from 'posthog-node/experimental'; | ||
| import { redisGet, redisSet, redisSetNX, redisDel } from '@/lib/redis'; | ||
| import { | ||
| POSTHOG_FLAG_DEFINITIONS_REDIS_KEY, | ||
| POSTHOG_FLAG_CACHE_LOCK_REDIS_KEY, | ||
| } from '@/lib/redis-keys'; | ||
|
|
||
| const LOCK_TTL_SECONDS = 90; // longer than the default 30s polling interval | ||
| const CACHE_TTL_SECONDS = 60 * 60 * 24; // 24 hours; flags are also held in memory | ||
|
|
||
| export function createPostHogFlagCache(): FlagDefinitionCacheProvider { | ||
| return { | ||
| async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> { | ||
| const cached = await redisGet(POSTHOG_FLAG_DEFINITIONS_REDIS_KEY); | ||
| return cached ? (JSON.parse(cached) as FlagDefinitionCacheData) : undefined; | ||
| }, | ||
|
|
||
| async shouldFetchFlagDefinitions(): Promise<boolean> { | ||
| // Try to acquire the lock. Returns false if Redis is unavailable (fail open: | ||
| // every instance will then fall back to fetching independently). | ||
| try { | ||
| return await redisSetNX(POSTHOG_FLAG_CACHE_LOCK_REDIS_KEY, '1', LOCK_TTL_SECONDS); | ||
| } catch { | ||
| // Redis unavailable – let this instance fetch so flag evaluation is not broken. | ||
| return true; | ||
| } | ||
| }, | ||
|
|
||
| async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> { | ||
| await redisSet(POSTHOG_FLAG_DEFINITIONS_REDIS_KEY, JSON.stringify(data), CACHE_TTL_SECONDS); | ||
| // Release the lock so the next poll cycle can re-elect a leader. | ||
| await redisDel(POSTHOG_FLAG_CACHE_LOCK_REDIS_KEY); | ||
| }, | ||
|
|
||
| async shutdown(): Promise<void> { | ||
| // Best-effort lock release on graceful shutdown; ignore errors. | ||
| try { | ||
| await redisDel(POSTHOG_FLAG_CACHE_LOCK_REDIS_KEY); | ||
| } catch { | ||
| // intentionally ignored | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,7 +1,10 @@ | ||||||
| import { getEnvVariable } from '@/lib/dotenvx'; | ||||||
| import { PostHog } from 'posthog-node'; | ||||||
| import type { FlagDefinitionCacheProvider } from 'posthog-node/experimental'; | ||||||
| import { createPostHogFlagCache } from '@/lib/posthog-flag-cache'; | ||||||
|
|
||||||
| let instance: PostHog | null = null; | ||||||
| let flagCache: FlagDefinitionCacheProvider | null = null; | ||||||
|
|
||||||
| export default function PostHogClient(): Pick< | ||||||
| PostHog, | ||||||
|
|
@@ -21,28 +24,40 @@ export default function PostHogClient(): Pick< | |||||
| alias: () => {}, | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| // Local evaluation requires a personal API key. When present, flag checks are | ||||||
| // evaluated in-process against cached flag definitions instead of making a | ||||||
| // network call to PostHog on every request. | ||||||
| const personalApiKey = process.env.POSTHOG_PERSONAL_API_KEY; | ||||||
|
|
||||||
| if (personalApiKey && !flagCache) { | ||||||
| // Create the Redis-backed cache for sharing flag definitions across processes. | ||||||
| // Falls back gracefully to per-instance in-memory state when Redis is absent. | ||||||
| flagCache = createPostHogFlagCache(); | ||||||
| } | ||||||
|
|
||||||
| // Single shared PostHog client for the process. | ||||||
| // Disabled outside production to avoid sending real events during tests/dev. | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Stale comment — The original code passed
Suggested change
|
||||||
| instance = new PostHog(isProduction ? key : key || 'disabled', { | ||||||
| instance = new PostHog(key, { | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: The original code used Consider an early guard: if (!key) {
// misconfigured — return stub to avoid silent event loss
return { capture: () => {}, isFeatureEnabled: async () => false, getFeatureFlag: async () => undefined, debug: () => {}, getFeatureFlagPayload: async () => undefined, alias: () => {} };
} |
||||||
| host: 'https://us.i.posthog.com', | ||||||
| flushAt: 1, | ||||||
| flushInterval: 0, | ||||||
| disabled: !isProduction, | ||||||
| ...(personalApiKey | ||||||
| ? { | ||||||
| personalApiKey, | ||||||
| enableLocalEvaluation: true, | ||||||
| flagDefinitionCacheProvider: flagCache ?? undefined, | ||||||
| } | ||||||
| : {}), | ||||||
| }); | ||||||
|
|
||||||
| // if (!isProduction) { | ||||||
| // // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||||
| // (instance as any).capture = function (...args: any[]) { | ||||||
| // console.log('POSTHOG CAPTURE', ...args); | ||||||
| // }; | ||||||
| // } | ||||||
|
|
||||||
| return instance; | ||||||
| } | ||||||
|
|
||||||
| export async function shutdownPosthog(): Promise<void> { | ||||||
| if (instance) { | ||||||
| await instance.shutdown(); | ||||||
| instance = null; | ||||||
| flagCache = null; | ||||||
| } | ||||||
| } | ||||||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING:
redisDelerrors propagate uncaught here, which may surface to the PostHog SDK's polling mechanism.If
redisSetat line 52 succeeds butredisDelthrows (e.g. Redis timeout), the lock remains held for the full 90s TTL even though the data was already written to Redis. All other instances will wait up to 90s before the next poll cycle can elect a new leader. This is a latency issue: flag definitions will be stale until the lock expires.Consider wrapping
redisDelin a try/catch here (similar to how it's wrapped inshutdown()) so errors are silently absorbed rather than surfacing to the SDK: