From d35c037f6dcfdd1da8aa6b3fbcfdb2274b02c7cc Mon Sep 17 00:00:00 2001 From: Jaap Elst Date: Wed, 10 Jun 2026 20:17:07 +0200 Subject: [PATCH 1/2] feat(feature-flags) local evaluation psr 16 caching --- composer.json | 7 +- composer.lock | 712 +++++++++++++++++++++++++++++++++- lib/Client.php | 133 ++++++- lib/PostHog.php | 3 + test/ArrayCache.php | 103 +++++ test/FeatureFlagCacheTest.php | 179 +++++++++ test/InvalidCacheKey.php | 12 + 7 files changed, 1146 insertions(+), 3 deletions(-) create mode 100644 test/ArrayCache.php create mode 100644 test/FeatureFlagCacheTest.php create mode 100644 test/InvalidCacheKey.php diff --git a/composer.json b/composer.json index bbe15b8..424fb9f 100644 --- a/composer.json +++ b/composer.json @@ -16,11 +16,16 @@ "require": { "ext-json": "*", "php": ">=8.2", + "psr/simple-cache": "^1.0|^2.0|^3.0", "symfony/clock": "^6.2|^7.0|^8.0" }, "require-dev": { "phpunit/phpunit": "^11.0", - "squizlabs/php_codesniffer": "^3.7" + "squizlabs/php_codesniffer": "^3.7", + "symfony/cache": "^6.0|^7.0|^8.0" + }, + "suggest": { + "psr/simple-cache-implementation": "A PSR-16 cache (e.g. symfony/cache) to share local feature flag definitions across requests" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 25dfc87..20be62b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fa6b8c376c78c05b1498f4efb41a242a", + "content-hash": "6e26be80d47f7eeaf92821254b514c28", "packages": [ { "name": "psr/clock", @@ -54,6 +54,57 @@ }, "time": "2022-11-25T14:36:26+00:00" }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, { "name": "symfony/clock", "version": "v7.4.8", @@ -907,6 +958,158 @@ ], "time": "2026-02-18T12:37:06+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, { "name": "sebastian/cli-parser", "version": "3.0.2", @@ -2024,6 +2227,513 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/cache", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", + "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^8.1" + }, + "conflict": { + "ext-redis": "<6.1", + "ext-relay": "<0.12.1" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "225e8a254166bd3442e370c6f50145465db63831" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", + "reference": "225e8a254166bd3442e370c6f50145465db63831", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-05T15:33:14+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/polyfill-deepclone", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "c1b95c370cb2ee4ee221f0a317f5ae5dfae9a42e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/c1b95c370cb2ee4ee221f0a317f5ae5dfae9a42e", + "reference": "c1b95c370cb2ee4ee221f0a317f5ae5dfae9a42e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\DeepClone\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the deepclone extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-08T20:10:26+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.37" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "deep-clone", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "theseer/tokenizer", "version": "1.3.1", diff --git a/lib/Client.php b/lib/Client.php index ba2c8ab..03b5520 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -8,6 +8,8 @@ use PostHog\Consumer\LibCurl; use PostHog\Consumer\NoOp; use PostHog\Consumer\Socket; +use Psr\SimpleCache\CacheInterface; +use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; use Symfony\Component\Clock\Clock; /** @@ -82,6 +84,28 @@ class Client implements FeatureFlagEvaluationsHost */ private $flagsEtag; + /** + * @var CacheInterface|null Optional PSR-16 cache for sharing flag definitions across requests + */ + private ?CacheInterface $featureFlagsCache; + + /** + * @var int Freshness window in seconds; within it, cached definitions are served without an + * HTTP refetch. Past it, the SDK tries to refresh but keeps serving the stale entry. + */ + private int $featureFlagsCacheTtl; + + /** + * @var int How long (seconds) the cached definitions are retained as a stale fallback, used to + * keep evaluating locally when PostHog is unreachable. Defaults to 7 days. + */ + private int $featureFlagsCacheStaleTtl; + + /** + * @var string Namespaced cache key for this client's feature flag definitions + */ + private string $featureFlagsCacheKey; + /** * @var bool */ @@ -113,6 +137,9 @@ class Client implements FeatureFlagEvaluationsHost * timeout?: int, * verify_batch_events_request?: bool, * feature_flag_request_timeout_ms?: int, + * feature_flags_cache?: \Psr\SimpleCache\CacheInterface, + * feature_flags_cache_ttl?: int, + * feature_flags_cache_stale_ttl?: int, * maximum_backoff_duration?: int, * consumer?: 'socket'|'file'|'fork_curl'|'lib_curl'|'noop', * debug?: bool, @@ -172,6 +199,12 @@ public function __construct( $this->distinctIdsFeatureFlagsReported = new SizeLimitedHash(self::SIZE_LIMIT); $this->flagsEtag = null; + $cache = $options['feature_flags_cache'] ?? null; + $this->featureFlagsCache = $cache instanceof CacheInterface ? $cache : null; + $this->featureFlagsCacheTtl = (int) ($options['feature_flags_cache_ttl'] ?? 30); + $this->featureFlagsCacheStaleTtl = (int) ($options['feature_flags_cache_stale_ttl'] ?? 604800); + $this->featureFlagsCacheKey = 'posthog:flags:' . sha1(($this->options['host'] ?? '') . ':' . $this->apiKey); + if ($this->enabled) { ExceptionCapture::configure($this, $options['error_tracking'] ?? []); } @@ -1109,15 +1142,46 @@ private function fetchFlagsResponse( /** * Load local feature flag definitions using the configured personal API key. * + * When a PSR-16 cache is configured (via the `feature_flags_cache` option), definitions are + * served from the cache within the freshness window (`feature_flags_cache_ttl`), skipping the + * HTTP round-trip. Past that window the SDK refetches, but the entry is retained as a stale + * fallback for `feature_flags_cache_stale_ttl` so local evaluation keeps working when PostHog + * is unreachable. Pass $force to bypass the freshness window and refetch immediately. + * + * @param bool $force Bypass the freshness window and refetch definitions from the API. * @return void * @throws Exception */ - public function loadFlags() + public function loadFlags(bool $force = false) { if (!$this->enabled) { return; } + // Load whatever is in the shared PSR-16 cache. If the entry is still fresh we serve it and + // skip the HTTP round-trip; if it is stale we keep it in memory as a fallback (so we can + // still evaluate locally if the refetch below fails) and restore its ETag for a cheap 304. + if ($this->featureFlagsCache !== null) { + try { + $cached = $this->featureFlagsCache->get($this->featureFlagsCacheKey); + } catch (CacheInvalidArgumentException $e) { + $cached = null; + } + if (is_array($cached)) { + $this->hydrateFlags($cached); + $this->flagsEtag = $cached['etag'] ?? null; + + $fetchedAt = (int) ($cached['fetched_at'] ?? 0); + $age = Clock::get()->now()->getTimestamp() - $fetchedAt; + if (!$force && $age < $this->featureFlagsCacheTtl) { + if ($this->debug) { + error_log("[PostHog][Client] Serving fresh feature flag definitions from cache"); + } + return; + } + } + } + $response = $this->localFlags(); // Handle 304 Not Modified - flags haven't changed, skip processing. @@ -1131,6 +1195,8 @@ public function loadFlags() if ($this->debug) { error_log("[PostHog][Client] Flags not modified (304), using cached data"); } + // Refresh the cache TTL so the unchanged definitions remain served from cache. + $this->storeFlagsInCache(); return; } @@ -1153,6 +1219,20 @@ public function loadFlags() // the cached flag data. A null ETag means the server doesn't support caching. $this->flagsEtag = $response->getEtag(); + $this->hydrateFlags($payload); + + $this->storeFlagsInCache(); + } + + /** + * Populate in-memory flag definitions from a definitions payload (from the API or cache) and + * rebuild the by-key lookup used for dependency resolution. + * + * @param array $payload + * @return void + */ + private function hydrateFlags(array $payload): void + { $this->featureFlags = $payload['flags'] ?? []; $this->groupTypeMapping = $payload['group_type_mapping'] ?? []; $this->cohorts = $payload['cohorts'] ?? []; @@ -1164,6 +1244,57 @@ public function loadFlags() } } + /** + * Persist the current flag definitions (plus ETag) to the configured PSR-16 cache, if any. A + * cache failure must never break evaluation, so write errors are swallowed (logged in debug). + * + * @return void + */ + private function storeFlagsInCache(): void + { + if ($this->featureFlagsCache === null) { + return; + } + + try { + $this->featureFlagsCache->set( + $this->featureFlagsCacheKey, + [ + 'flags' => $this->featureFlags, + 'group_type_mapping' => $this->groupTypeMapping, + 'cohorts' => $this->cohorts, + 'etag' => $this->flagsEtag, + 'fetched_at' => Clock::get()->now()->getTimestamp(), + ], + $this->featureFlagsCacheStaleTtl + ); + } catch (CacheInvalidArgumentException $e) { + if ($this->debug) { + error_log("[PostHog][Client] Failed to cache feature flag definitions: " . $e->getMessage()); + } + } + } + + /** + * Remove the cached feature flag definitions, forcing the next loadFlags() to refetch. + * + * @return void + */ + public function clearFeatureFlagCache(): void + { + if ($this->featureFlagsCache === null) { + return; + } + + try { + $this->featureFlagsCache->delete($this->featureFlagsCacheKey); + } catch (CacheInvalidArgumentException $e) { + if ($this->debug) { + error_log("[PostHog][Client] Failed to clear feature flag cache: " . $e->getMessage()); + } + } + } + /** * Fetch local feature flag definitions from the PostHog API. diff --git a/lib/PostHog.php b/lib/PostHog.php index 938111b..39df1b9 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -29,6 +29,9 @@ class PostHog * timeout?: int, * verify_batch_events_request?: bool, * feature_flag_request_timeout_ms?: int, + * feature_flags_cache?: \Psr\SimpleCache\CacheInterface, + * feature_flags_cache_ttl?: int, + * feature_flags_cache_stale_ttl?: int, * maximum_backoff_duration?: int, * consumer?: 'socket'|'file'|'fork_curl'|'lib_curl'|'noop', * debug?: bool, diff --git a/test/ArrayCache.php b/test/ArrayCache.php new file mode 100644 index 0000000..5823499 --- /dev/null +++ b/test/ArrayCache.php @@ -0,0 +1,103 @@ + */ + private array $store = []; + + public bool $throwOnAccess = false; + + private function now(): int + { + return Clock::get()->now()->getTimestamp(); + } + + private function guard(): void + { + if ($this->throwOnAccess) { + throw new InvalidCacheKey('simulated cache failure'); + } + } + + public function get($key, $default = null): mixed + { + $this->guard(); + if (!array_key_exists($key, $this->store)) { + return $default; + } + $entry = $this->store[$key]; + if ($entry['expiresAt'] !== null && $this->now() >= $entry['expiresAt']) { + unset($this->store[$key]); + return $default; + } + return $entry['value']; + } + + public function set($key, $value, $ttl = null): bool + { + $this->guard(); + $expiresAt = null; + if (is_int($ttl)) { + $expiresAt = $this->now() + $ttl; + } elseif ($ttl instanceof \DateInterval) { + $expiresAt = $this->now() + ((new \DateTimeImmutable('@0'))->add($ttl)->getTimestamp()); + } + $this->store[$key] = ['value' => $value, 'expiresAt' => $expiresAt]; + return true; + } + + public function delete($key): bool + { + unset($this->store[$key]); + return true; + } + + public function clear(): bool + { + $this->store = []; + return true; + } + + public function getMultiple($keys, $default = null): iterable + { + $result = []; + foreach ($keys as $key) { + $result[$key] = $this->get($key, $default); + } + return $result; + } + + public function setMultiple($values, $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set($key, $value, $ttl); + } + return true; + } + + public function deleteMultiple($keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + return true; + } + + public function has($key): bool + { + $this->guard(); + return $this->get($key, $this) !== $this; + } +} diff --git a/test/FeatureFlagCacheTest.php b/test/FeatureFlagCacheTest.php new file mode 100644 index 0000000..90204ad --- /dev/null +++ b/test/FeatureFlagCacheTest.php @@ -0,0 +1,179 @@ +assertTrue(empty($errorMessages), "Error logs are not empty: " . implode("\n", $errorMessages)); + } + + private function makeHttpClient(int $responseCode = 200): MockedHttpClient + { + return new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_REQUEST, + flagEndpointEtag: '"etag-v1"', + flagEndpointResponseCode: $responseCode + ); + } + + private function definitionsCallCount(MockedHttpClient $http): int + { + $calls = $http->calls ?? []; + return count(array_filter($calls, fn($c) => str_starts_with($c['path'], '/flags/definitions'))); + } + + public function testFirstClientFetchesAndPopulatesCache(): void + { + $cache = new ArrayCache(); + $http = $this->makeHttpClient(); + + $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http) { + $client = new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http, "test"); + $this->assertCount(1, $client->featureFlags); + $this->assertEquals('person-flag', $client->featureFlags[0]['key']); + }); + + // Exactly one definitions fetch populated the cache (cache contents verified via the + // second-client test, which restores the ETag from this entry). + $this->assertEquals(1, $this->definitionsCallCount($http)); + $this->checkEmptyErrorLogs(); + } + + public function testSecondClientServesFromCacheWithoutFetching(): void + { + $cache = new ArrayCache(); + $http1 = $this->makeHttpClient(); + $http2 = $this->makeHttpClient(); + + $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { + new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http1, "test"); + }); + + // Second client a few seconds later (within the 30s freshness window): no HTTP fetch. + $client2 = $this->executeAtFrozenDateTime( + new \DateTimeImmutable('2026-06-10 12:00:05'), + fn() => new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http2, "test") + ); + + $this->assertEquals(0, $this->definitionsCallCount($http2), "Second client should not hit the API"); + $this->assertCount(1, $client2->featureFlags); + $this->assertEquals('person-flag', $client2->featureFlags[0]['key']); + $this->assertEquals('"etag-v1"', $client2->getFlagsEtag()); + $this->checkEmptyErrorLogs(); + } + + public function testRefetchesAfterFreshnessWindowExpires(): void + { + $cache = new ArrayCache(); + $http1 = $this->makeHttpClient(); + $http2 = $this->makeHttpClient(); + + $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { + new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http1, "test"); + }); + + // 31s later — past the 30s freshness window: the client refetches. + $this->executeAtFrozenDateTime( + new \DateTimeImmutable('2026-06-10 12:00:31'), + fn() => new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http2, "test") + ); + + $this->assertEquals(1, $this->definitionsCallCount($http2), "Stale entry should trigger a refetch"); + $this->checkEmptyErrorLogs(); + } + + public function testForceBypassesFreshCache(): void + { + $cache = new ArrayCache(); + $http1 = $this->makeHttpClient(); + $http2 = $this->makeHttpClient(); + + $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { + new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http1, "test"); + }); + + $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:05'), function () use ($cache, $http2) { + // Construction serves from fresh cache (no fetch), then force refetches. + $client2 = new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http2, "test"); + $this->assertEquals(0, $this->definitionsCallCount($http2)); + $client2->loadFlags(true); + $this->assertEquals(1, $this->definitionsCallCount($http2)); + }); + + $this->checkEmptyErrorLogs(); + } + + public function testServesStaleCacheWhenApiIsDown(): void + { + $cache = new ArrayCache(); + $http1 = $this->makeHttpClient(); + $httpDown = $this->makeHttpClient(500); // API returns 500 + + $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { + new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http1, "test"); + }); + + // Past freshness window, API is down: definitions are still served from the stale cache. + $client2 = $this->executeAtFrozenDateTime( + new \DateTimeImmutable('2026-06-10 12:01:00'), + fn() => new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $httpDown, "test") + ); + + $this->assertEquals(1, $this->definitionsCallCount($httpDown), "It should attempt a refetch"); + // ...but evaluation keeps working from the stale entry. + $this->assertCount(1, $client2->featureFlags); + $this->assertEquals('person-flag', $client2->featureFlags[0]['key']); + } + + public function testFallsBackToHttpWhenCacheThrows(): void + { + $cache = new ArrayCache(); + $cache->throwOnAccess = true; + $http = $this->makeHttpClient(); + + $client = $this->executeAtFrozenDateTime( + new \DateTimeImmutable('2026-06-10 12:00:00'), + fn() => new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http, "test") + ); + + // Cache get/set both throw; the client must still load definitions over HTTP, no fatal. + $this->assertEquals(1, $this->definitionsCallCount($http)); + $this->assertCount(1, $client->featureFlags); + $this->assertEquals('person-flag', $client->featureFlags[0]['key']); + $this->checkEmptyErrorLogs(); + } + + public function testNoCacheConfiguredKeepsExistingBehavior(): void + { + $http = $this->makeHttpClient(); + $client = new Client(self::FAKE_API_KEY, [], $http, "test"); + + $this->assertEquals(1, $this->definitionsCallCount($http)); + $this->assertCount(1, $client->featureFlags); + $client->loadFlags(); + $this->assertEquals(2, $this->definitionsCallCount($http), "Without a cache every loadFlags hits the API"); + $this->checkEmptyErrorLogs(); + } +} diff --git a/test/InvalidCacheKey.php b/test/InvalidCacheKey.php new file mode 100644 index 0000000..4c492f2 --- /dev/null +++ b/test/InvalidCacheKey.php @@ -0,0 +1,12 @@ + Date: Wed, 10 Jun 2026 20:32:17 +0200 Subject: [PATCH 2/2] fix error catch --- lib/Client.php | 10 +++++----- lib/PostHog.php | 3 ++- test/ArrayCache.php | 6 ++++-- test/FeatureFlagCacheTest.php | 21 +++++++++++---------- test/InvalidCacheKey.php | 3 ++- 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index 03b5520..13cddeb 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -9,8 +9,8 @@ use PostHog\Consumer\NoOp; use PostHog\Consumer\Socket; use Psr\SimpleCache\CacheInterface; -use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; use Symfony\Component\Clock\Clock; +use Throwable; /** * PostHog PHP SDK client for event capture, user identification, feature flags, and error tracking. @@ -137,7 +137,7 @@ class Client implements FeatureFlagEvaluationsHost * timeout?: int, * verify_batch_events_request?: bool, * feature_flag_request_timeout_ms?: int, - * feature_flags_cache?: \Psr\SimpleCache\CacheInterface, + * feature_flags_cache?: CacheInterface, * feature_flags_cache_ttl?: int, * feature_flags_cache_stale_ttl?: int, * maximum_backoff_duration?: int, @@ -1164,7 +1164,7 @@ public function loadFlags(bool $force = false) if ($this->featureFlagsCache !== null) { try { $cached = $this->featureFlagsCache->get($this->featureFlagsCacheKey); - } catch (CacheInvalidArgumentException $e) { + } catch (Throwable $e) { $cached = null; } if (is_array($cached)) { @@ -1268,7 +1268,7 @@ private function storeFlagsInCache(): void ], $this->featureFlagsCacheStaleTtl ); - } catch (CacheInvalidArgumentException $e) { + } catch (Throwable $e) { if ($this->debug) { error_log("[PostHog][Client] Failed to cache feature flag definitions: " . $e->getMessage()); } @@ -1288,7 +1288,7 @@ public function clearFeatureFlagCache(): void try { $this->featureFlagsCache->delete($this->featureFlagsCacheKey); - } catch (CacheInvalidArgumentException $e) { + } catch (Throwable $e) { if ($this->debug) { error_log("[PostHog][Client] Failed to clear feature flag cache: " . $e->getMessage()); } diff --git a/lib/PostHog.php b/lib/PostHog.php index 39df1b9..869487d 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -3,6 +3,7 @@ namespace PostHog; use Exception; +use Psr\SimpleCache\CacheInterface; /** * Static facade for the default PostHog PHP SDK client. @@ -29,7 +30,7 @@ class PostHog * timeout?: int, * verify_batch_events_request?: bool, * feature_flag_request_timeout_ms?: int, - * feature_flags_cache?: \Psr\SimpleCache\CacheInterface, + * feature_flags_cache?: CacheInterface, * feature_flags_cache_ttl?: int, * feature_flags_cache_stale_ttl?: int, * maximum_backoff_duration?: int, diff --git a/test/ArrayCache.php b/test/ArrayCache.php index 5823499..17cf6a3 100644 --- a/test/ArrayCache.php +++ b/test/ArrayCache.php @@ -2,6 +2,8 @@ namespace PostHog\Test; +use DateInterval; +use DateTimeImmutable; use Psr\SimpleCache\CacheInterface; use Symfony\Component\Clock\Clock; @@ -51,8 +53,8 @@ public function set($key, $value, $ttl = null): bool $expiresAt = null; if (is_int($ttl)) { $expiresAt = $this->now() + $ttl; - } elseif ($ttl instanceof \DateInterval) { - $expiresAt = $this->now() + ((new \DateTimeImmutable('@0'))->add($ttl)->getTimestamp()); + } elseif ($ttl instanceof DateInterval) { + $expiresAt = $this->now() + ((new DateTimeImmutable('@0'))->add($ttl)->getTimestamp()); } $this->store[$key] = ['value' => $value, 'expiresAt' => $expiresAt]; return true; diff --git a/test/FeatureFlagCacheTest.php b/test/FeatureFlagCacheTest.php index 90204ad..288a809 100644 --- a/test/FeatureFlagCacheTest.php +++ b/test/FeatureFlagCacheTest.php @@ -5,6 +5,7 @@ // comment out below to print all logs instead of failing tests require_once 'test/error_log_mock.php'; +use DateTimeImmutable; use PHPUnit\Framework\TestCase; use PostHog\Client; use PostHog\Test\Assets\MockedResponses; @@ -49,7 +50,7 @@ public function testFirstClientFetchesAndPopulatesCache(): void $cache = new ArrayCache(); $http = $this->makeHttpClient(); - $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http) { + $this->executeAtFrozenDateTime(new DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http) { $client = new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http, "test"); $this->assertCount(1, $client->featureFlags); $this->assertEquals('person-flag', $client->featureFlags[0]['key']); @@ -67,13 +68,13 @@ public function testSecondClientServesFromCacheWithoutFetching(): void $http1 = $this->makeHttpClient(); $http2 = $this->makeHttpClient(); - $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { + $this->executeAtFrozenDateTime(new DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http1, "test"); }); // Second client a few seconds later (within the 30s freshness window): no HTTP fetch. $client2 = $this->executeAtFrozenDateTime( - new \DateTimeImmutable('2026-06-10 12:00:05'), + new DateTimeImmutable('2026-06-10 12:00:05'), fn() => new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http2, "test") ); @@ -90,13 +91,13 @@ public function testRefetchesAfterFreshnessWindowExpires(): void $http1 = $this->makeHttpClient(); $http2 = $this->makeHttpClient(); - $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { + $this->executeAtFrozenDateTime(new DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http1, "test"); }); // 31s later — past the 30s freshness window: the client refetches. $this->executeAtFrozenDateTime( - new \DateTimeImmutable('2026-06-10 12:00:31'), + new DateTimeImmutable('2026-06-10 12:00:31'), fn() => new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http2, "test") ); @@ -110,11 +111,11 @@ public function testForceBypassesFreshCache(): void $http1 = $this->makeHttpClient(); $http2 = $this->makeHttpClient(); - $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { + $this->executeAtFrozenDateTime(new DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http1, "test"); }); - $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:05'), function () use ($cache, $http2) { + $this->executeAtFrozenDateTime(new DateTimeImmutable('2026-06-10 12:00:05'), function () use ($cache, $http2) { // Construction serves from fresh cache (no fetch), then force refetches. $client2 = new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http2, "test"); $this->assertEquals(0, $this->definitionsCallCount($http2)); @@ -131,13 +132,13 @@ public function testServesStaleCacheWhenApiIsDown(): void $http1 = $this->makeHttpClient(); $httpDown = $this->makeHttpClient(500); // API returns 500 - $this->executeAtFrozenDateTime(new \DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { + $this->executeAtFrozenDateTime(new DateTimeImmutable('2026-06-10 12:00:00'), function () use ($cache, $http1) { new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http1, "test"); }); // Past freshness window, API is down: definitions are still served from the stale cache. $client2 = $this->executeAtFrozenDateTime( - new \DateTimeImmutable('2026-06-10 12:01:00'), + new DateTimeImmutable('2026-06-10 12:01:00'), fn() => new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $httpDown, "test") ); @@ -154,7 +155,7 @@ public function testFallsBackToHttpWhenCacheThrows(): void $http = $this->makeHttpClient(); $client = $this->executeAtFrozenDateTime( - new \DateTimeImmutable('2026-06-10 12:00:00'), + new DateTimeImmutable('2026-06-10 12:00:00'), fn() => new Client(self::FAKE_API_KEY, ['feature_flags_cache' => $cache], $http, "test") ); diff --git a/test/InvalidCacheKey.php b/test/InvalidCacheKey.php index 4c492f2..dbdb494 100644 --- a/test/InvalidCacheKey.php +++ b/test/InvalidCacheKey.php @@ -3,10 +3,11 @@ namespace PostHog\Test; use Psr\SimpleCache\InvalidArgumentException; +use RuntimeException; /** * PSR-16 exception used by ArrayCache to simulate a failing cache backend in tests. */ -class InvalidCacheKey extends \RuntimeException implements InvalidArgumentException +class InvalidCacheKey extends RuntimeException implements InvalidArgumentException { }