From 58a8572a2ae48d7cdfded2c4b5782fc4c2b9b03b Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 09:24:11 +0000 Subject: [PATCH] feat(posthog): add Redis-backed flag definition cache for local evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables PostHog local evaluation with a distributed Redis cache so that multiple Next.js server processes share a single copy of flag definitions instead of each making independent API calls on every polling interval. - Upgrade posthog-node 5.10.4 → 5.34.2 to get FlagDefinitionCacheProvider - Add posthog-flag-cache.ts: implements the cache with acquire-fetch-release distributed locking (SET NX) so only one process polls PostHog at a time - Add redisSetNX to redis.ts for atomic lock acquisition - Add POSTHOG_FLAG_DEFINITIONS_REDIS_KEY and POSTHOG_FLAG_CACHE_LOCK_REDIS_KEY to redis-keys.ts - Wire flagDefinitionCacheProvider + enableLocalEvaluation + personalApiKey into PostHogClient() when POSTHOG_PERSONAL_API_KEY is set - Document POSTHOG_PERSONAL_API_KEY in .env.local.example When Redis is absent or POSTHOG_PERSONAL_API_KEY is unset the client falls back to the previous behaviour (remote evaluation per request). --- .env.local.example | 4 ++ apps/web/package.json | 2 +- apps/web/src/lib/posthog-flag-cache.ts | 66 ++++++++++++++++++++++++++ apps/web/src/lib/posthog.ts | 33 +++++++++---- apps/web/src/lib/redis-keys.ts | 6 +++ apps/web/src/lib/redis.ts | 25 ++++++++++ pnpm-lock.yaml | 41 +++++++++++----- 7 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/lib/posthog-flag-cache.ts diff --git a/.env.local.example b/.env.local.example index 3dc26a141e..c95c9dc3ec 100644 --- a/.env.local.example +++ b/.env.local.example @@ -124,6 +124,10 @@ MILVUS_ADDRESS= MILVUS_TOKEN= # Analytics NEXT_PUBLIC_POSTHOG_KEY= +# PostHog personal API key — enables local evaluation of feature flags (no per-request API call). +# Obtain from https://us.posthog.com/settings/user-api-keys +# When set, REDIS_URL should also be configured to share flag definitions across server processes. +POSTHOG_PERSONAL_API_KEY= # Sentry (error tracking) SENTRY_ORG= SENTRY_PROJECT= diff --git a/apps/web/package.json b/apps/web/package.json index 9419a06182..801f2c4d4c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -141,7 +141,7 @@ "openai": "6.29.0", "p-limit": "catalog:", "posthog-js": "1.360.2", - "posthog-node": "5.10.4", + "posthog-node": "5.34.2", "react": "19.2.6", "react-countup": "6.5.3", "react-dom": "19.2.6", diff --git a/apps/web/src/lib/posthog-flag-cache.ts b/apps/web/src/lib/posthog-flag-cache.ts new file mode 100644 index 0000000000..52c394acb7 --- /dev/null +++ b/apps/web/src/lib/posthog-flag-cache.ts @@ -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 { + const cached = await redisGet(POSTHOG_FLAG_DEFINITIONS_REDIS_KEY); + return cached ? (JSON.parse(cached) as FlagDefinitionCacheData) : undefined; + }, + + async shouldFetchFlagDefinitions(): Promise { + // 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 { + 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 { + // Best-effort lock release on graceful shutdown; ignore errors. + try { + await redisDel(POSTHOG_FLAG_CACHE_LOCK_REDIS_KEY); + } catch { + // intentionally ignored + } + }, + }; +} diff --git a/apps/web/src/lib/posthog.ts b/apps/web/src/lib/posthog.ts index 0993a4cc48..715bbbc580 100644 --- a/apps/web/src/lib/posthog.ts +++ b/apps/web/src/lib/posthog.ts @@ -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,22 +24,33 @@ 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. - instance = new PostHog(isProduction ? key : key || 'disabled', { + instance = new PostHog(key, { 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; } @@ -44,5 +58,6 @@ export async function shutdownPosthog(): Promise { if (instance) { await instance.shutdown(); instance = null; + flagCache = null; } } diff --git a/apps/web/src/lib/redis-keys.ts b/apps/web/src/lib/redis-keys.ts index 65e72a22b5..c643c0ac29 100644 --- a/apps/web/src/lib/redis-keys.ts +++ b/apps/web/src/lib/redis-keys.ts @@ -61,3 +61,9 @@ export const modelExperimentRedisKey = (publicId: string) => export const gitLabOAuthCredentialsRedisKey = (credentialRef: string) => redisKey(`auth-credentials:gitlab:${credentialRef}`); + +/** Cached PostHog feature flag definitions for local evaluation (JSON blob). */ +export const POSTHOG_FLAG_DEFINITIONS_REDIS_KEY = redisKey('posthog:flags:definitions'); + +/** Distributed lock key used to elect a single leader for polling PostHog flag definitions. */ +export const POSTHOG_FLAG_CACHE_LOCK_REDIS_KEY = redisKey('posthog:flags:lock'); diff --git a/apps/web/src/lib/redis.ts b/apps/web/src/lib/redis.ts index eab8e1cff0..710540ae30 100644 --- a/apps/web/src/lib/redis.ts +++ b/apps/web/src/lib/redis.ts @@ -88,6 +88,31 @@ export async function redisSet( } } +/** + * Atomically sets a key only if it does not already exist (SET NX). + * Returns true if the key was set (lock acquired), false if the key already existed. + * Returns false if Redis is not configured (REDIS_URL unset). + */ +export async function redisSetNX( + key: RedisKey, + value: string, + ttlSeconds: number +): Promise { + const c = getOrCreateClient(); + if (!c) return false; + try { + await withTimeout(ensureConnected(c), CONNECT_TIMEOUT_MS); + const result = await withTimeout( + c.set(key, value, { NX: true, EX: ttlSeconds }), + COMMAND_TIMEOUT_MS + ); + return result === 'OK'; + } catch (err) { + captureException(err, { tags: { service: 'redis', operation: 'setnx' }, extra: { key } }); + throw err; + } +} + /** Returns false if Redis is not configured (REDIS_URL unset). */ export async function redisDel(key: RedisKey): Promise { const c = getOrCreateClient(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 859a2be95b..79d981e84d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -815,8 +815,8 @@ importers: specifier: 1.360.2 version: 1.360.2 posthog-node: - specifier: 5.10.4 - version: 5.10.4 + specifier: 5.34.2 + version: 5.34.2(rxjs@7.8.2) react: specifier: 19.2.6 version: 19.2.6 @@ -6360,12 +6360,15 @@ packages: '@posthog/core@1.23.4': resolution: {integrity: sha512-gSM1gnIuw5UOBUOTz0IhCTH8jOHoFr5rzSDb5m7fn9ofLHvz3boZT1L1f+bcuk+mvzNJfrJ3ByVQGKmUQnKQ8g==} - '@posthog/core@1.4.0': - resolution: {integrity: sha512-jmW8/I//YOHAfjzokqas+Qtc2T57Ux8d2uIJu7FLcMGxywckHsl6od59CD18jtUzKToQdjQhV6Y3429qj+KeNw==} + '@posthog/core@1.29.2': + resolution: {integrity: sha512-DYhR0Sl7pVdUXa+C9poCVjTj3D6SI9P7RLhIhr74YyHeHuCGL/MZsDEWcz3ul3qHDIhZU9myIUjID890QiQw+g==} '@posthog/types@1.360.2': resolution: {integrity: sha512-U48CbtmX5kETZvWjaJVlublSA1aLV99m71TQtgxWksBMXINS/3C7j+KqlMO6wH7SuaEZQnjaxh1KYGH4nRCaaA==} + '@posthog/types@1.373.5': + resolution: {integrity: sha512-K7STCnRG/WBE1q0BwEkIcrJB5OqECaymsQj6Hp4Ntvaek4dqHkZGfp6hxwIPqQPjlOXwidwPLo+XGsn+CoZUyw==} + '@prisma/instrumentation@7.2.0': resolution: {integrity: sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==} peerDependencies: @@ -7134,6 +7137,7 @@ packages: '@rocicorp/resolver@1.0.2': resolution: {integrity: sha512-TfjMTQp9cNNqNtHFfa+XHEGdA7NnmDRu+ZJH4YF3dso0Xk/b9DMhg/sl+b6CR4ThFZArXXDsG1j8Mwl34wcOZQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + deprecated: Use Promise.withResolvers instead '@rolldown/binding-android-arm64@1.0.0-rc.17': resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} @@ -14222,9 +14226,14 @@ packages: posthog-js@1.360.2: resolution: {integrity: sha512-/Wed0mOuRUfyEGT/BRQaokCqBlxrEceE7MDT9A00lU5tXo443/2Pg9ZiqN5sucUluZF47hwGORpYPoVUt32UFw==} - posthog-node@5.10.4: - resolution: {integrity: sha512-sy020/Q4mt18eCkVo/cGEJD+wgdxeg5DTgNUaA85awBCbuEP43BOdCTOOw/K2Tvgw2oZfag3PFyhkVuQ6nJfBg==} - engines: {node: '>=20'} + posthog-node@5.34.2: + resolution: {integrity: sha512-lGp7zyyvzNZqrto3CPq3nQzVn9ZUg5tApE8jNh12Cu8kOqT9KTABZ8kMgiybfzxVttSdE3m25RsBPLBHkxCWDg==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true preact-render-to-string@5.2.6: resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} @@ -18484,7 +18493,7 @@ snapshots: cjs-module-lexer: 1.4.3 esbuild: 0.27.4 miniflare: 4.20260508.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) wrangler: 4.90.1(@cloudflare/workers-types@4.20260511.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 transitivePeerDependencies: @@ -21545,10 +21554,14 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@posthog/core@1.4.0': {} + '@posthog/core@1.29.2': + dependencies: + '@posthog/types': 1.373.5 '@posthog/types@1.360.2': {} + '@posthog/types@1.373.5': {} + '@prisma/instrumentation@7.2.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -24928,7 +24941,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/expect@3.2.4': dependencies: @@ -25034,7 +25047,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/utils@3.2.4': dependencies: @@ -31452,9 +31465,11 @@ snapshots: query-selector-shadow-dom: 1.0.1 web-vitals: 5.1.0 - posthog-node@5.10.4: + posthog-node@5.34.2(rxjs@7.8.2): dependencies: - '@posthog/core': 1.4.0 + '@posthog/core': 1.29.2 + optionalDependencies: + rxjs: 7.8.2 preact-render-to-string@5.2.6(preact@10.28.4): dependencies: