From f3ceae1fef92a815d7c22dbffb03e9dc70f0b02a Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 22 Jun 2026 16:44:37 -0400 Subject: [PATCH 01/20] feat(flags): add feature flag support (local + remote evaluation) Adds $mp->flags facade with both local in-process evaluation (FNV-1a bucketing + JSON Logic runtime rules) and remote evaluation (HTTP /flags). Mirrors the public API shape of the Python, Ruby, Go, and Java SDKs so cross-SDK behavior is consistent. Mitigates known cross-SDK bugs from the OpenFeature audit: - distinguishable failure reasons (lastFailureReason) for the future OpenFeature wrapper - backend errors propagate distinctly from "flag not found" - exposure events route through the existing event queue so evaluation never blocks on HTTP - missing-distinct_id-with-custom-bucketing-key warns via error_callback instead of silently dropping exposure - explicit shutdown(); ext checks fire only when flags are used - REASON_NOT_READY distinguishes pre-loadDefinitions calls Bumps PHP minimum to 7.2 and PHPUnit dev dep to ^7.5||^8.5||^9.5 to support modern toolchains; updates existing tests for the new PHPUnit baseline. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 6 + README.md | 63 ++++ composer.json | 10 +- examples/feature_flags.php | 69 ++++ lib/FeatureFlags/MixpanelFlags.php | 129 +++++++ lib/FeatureFlags/MixpanelFlagsBase.php | 249 ++++++++++++++ lib/FeatureFlags/MixpanelFlagsUtils.php | 138 ++++++++ lib/FeatureFlags/MixpanelLocalFlags.php | 316 ++++++++++++++++++ lib/FeatureFlags/MixpanelRemoteFlags.php | 93 ++++++ lib/FeatureFlags/MixpanelSelectedVariant.php | 75 +++++ lib/Mixpanel.php | 30 +- phpunit.xml.dist | 23 +- test/Base/MixpanelBaseProducerTest.php | 6 +- .../AbstractConsumerTest.php | 6 +- test/ConsumerStrategies/CurlConsumerTest.php | 6 +- test/ConsumerStrategies/FileConsumerTest.php | 6 +- .../ConsumerStrategies/SocketConsumerTest.php | 6 +- test/FeatureFlags/MixpanelFlagsTest.php | 79 +++++ test/FeatureFlags/MixpanelFlagsUtilsTest.php | 92 +++++ test/FeatureFlags/MixpanelLocalFlagsTest.php | 284 ++++++++++++++++ test/FeatureFlags/MixpanelRemoteFlagsTest.php | 145 ++++++++ test/MixpanelTest.php | 6 +- test/Producers/MixpanelEventsProducerTest.php | 8 +- test/Producers/MixpanelGroupsProducerTest.php | 12 +- test/Producers/MixpanelPeopleProducerTest.php | 6 +- 25 files changed, 1813 insertions(+), 50 deletions(-) create mode 100644 examples/feature_flags.php create mode 100644 lib/FeatureFlags/MixpanelFlags.php create mode 100644 lib/FeatureFlags/MixpanelFlagsBase.php create mode 100644 lib/FeatureFlags/MixpanelFlagsUtils.php create mode 100644 lib/FeatureFlags/MixpanelLocalFlags.php create mode 100644 lib/FeatureFlags/MixpanelRemoteFlags.php create mode 100644 lib/FeatureFlags/MixpanelSelectedVariant.php create mode 100644 test/FeatureFlags/MixpanelFlagsTest.php create mode 100644 test/FeatureFlags/MixpanelFlagsUtilsTest.php create mode 100644 test/FeatureFlags/MixpanelLocalFlagsTest.php create mode 100644 test/FeatureFlags/MixpanelRemoteFlagsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 56af575..cbfe19e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Added feature flag support (local and remote evaluation) via `$mp->flags` +- Bumped PHP minimum to 7.2 and PHPUnit dev dep to ^7.5 || ^8.5 || ^9.5 +- Added `jwadhams/json-logic-php` as a runtime dependency + ## [2.11.0](https://github.com/mixpanel/mixpanel-php/tree/2.11.0) (2026-05-13) - Fix identify regex for $anon_id diff --git a/README.md b/README.md index b6ffa8f..33ca2c5 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,69 @@ $mp->people->set(12345, array( )); ``` +Feature Flags +------------- + +Feature flags let you ship code that is dark to most users and roll it out to a +configurable percentage of traffic, optionally gated by per-user runtime rules. +The PHP SDK supports both **remote evaluation** (the Mixpanel API decides the +variant for each call) and **local evaluation** (the SDK fetches definitions +once per process and evaluates in-process). + +> **Heads up on `Mixpanel::getInstance()`.** The singleton caches per-token and +> **ignores `$options` on subsequent calls for the same token**. If any earlier +> code path constructs the instance without `flags`, a later +> `Mixpanel::getInstance($token, ['flags' => ...])` will return the cached +> instance and `$mp->flags` will be `null`. Either always pass the flags config +> at the first call site, or use explicit construction +> (`new Mixpanel($token, ['flags' => ...])`) to bypass the singleton entirely. + +```php +$mp = Mixpanel::getInstance("MIXPANEL_PROJECT_TOKEN", array( + "flags" => array( + "mode" => "remote", // or "local" + "report_exposure" => true, + ), +)); + +// Always supply the explicit bucketing-key attribute the flag was configured +// against, NOT just a generic "targeting key". For flags bucketed on +// distinct_id alone you can pass only distinct_id; for flags bucketed on +// device_id or a custom group key, pass that attribute AND distinct_id (the +// distinct_id is required to attach the exposure event to a profile). +$context = array( + "distinct_id" => "user-12345", + "device_id" => "abcdef-12345", + "custom_properties" => array("email" => "alice@example.com", "plan" => "pro"), +); + +if ($mp->flags->isEnabled("new-checkout", $context)) { + // ... +} + +$theme = $mp->flags->getVariantValue("ui-theme", "light", $context); +$variant = $mp->flags->getVariant( + "experiment-pricing", + new FeatureFlags_MixpanelSelectedVariant(null, "control"), + $context +); +``` + +In local mode, definitions are loaded explicitly — PHP's request-per-process +model means we deliberately do not spawn background polling threads: + +```php +$mp = Mixpanel::getInstance("TOKEN", array("flags" => array("mode" => "local"))); +$mp->flags->loadDefinitions(); // fetch once per process +$enabled = $mp->flags->isEnabled("my-flag", $context); +``` + +`lastFailureReason()` distinguishes the four ways an evaluation can fall +through (`FLAG_NOT_FOUND`, `MISSING_CONTEXT_KEY`, `NO_ROLLOUT_MATCH`, +`BACKEND_ERROR`) — useful when debugging or building higher-level wrappers. + +See `examples/feature_flags.php` for a full walk-through. + Production Notes ------------- By default, data is sent using ssl over cURL. This works fine when you're tracking a small number of events or aren't concerned with the potentially blocking nature of the PHP cURL calls. However, this isn't very efficient when you're sending hundreds of events (such as in batch processing). Our library comes packaged with an easy way to use a persistent socket connection for much more efficient writes. To enable the persistent socket, simply pass `'consumer' => 'socket'` as an entry in the `$options` array when you instantiate the Mixpanel class. Additionally, you can contribute your own persistence implementation by creating a custom Consumer. diff --git a/composer.json b/composer.json index 5d43e7a..6d601c7 100644 --- a/composer.json +++ b/composer.json @@ -17,11 +17,15 @@ } ], "require": { - "php": ">=5.0" + "php": ">=7.2", + "ext-bcmath": "*", + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "jwadhams/json-logic-php": "^1.5" }, "require-dev": { - "phpunit/phpunit": "5.6.*", - "phpdocumentor/phpdocumentor": "2.9.*" + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" }, "autoload": { "files": ["lib/Mixpanel.php"] diff --git a/examples/feature_flags.php b/examples/feature_flags.php new file mode 100644 index 0000000..5b8c893 --- /dev/null +++ b/examples/feature_flags.php @@ -0,0 +1,69 @@ + array( + "mode" => "remote", // 'local' or 'remote' + "report_exposure" => true, + ), +)); + +// IMPORTANT: when a flag uses a non-default Variant Assignment Key +// (e.g., device_id or a custom group key), supply BOTH that key AND +// distinct_id in the context — otherwise the SDK can't tie the +// exposure event back to a profile. +$context = array( + "distinct_id" => "user-12345", + "device_id" => "abcdef-12345", // for flags bucketed on device_id + "custom_properties" => array( + "email" => "alice@example.com", + "plan" => "pro", + ), +); + +// Boolean-style probe. +if ($mp->flags->isEnabled("new-checkout", $context)) { + echo "new-checkout is enabled\n"; +} else { + echo "new-checkout fell back; reason: " . $mp->flags->lastFailureReason() . "\n"; +} + +// Variant value with a typed fallback. +$theme = $mp->flags->getVariantValue("ui-theme", "light", $context); +echo "theme = $theme\n"; + +// Full variant for richer reporting. +$variant = $mp->flags->getVariant( + "experiment-pricing", + new FeatureFlags_MixpanelSelectedVariant(null, "control"), + $context +); +printf("experiment-pricing => variant=%s value=%s exp_id=%s\n", + $variant->variantKey, + json_encode($variant->variantValue), + $variant->experimentId +); + +// Bulk evaluation. trackExposure() can be called per-flag after the +// caller actually consumes the value. +$all = $mp->flags->getAllVariants($context); +foreach ($all as $key => $v) { + echo "[$key] {$v->variantKey} = " . json_encode($v->variantValue) . "\n"; +} + +// Local mode usage: +// +// $mp = Mixpanel::getInstance("MY_TOKEN", array( +// "flags" => array("mode" => "local"), +// )); +// $mp->flags->loadDefinitions(); // fetch once per process +// $variant = $mp->flags->getVariant("my-flag", $fallback, $context); +// +// In long-running CLI workers you can call loadDefinitions() on +// whatever schedule fits (e.g., every N minutes). The PHP SDK does not +// spawn background polling threads — request-per-process FPM/Apache +// deployments don't have a place to host them. + +$mp->flags->shutdown(); +$mp->flush(); diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php new file mode 100644 index 0000000..09d878d --- /dev/null +++ b/lib/FeatureFlags/MixpanelFlags.php @@ -0,0 +1,129 @@ +flags. Decides between the local + * and remote provider based on the `mode` option, owns the underlying + * provider's lifecycle, and forwards every public method to it. + * + * Usage: + * + * $mp = Mixpanel::getInstance('TOKEN', array( + * 'flags' => array('mode' => 'remote'), + * )); + * $enabled = $mp->flags->isEnabled('my-flag', array( + * 'distinct_id' => 'user-123', + * )); + */ +class FeatureFlags_MixpanelFlags { + + /** @var FeatureFlags_MixpanelFlagsBase */ + private $_provider; + + /** @var string */ + private $_mode; + + public function __construct($token, $version, $tracker, array $options) { + // Check required extensions only when flags are actually + // enabled — pre-flags the SDK degraded gracefully on hosts + // without bcmath/curl/mbstring, and we want to preserve that + // for tracking-only callers. + $missing = array(); + foreach (array('bcmath', 'curl', 'mbstring') as $ext) { + if (!extension_loaded($ext)) { + $missing[] = $ext; + } + } + if (!empty($missing)) { + throw new Exception( + 'The Mixpanel feature flags module requires the following PHP extension(s): ' + . implode(', ', $missing) + ); + } + + $flagsOpts = isset($options['flags']) && is_array($options['flags']) ? $options['flags'] : array(); + $this->_mode = isset($flagsOpts['mode']) ? strtolower((string) $flagsOpts['mode']) : 'remote'; + + if ($this->_mode === 'local') { + $this->_provider = new FeatureFlags_MixpanelLocalFlags($token, $version, $tracker, $options); + } else { + $this->_provider = new FeatureFlags_MixpanelRemoteFlags($token, $version, $tracker, $options); + } + } + + public function __destruct() { + $this->shutdown(); + } + + /** @return string 'local' or 'remote' */ + public function getMode() { + return $this->_mode; + } + + /** @return FeatureFlags_MixpanelFlagsBase */ + public function getProvider() { + return $this->_provider; + } + + /** + * Fetch flag definitions from the server. Local mode only; no-op + * (returns true) in remote mode. + * + * @return bool true on success + */ + public function loadDefinitions() { + if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { + return $this->_provider->loadDefinitions(); + } + return true; + } + + /** @return bool */ + public function areFlagsReady() { + if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { + return $this->_provider->areFlagsReady(); + } + return true; + } + + /** @return int|null */ + public function lastSyncedAt() { + if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { + return $this->_provider->lastSyncedAt(); + } + return null; + } + + /** @return string one of the FeatureFlags_MixpanelFlagsBase::REASON_* constants */ + public function lastFailureReason() { + return $this->_provider->lastFailureReason(); + } + + public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = null) { + return $this->_provider->getVariant($flagKey, $fallback, $context, $reportExposure); + } + + public function getVariantValue($flagKey, $fallbackValue, array $context) { + return $this->_provider->getVariantValue($flagKey, $fallbackValue, $context); + } + + public function isEnabled($flagKey, array $context) { + return $this->_provider->isEnabled($flagKey, $context); + } + + public function getAllVariants(array $context) { + return $this->_provider->getAllVariants($context); + } + + public function trackExposure($flagKey, FeatureFlags_MixpanelSelectedVariant $variant, array $context) { + $this->_provider->trackExposure($flagKey, $variant, $context); + } + + public function shutdown() { + if ($this->_provider !== null) { + $this->_provider->shutdown(); + } + } +} diff --git a/lib/FeatureFlags/MixpanelFlagsBase.php b/lib/FeatureFlags/MixpanelFlagsBase.php new file mode 100644 index 0000000..d58481c --- /dev/null +++ b/lib/FeatureFlags/MixpanelFlagsBase.php @@ -0,0 +1,249 @@ +track($eventName, $properties + ['distinct_id' => $distinctId]) */ + protected $_tracker; + + /** @var string */ + protected $_apiHost; + + /** @var int seconds */ + protected $_requestTimeout; + + /** @var int seconds */ + protected $_connectTimeout; + + /** @var bool */ + protected $_reportExposureDefault; + + /** @var string most recent evaluation outcome */ + protected $_lastFailureReason = self::REASON_OK; + + public function __construct($token, $version, $tracker, array $options) { + parent::__construct($options); + $this->_token = $token; + $this->_version = $version; + $this->_tracker = $tracker; + + $flagsOpts = isset($options['flags']) && is_array($options['flags']) ? $options['flags'] : array(); + // Precedence: flags.api_host (explicit override) > top-level + // `host` (shared with the event/people consumers) > default. + // This means EU/India endpoints or local mocks only need to be + // configured once at the SDK level. + if (isset($flagsOpts['api_host'])) { + $this->_apiHost = $flagsOpts['api_host']; + } elseif (isset($options['host'])) { + $this->_apiHost = $options['host']; + } else { + $this->_apiHost = 'api.mixpanel.com'; + } + $this->_requestTimeout = isset($flagsOpts['request_timeout_in_seconds']) ? (int) $flagsOpts['request_timeout_in_seconds'] : 10; + $this->_connectTimeout = isset($flagsOpts['connect_timeout_in_seconds']) ? (int) $flagsOpts['connect_timeout_in_seconds'] : 5; + $this->_reportExposureDefault = isset($flagsOpts['report_exposure']) ? (bool) $flagsOpts['report_exposure'] : true; + } + + /** @return string one of the REASON_* constants */ + public function lastFailureReason() { + return $this->_lastFailureReason; + } + + /** Release any held resources. Subclasses override to close cURL handles. */ + public function shutdown() { + // default: nothing held + } + + /** + * Perform an authenticated GET against the flags API and return the + * decoded JSON body. Throws on HTTP error or transport failure — + * the remote provider catches this and surfaces the failure to the + * caller (audit finding #7: don't silently swallow backend errors). + * + * @param string $path e.g. "/flags" or "/flags/definitions" + * @param array $query query params (merged with token / mp_lib / lib_version) + * @return array decoded JSON + * @throws Exception on HTTP non-2xx or cURL transport error + */ + protected function _httpGet($path, array $query = array()) { + $params = array_merge( + FeatureFlags_MixpanelFlagsUtils::commonQueryParams($this->_token, $this->_version), + $query + ); + $url = 'https://' . $this->_apiHost . $path . '?' . http_build_query($params); + + $headers = array( + 'Content-Type: application/json', + 'X-Scheme: https', + 'X-Forwarded-Proto: https', + 'traceparent: ' . FeatureFlags_MixpanelFlagsUtils::generateTraceparent(), + // Basic Auth with token as username, empty password. + 'Authorization: Basic ' . base64_encode($this->_token . ':'), + ); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->_connectTimeout); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->_requestTimeout); + $body = curl_exec($ch); + $errno = curl_errno($ch); + $errmsg = curl_error($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + // Don't call curl_close($ch): PHP 8.0+ closes on unset and 8.5 + // marks the explicit call as deprecated. + unset($ch); + + if ($errno !== 0) { + // Carry the cURL errno as the exception code so it reaches + // the user's error_callback intact. + throw new Exception('Mixpanel flags HTTP transport error (' . $errno . '): ' . $errmsg, $errno); + } + if ($status < 200 || $status >= 300) { + throw new Exception( + 'Mixpanel flags HTTP ' . $status . ': ' . substr((string) $body, 0, 500), + (int) $status + ); + } + + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + throw new Exception('Mixpanel flags response was not valid JSON: ' . substr((string) $body, 0, 200)); + } + return $decoded; + } + + /** + * Build the standard $experiment_started property set, optionally + * tagged with a latency measurement. + * + * @param string $flagKey + * @param FeatureFlags_MixpanelSelectedVariant $variant + * @param string $evaluationMode 'local' or 'remote' + * @param float|null $latencyMs + * @return array + */ + protected function _buildExposureProperties($flagKey, FeatureFlags_MixpanelSelectedVariant $variant, $evaluationMode, $latencyMs = null) { + $properties = array( + 'Experiment name' => $flagKey, + 'Variant name' => $variant->variantKey, + '$experiment_type' => 'feature_flag', + 'Flag evaluation mode' => $evaluationMode, + '$experiment_id' => $variant->experimentId, + '$is_experiment_active' => $variant->isExperimentActive, + '$is_qa_tester' => $variant->isQaTester, + ); + if ($latencyMs !== null) { + $properties['Variant fetch latency (ms)'] = $latencyMs; + } + return $properties; + } + + /** + * Dispatch the exposure event. Routes through the configured + * tracker (normally Mixpanel::track), which means the event lands + * in the same buffered queue as every other event — flushed at + * end of request. That sidesteps audit finding #3 (synchronous + * exposure blocks eval) on PHP without us needing a separate + * async path. + * + * @param string $flagKey + * @param FeatureFlags_MixpanelSelectedVariant $variant + * @param array $context + * @param string $evaluationMode + * @param float|null $latencyMs + */ + protected function _trackExposure($flagKey, FeatureFlags_MixpanelSelectedVariant $variant, array $context, $evaluationMode, $latencyMs = null) { + if (!isset($context['distinct_id']) || $context['distinct_id'] === '' || $context['distinct_id'] === null) { + // Don't drop silently — surface to the error_callback so the + // caller learns why their exposure analytics are empty + // (audit finding #8). + $this->_handleError( + 'mixpanel-flags', + "Cannot track exposure for flag '{$flagKey}': distinct_id missing from context" + ); + return; + } + $distinctId = $context['distinct_id']; + $properties = $this->_buildExposureProperties($flagKey, $variant, $evaluationMode, $latencyMs); + + try { + call_user_func($this->_tracker, $distinctId, FeatureFlags_MixpanelFlagsUtils::EXPOSURE_EVENT, $properties); + } catch (Exception $e) { + $this->_handleError($e->getCode(), $e->getMessage()); + } + } + + /** + * Forward an error to the user-supplied error_callback if one was + * configured (matches the existing AbstractConsumer convention). + * + * @param mixed $code + * @param string $message + */ + protected function _handleError($code, $message) { + if (isset($this->_options['error_callback']) && is_callable($this->_options['error_callback'])) { + call_user_func($this->_options['error_callback'], $code, $message); + } elseif ($this->_debug()) { + $this->_log('[flags] ' . $message); + } + } + + abstract public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = null); + + public function getVariantValue($flagKey, $fallbackValue, array $context) { + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, $fallbackValue); + $variant = $this->getVariant($flagKey, $fallback, $context); + return $variant->variantValue; + } + + public function isEnabled($flagKey, array $context) { + return $this->getVariantValue($flagKey, false, $context) === true; + } + + /** + * Manually track exposure for a previously evaluated variant. Used + * with getAllVariants() so callers can record exposure only for the + * flags they actually consume. + * + * @param string $flagKey + * @param FeatureFlags_MixpanelSelectedVariant $variant + * @param array $context + */ + public function trackExposure($flagKey, FeatureFlags_MixpanelSelectedVariant $variant, array $context) { + $mode = $this->_evaluationMode(); + $this->_trackExposure($flagKey, $variant, $context, $mode); + } + + /** @return string */ + abstract protected function _evaluationMode(); +} diff --git a/lib/FeatureFlags/MixpanelFlagsUtils.php b/lib/FeatureFlags/MixpanelFlagsUtils.php new file mode 100644 index 0000000..bd395fb --- /dev/null +++ b/lib/FeatureFlags/MixpanelFlagsUtils.php @@ -0,0 +1,138 @@ +-<16 hex>-01. + * The values are random per call; their only purpose is to give the + * Mixpanel server a correlation id for the request. + * + * @return string + */ + public static function generateTraceparent() { + $traceId = bin2hex(random_bytes(16)); + $spanId = bin2hex(random_bytes(8)); + return '00-' . $traceId . '-' . $spanId . '-01'; + } + + /** + * The mp_lib / lib_version / token tuple that every flags request + * sends. lib_version is read from a constant on the main Mixpanel + * class so it tracks the package release. + * + * @param string $token + * @param string $version + * @return array + */ + public static function commonQueryParams($token, $version) { + return array( + 'mp_lib' => 'php', + 'lib_version' => $version, + 'token' => $token, + ); + } + + /** + * Recursively casefold (lowercase) only the leaf string nodes of a + * structure, leaving the operator/keyword keys of a JSON-Logic rule + * intact. Used on the rule side of runtime evaluation so that + * comparisons against context values are case-insensitive without + * mangling operator names like "in" or "==". + * + * @param mixed $value + * @return mixed + */ + public static function lowercaseLeafNodes($value) { + if (is_string($value)) { + return mb_strtolower($value, 'UTF-8'); + } + if (is_array($value)) { + $out = array(); + foreach ($value as $key => $sub) { + $out[$key] = self::lowercaseLeafNodes($sub); + } + return $out; + } + return $value; + } + + /** + * Recursively casefold both keys and string values. Used on the + * runtime-parameters side (custom_properties) so that user-supplied + * "Email"/"EMAIL"/"email" all collide with the same rule operand. + * + * @param mixed $value + * @return mixed + */ + public static function lowercaseKeysAndValues($value) { + if (is_string($value)) { + return mb_strtolower($value, 'UTF-8'); + } + if (is_array($value)) { + $out = array(); + foreach ($value as $key => $sub) { + $newKey = is_string($key) ? mb_strtolower($key, 'UTF-8') : $key; + $out[$newKey] = self::lowercaseKeysAndValues($sub); + } + return $out; + } + return $value; + } +} diff --git a/lib/FeatureFlags/MixpanelLocalFlags.php b/lib/FeatureFlags/MixpanelLocalFlags.php new file mode 100644 index 0000000..d3ebf17 --- /dev/null +++ b/lib/FeatureFlags/MixpanelLocalFlags.php @@ -0,0 +1,316 @@ + flag definition (decoded JSON) */ + private $_definitions = array(); + + /** @var bool */ + private $_ready = false; + + /** @var int|null unix timestamp of last successful loadDefinitions */ + private $_lastSyncedAt = null; + + /** + * Fetch the latest flag definitions from Mixpanel. Throws nothing + * on transport failure — the error is routed to error_callback so + * the existing definitions (if any) keep working until the next + * successful call. Returns true on success. + * + * @return bool + */ + public function loadDefinitions() { + try { + $response = $this->_httpGet(self::DEFINITIONS_PATH); + } catch (Exception $e) { + $this->_handleError($e->getCode(), 'Failed to fetch flag definitions: ' . $e->getMessage()); + return false; + } + + $flags = isset($response['flags']) && is_array($response['flags']) ? $response['flags'] : array(); + $byKey = array(); + foreach ($flags as $flag) { + if (!isset($flag['key'])) { + continue; + } + if (isset($flag['ruleset']['variants']) && is_array($flag['ruleset']['variants'])) { + // Sort variants by key for deterministic bucket assignment. + usort($flag['ruleset']['variants'], array(__CLASS__, '_compareVariantKeys')); + } + $byKey[$flag['key']] = $flag; + } + $this->_definitions = $byKey; + $this->_ready = true; + $this->_lastSyncedAt = time(); + return true; + } + + public static function _compareVariantKeys($a, $b) { + $ak = isset($a['key']) ? (string) $a['key'] : ''; + $bk = isset($b['key']) ? (string) $b['key'] : ''; + return strcmp($ak, $bk); + } + + /** @return bool true once loadDefinitions has succeeded at least once */ + public function areFlagsReady() { + return $this->_ready; + } + + /** @return int|null unix timestamp of most recent successful sync */ + public function lastSyncedAt() { + return $this->_lastSyncedAt; + } + + protected function _evaluationMode() { + return 'local'; + } + + public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = null) { + $reportExposure = $reportExposure === null ? $this->_reportExposureDefault : (bool) $reportExposure; + $startTime = microtime(true); + + if (!$this->_ready) { + // Distinguish "definitions never loaded" from "definitions + // loaded but flag not present" — the audit-driven reason + // enum is the seam a future OpenFeature wrapper uses. + $this->_lastFailureReason = self::REASON_NOT_READY; + $this->_handleError( + 'mixpanel-flags', + "getVariant called before loadDefinitions() succeeded; call loadDefinitions() first." + ); + return $fallback; + } + + if (!isset($this->_definitions[$flagKey])) { + $this->_lastFailureReason = self::REASON_FLAG_NOT_FOUND; + return $fallback; + } + + $flag = $this->_definitions[$flagKey]; + $bucketingKey = isset($flag['context']) ? $flag['context'] : 'distinct_id'; + if (!isset($context[$bucketingKey]) || $context[$bucketingKey] === '' || $context[$bucketingKey] === null) { + $this->_lastFailureReason = self::REASON_MISSING_CONTEXT_KEY; + $this->_handleError( + 'mixpanel-flags', + "Flag '{$flagKey}' requires context key '{$bucketingKey}' which was not supplied" + ); + return $fallback; + } + $contextValue = (string) $context[$bucketingKey]; + + // Test-user variant overrides always win, by design. + $selected = $this->_overrideForTestUser($flag, $context); + + if ($selected === null) { + $rollout = $this->_assignedRollout($flag, $contextValue, $context); + if ($rollout !== null) { + $selected = $this->_assignedVariant($flag, $contextValue, $flagKey, $rollout); + } + } + + if ($selected === null) { + $this->_lastFailureReason = self::REASON_NO_ROLLOUT_MATCH; + return $fallback; + } + + $this->_lastFailureReason = self::REASON_OK; + + if ($reportExposure) { + $latencyMs = (microtime(true) - $startTime) * 1000.0; + $this->_trackExposure($flagKey, $selected, $context, 'local', $latencyMs); + } + + return $selected; + } + + public function getAllVariants(array $context) { + $out = array(); + foreach ($this->_definitions as $flagKey => $_def) { + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, null); + $variant = $this->getVariant($flagKey, $fallback, $context, false); + if ($variant->variantKey !== null) { + $out[$flagKey] = $variant; + } + } + return $out; + } + + private function _overrideForTestUser(array $flag, array $context) { + if (!isset($flag['ruleset']['test']['users']) || !is_array($flag['ruleset']['test']['users'])) { + return null; + } + if (!isset($context['distinct_id'])) { + return null; + } + $distinctId = (string) $context['distinct_id']; + $users = $flag['ruleset']['test']['users']; + if (!isset($users[$distinctId])) { + return null; + } + return $this->_matchingVariant($users[$distinctId], $flag, /* isQaTester */ true); + } + + private function _matchingVariant($variantKey, array $flag, $isQaTester = false) { + if (!isset($flag['ruleset']['variants']) || !is_array($flag['ruleset']['variants'])) { + return null; + } + $targetKey = mb_strtolower((string) $variantKey, 'UTF-8'); + foreach ($flag['ruleset']['variants'] as $variant) { + if (!isset($variant['key'])) { + continue; + } + if (mb_strtolower((string) $variant['key'], 'UTF-8') === $targetKey) { + return new FeatureFlags_MixpanelSelectedVariant( + $variant['key'], + isset($variant['value']) ? $variant['value'] : null, + isset($flag['experiment_id']) ? $flag['experiment_id'] : null, + isset($flag['is_experiment_active']) ? $flag['is_experiment_active'] : null, + $isQaTester ? true : null + ); + } + } + return null; + } + + private function _assignedRollout(array $flag, $contextValue, array $context) { + if (!isset($flag['ruleset']['rollout']) || !is_array($flag['ruleset']['rollout'])) { + return null; + } + $flagKey = isset($flag['key']) ? $flag['key'] : ''; + $hashSalt = isset($flag['hash_salt']) ? $flag['hash_salt'] : null; + + foreach ($flag['ruleset']['rollout'] as $index => $rollout) { + if ($hashSalt !== null) { + $salt = $flagKey . $hashSalt . $index; + } else { + $salt = $flagKey . 'rollout'; + } + $rolloutHash = FeatureFlags_MixpanelFlagsUtils::normalizedHash($contextValue, $salt); + + $rolloutPercentage = isset($rollout['rollout_percentage']) ? (float) $rollout['rollout_percentage'] : 0.0; + if ($rolloutHash < $rolloutPercentage && $this->_runtimeRulesSatisfied($rollout, $context)) { + return $rollout; + } + } + return null; + } + + private function _assignedVariant(array $flag, $contextValue, $flagKey, array $rollout) { + if (isset($rollout['variant_override']['key'])) { + $override = $this->_matchingVariant($rollout['variant_override']['key'], $flag); + if ($override !== null) { + return $override; + } + } + + $hashSalt = isset($flag['hash_salt']) ? $flag['hash_salt'] : ''; + $salt = $flagKey . $hashSalt . 'variant'; + $variantHash = FeatureFlags_MixpanelFlagsUtils::normalizedHash($contextValue, $salt); + + $variants = isset($flag['ruleset']['variants']) ? $flag['ruleset']['variants'] : array(); + // Apply per-rollout split overrides without mutating the cached definition. + if (isset($rollout['variant_splits']) && is_array($rollout['variant_splits'])) { + foreach ($variants as $i => $v) { + if (isset($v['key']) && isset($rollout['variant_splits'][$v['key']])) { + $variants[$i]['split'] = $rollout['variant_splits'][$v['key']]; + } + } + } + + $selected = isset($variants[0]) ? $variants[0] : null; + $cumulative = 0.0; + foreach ($variants as $variant) { + $selected = $variant; + $cumulative += isset($variant['split']) ? (float) $variant['split'] : 0.0; + if ($variantHash < $cumulative) { + break; + } + } + + if ($selected === null) { + return null; + } + + return new FeatureFlags_MixpanelSelectedVariant( + isset($selected['key']) ? $selected['key'] : null, + isset($selected['value']) ? $selected['value'] : null, + isset($flag['experiment_id']) ? $flag['experiment_id'] : null, + isset($flag['is_experiment_active']) ? $flag['is_experiment_active'] : null, + null + ); + } + + private function _runtimeRulesSatisfied(array $rollout, array $context) { + if (isset($rollout['runtime_evaluation_rule']) && $rollout['runtime_evaluation_rule']) { + $params = $this->_runtimeParameters($context); + if ($params === null) { + return false; + } + try { + $rule = FeatureFlags_MixpanelFlagsUtils::lowercaseLeafNodes($rollout['runtime_evaluation_rule']); + $result = JWadhams\JsonLogic::apply($rule, $params); + return (bool) $result; + } catch (Exception $e) { + $this->_handleError($e->getCode(), 'Runtime rule evaluation error: ' . $e->getMessage()); + return false; + } catch (Error $e) { + // PHP 7+ throws Error (not Exception) for some failure modes. + $this->_handleError($e->getCode(), 'Runtime rule evaluation error: ' . $e->getMessage()); + return false; + } + } + + if (isset($rollout['runtime_evaluation_definition']) && is_array($rollout['runtime_evaluation_definition'])) { + return $this->_legacyRuntimeRuleSatisfied($rollout['runtime_evaluation_definition'], $context); + } + + return true; + } + + private function _legacyRuntimeRuleSatisfied(array $definition, array $context) { + $params = $this->_runtimeParameters($context); + if ($params === null) { + return false; + } + foreach ($definition as $key => $expectedValue) { + if (!array_key_exists($key, $params)) { + return false; + } + // Legacy runtime rules only meaningfully compare scalars. + // Casting an array via (string) yields "Array" and an + // E_NOTICE — treat non-scalar operands as "no match" + // instead of producing junk. + if (!is_scalar($params[$key]) || !is_scalar($expectedValue)) { + return false; + } + $actual = mb_strtolower((string) $params[$key], 'UTF-8'); + $expected = mb_strtolower((string) $expectedValue, 'UTF-8'); + if ($actual !== $expected) { + return false; + } + } + return true; + } + + private function _runtimeParameters(array $context) { + if (!isset($context['custom_properties']) || !is_array($context['custom_properties'])) { + return null; + } + return FeatureFlags_MixpanelFlagsUtils::lowercaseKeysAndValues($context['custom_properties']); + } +} diff --git a/lib/FeatureFlags/MixpanelRemoteFlags.php b/lib/FeatureFlags/MixpanelRemoteFlags.php new file mode 100644 index 0000000..edb5d6b --- /dev/null +++ b/lib/FeatureFlags/MixpanelRemoteFlags.php @@ -0,0 +1,93 @@ + on the Mixpanel API. The server runs + * the full evaluation and returns the selected variant. + * + * Compared with local mode, remote eval requires no cached definitions + * and works correctly even on serverless / short-lived PHP processes + * where loading definitions for every request would be wasteful. + */ +class FeatureFlags_MixpanelRemoteFlags extends FeatureFlags_MixpanelFlagsBase { + + const FLAGS_PATH = '/flags'; + + protected function _evaluationMode() { + return 'remote'; + } + + public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = null) { + $reportExposure = $reportExposure === null ? $this->_reportExposureDefault : (bool) $reportExposure; + + $startTime = microtime(true); + try { + $flags = $this->_fetchFlags($context, $flagKey); + } catch (Exception $e) { + // Audit finding #7: don't silently swallow backend errors. + // Surface to error_callback, mark the failure reason, and + // return fallback so a future OF wrapper can translate to + // GENERAL instead of FLAG_NOT_FOUND. + $this->_lastFailureReason = self::REASON_BACKEND_ERROR; + $this->_handleError($e->getCode(), 'Remote flag fetch failed: ' . $e->getMessage()); + return $fallback; + } + $latencyMs = (microtime(true) - $startTime) * 1000.0; + + if (!isset($flags[$flagKey])) { + $this->_lastFailureReason = self::REASON_FLAG_NOT_FOUND; + return $fallback; + } + + $selected = FeatureFlags_MixpanelSelectedVariant::fromArray($flags[$flagKey]); + $this->_lastFailureReason = self::REASON_OK; + + if ($reportExposure) { + $this->_trackExposure($flagKey, $selected, $context, 'remote', $latencyMs); + } + + return $selected; + } + + public function getAllVariants(array $context) { + try { + $flags = $this->_fetchFlags($context, null); + } catch (Exception $e) { + $this->_lastFailureReason = self::REASON_BACKEND_ERROR; + $this->_handleError($e->getCode(), 'Remote flag fetch failed: ' . $e->getMessage()); + return array(); + } + + $out = array(); + foreach ($flags as $key => $payload) { + $out[$key] = FeatureFlags_MixpanelSelectedVariant::fromArray($payload); + } + $this->_lastFailureReason = self::REASON_OK; + return $out; + } + + /** + * @param array $context + * @param string|null $flagKey when set, the server scopes the response to that one flag + * @return array map of flag_key => variant payload + */ + private function _fetchFlags(array $context, $flagKey) { + $query = array( + // The Python and Ruby SDKs URL-encode the context JSON + // before placing it in the query string. http_build_query + // would do that for us, but we pre-encode to JSON first so + // the server sees the expected JSON shape. + 'context' => json_encode($context), + ); + if ($flagKey !== null) { + $query['flag_key'] = $flagKey; + } + $response = $this->_httpGet(self::FLAGS_PATH, $query); + if (isset($response['flags']) && is_array($response['flags'])) { + return $response['flags']; + } + return array(); + } +} diff --git a/lib/FeatureFlags/MixpanelSelectedVariant.php b/lib/FeatureFlags/MixpanelSelectedVariant.php new file mode 100644 index 0000000..1e2f887 --- /dev/null +++ b/lib/FeatureFlags/MixpanelSelectedVariant.php @@ -0,0 +1,75 @@ +variantKey = $variantKey; + $this->variantValue = $variantValue; + $this->experimentId = $experimentId; + $this->isExperimentActive = $isExperimentActive; + $this->isQaTester = $isQaTester; + } + + /** + * Build a SelectedVariant from the JSON shape returned by the + * /flags remote endpoint or stored inside a flag definition. + * + * @param array $data + * @return FeatureFlags_MixpanelSelectedVariant + */ + public static function fromArray(array $data) { + return new self( + isset($data['variant_key']) ? $data['variant_key'] : null, + isset($data['variant_value']) ? $data['variant_value'] : null, + isset($data['experiment_id']) ? $data['experiment_id'] : null, + isset($data['is_experiment_active']) ? $data['is_experiment_active'] : null, + isset($data['is_qa_tester']) ? $data['is_qa_tester'] : null + ); + } + + /** + * @return array + */ + public function toArray() { + return array( + 'variant_key' => $this->variantKey, + 'variant_value' => $this->variantValue, + 'experiment_id' => $this->experimentId, + 'is_experiment_active' => $this->isExperimentActive, + 'is_qa_tester' => $this->isQaTester, + ); + } +} diff --git a/lib/Mixpanel.php b/lib/Mixpanel.php index 632bbd7..0f3657b 100644 --- a/lib/Mixpanel.php +++ b/lib/Mixpanel.php @@ -4,6 +4,7 @@ require_once(dirname(__FILE__) . "/Producers/MixpanelPeople.php"); require_once(dirname(__FILE__) . "/Producers/MixpanelEvents.php"); require_once(dirname(__FILE__) . "/Producers/MixpanelGroups.php"); +require_once(dirname(__FILE__) . "/FeatureFlags/MixpanelFlags.php"); /** * This is the main class for the Mixpanel PHP Library which provides all of the methods you need to track events, @@ -109,6 +110,11 @@ */ class Mixpanel extends Base_MixpanelBase { + /** + * The library version, sent as lib_version on every request. + */ + const VERSION = '2.11.0'; + /** * An instance of the MixpanelPeople class (used to create/update profiles) @@ -128,7 +134,15 @@ class Mixpanel extends Base_MixpanelBase { * @var Producers_MixpanelPeople */ public $group; - + + + /** + * An instance of the MixpanelFlags facade, present only when the + * caller passed an 'flags' entry in $options. Use it for + * `$mp->flags->isEnabled(...)` and similar calls. + * @var FeatureFlags_MixpanelFlags|null + */ + public $flags; /** @@ -136,7 +150,7 @@ class Mixpanel extends Base_MixpanelBase { * @var Mixpanel[] */ private static $_instances = array(); - + /** * Instantiates a new Mixpanel instance. @@ -148,6 +162,18 @@ public function __construct($token, $options = array()) { $this->people = new Producers_MixpanelPeople($token, $options); $this->_events = new Producers_MixpanelEvents($token, $options); $this->group = new Producers_MixpanelGroups($token, $options); + + if (isset($options['flags'])) { + $events = $this->_events; + // The flags providers track exposure by routing the event + // through the existing event queue, so it benefits from + // the same batching/flushing as every other tracked event. + $tracker = function ($distinctId, $eventName, $properties) use ($events) { + $properties['distinct_id'] = $distinctId; + $events->track($eventName, $properties); + }; + $this->flags = new FeatureFlags_MixpanelFlags($token, self::VERSION, $tracker, $options); + } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 81c67c7..bfcee08 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,29 +1,24 @@ - + stopOnFailure="false"> + ./test/ - - - - examples - vendor - test - - + + + + ./lib + + diff --git a/test/Base/MixpanelBaseProducerTest.php b/test/Base/MixpanelBaseProducerTest.php index f7beca6..9f65150 100644 --- a/test/Base/MixpanelBaseProducerTest.php +++ b/test/Base/MixpanelBaseProducerTest.php @@ -1,19 +1,19 @@ _file = dirname(__FILE__)."/output-".time().".txt"; $this->_instance = new _Producers_MixpanelBaseProducer("token", array("consumer" => "file", "debug" => true, "file" => $this->_file)); } - protected function tearDown() { + protected function tearDown() : void { parent::tearDown(); $this->_instance->reset(); $this->_instance = null; diff --git a/test/ConsumerStrategies/AbstractConsumerTest.php b/test/ConsumerStrategies/AbstractConsumerTest.php index 5605cbf..f84b206 100644 --- a/test/ConsumerStrategies/AbstractConsumerTest.php +++ b/test/ConsumerStrategies/AbstractConsumerTest.php @@ -1,19 +1,19 @@ _instance = new AbstractConsumer(); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance = null; diff --git a/test/ConsumerStrategies/CurlConsumerTest.php b/test/ConsumerStrategies/CurlConsumerTest.php index dd12e32..b2a82d1 100644 --- a/test/ConsumerStrategies/CurlConsumerTest.php +++ b/test/ConsumerStrategies/CurlConsumerTest.php @@ -1,6 +1,6 @@ assertEquals($expected, $cmd); // The dangerous metacharacters must live inside single quotes, never bare. - $this->assertNotContains('"; touch', str_replace(escapeshellarg($url), '', $cmd)); - $this->assertNotContains('`whoami`', str_replace(escapeshellarg($data), '', $cmd)); + $this->assertStringNotContainsString('"; touch', str_replace(escapeshellarg($url), '', $cmd)); + $this->assertStringNotContainsString('`whoami`', str_replace(escapeshellarg($data), '', $cmd)); } public function testOptions() { diff --git a/test/ConsumerStrategies/FileConsumerTest.php b/test/ConsumerStrategies/FileConsumerTest.php index 703e3c9..bed1053 100644 --- a/test/ConsumerStrategies/FileConsumerTest.php +++ b/test/ConsumerStrategies/FileConsumerTest.php @@ -1,20 +1,20 @@ _file = dirname(__FILE__)."/output-".time().".txt"; $this->_instance = new ConsumerStrategies_FileConsumer(array("file" => $this->_file)); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance = null; diff --git a/test/ConsumerStrategies/SocketConsumerTest.php b/test/ConsumerStrategies/SocketConsumerTest.php index 2115b8d..ee9479a 100644 --- a/test/ConsumerStrategies/SocketConsumerTest.php +++ b/test/ConsumerStrategies/SocketConsumerTest.php @@ -1,13 +1,13 @@ _instance = new ConsumerStrategies_SocketConsumer(array( @@ -18,7 +18,7 @@ protected function setUp() )); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance = null; diff --git a/test/FeatureFlags/MixpanelFlagsTest.php b/test/FeatureFlags/MixpanelFlagsTest.php new file mode 100644 index 0000000..971e86d --- /dev/null +++ b/test/FeatureFlags/MixpanelFlagsTest.php @@ -0,0 +1,79 @@ +assertNull($mp->flags); + } + + public function testMixpanelInstanceConstructsFlagsWhenConfigured() { + $mp = new Mixpanel('token-with-flags', array( + 'flags' => array('mode' => 'remote'), + )); + $this->assertInstanceOf('FeatureFlags_MixpanelFlags', $mp->flags); + $this->assertEquals('remote', $mp->flags->getMode()); + $this->assertInstanceOf('FeatureFlags_MixpanelRemoteFlags', $mp->flags->getProvider()); + } + + public function testLocalModeProvider() { + $mp = new Mixpanel('token-local', array( + 'flags' => array('mode' => 'local'), + )); + $this->assertEquals('local', $mp->flags->getMode()); + $this->assertInstanceOf('FeatureFlags_MixpanelLocalFlags', $mp->flags->getProvider()); + // Before loadDefinitions runs, the local provider is not ready. + $this->assertFalse($mp->flags->areFlagsReady()); + $this->assertNull($mp->flags->lastSyncedAt()); + } + + public function testRemoteModeReportsReadyAndNoSync() { + $mp = new Mixpanel('token-r', array( + 'flags' => array('mode' => 'remote'), + )); + // Remote mode has no concept of "definitions ready" — it's + // always ready to make a call. loadDefinitions is a no-op + // returning true. lastSyncedAt is null. + $this->assertTrue($mp->flags->areFlagsReady()); + $this->assertTrue($mp->flags->loadDefinitions()); + $this->assertNull($mp->flags->lastSyncedAt()); + } + + public function testDefaultModeIsRemote() { + $mp = new Mixpanel('token-default', array( + 'flags' => array(), + )); + $this->assertEquals('remote', $mp->flags->getMode()); + } + + public function testFlagsApiHostInheritsFromTopLevelHost() { + // If the caller already pointed the SDK at a regional or mock + // endpoint via the top-level `host` option, the flags module + // should pick that up instead of going to api.mixpanel.com. + $mp = new Mixpanel('token-eu', array( + 'host' => 'api-eu.mixpanel.com', + 'flags' => array('mode' => 'remote'), + )); + $provider = $mp->flags->getProvider(); + $reflection = new ReflectionClass('FeatureFlags_MixpanelFlagsBase'); + $apiHostProp = $reflection->getProperty('_apiHost'); + if (PHP_VERSION_ID < 80100) { + $apiHostProp->setAccessible(true); + } + $this->assertEquals('api-eu.mixpanel.com', $apiHostProp->getValue($provider)); + } + + public function testFlagsApiHostExplicitOverrideWinsOverTopLevelHost() { + $mp = new Mixpanel('token-mix', array( + 'host' => 'api-eu.mixpanel.com', + 'flags' => array('mode' => 'remote', 'api_host' => 'localhost:8080'), + )); + $provider = $mp->flags->getProvider(); + $reflection = new ReflectionClass('FeatureFlags_MixpanelFlagsBase'); + $apiHostProp = $reflection->getProperty('_apiHost'); + if (PHP_VERSION_ID < 80100) { + $apiHostProp->setAccessible(true); + } + $this->assertEquals('localhost:8080', $apiHostProp->getValue($provider)); + } +} diff --git a/test/FeatureFlags/MixpanelFlagsUtilsTest.php b/test/FeatureFlags/MixpanelFlagsUtilsTest.php new file mode 100644 index 0000000..15009ec --- /dev/null +++ b/test/FeatureFlags/MixpanelFlagsUtilsTest.php @@ -0,0 +1,92 @@ +assertEquals( + FeatureFlags_MixpanelFlagsUtils::FNV_OFFSET_BASIS, + FeatureFlags_MixpanelFlagsUtils::fnv1a64('') + ); + } + + public function testFnvSingleByteAMatchesCanonicalVector() { + // RFC-style FNV-1a 64 of "a" is 0xaf63dc4c8601ec8c. This is the + // canonical cross-language reference value — if we don't match + // it, no other Mixpanel SDK will agree with PHP on bucketing. + $this->assertEquals( + '12638187200555641996', + FeatureFlags_MixpanelFlagsUtils::fnv1a64('a') + ); + } + + public function testFnvFoobarMatchesCanonicalVector() { + // FNV-1a 64 of "foobar" = 0x85944171f73967e8 per the reference vectors. + $this->assertEquals( + '9625390261332436968', + FeatureFlags_MixpanelFlagsUtils::fnv1a64('foobar') + ); + } + + public function testNormalizedHashInRange() { + $val = FeatureFlags_MixpanelFlagsUtils::normalizedHash('user-123', 'flag-key' . 'rollout'); + $this->assertGreaterThanOrEqual(0.0, $val); + $this->assertLessThan(1.0, $val); + } + + public function testNormalizedHashIsDeterministic() { + $a = FeatureFlags_MixpanelFlagsUtils::normalizedHash('user-123', 'flag-key' . 'rollout'); + $b = FeatureFlags_MixpanelFlagsUtils::normalizedHash('user-123', 'flag-key' . 'rollout'); + $this->assertSame($a, $b); + } + + public function testNormalizedHashDiffersByKey() { + $a = FeatureFlags_MixpanelFlagsUtils::normalizedHash('user-123', 'salt'); + $b = FeatureFlags_MixpanelFlagsUtils::normalizedHash('user-124', 'salt'); + $this->assertNotEquals($a, $b); + } + + public function testTraceparentShape() { + $tp = FeatureFlags_MixpanelFlagsUtils::generateTraceparent(); + // Avoid assertMatchesRegularExpression so the suite runs across + // PHPUnit 7.5–9.x without renaming the assertion. + $this->assertEquals(1, preg_match('/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/', $tp)); + } + + public function testCommonQueryParams() { + $params = FeatureFlags_MixpanelFlagsUtils::commonQueryParams('TKN', '2.11.0'); + $this->assertEquals('php', $params['mp_lib']); + $this->assertEquals('2.11.0', $params['lib_version']); + $this->assertEquals('TKN', $params['token']); + } + + public function testLowercaseLeafNodes() { + $rule = array('==' => array(array('var' => 'Email'), 'Alice@Example.COM')); + $out = FeatureFlags_MixpanelFlagsUtils::lowercaseLeafNodes($rule); + // Operator/"var" keys are preserved; only the leaf string values + // (the property name fetched by "var" and the literal compared + // against) get casefolded. + $this->assertArrayHasKey('==', $out); + $this->assertEquals('email', $out['=='][0]['var']); + $this->assertEquals('alice@example.com', $out['=='][1]); + } + + public function testLowercaseKeysAndValues() { + $data = array('Email' => 'Alice@Example.COM', 'Count' => 5); + $out = FeatureFlags_MixpanelFlagsUtils::lowercaseKeysAndValues($data); + $this->assertArrayHasKey('email', $out); + $this->assertEquals('alice@example.com', $out['email']); + // Non-string values pass through unchanged. + $this->assertSame(5, $out['count']); + } +} diff --git a/test/FeatureFlags/MixpanelLocalFlagsTest.php b/test/FeatureFlags/MixpanelLocalFlagsTest.php new file mode 100644 index 0000000..cad7858 --- /dev/null +++ b/test/FeatureFlags/MixpanelLocalFlagsTest.php @@ -0,0 +1,284 @@ +getProperty('_definitions'); + if (PHP_VERSION_ID < 80100) { + $defsProp->setAccessible(true); + } + $defsProp->setValue($this, $defs); + $readyProp = $reflection->getProperty('_ready'); + if (PHP_VERSION_ID < 80100) { + $readyProp->setAccessible(true); + } + $readyProp->setValue($this, true); + } +} + +class MixpanelLocalFlagsTest extends PHPUnit\Framework\TestCase { + + /** @var array exposure events captured by the spy tracker */ + private $_captured; + + /** @var callable */ + private $_tracker; + + /** @var _TestableLocalFlags */ + private $_provider; + + protected function setUp() : void { + $this->_captured = array(); + $captured = &$this->_captured; + $this->_tracker = function ($distinctId, $eventName, $properties) use (&$captured) { + $captured[] = array($distinctId, $eventName, $properties); + }; + $this->_provider = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array('mode' => 'local'), + )); + } + + /** + * Build a minimal flag definition fixture. A single rollout at + * 100% with no runtime rules, and as many variants as supplied + * with equal splits summing to 1.0. + */ + private function makeFlag($key, array $variants, $context = 'distinct_id', $rolloutPct = 1.0, $extra = array()) { + $variantDefs = array(); + foreach ($variants as $variantKey => $value) { + $variantDefs[] = array( + 'key' => $variantKey, + 'value' => $value, + 'is_control' => false, + 'split' => 1.0 / count($variants), + ); + } + $flag = array( + 'id' => 'fid-' . $key, + 'name' => $key, + 'key' => $key, + 'status' => 'active', + 'project_id' => 1, + 'context' => $context, + 'experiment_id' => 'exp-' . $key, + 'is_experiment_active' => true, + 'ruleset' => array( + 'variants' => $variantDefs, + 'rollout' => array( + array('rollout_percentage' => $rolloutPct), + ), + ), + ); + return array_merge($flag, $extra); + } + + public function testReturnsFallbackAndSetsReasonWhenFlagMissing() { + $this->_provider->setDefinitionsForTest(array()); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); + $result = $this->_provider->getVariant('unknown', $fallback, array('distinct_id' => 'u1')); + $this->assertSame($fallback, $result); + $this->assertEquals( + FeatureFlags_MixpanelFlagsBase::REASON_FLAG_NOT_FOUND, + $this->_provider->lastFailureReason() + ); + } + + public function testGetVariantBeforeLoadReturnsNotReady() { + // Brand-new provider — no loadDefinitions / no setDefinitionsForTest. + $fresh = new FeatureFlags_MixpanelLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array('mode' => 'local'), + )); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); + $result = $fresh->getVariant('any-flag', $fallback, array('distinct_id' => 'u1')); + $this->assertSame($fallback, $result); + $this->assertEquals( + FeatureFlags_MixpanelFlagsBase::REASON_NOT_READY, + $fresh->lastFailureReason() + ); + } + + public function testReturnsFallbackAndSetsReasonWhenContextMissing() { + $this->_provider->setDefinitionsForTest(array( + 'my-flag' => $this->makeFlag('my-flag', array('on' => true)), + )); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); + // No distinct_id in context, but the flag's bucketing key IS distinct_id. + $result = $this->_provider->getVariant('my-flag', $fallback, array('email' => 'x@y.com')); + $this->assertSame($fallback, $result); + $this->assertEquals( + FeatureFlags_MixpanelFlagsBase::REASON_MISSING_CONTEXT_KEY, + $this->_provider->lastFailureReason() + ); + } + + public function testReturnsVariantOnSuccessfulEval() { + $this->_provider->setDefinitionsForTest(array( + 'my-flag' => $this->makeFlag('my-flag', array('on' => true)), + )); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); + $result = $this->_provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); + $this->assertEquals('on', $result->variantKey); + $this->assertSame(true, $result->variantValue); + $this->assertEquals('exp-my-flag', $result->experimentId); + $this->assertTrue($result->isExperimentActive); + $this->assertEquals(FeatureFlags_MixpanelFlagsBase::REASON_OK, $this->_provider->lastFailureReason()); + } + + public function testTracksExposureByDefault() { + $this->_provider->setDefinitionsForTest(array( + 'my-flag' => $this->makeFlag('my-flag', array('on' => true)), + )); + $this->_provider->getVariant( + 'my-flag', + new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'), + array('distinct_id' => 'u1') + ); + $this->assertCount(1, $this->_captured); + list($distinctId, $eventName, $props) = $this->_captured[0]; + $this->assertEquals('u1', $distinctId); + $this->assertEquals('$experiment_started', $eventName); + $this->assertEquals('my-flag', $props['Experiment name']); + $this->assertEquals('on', $props['Variant name']); + $this->assertEquals('feature_flag', $props['$experiment_type']); + $this->assertEquals('local', $props['Flag evaluation mode']); + $this->assertArrayHasKey('Variant fetch latency (ms)', $props); + } + + public function testReportExposureFalseSkipsTracking() { + $this->_provider->setDefinitionsForTest(array( + 'my-flag' => $this->makeFlag('my-flag', array('on' => true)), + )); + $this->_provider->getVariant( + 'my-flag', + new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'), + array('distinct_id' => 'u1'), + false + ); + $this->assertCount(0, $this->_captured); + } + + public function testReturnsFallbackWhenRolloutIsZero() { + $this->_provider->setDefinitionsForTest(array( + 'my-flag' => $this->makeFlag('my-flag', array('on' => true), 'distinct_id', 0.0), + )); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); + $result = $this->_provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); + $this->assertSame($fallback, $result); + $this->assertEquals( + FeatureFlags_MixpanelFlagsBase::REASON_NO_ROLLOUT_MATCH, + $this->_provider->lastFailureReason() + ); + } + + public function testCustomBucketingKeyWithoutDistinctIdLogsButReturnsValue() { + // Audit finding #8: when the bucketing key is non-distinct_id + // and distinct_id is missing, the SDK should not silently drop + // exposure — it should surface the problem. + $errors = array(); + $errorCallback = function ($code, $message) use (&$errors) { + $errors[] = array($code, $message); + }; + $provider = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'error_callback' => $errorCallback, + 'flags' => array('mode' => 'local'), + )); + $provider->setDefinitionsForTest(array( + 'device-flag' => $this->makeFlag('device-flag', array('on' => true), 'device_id'), + )); + + $result = $provider->getVariant( + 'device-flag', + new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'), + array('device_id' => 'd1') + ); + $this->assertEquals('on', $result->variantKey); + // Exposure NOT fired (no distinct_id), but the error_callback fired. + $this->assertCount(0, $this->_captured); + $this->assertCount(1, $errors); + $this->assertStringContainsString('distinct_id', $errors[0][1]); + } + + public function testNestedNumericValuesPreserveType() { + // Audit finding #11: nested ints in object-valued variants + // must round-trip as ints. PHP's json_decode($x, true) does + // this by default, so the guarantee is mostly about not + // accidentally casting along the way. + $this->_provider->setDefinitionsForTest(array( + 'obj-flag' => $this->makeFlag('obj-flag', array( + 'on' => array('threshold' => 42, 'nested' => array('count' => 7)), + )), + )); + $result = $this->_provider->getVariant( + 'obj-flag', + new FeatureFlags_MixpanelSelectedVariant(null, null), + array('distinct_id' => 'u1'), + false + ); + $this->assertSame(42, $result->variantValue['threshold']); + $this->assertSame(7, $result->variantValue['nested']['count']); + } + + public function testRuntimeRuleEmailContainsMatch() { + $rule = array( + 'in' => array('gmail', array('var' => 'email')), + ); + $flag = $this->makeFlag('rt-flag', array('on' => true)); + $flag['ruleset']['rollout'][0]['runtime_evaluation_rule'] = $rule; + $this->_provider->setDefinitionsForTest(array('rt-flag' => $flag)); + + $result = $this->_provider->getVariant( + 'rt-flag', + new FeatureFlags_MixpanelSelectedVariant(null, false), + array( + 'distinct_id' => 'u1', + 'custom_properties' => array('email' => 'Alice@GMAIL.com'), + ), + false + ); + $this->assertEquals('on', $result->variantKey); + } + + public function testRuntimeRuleMissingCustomPropertiesIsNotMatch() { + $rule = array('in' => array('gmail', array('var' => 'email'))); + $flag = $this->makeFlag('rt-flag', array('on' => true)); + $flag['ruleset']['rollout'][0]['runtime_evaluation_rule'] = $rule; + $this->_provider->setDefinitionsForTest(array('rt-flag' => $flag)); + + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, false); + $result = $this->_provider->getVariant('rt-flag', $fallback, array('distinct_id' => 'u1'), false); + $this->assertSame($fallback, $result); + $this->assertEquals( + FeatureFlags_MixpanelFlagsBase::REASON_NO_ROLLOUT_MATCH, + $this->_provider->lastFailureReason() + ); + } + + public function testIsEnabledReturnsTrueOnlyForBooleanTrueVariantValue() { + $this->_provider->setDefinitionsForTest(array( + 'bool-flag' => $this->makeFlag('bool-flag', array('on' => true)), + 'str-flag' => $this->makeFlag('str-flag', array('on' => 'yes')), + )); + $this->assertTrue($this->_provider->isEnabled('bool-flag', array('distinct_id' => 'u1'))); + $this->assertFalse($this->_provider->isEnabled('str-flag', array('distinct_id' => 'u1'))); + $this->assertFalse($this->_provider->isEnabled('missing-flag', array('distinct_id' => 'u1'))); + } + + public function testGetAllVariantsExcludesFallbacks() { + $this->_provider->setDefinitionsForTest(array( + 'a' => $this->makeFlag('a', array('on' => 1)), + 'b' => $this->makeFlag('b', array('on' => 2), 'distinct_id', 0.0), // 0% rollout + )); + $all = $this->_provider->getAllVariants(array('distinct_id' => 'u1')); + $this->assertArrayHasKey('a', $all); + $this->assertArrayNotHasKey('b', $all); + } +} diff --git a/test/FeatureFlags/MixpanelRemoteFlagsTest.php b/test/FeatureFlags/MixpanelRemoteFlagsTest.php new file mode 100644 index 0000000..7170d30 --- /dev/null +++ b/test/FeatureFlags/MixpanelRemoteFlagsTest.php @@ -0,0 +1,145 @@ +lastRequest = array('path' => $path, 'query' => $query); + if ($this->nextError !== null) { + throw new Exception($this->nextError); + } + return $this->nextResponse === null ? array() : $this->nextResponse; + } +} + +class MixpanelRemoteFlagsTest extends PHPUnit\Framework\TestCase { + + /** @var array */ + private $_captured; + + /** @var _TestableRemoteFlags */ + private $_provider; + + protected function setUp() : void { + $this->_captured = array(); + $captured = &$this->_captured; + $tracker = function ($distinctId, $eventName, $properties) use (&$captured) { + $captured[] = array($distinctId, $eventName, $properties); + }; + $this->_provider = new _TestableRemoteFlags('token', '2.11.0', $tracker, array( + 'flags' => array('mode' => 'remote'), + )); + } + + public function testGetVariantSendsContextAndFlagKey() { + $this->_provider->nextResponse = array('flags' => array( + 'my-flag' => array('variant_key' => 'on', 'variant_value' => true), + )); + $context = array('distinct_id' => 'u1', 'custom_properties' => array('email' => 'a@b.com')); + $variant = $this->_provider->getVariant( + 'my-flag', + new FeatureFlags_MixpanelSelectedVariant(null, false), + $context + ); + + $this->assertEquals('on', $variant->variantKey); + $this->assertSame(true, $variant->variantValue); + $this->assertEquals('/flags', $this->_provider->lastRequest['path']); + $this->assertEquals('my-flag', $this->_provider->lastRequest['query']['flag_key']); + $this->assertEquals(json_encode($context), $this->_provider->lastRequest['query']['context']); + } + + public function testTracksExposureOnSuccess() { + $this->_provider->nextResponse = array('flags' => array( + 'my-flag' => array('variant_key' => 'on', 'variant_value' => true, 'experiment_id' => 'X'), + )); + $this->_provider->getVariant( + 'my-flag', + new FeatureFlags_MixpanelSelectedVariant(null, false), + array('distinct_id' => 'u1') + ); + $this->assertCount(1, $this->_captured); + list($distinctId, $event, $props) = $this->_captured[0]; + $this->assertEquals('u1', $distinctId); + $this->assertEquals('$experiment_started', $event); + $this->assertEquals('remote', $props['Flag evaluation mode']); + $this->assertEquals('X', $props['$experiment_id']); + } + + public function testFlagMissingInResponseSetsFlagNotFoundReason() { + $this->_provider->nextResponse = array('flags' => array( + 'other-flag' => array('variant_key' => 'on', 'variant_value' => true), + )); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); + $variant = $this->_provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); + $this->assertSame($fallback, $variant); + $this->assertEquals( + FeatureFlags_MixpanelFlagsBase::REASON_FLAG_NOT_FOUND, + $this->_provider->lastFailureReason() + ); + } + + public function testBackendErrorIsSurfacedDistinctlyFromFlagNotFound() { + // Audit finding #7: a backend error (HTTP 4xx/5xx) must not be + // indistinguishable from "flag not found" — otherwise a future + // OF wrapper translates it to FLAG_NOT_FOUND when it should be + // GENERAL. + $this->_provider->nextError = 'simulated HTTP 500'; + $errors = array(); + $errorCallback = function ($code, $message) use (&$errors) { + $errors[] = $message; + }; + $captured = array(); + $tracker = function ($d, $e, $p) use (&$captured) { + $captured[] = $p; + }; + $provider = new _TestableRemoteFlags('token', '2.11.0', $tracker, array( + 'error_callback' => $errorCallback, + 'flags' => array('mode' => 'remote'), + )); + $provider->nextError = 'simulated HTTP 500'; + + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); + $variant = $provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); + $this->assertSame($fallback, $variant); + $this->assertEquals( + FeatureFlags_MixpanelFlagsBase::REASON_BACKEND_ERROR, + $provider->lastFailureReason() + ); + $this->assertCount(1, $errors); + $this->assertStringContainsString('simulated HTTP 500', $errors[0]); + } + + public function testReportExposureFalseSkipsTracking() { + $this->_provider->nextResponse = array('flags' => array( + 'my-flag' => array('variant_key' => 'on', 'variant_value' => true), + )); + $this->_provider->getVariant( + 'my-flag', + new FeatureFlags_MixpanelSelectedVariant(null, false), + array('distinct_id' => 'u1'), + false + ); + $this->assertCount(0, $this->_captured); + } + + public function testGetAllVariantsOmitsFlagKeyQueryParam() { + $this->_provider->nextResponse = array('flags' => array( + 'a' => array('variant_key' => 'on', 'variant_value' => 1), + )); + $this->_provider->getAllVariants(array('distinct_id' => 'u1')); + $this->assertArrayNotHasKey('flag_key', $this->_provider->lastRequest['query']); + } +} diff --git a/test/MixpanelTest.php b/test/MixpanelTest.php index 9380612..59db582 100644 --- a/test/MixpanelTest.php +++ b/test/MixpanelTest.php @@ -1,18 +1,18 @@ _instance = Mixpanel::getInstance("token"); } - protected function tearDown() { + protected function tearDown() : void { parent::tearDown(); $this->_instance->reset(); $this->_instance = null; diff --git a/test/Producers/MixpanelEventsProducerTest.php b/test/Producers/MixpanelEventsProducerTest.php index b38172b..d169605 100644 --- a/test/Producers/MixpanelEventsProducerTest.php +++ b/test/Producers/MixpanelEventsProducerTest.php @@ -1,19 +1,19 @@ _instance = new Producers_MixpanelEvents("token"); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance->reset(); @@ -86,7 +86,7 @@ public function testCreateAlias() { public function testCreateAliasRespectsConsumerSetting() { $tmp_file = __DIR__ . '/test.tmp'; - $this->assertFileNotExists($tmp_file); + $this->assertFalse(file_exists($tmp_file)); $options = array('consumer' => 'file', 'file' => $tmp_file); $instance = new Producers_MixpanelEvents('token', $options); diff --git a/test/Producers/MixpanelGroupsProducerTest.php b/test/Producers/MixpanelGroupsProducerTest.php index 8c735ef..e0c8ed3 100644 --- a/test/Producers/MixpanelGroupsProducerTest.php +++ b/test/Producers/MixpanelGroupsProducerTest.php @@ -1,19 +1,19 @@ _instance = new Producers_MixpanelGroups("token"); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance->reset(); @@ -97,9 +97,9 @@ public function testRemove() { $this->assertEquals("Mixpanel", $msg['$group_id']); $this->assertEquals("token", $msg['$token']); $this->assertArrayNotHasKey('$ignore_time', $msg); - $this->assertArrayHasKey('$unset', $msg); - $this->assertArrayHasKey("industry", $msg['$unset']); - $this->assertEquals("tech", $msg['$unset']['industry']); + $this->assertArrayHasKey('$remove', $msg); + $this->assertArrayHasKey("industry", $msg['$remove']); + $this->assertEquals("tech", $msg['$remove']['industry']); } diff --git a/test/Producers/MixpanelPeopleProducerTest.php b/test/Producers/MixpanelPeopleProducerTest.php index e748e26..d4d29f2 100644 --- a/test/Producers/MixpanelPeopleProducerTest.php +++ b/test/Producers/MixpanelPeopleProducerTest.php @@ -1,19 +1,19 @@ _instance = new Producers_MixpanelPeople("token"); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance->reset(); From b8dcf50621dbd2b711ad2bf630214b0ea8341c5b Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Tue, 23 Jun 2026 11:35:54 -0400 Subject: [PATCH 02/20] feat(flags): emit ISO start/complete timestamps in remote exposure Aligns the PHP remote-mode $experiment_started payload with the Python, Ruby, Go, Java, Node, and Browser SDKs, which all include "Variant fetch start time" and "Variant fetch complete time" as ISO-8601 local-time strings with microsecond precision. Local mode is unchanged (only "Variant fetch latency (ms)" applies when there's no network round-trip to span). Co-Authored-By: Claude Opus 4.7 --- lib/FeatureFlags/MixpanelFlagsBase.php | 61 +++++++++++++++++-- lib/FeatureFlags/MixpanelRemoteFlags.php | 8 ++- test/FeatureFlags/MixpanelRemoteFlagsTest.php | 8 +++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/lib/FeatureFlags/MixpanelFlagsBase.php b/lib/FeatureFlags/MixpanelFlagsBase.php index d58481c..b80d4f9 100644 --- a/lib/FeatureFlags/MixpanelFlagsBase.php +++ b/lib/FeatureFlags/MixpanelFlagsBase.php @@ -143,16 +143,31 @@ protected function _httpGet($path, array $query = array()) { } /** - * Build the standard $experiment_started property set, optionally - * tagged with a latency measurement. + * Build the standard $experiment_started property set. + * + * Local mode supplies $latencyMs (derived from microtime around the + * in-process eval); remote mode supplies $startTime / $endTime + * (microtime floats around the HTTP call) and we derive latency + * here and emit ISO-8601 "Variant fetch start time" / "complete + * time" strings to match the Python, Ruby, Go, Java, Node, and + * Browser SDKs in remote mode. * * @param string $flagKey * @param FeatureFlags_MixpanelSelectedVariant $variant * @param string $evaluationMode 'local' or 'remote' - * @param float|null $latencyMs + * @param float|null $latencyMs when supplied directly (local mode) + * @param float|null $startTime microtime(true) before the HTTP call (remote mode) + * @param float|null $endTime microtime(true) after the HTTP call (remote mode) * @return array */ - protected function _buildExposureProperties($flagKey, FeatureFlags_MixpanelSelectedVariant $variant, $evaluationMode, $latencyMs = null) { + protected function _buildExposureProperties( + $flagKey, + FeatureFlags_MixpanelSelectedVariant $variant, + $evaluationMode, + $latencyMs = null, + $startTime = null, + $endTime = null + ) { $properties = array( 'Experiment name' => $flagKey, 'Variant name' => $variant->variantKey, @@ -162,12 +177,36 @@ protected function _buildExposureProperties($flagKey, FeatureFlags_MixpanelSelec '$is_experiment_active' => $variant->isExperimentActive, '$is_qa_tester' => $variant->isQaTester, ); + if ($startTime !== null && $endTime !== null) { + $properties['Variant fetch start time'] = self::_formatIsoMicrotime($startTime); + $properties['Variant fetch complete time'] = self::_formatIsoMicrotime($endTime); + if ($latencyMs === null) { + $latencyMs = ($endTime - $startTime) * 1000.0; + } + } if ($latencyMs !== null) { $properties['Variant fetch latency (ms)'] = $latencyMs; } return $properties; } + /** + * Format a microtime(true) float as a local-time ISO-8601 string + * with microsecond precision, matching Python's + * `datetime.now().isoformat()` output shape so cross-SDK analytics + * keyed on these properties parse consistently. + */ + private static function _formatIsoMicrotime($microtime) { + $seconds = (int) floor($microtime); + $micros = (int) round(($microtime - $seconds) * 1000000); + if ($micros >= 1000000) { + // round-up edge case at the second boundary + $seconds += 1; + $micros = 0; + } + return date('Y-m-d\TH:i:s', $seconds) . '.' . sprintf('%06d', $micros); + } + /** * Dispatch the exposure event. Routes through the configured * tracker (normally Mixpanel::track), which means the event lands @@ -182,7 +221,15 @@ protected function _buildExposureProperties($flagKey, FeatureFlags_MixpanelSelec * @param string $evaluationMode * @param float|null $latencyMs */ - protected function _trackExposure($flagKey, FeatureFlags_MixpanelSelectedVariant $variant, array $context, $evaluationMode, $latencyMs = null) { + protected function _trackExposure( + $flagKey, + FeatureFlags_MixpanelSelectedVariant $variant, + array $context, + $evaluationMode, + $latencyMs = null, + $startTime = null, + $endTime = null + ) { if (!isset($context['distinct_id']) || $context['distinct_id'] === '' || $context['distinct_id'] === null) { // Don't drop silently — surface to the error_callback so the // caller learns why their exposure analytics are empty @@ -194,7 +241,9 @@ protected function _trackExposure($flagKey, FeatureFlags_MixpanelSelectedVariant return; } $distinctId = $context['distinct_id']; - $properties = $this->_buildExposureProperties($flagKey, $variant, $evaluationMode, $latencyMs); + $properties = $this->_buildExposureProperties( + $flagKey, $variant, $evaluationMode, $latencyMs, $startTime, $endTime + ); try { call_user_func($this->_tracker, $distinctId, FeatureFlags_MixpanelFlagsUtils::EXPOSURE_EVENT, $properties); diff --git a/lib/FeatureFlags/MixpanelRemoteFlags.php b/lib/FeatureFlags/MixpanelRemoteFlags.php index edb5d6b..fe7e54e 100644 --- a/lib/FeatureFlags/MixpanelRemoteFlags.php +++ b/lib/FeatureFlags/MixpanelRemoteFlags.php @@ -34,7 +34,7 @@ public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallb $this->_handleError($e->getCode(), 'Remote flag fetch failed: ' . $e->getMessage()); return $fallback; } - $latencyMs = (microtime(true) - $startTime) * 1000.0; + $endTime = microtime(true); if (!isset($flags[$flagKey])) { $this->_lastFailureReason = self::REASON_FLAG_NOT_FOUND; @@ -45,7 +45,11 @@ public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallb $this->_lastFailureReason = self::REASON_OK; if ($reportExposure) { - $this->_trackExposure($flagKey, $selected, $context, 'remote', $latencyMs); + // Pass start/end so the exposure event carries + // "Variant fetch start time" / "Variant fetch complete + // time" ISO strings, matching Python/Ruby/Go/Java/Node + // remote-mode payloads. Latency is derived from the pair. + $this->_trackExposure($flagKey, $selected, $context, 'remote', null, $startTime, $endTime); } return $selected; diff --git a/test/FeatureFlags/MixpanelRemoteFlagsTest.php b/test/FeatureFlags/MixpanelRemoteFlagsTest.php index 7170d30..93b5fba 100644 --- a/test/FeatureFlags/MixpanelRemoteFlagsTest.php +++ b/test/FeatureFlags/MixpanelRemoteFlagsTest.php @@ -76,6 +76,14 @@ public function testTracksExposureOnSuccess() { $this->assertEquals('$experiment_started', $event); $this->assertEquals('remote', $props['Flag evaluation mode']); $this->assertEquals('X', $props['$experiment_id']); + // Remote-mode exposure carries the ISO start/complete timestamps + // so PHP analytics align with Python/Ruby/Go/Java/Node remote payloads. + $this->assertArrayHasKey('Variant fetch start time', $props); + $this->assertArrayHasKey('Variant fetch complete time', $props); + $this->assertArrayHasKey('Variant fetch latency (ms)', $props); + $iso = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}$/'; + $this->assertEquals(1, preg_match($iso, $props['Variant fetch start time'])); + $this->assertEquals(1, preg_match($iso, $props['Variant fetch complete time'])); } public function testFlagMissingInResponseSetsFlagNotFoundReason() { From d8ae1f9e63e3fd88e26661173fba95b8fab1da77 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 24 Jun 2026 11:50:00 -0400 Subject: [PATCH 03/20] feat(flags): add MODE_LOCAL / MODE_REMOTE class constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gives callers a grep-able, IDE-checkable alternative to bare "local"/"remote" string literals for the flags mode config. The raw strings remain valid input — these are exact aliases — so this is purely additive. PHP 7.x has no native enums; adopting these constants gets us most of the type-safety benefit without bumping the composer floor. A future major version targeting PHP 8.1+ can promote these to a backed enum. Co-Authored-By: Claude Opus 4.7 --- README.md | 8 ++++++-- examples/feature_flags.php | 6 ++++-- lib/FeatureFlags/MixpanelFlags.php | 17 +++++++++++++---- test/FeatureFlags/MixpanelFlagsTest.php | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 33ca2c5..4e4ffdd 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ once per process and evaluates in-process). ```php $mp = Mixpanel::getInstance("MIXPANEL_PROJECT_TOKEN", array( "flags" => array( - "mode" => "remote", // or "local" + // Mode accepts the MODE_* class constants or the raw strings + // "remote" / "local" — both are equivalent. + "mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE, "report_exposure" => true, ), )); @@ -120,7 +122,9 @@ In local mode, definitions are loaded explicitly — PHP's request-per-process model means we deliberately do not spawn background polling threads: ```php -$mp = Mixpanel::getInstance("TOKEN", array("flags" => array("mode" => "local"))); +$mp = Mixpanel::getInstance("TOKEN", array( + "flags" => array("mode" => FeatureFlags_MixpanelFlags::MODE_LOCAL), +)); $mp->flags->loadDefinitions(); // fetch once per process $enabled = $mp->flags->isEnabled("my-flag", $context); ``` diff --git a/examples/feature_flags.php b/examples/feature_flags.php index 5b8c893..6dc4a94 100644 --- a/examples/feature_flags.php +++ b/examples/feature_flags.php @@ -4,7 +4,9 @@ // Replace with your project token. $mp = Mixpanel::getInstance("MY_TOKEN", array( "flags" => array( - "mode" => "remote", // 'local' or 'remote' + // Either the MODE_* constants or the raw strings 'local' / + // 'remote' are accepted. + "mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE, "report_exposure" => true, ), )); @@ -55,7 +57,7 @@ // Local mode usage: // // $mp = Mixpanel::getInstance("MY_TOKEN", array( -// "flags" => array("mode" => "local"), +// "flags" => array("mode" => FeatureFlags_MixpanelFlags::MODE_LOCAL), // )); // $mp->flags->loadDefinitions(); // fetch once per process // $variant = $mp->flags->getVariant("my-flag", $fallback, $context); diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php index 09d878d..73894cf 100644 --- a/lib/FeatureFlags/MixpanelFlags.php +++ b/lib/FeatureFlags/MixpanelFlags.php @@ -11,7 +11,7 @@ * Usage: * * $mp = Mixpanel::getInstance('TOKEN', array( - * 'flags' => array('mode' => 'remote'), + * 'flags' => array('mode' => FeatureFlags_MixpanelFlags::MODE_REMOTE), * )); * $enabled = $mp->flags->isEnabled('my-flag', array( * 'distinct_id' => 'user-123', @@ -19,10 +19,19 @@ */ class FeatureFlags_MixpanelFlags { + /** + * Evaluation mode values for the `mode` config key. PHP 7.x has no + * native enums, but these constants give callers an IDE-checkable, + * grep-able alternative to bare string literals. The raw strings + * remain valid input — these are exact aliases. + */ + const MODE_LOCAL = 'local'; + const MODE_REMOTE = 'remote'; + /** @var FeatureFlags_MixpanelFlagsBase */ private $_provider; - /** @var string */ + /** @var string one of the MODE_* constants */ private $_mode; public function __construct($token, $version, $tracker, array $options) { @@ -44,9 +53,9 @@ public function __construct($token, $version, $tracker, array $options) { } $flagsOpts = isset($options['flags']) && is_array($options['flags']) ? $options['flags'] : array(); - $this->_mode = isset($flagsOpts['mode']) ? strtolower((string) $flagsOpts['mode']) : 'remote'; + $this->_mode = isset($flagsOpts['mode']) ? strtolower((string) $flagsOpts['mode']) : self::MODE_REMOTE; - if ($this->_mode === 'local') { + if ($this->_mode === self::MODE_LOCAL) { $this->_provider = new FeatureFlags_MixpanelLocalFlags($token, $version, $tracker, $options); } else { $this->_provider = new FeatureFlags_MixpanelRemoteFlags($token, $version, $tracker, $options); diff --git a/test/FeatureFlags/MixpanelFlagsTest.php b/test/FeatureFlags/MixpanelFlagsTest.php index 971e86d..d6bbe7c 100644 --- a/test/FeatureFlags/MixpanelFlagsTest.php +++ b/test/FeatureFlags/MixpanelFlagsTest.php @@ -46,6 +46,27 @@ public function testDefaultModeIsRemote() { $this->assertEquals('remote', $mp->flags->getMode()); } + public function testModeConstantsAreStable() { + // The MODE_* constants are part of the public API; lock their + // string values so a refactor can't silently change them and + // break callers that compare against them. + $this->assertSame('local', FeatureFlags_MixpanelFlags::MODE_LOCAL); + $this->assertSame('remote', FeatureFlags_MixpanelFlags::MODE_REMOTE); + } + + public function testModeAcceptsConstantOrStringLiteral() { + $viaConstant = new Mixpanel('token-c', array( + 'flags' => array('mode' => FeatureFlags_MixpanelFlags::MODE_LOCAL), + )); + $viaLiteral = new Mixpanel('token-l', array( + 'flags' => array('mode' => 'local'), + )); + $this->assertEquals('local', $viaConstant->flags->getMode()); + $this->assertEquals('local', $viaLiteral->flags->getMode()); + $this->assertInstanceOf('FeatureFlags_MixpanelLocalFlags', $viaConstant->flags->getProvider()); + $this->assertInstanceOf('FeatureFlags_MixpanelLocalFlags', $viaLiteral->flags->getProvider()); + } + public function testFlagsApiHostInheritsFromTopLevelHost() { // If the caller already pointed the SDK at a regional or mock // endpoint via the top-level `host` option, the flags module From f42140c2eb6e1bdcc5663ccf93b9ba7a2d683173 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 24 Jun 2026 12:14:43 -0400 Subject: [PATCH 04/20] feat(flags): add needsRefresh() / refresh() for local mode Long-running PHP workers can now set refresh_interval_in_seconds on the flags config and call $mp->flags->refresh() inside their main loop. refresh() is a no-op until the configured interval elapses (and performs the initial fetch if loadDefinitions never ran), so it's safe to invoke on every iteration. needsRefresh() exposes the staleness check for callers who want to handle the refresh themselves (logging, scheduling, etc.). PHP can't run a background polling thread the way Python/Ruby/ Go/Java/Node do, so this synchronous refresh-from-the-loop pattern is the closest analog. Remote mode forwards both methods as no-ops (needsRefresh returns false, refresh returns true) so callers can write mode-agnostic code. Co-Authored-By: Claude Opus 4.7 --- README.md | 27 ++++- examples/feature_flags.php | 23 ++++- lib/FeatureFlags/MixpanelFlags.php | 28 +++++ lib/FeatureFlags/MixpanelLocalFlags.php | 72 ++++++++++++- test/FeatureFlags/MixpanelFlagsTest.php | 12 +++ test/FeatureFlags/MixpanelLocalFlagsTest.php | 101 +++++++++++++++++++ 6 files changed, 253 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4e4ffdd..2fea81d 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,9 @@ $variant = $mp->flags->getVariant( ); ``` -In local mode, definitions are loaded explicitly — PHP's request-per-process -model means we deliberately do not spawn background polling threads: +In local mode, definitions are loaded explicitly — PHP has no native threading +and FPM/Apache processes don't survive past a single request, so the SDK can't +run the background polling thread the other Mixpanel server SDKs use: ```php $mp = Mixpanel::getInstance("TOKEN", array( @@ -129,6 +130,28 @@ $mp->flags->loadDefinitions(); // fetch once per process $enabled = $mp->flags->isEnabled("my-flag", $context); ``` +For long-running CLI workers, set `refresh_interval_in_seconds` and call +`refresh()` in your main loop. It's a no-op until the interval elapses (and +performs the initial fetch if `loadDefinitions()` was never called), so it's +safe to call on every iteration: + +```php +$mp = Mixpanel::getInstance("TOKEN", array( + "flags" => array( + "mode" => FeatureFlags_MixpanelFlags::MODE_LOCAL, + "refresh_interval_in_seconds" => 60, + ), +)); + +while ($job = $queue->next()) { + $mp->flags->refresh(); // re-fetches definitions when older than 60s + processJob($job, $mp); +} +``` + +Use `needsRefresh()` if you want to make the staleness check yourself (e.g., +log it, schedule the refresh elsewhere). + `lastFailureReason()` distinguishes the four ways an evaluation can fall through (`FLAG_NOT_FOUND`, `MISSING_CONTEXT_KEY`, `NO_ROLLOUT_MATCH`, `BACKEND_ERROR`) — useful when debugging or building higher-level wrappers. diff --git a/examples/feature_flags.php b/examples/feature_flags.php index 6dc4a94..f24ffd3 100644 --- a/examples/feature_flags.php +++ b/examples/feature_flags.php @@ -62,10 +62,25 @@ // $mp->flags->loadDefinitions(); // fetch once per process // $variant = $mp->flags->getVariant("my-flag", $fallback, $context); // -// In long-running CLI workers you can call loadDefinitions() on -// whatever schedule fits (e.g., every N minutes). The PHP SDK does not -// spawn background polling threads — request-per-process FPM/Apache -// deployments don't have a place to host them. +// In long-running CLI workers, set "refresh_interval_in_seconds" and +// call $mp->flags->refresh() in your main loop. refresh() is a no-op +// until the interval elapses (and performs the initial fetch if +// loadDefinitions hasn't run), so it's safe on every iteration: +// +// $mp = Mixpanel::getInstance("MY_TOKEN", array( +// "flags" => array( +// "mode" => FeatureFlags_MixpanelFlags::MODE_LOCAL, +// "refresh_interval_in_seconds" => 60, +// ), +// )); +// while ($job = $queue->next()) { +// $mp->flags->refresh(); +// processJob($job); +// } +// +// PHP can't spawn background polling threads like Python/Ruby/Go/Java/ +// Node, so refresh() is the closest analog — synchronous, driven by +// the worker's main loop instead of a background thread. $mp->flags->shutdown(); $mp->flush(); diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php index 73894cf..84865ea 100644 --- a/lib/FeatureFlags/MixpanelFlags.php +++ b/lib/FeatureFlags/MixpanelFlags.php @@ -89,6 +89,34 @@ public function loadDefinitions() { return true; } + /** + * Local mode: true if cached definitions are missing or older + * than `refresh_interval_in_seconds`. Remote mode: always false + * (definitions aren't cached client-side). + * + * @return bool + */ + public function needsRefresh() { + if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { + return $this->_provider->needsRefresh(); + } + return false; + } + + /** + * Local mode: fetch definitions only when needsRefresh() returns + * true. Safe to call on every iteration of a worker loop. Remote + * mode: no-op (returns true). + * + * @return bool + */ + public function refresh() { + if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { + return $this->_provider->refresh(); + } + return true; + } + /** @return bool */ public function areFlagsReady() { if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { diff --git a/lib/FeatureFlags/MixpanelLocalFlags.php b/lib/FeatureFlags/MixpanelLocalFlags.php index d3ebf17..c220755 100644 --- a/lib/FeatureFlags/MixpanelLocalFlags.php +++ b/lib/FeatureFlags/MixpanelLocalFlags.php @@ -8,10 +8,13 @@ * call against the cached definitions using the same FNV-1a + JSON * Logic algorithms as every other Mixpanel SDK. * - * PHP's request-per-process model means we deliberately do NOT run a - * background polling thread (the Ruby SDK's poller was the source of - * the daemon-thread bug — audit finding #2). Long-lived CLI workers - * can call loadDefinitions() on whatever schedule they like. + * PHP has no native threading and FPM/Apache processes don't survive + * past a single request, so this SDK deliberately omits the background + * polling thread that the Python, Ruby, Go, Java, and Node server SDKs + * use to refresh definitions. Long-lived CLI workers should instead + * configure `refresh_interval_in_seconds` and call refresh() inside + * their main loop — refresh() is a no-op until that interval elapses, + * so it's safe to call on every iteration. */ class FeatureFlags_MixpanelLocalFlags extends FeatureFlags_MixpanelFlagsBase { @@ -26,6 +29,67 @@ class FeatureFlags_MixpanelLocalFlags extends FeatureFlags_MixpanelFlagsBase { /** @var int|null unix timestamp of last successful loadDefinitions */ private $_lastSyncedAt = null; + /** + * @var int|null seconds after which cached definitions are + * considered stale. null = no staleness check; refresh() does + * nothing in that case and the caller must manage refreshes via + * loadDefinitions() directly. + */ + private $_refreshInterval = null; + + public function __construct($token, $version, $tracker, array $options) { + parent::__construct($token, $version, $tracker, $options); + $flagsOpts = isset($options['flags']) && is_array($options['flags']) ? $options['flags'] : array(); + if (isset($flagsOpts['refresh_interval_in_seconds'])) { + $val = (int) $flagsOpts['refresh_interval_in_seconds']; + // Treat 0/negative as "always stale" (refresh fetches on + // every call) only if the caller explicitly set it; null + // means "no staleness behavior at all." + $this->_refreshInterval = max(0, $val); + } + } + + /** + * Whether cached definitions are missing or older than the + * configured refresh interval. Always returns true when + * loadDefinitions() has never succeeded; returns false in remote + * mode (the facade forwards there) since there's nothing cached + * to go stale. + * + * @return bool + */ + public function needsRefresh() { + if (!$this->_ready) { + return true; + } + if ($this->_refreshInterval === null) { + // Customer opted out of staleness checks — definitions + // are considered fresh until they explicitly reload. + return false; + } + if ($this->_lastSyncedAt === null) { + return true; + } + return (time() - $this->_lastSyncedAt) >= $this->_refreshInterval; + } + + /** + * Convenience for long-running workers: call inside the main loop + * and the SDK refreshes definitions only when they're missing or + * past the configured `refresh_interval_in_seconds`. Returns true + * when definitions are usable after the call (either already + * fresh, or freshly fetched); false when a fetch was attempted + * and failed. + * + * @return bool + */ + public function refresh() { + if (!$this->needsRefresh()) { + return true; + } + return $this->loadDefinitions(); + } + /** * Fetch the latest flag definitions from Mixpanel. Throws nothing * on transport failure — the error is routed to error_callback so diff --git a/test/FeatureFlags/MixpanelFlagsTest.php b/test/FeatureFlags/MixpanelFlagsTest.php index d6bbe7c..5f95529 100644 --- a/test/FeatureFlags/MixpanelFlagsTest.php +++ b/test/FeatureFlags/MixpanelFlagsTest.php @@ -39,6 +39,18 @@ public function testRemoteModeReportsReadyAndNoSync() { $this->assertNull($mp->flags->lastSyncedAt()); } + public function testRemoteModeNeedsRefreshAlwaysFalseAndRefreshIsNoOp() { + // Remote mode has nothing cached client-side, so the + // staleness/refresh API simply reports "never needs refresh" + // and refresh() is a no-op returning true. This lets callers + // write mode-agnostic code that targets either provider. + $mp = new Mixpanel('token-r2', array( + 'flags' => array('mode' => 'remote', 'refresh_interval_in_seconds' => 60), + )); + $this->assertFalse($mp->flags->needsRefresh()); + $this->assertTrue($mp->flags->refresh()); + } + public function testDefaultModeIsRemote() { $mp = new Mixpanel('token-default', array( 'flags' => array(), diff --git a/test/FeatureFlags/MixpanelLocalFlagsTest.php b/test/FeatureFlags/MixpanelLocalFlagsTest.php index cad7858..3ac5e8a 100644 --- a/test/FeatureFlags/MixpanelLocalFlagsTest.php +++ b/test/FeatureFlags/MixpanelLocalFlagsTest.php @@ -7,6 +7,9 @@ * would be a foot-gun — so we reach in via a subclass here. */ class _TestableLocalFlags extends FeatureFlags_MixpanelLocalFlags { + /** @var int count of loadDefinitions() calls — for refresh() tests */ + public $loadCount = 0; + public function setDefinitionsForTest(array $defs) { // Reach into private state via reflection. On PHP 8.1+ // setAccessible() is implicit, but calling it remains harmless @@ -23,6 +26,26 @@ public function setDefinitionsForTest(array $defs) { } $readyProp->setValue($this, true); } + + public function setLastSyncedAtForTest($ts) { + $reflection = new ReflectionClass('FeatureFlags_MixpanelLocalFlags'); + $prop = $reflection->getProperty('_lastSyncedAt'); + if (PHP_VERSION_ID < 80100) { + $prop->setAccessible(true); + } + $prop->setValue($this, $ts); + } + + /** + * Stub loadDefinitions so refresh()-based tests don't hit the + * network. Counts invocations and pretends every call succeeds. + */ + public function loadDefinitions() { + $this->loadCount++; + $this->setDefinitionsForTest(array()); + $this->setLastSyncedAtForTest(time()); + return true; + } } class MixpanelLocalFlagsTest extends PHPUnit\Framework\TestCase { @@ -281,4 +304,82 @@ public function testGetAllVariantsExcludesFallbacks() { $this->assertArrayHasKey('a', $all); $this->assertArrayNotHasKey('b', $all); } + + // ---- needsRefresh() / refresh() ---- + + public function testNeedsRefreshTrueBeforeFirstLoad() { + $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array('refresh_interval_in_seconds' => 60), + )); + $this->assertTrue($p->needsRefresh()); + } + + public function testNeedsRefreshFalseAfterFreshLoadWithinInterval() { + $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array('refresh_interval_in_seconds' => 60), + )); + $p->loadDefinitions(); + $this->assertFalse($p->needsRefresh()); + } + + public function testNeedsRefreshTrueAfterIntervalElapses() { + $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array('refresh_interval_in_seconds' => 60), + )); + $p->loadDefinitions(); + // Backdate lastSyncedAt past the interval. + $p->setLastSyncedAtForTest(time() - 120); + $this->assertTrue($p->needsRefresh()); + } + + public function testNeedsRefreshFalseWhenIntervalNotConfiguredEvenIfOld() { + $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array(), // no refresh_interval_in_seconds + )); + $p->loadDefinitions(); + $p->setLastSyncedAtForTest(time() - 99999); + $this->assertFalse($p->needsRefresh()); + } + + public function testRefreshIsNoOpWhenFresh() { + $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array('refresh_interval_in_seconds' => 60), + )); + $p->loadDefinitions(); + $loadsBefore = $p->loadCount; + $this->assertTrue($p->refresh()); + $this->assertEquals($loadsBefore, $p->loadCount); + } + + public function testRefreshFetchesWhenStale() { + $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array('refresh_interval_in_seconds' => 60), + )); + $p->loadDefinitions(); + $p->setLastSyncedAtForTest(time() - 120); + $loadsBefore = $p->loadCount; + $this->assertTrue($p->refresh()); + $this->assertEquals($loadsBefore + 1, $p->loadCount); + } + + public function testRefreshPerformsInitialFetchWhenNotYetLoaded() { + // No prior loadDefinitions(); refresh() should kick off the first fetch. + $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array('refresh_interval_in_seconds' => 60), + )); + $this->assertEquals(0, $p->loadCount); + $this->assertTrue($p->refresh()); + $this->assertEquals(1, $p->loadCount); + $this->assertTrue($p->areFlagsReady()); + } + + public function testRefreshPerformsInitialFetchEvenWithoutConfiguredInterval() { + // needsRefresh() returns true when !ready regardless of interval config; + // refresh() should still do the initial fetch. + $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array(), + )); + $this->assertTrue($p->refresh()); + $this->assertEquals(1, $p->loadCount); + } } From b09af45078923f2bf2c40e54dab73458f55ffcf5 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 24 Jun 2026 12:18:48 -0400 Subject: [PATCH 05/20] Revert "feat(flags): add needsRefresh() / refresh() for local mode" This reverts commit f42140c2eb6e1bdcc5663ccf93b9ba7a2d683173. --- README.md | 27 +---- examples/feature_flags.php | 23 +---- lib/FeatureFlags/MixpanelFlags.php | 28 ----- lib/FeatureFlags/MixpanelLocalFlags.php | 72 +------------ test/FeatureFlags/MixpanelFlagsTest.php | 12 --- test/FeatureFlags/MixpanelLocalFlagsTest.php | 101 ------------------- 6 files changed, 10 insertions(+), 253 deletions(-) diff --git a/README.md b/README.md index 2fea81d..4e4ffdd 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,8 @@ $variant = $mp->flags->getVariant( ); ``` -In local mode, definitions are loaded explicitly — PHP has no native threading -and FPM/Apache processes don't survive past a single request, so the SDK can't -run the background polling thread the other Mixpanel server SDKs use: +In local mode, definitions are loaded explicitly — PHP's request-per-process +model means we deliberately do not spawn background polling threads: ```php $mp = Mixpanel::getInstance("TOKEN", array( @@ -130,28 +129,6 @@ $mp->flags->loadDefinitions(); // fetch once per process $enabled = $mp->flags->isEnabled("my-flag", $context); ``` -For long-running CLI workers, set `refresh_interval_in_seconds` and call -`refresh()` in your main loop. It's a no-op until the interval elapses (and -performs the initial fetch if `loadDefinitions()` was never called), so it's -safe to call on every iteration: - -```php -$mp = Mixpanel::getInstance("TOKEN", array( - "flags" => array( - "mode" => FeatureFlags_MixpanelFlags::MODE_LOCAL, - "refresh_interval_in_seconds" => 60, - ), -)); - -while ($job = $queue->next()) { - $mp->flags->refresh(); // re-fetches definitions when older than 60s - processJob($job, $mp); -} -``` - -Use `needsRefresh()` if you want to make the staleness check yourself (e.g., -log it, schedule the refresh elsewhere). - `lastFailureReason()` distinguishes the four ways an evaluation can fall through (`FLAG_NOT_FOUND`, `MISSING_CONTEXT_KEY`, `NO_ROLLOUT_MATCH`, `BACKEND_ERROR`) — useful when debugging or building higher-level wrappers. diff --git a/examples/feature_flags.php b/examples/feature_flags.php index f24ffd3..6dc4a94 100644 --- a/examples/feature_flags.php +++ b/examples/feature_flags.php @@ -62,25 +62,10 @@ // $mp->flags->loadDefinitions(); // fetch once per process // $variant = $mp->flags->getVariant("my-flag", $fallback, $context); // -// In long-running CLI workers, set "refresh_interval_in_seconds" and -// call $mp->flags->refresh() in your main loop. refresh() is a no-op -// until the interval elapses (and performs the initial fetch if -// loadDefinitions hasn't run), so it's safe on every iteration: -// -// $mp = Mixpanel::getInstance("MY_TOKEN", array( -// "flags" => array( -// "mode" => FeatureFlags_MixpanelFlags::MODE_LOCAL, -// "refresh_interval_in_seconds" => 60, -// ), -// )); -// while ($job = $queue->next()) { -// $mp->flags->refresh(); -// processJob($job); -// } -// -// PHP can't spawn background polling threads like Python/Ruby/Go/Java/ -// Node, so refresh() is the closest analog — synchronous, driven by -// the worker's main loop instead of a background thread. +// In long-running CLI workers you can call loadDefinitions() on +// whatever schedule fits (e.g., every N minutes). The PHP SDK does not +// spawn background polling threads — request-per-process FPM/Apache +// deployments don't have a place to host them. $mp->flags->shutdown(); $mp->flush(); diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php index 84865ea..73894cf 100644 --- a/lib/FeatureFlags/MixpanelFlags.php +++ b/lib/FeatureFlags/MixpanelFlags.php @@ -89,34 +89,6 @@ public function loadDefinitions() { return true; } - /** - * Local mode: true if cached definitions are missing or older - * than `refresh_interval_in_seconds`. Remote mode: always false - * (definitions aren't cached client-side). - * - * @return bool - */ - public function needsRefresh() { - if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { - return $this->_provider->needsRefresh(); - } - return false; - } - - /** - * Local mode: fetch definitions only when needsRefresh() returns - * true. Safe to call on every iteration of a worker loop. Remote - * mode: no-op (returns true). - * - * @return bool - */ - public function refresh() { - if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { - return $this->_provider->refresh(); - } - return true; - } - /** @return bool */ public function areFlagsReady() { if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { diff --git a/lib/FeatureFlags/MixpanelLocalFlags.php b/lib/FeatureFlags/MixpanelLocalFlags.php index c220755..d3ebf17 100644 --- a/lib/FeatureFlags/MixpanelLocalFlags.php +++ b/lib/FeatureFlags/MixpanelLocalFlags.php @@ -8,13 +8,10 @@ * call against the cached definitions using the same FNV-1a + JSON * Logic algorithms as every other Mixpanel SDK. * - * PHP has no native threading and FPM/Apache processes don't survive - * past a single request, so this SDK deliberately omits the background - * polling thread that the Python, Ruby, Go, Java, and Node server SDKs - * use to refresh definitions. Long-lived CLI workers should instead - * configure `refresh_interval_in_seconds` and call refresh() inside - * their main loop — refresh() is a no-op until that interval elapses, - * so it's safe to call on every iteration. + * PHP's request-per-process model means we deliberately do NOT run a + * background polling thread (the Ruby SDK's poller was the source of + * the daemon-thread bug — audit finding #2). Long-lived CLI workers + * can call loadDefinitions() on whatever schedule they like. */ class FeatureFlags_MixpanelLocalFlags extends FeatureFlags_MixpanelFlagsBase { @@ -29,67 +26,6 @@ class FeatureFlags_MixpanelLocalFlags extends FeatureFlags_MixpanelFlagsBase { /** @var int|null unix timestamp of last successful loadDefinitions */ private $_lastSyncedAt = null; - /** - * @var int|null seconds after which cached definitions are - * considered stale. null = no staleness check; refresh() does - * nothing in that case and the caller must manage refreshes via - * loadDefinitions() directly. - */ - private $_refreshInterval = null; - - public function __construct($token, $version, $tracker, array $options) { - parent::__construct($token, $version, $tracker, $options); - $flagsOpts = isset($options['flags']) && is_array($options['flags']) ? $options['flags'] : array(); - if (isset($flagsOpts['refresh_interval_in_seconds'])) { - $val = (int) $flagsOpts['refresh_interval_in_seconds']; - // Treat 0/negative as "always stale" (refresh fetches on - // every call) only if the caller explicitly set it; null - // means "no staleness behavior at all." - $this->_refreshInterval = max(0, $val); - } - } - - /** - * Whether cached definitions are missing or older than the - * configured refresh interval. Always returns true when - * loadDefinitions() has never succeeded; returns false in remote - * mode (the facade forwards there) since there's nothing cached - * to go stale. - * - * @return bool - */ - public function needsRefresh() { - if (!$this->_ready) { - return true; - } - if ($this->_refreshInterval === null) { - // Customer opted out of staleness checks — definitions - // are considered fresh until they explicitly reload. - return false; - } - if ($this->_lastSyncedAt === null) { - return true; - } - return (time() - $this->_lastSyncedAt) >= $this->_refreshInterval; - } - - /** - * Convenience for long-running workers: call inside the main loop - * and the SDK refreshes definitions only when they're missing or - * past the configured `refresh_interval_in_seconds`. Returns true - * when definitions are usable after the call (either already - * fresh, or freshly fetched); false when a fetch was attempted - * and failed. - * - * @return bool - */ - public function refresh() { - if (!$this->needsRefresh()) { - return true; - } - return $this->loadDefinitions(); - } - /** * Fetch the latest flag definitions from Mixpanel. Throws nothing * on transport failure — the error is routed to error_callback so diff --git a/test/FeatureFlags/MixpanelFlagsTest.php b/test/FeatureFlags/MixpanelFlagsTest.php index 5f95529..d6bbe7c 100644 --- a/test/FeatureFlags/MixpanelFlagsTest.php +++ b/test/FeatureFlags/MixpanelFlagsTest.php @@ -39,18 +39,6 @@ public function testRemoteModeReportsReadyAndNoSync() { $this->assertNull($mp->flags->lastSyncedAt()); } - public function testRemoteModeNeedsRefreshAlwaysFalseAndRefreshIsNoOp() { - // Remote mode has nothing cached client-side, so the - // staleness/refresh API simply reports "never needs refresh" - // and refresh() is a no-op returning true. This lets callers - // write mode-agnostic code that targets either provider. - $mp = new Mixpanel('token-r2', array( - 'flags' => array('mode' => 'remote', 'refresh_interval_in_seconds' => 60), - )); - $this->assertFalse($mp->flags->needsRefresh()); - $this->assertTrue($mp->flags->refresh()); - } - public function testDefaultModeIsRemote() { $mp = new Mixpanel('token-default', array( 'flags' => array(), diff --git a/test/FeatureFlags/MixpanelLocalFlagsTest.php b/test/FeatureFlags/MixpanelLocalFlagsTest.php index 3ac5e8a..cad7858 100644 --- a/test/FeatureFlags/MixpanelLocalFlagsTest.php +++ b/test/FeatureFlags/MixpanelLocalFlagsTest.php @@ -7,9 +7,6 @@ * would be a foot-gun — so we reach in via a subclass here. */ class _TestableLocalFlags extends FeatureFlags_MixpanelLocalFlags { - /** @var int count of loadDefinitions() calls — for refresh() tests */ - public $loadCount = 0; - public function setDefinitionsForTest(array $defs) { // Reach into private state via reflection. On PHP 8.1+ // setAccessible() is implicit, but calling it remains harmless @@ -26,26 +23,6 @@ public function setDefinitionsForTest(array $defs) { } $readyProp->setValue($this, true); } - - public function setLastSyncedAtForTest($ts) { - $reflection = new ReflectionClass('FeatureFlags_MixpanelLocalFlags'); - $prop = $reflection->getProperty('_lastSyncedAt'); - if (PHP_VERSION_ID < 80100) { - $prop->setAccessible(true); - } - $prop->setValue($this, $ts); - } - - /** - * Stub loadDefinitions so refresh()-based tests don't hit the - * network. Counts invocations and pretends every call succeeds. - */ - public function loadDefinitions() { - $this->loadCount++; - $this->setDefinitionsForTest(array()); - $this->setLastSyncedAtForTest(time()); - return true; - } } class MixpanelLocalFlagsTest extends PHPUnit\Framework\TestCase { @@ -304,82 +281,4 @@ public function testGetAllVariantsExcludesFallbacks() { $this->assertArrayHasKey('a', $all); $this->assertArrayNotHasKey('b', $all); } - - // ---- needsRefresh() / refresh() ---- - - public function testNeedsRefreshTrueBeforeFirstLoad() { - $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( - 'flags' => array('refresh_interval_in_seconds' => 60), - )); - $this->assertTrue($p->needsRefresh()); - } - - public function testNeedsRefreshFalseAfterFreshLoadWithinInterval() { - $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( - 'flags' => array('refresh_interval_in_seconds' => 60), - )); - $p->loadDefinitions(); - $this->assertFalse($p->needsRefresh()); - } - - public function testNeedsRefreshTrueAfterIntervalElapses() { - $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( - 'flags' => array('refresh_interval_in_seconds' => 60), - )); - $p->loadDefinitions(); - // Backdate lastSyncedAt past the interval. - $p->setLastSyncedAtForTest(time() - 120); - $this->assertTrue($p->needsRefresh()); - } - - public function testNeedsRefreshFalseWhenIntervalNotConfiguredEvenIfOld() { - $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( - 'flags' => array(), // no refresh_interval_in_seconds - )); - $p->loadDefinitions(); - $p->setLastSyncedAtForTest(time() - 99999); - $this->assertFalse($p->needsRefresh()); - } - - public function testRefreshIsNoOpWhenFresh() { - $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( - 'flags' => array('refresh_interval_in_seconds' => 60), - )); - $p->loadDefinitions(); - $loadsBefore = $p->loadCount; - $this->assertTrue($p->refresh()); - $this->assertEquals($loadsBefore, $p->loadCount); - } - - public function testRefreshFetchesWhenStale() { - $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( - 'flags' => array('refresh_interval_in_seconds' => 60), - )); - $p->loadDefinitions(); - $p->setLastSyncedAtForTest(time() - 120); - $loadsBefore = $p->loadCount; - $this->assertTrue($p->refresh()); - $this->assertEquals($loadsBefore + 1, $p->loadCount); - } - - public function testRefreshPerformsInitialFetchWhenNotYetLoaded() { - // No prior loadDefinitions(); refresh() should kick off the first fetch. - $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( - 'flags' => array('refresh_interval_in_seconds' => 60), - )); - $this->assertEquals(0, $p->loadCount); - $this->assertTrue($p->refresh()); - $this->assertEquals(1, $p->loadCount); - $this->assertTrue($p->areFlagsReady()); - } - - public function testRefreshPerformsInitialFetchEvenWithoutConfiguredInterval() { - // needsRefresh() returns true when !ready regardless of interval config; - // refresh() should still do the initial fetch. - $p = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( - 'flags' => array(), - )); - $this->assertTrue($p->refresh()); - $this->assertEquals(1, $p->loadCount); - } } From 539fb6d32224b9088b93c92d629649b9fed8abdf Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 24 Jun 2026 12:49:36 -0400 Subject: [PATCH 06/20] refactor(flags): align config surface with peer server SDKs Remove two PHP-specific config options that no other Mixpanel server SDK exposes: - "report_exposure" config key (was: constructor-level default). Other SDKs handle exposure-reporting only as a per-call parameter on getVariant(); callers wanting a global default should set it themselves. The per-call $reportExposure param on getVariant() stays (default true), matching Python/Ruby/Go/ Java/Node. - "connect_timeout_in_seconds" config key. Other SDKs use a single timeout covering both connect and read phases (httpx, Net::HTTP, http.Client all behave this way). cURL now applies request_timeout_in_seconds to both CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT. Co-Authored-By: Claude Opus 4.7 --- README.md | 3 +-- examples/feature_flags.php | 3 +-- lib/FeatureFlags/MixpanelFlags.php | 2 +- lib/FeatureFlags/MixpanelFlagsBase.php | 15 +++++---------- lib/FeatureFlags/MixpanelLocalFlags.php | 4 ++-- lib/FeatureFlags/MixpanelRemoteFlags.php | 4 ++-- 6 files changed, 12 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4e4ffdd..f905a7f 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,7 @@ $mp = Mixpanel::getInstance("MIXPANEL_PROJECT_TOKEN", array( "flags" => array( // Mode accepts the MODE_* class constants or the raw strings // "remote" / "local" — both are equivalent. - "mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE, - "report_exposure" => true, + "mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE, ), )); diff --git a/examples/feature_flags.php b/examples/feature_flags.php index 6dc4a94..852c389 100644 --- a/examples/feature_flags.php +++ b/examples/feature_flags.php @@ -6,8 +6,7 @@ "flags" => array( // Either the MODE_* constants or the raw strings 'local' / // 'remote' are accepted. - "mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE, - "report_exposure" => true, + "mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE, ), )); diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php index 73894cf..307e8a6 100644 --- a/lib/FeatureFlags/MixpanelFlags.php +++ b/lib/FeatureFlags/MixpanelFlags.php @@ -110,7 +110,7 @@ public function lastFailureReason() { return $this->_provider->lastFailureReason(); } - public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = null) { + public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = true) { return $this->_provider->getVariant($flagKey, $fallback, $context, $reportExposure); } diff --git a/lib/FeatureFlags/MixpanelFlagsBase.php b/lib/FeatureFlags/MixpanelFlagsBase.php index b80d4f9..a2659a9 100644 --- a/lib/FeatureFlags/MixpanelFlagsBase.php +++ b/lib/FeatureFlags/MixpanelFlagsBase.php @@ -40,12 +40,6 @@ abstract class FeatureFlags_MixpanelFlagsBase extends Base_MixpanelBase { /** @var int seconds */ protected $_requestTimeout; - /** @var int seconds */ - protected $_connectTimeout; - - /** @var bool */ - protected $_reportExposureDefault; - /** @var string most recent evaluation outcome */ protected $_lastFailureReason = self::REASON_OK; @@ -68,8 +62,6 @@ public function __construct($token, $version, $tracker, array $options) { $this->_apiHost = 'api.mixpanel.com'; } $this->_requestTimeout = isset($flagsOpts['request_timeout_in_seconds']) ? (int) $flagsOpts['request_timeout_in_seconds'] : 10; - $this->_connectTimeout = isset($flagsOpts['connect_timeout_in_seconds']) ? (int) $flagsOpts['connect_timeout_in_seconds'] : 5; - $this->_reportExposureDefault = isset($flagsOpts['report_exposure']) ? (bool) $flagsOpts['report_exposure'] : true; } /** @return string one of the REASON_* constants */ @@ -113,7 +105,10 @@ protected function _httpGet($path, array $query = array()) { curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->_connectTimeout); + // Single timeout budget for the whole call, matching the other + // server SDKs (httpx in Python, Net::HTTP in Ruby, http.Client + // in Go all use one timeout covering connect + read). + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->_requestTimeout); curl_setopt($ch, CURLOPT_TIMEOUT, $this->_requestTimeout); $body = curl_exec($ch); $errno = curl_errno($ch); @@ -267,7 +262,7 @@ protected function _handleError($code, $message) { } } - abstract public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = null); + abstract public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = true); public function getVariantValue($flagKey, $fallbackValue, array $context) { $fallback = new FeatureFlags_MixpanelSelectedVariant(null, $fallbackValue); diff --git a/lib/FeatureFlags/MixpanelLocalFlags.php b/lib/FeatureFlags/MixpanelLocalFlags.php index d3ebf17..2c52fe9 100644 --- a/lib/FeatureFlags/MixpanelLocalFlags.php +++ b/lib/FeatureFlags/MixpanelLocalFlags.php @@ -80,8 +80,8 @@ protected function _evaluationMode() { return 'local'; } - public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = null) { - $reportExposure = $reportExposure === null ? $this->_reportExposureDefault : (bool) $reportExposure; + public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = true) { + $reportExposure = (bool) $reportExposure; $startTime = microtime(true); if (!$this->_ready) { diff --git a/lib/FeatureFlags/MixpanelRemoteFlags.php b/lib/FeatureFlags/MixpanelRemoteFlags.php index fe7e54e..7a18cc0 100644 --- a/lib/FeatureFlags/MixpanelRemoteFlags.php +++ b/lib/FeatureFlags/MixpanelRemoteFlags.php @@ -19,8 +19,8 @@ protected function _evaluationMode() { return 'remote'; } - public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = null) { - $reportExposure = $reportExposure === null ? $this->_reportExposureDefault : (bool) $reportExposure; + public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = true) { + $reportExposure = (bool) $reportExposure; $startTime = microtime(true); try { From 5e5d78d0bb7ff198df3526feb88ac9dca770f1da Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 24 Jun 2026 15:13:55 -0400 Subject: [PATCH 07/20] refactor(flags): drop ext-* install requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bcmath-backed FNV-1a implementation with PHP's built-in `hash('fnv1a64', …)` (from core `ext-hash`, bundled in every PHP install). Mod-100 normalization runs byte-by-byte on the 8-byte raw digest — no big-int math needed, exact on every PHP build. Canonical RFC test vectors (`""`, `"a"`, `"foobar"`) still match. Replace the `ext-mbstring` hard requirement with `symfony/polyfill-mbstring` (pure-PHP implementation, no-op when the real extension is present). Net composer.json delta: - `ext-bcmath`: removed (no longer used) - `ext-mbstring`: removed (polyfilled) - `ext-curl`, `ext-json`: removed (weren't in `require` pre-PR; the existing producers' runtime checks gate them when needed) - `symfony/polyfill-mbstring`: added Tracking-only customers upgrading from 2.11.0 now see zero new install-time requirements. Flag customers on minimal Docker images (alpine, php:fpm) install without needing to add `docker-php-ext-install` layers for the SDK to work. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 5 +- composer.json | 7 +-- lib/FeatureFlags/MixpanelFlags.php | 21 ++------- lib/FeatureFlags/MixpanelFlagsUtils.php | 48 ++++++-------------- test/FeatureFlags/MixpanelFlagsUtilsTest.php | 44 +++++------------- 5 files changed, 35 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbfe19e..a6608d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ - Added feature flag support (local and remote evaluation) via `$mp->flags` - Bumped PHP minimum to 7.2 and PHPUnit dev dep to ^7.5 || ^8.5 || ^9.5 -- Added `jwadhams/json-logic-php` as a runtime dependency +- Added two composer dependencies (only pulled in when this version is installed): + - `jwadhams/json-logic-php` — for runtime rule evaluation in local mode + - `symfony/polyfill-mbstring` — Unicode case folding for runtime rules; no-op when `ext-mbstring` is present +- No new PHP extension requirements: FNV-1a bucketing uses PHP's built-in `hash('fnv1a64', …)` from core `ext-hash`, and case folding falls through to the polyfill on hosts without `ext-mbstring` ## [2.11.0](https://github.com/mixpanel/mixpanel-php/tree/2.11.0) (2026-05-13) diff --git a/composer.json b/composer.json index 6d601c7..87ddcce 100644 --- a/composer.json +++ b/composer.json @@ -18,11 +18,8 @@ ], "require": { "php": ">=7.2", - "ext-bcmath": "*", - "ext-curl": "*", - "ext-json": "*", - "ext-mbstring": "*", - "jwadhams/json-logic-php": "^1.5" + "jwadhams/json-logic-php": "^1.5", + "symfony/polyfill-mbstring": "^1.27" }, "require-dev": { "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php index 307e8a6..abe0382 100644 --- a/lib/FeatureFlags/MixpanelFlags.php +++ b/lib/FeatureFlags/MixpanelFlags.php @@ -35,23 +35,10 @@ class FeatureFlags_MixpanelFlags { private $_mode; public function __construct($token, $version, $tracker, array $options) { - // Check required extensions only when flags are actually - // enabled — pre-flags the SDK degraded gracefully on hosts - // without bcmath/curl/mbstring, and we want to preserve that - // for tracking-only callers. - $missing = array(); - foreach (array('bcmath', 'curl', 'mbstring') as $ext) { - if (!extension_loaded($ext)) { - $missing[] = $ext; - } - } - if (!empty($missing)) { - throw new Exception( - 'The Mixpanel feature flags module requires the following PHP extension(s): ' - . implode(', ', $missing) - ); - } - + // No extension checks — FNV-1a hashing uses PHP's built-in + // ext-hash (bundled in core), case folding uses + // symfony/polyfill-mbstring (composer dep), HTTP uses the + // existing CurlConsumer which already runtime-checks ext-curl. $flagsOpts = isset($options['flags']) && is_array($options['flags']) ? $options['flags'] : array(); $this->_mode = isset($flagsOpts['mode']) ? strtolower((string) $flagsOpts['mode']) : self::MODE_REMOTE; diff --git a/lib/FeatureFlags/MixpanelFlagsUtils.php b/lib/FeatureFlags/MixpanelFlagsUtils.php index bd395fb..79e7194 100644 --- a/lib/FeatureFlags/MixpanelFlagsUtils.php +++ b/lib/FeatureFlags/MixpanelFlagsUtils.php @@ -10,53 +10,31 @@ class FeatureFlags_MixpanelFlagsUtils { const EXPOSURE_EVENT = '$experiment_started'; - // FNV-1a 64-bit constants, as decimal strings so bcmath can use them - // on every PHP build regardless of int width. - // FNV_OFFSET_BASIS = 0xCBF29CE484222325 - // FNV_PRIME = 0x100000001B3 - // MASK64 = 2^64 - const FNV_OFFSET_BASIS = '14695981039346656037'; - const FNV_PRIME = '1099511628211'; - const MASK64 = '18446744073709551616'; - /** * Returns the hash of ($key . $salt) normalized to a [0.0, 1.0) * float by taking (hash mod 100) / 100. Must match the equivalent * function in every other Mixpanel SDK so the same user lands in * the same bucket across languages. * + * Implementation note: we use PHP's built-in FNV-1a 64 from ext-hash + * (bundled in core, present on every PHP install) and compute the + * mod-100 by walking the 8 raw bytes. That avoids needing bcmath + * or any other big-int facility while staying exact on 32-bit PHP. + * * @param string $key * @param string $salt * @return float */ public static function normalizedHash($key, $salt) { - $hashValue = self::fnv1a64($key . $salt); - $mod = (int) bcmod($hashValue, '100'); - return $mod / 100.0; - } - - /** - * FNV-1a 64-bit hash. Returns the digest as a decimal string so - * callers can safely modulo even on 32-bit PHP builds. - * - * @param string $data raw bytes (PHP strings are byte sequences) - * @return string decimal representation of the 64-bit unsigned digest - */ - public static function fnv1a64($data) { - $hash = self::FNV_OFFSET_BASIS; - $length = strlen($data); - for ($i = 0; $i < $length; $i++) { - $byte = ord($data[$i]); - // hash ^= byte: XOR with a byte affects only the low 8 bits. - // Pull out the low byte, XOR, and stitch the value back. - $lowByte = (int) bcmod($hash, '256'); - $newLowByte = $lowByte ^ $byte; - $hash = bcadd(bcsub($hash, (string) $lowByte), (string) $newLowByte); - - // hash = (hash * FNV_PRIME) mod 2^64 - $hash = bcmod(bcmul($hash, self::FNV_PRIME), self::MASK64); + $raw = hash('fnv1a64', $key . $salt, true); // 8 raw bytes, big-endian + // (uint64 mod 100), byte by byte. Each intermediate + // (mod*256 + byte) is at most 99*256 + 255 = 25599 — fits in + // 32-bit signed int on every PHP build, no overflow. + $mod = 0; + for ($i = 0; $i < 8; $i++) { + $mod = ($mod * 256 + ord($raw[$i])) % 100; } - return $hash; + return $mod / 100.0; } /** diff --git a/test/FeatureFlags/MixpanelFlagsUtilsTest.php b/test/FeatureFlags/MixpanelFlagsUtilsTest.php index 15009ec..c3f4e7f 100644 --- a/test/FeatureFlags/MixpanelFlagsUtilsTest.php +++ b/test/FeatureFlags/MixpanelFlagsUtilsTest.php @@ -1,41 +1,21 @@ assertEquals( - FeatureFlags_MixpanelFlagsUtils::FNV_OFFSET_BASIS, - FeatureFlags_MixpanelFlagsUtils::fnv1a64('') - ); - } - - public function testFnvSingleByteAMatchesCanonicalVector() { - // RFC-style FNV-1a 64 of "a" is 0xaf63dc4c8601ec8c. This is the - // canonical cross-language reference value — if we don't match - // it, no other Mixpanel SDK will agree with PHP on bucketing. - $this->assertEquals( - '12638187200555641996', - FeatureFlags_MixpanelFlagsUtils::fnv1a64('a') - ); - } - - public function testFnvFoobarMatchesCanonicalVector() { - // FNV-1a 64 of "foobar" = 0x85944171f73967e8 per the reference vectors. - $this->assertEquals( - '9625390261332436968', - FeatureFlags_MixpanelFlagsUtils::fnv1a64('foobar') - ); + public function testBuiltinFnv1a64MatchesCanonicalVectors() { + // FNV offset basis: the hash of the empty string. + $this->assertSame('cbf29ce484222325', hash('fnv1a64', '')); + // 0xaf63dc4c8601ec8c — canonical reference value for "a". + $this->assertSame('af63dc4c8601ec8c', hash('fnv1a64', 'a')); + // 0x85944171f73967e8 — canonical reference value for "foobar". + $this->assertSame('85944171f73967e8', hash('fnv1a64', 'foobar')); } public function testNormalizedHashInRange() { From 69df54274bc01de377ea9f296abebe206c00e0b4 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 24 Jun 2026 15:34:58 -0400 Subject: [PATCH 08/20] refactor(flags): move failure reason onto SelectedVariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the stateful `$mp->flags->lastFailureReason()` method with a `fallbackReason` field directly on the returned SelectedVariant. The field is null when evaluation succeeded and one of the REASON_* constants when the SDK returned the caller's fallback. This fixes three problems with the previous design: 1. State got overwritten on the next call — easy to miss the reason from the call you cared about. 2. getAllVariants() collapsed every iteration's reason into one global slot, so you couldn't tell per-flag why a particular flag fell back. 3. The pattern "call, then check a separate state-bearing method" is easy to forget and reads awkwardly. Renamed from `failureReason` to `fallbackReason` (more accurate — NO_ROLLOUT_MATCH isn't a "failure," it's the documented behavior for users not in the rollout) and made null on success (removes the REASON_OK sentinel, matches PHP's idiom for "absence", and maps 1:1 to OpenFeature's nullable errorCode field for a future OF wrapper). A new SelectedVariant::withFallbackReason() helper clones the caller's fallback so we never mutate state they own. Co-Authored-By: Claude Opus 4.7 --- README.md | 8 ++-- examples/feature_flags.php | 12 +++++- lib/FeatureFlags/MixpanelFlags.php | 5 --- lib/FeatureFlags/MixpanelFlagsBase.php | 32 ++++---------- lib/FeatureFlags/MixpanelLocalFlags.php | 18 +++----- lib/FeatureFlags/MixpanelRemoteFlags.php | 15 +++---- lib/FeatureFlags/MixpanelSelectedVariant.php | 42 ++++++++++++++++--- test/FeatureFlags/MixpanelLocalFlagsTest.php | 35 +++++++++------- test/FeatureFlags/MixpanelRemoteFlagsTest.php | 12 +++--- 9 files changed, 96 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index f905a7f..66cb99b 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,11 @@ $mp->flags->loadDefinitions(); // fetch once per process $enabled = $mp->flags->isEnabled("my-flag", $context); ``` -`lastFailureReason()` distinguishes the four ways an evaluation can fall -through (`FLAG_NOT_FOUND`, `MISSING_CONTEXT_KEY`, `NO_ROLLOUT_MATCH`, -`BACKEND_ERROR`) — useful when debugging or building higher-level wrappers. +Every `getVariant()` call returns a `FeatureFlags_MixpanelSelectedVariant` +with a `fallbackReason` field — `null` on success, or one of +`REASON_FLAG_NOT_FOUND` / `MISSING_CONTEXT_KEY` / `NO_ROLLOUT_MATCH` / +`BACKEND_ERROR` / `NOT_READY` when the SDK returned the fallback you passed +in. Useful when debugging or building higher-level wrappers. See `examples/feature_flags.php` for a full walk-through. diff --git a/examples/feature_flags.php b/examples/feature_flags.php index 852c389..5a9c7ba 100644 --- a/examples/feature_flags.php +++ b/examples/feature_flags.php @@ -27,7 +27,17 @@ if ($mp->flags->isEnabled("new-checkout", $context)) { echo "new-checkout is enabled\n"; } else { - echo "new-checkout fell back; reason: " . $mp->flags->lastFailureReason() . "\n"; + // To know *why* we got the fallback, use getVariant() instead — + // its returned SelectedVariant carries a fallbackReason that's null + // on success and one of the REASON_* constants when the fallback + // was returned. + $variant = $mp->flags->getVariant( + "new-checkout", + new FeatureFlags_MixpanelSelectedVariant(null, false), + $context, + false // don't double-count the exposure + ); + echo "new-checkout fell back; reason: " . $variant->fallbackReason . "\n"; } // Variant value with a typed fallback. diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php index abe0382..4695239 100644 --- a/lib/FeatureFlags/MixpanelFlags.php +++ b/lib/FeatureFlags/MixpanelFlags.php @@ -92,11 +92,6 @@ public function lastSyncedAt() { return null; } - /** @return string one of the FeatureFlags_MixpanelFlagsBase::REASON_* constants */ - public function lastFailureReason() { - return $this->_provider->lastFailureReason(); - } - public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = true) { return $this->_provider->getVariant($flagKey, $fallback, $context, $reportExposure); } diff --git a/lib/FeatureFlags/MixpanelFlagsBase.php b/lib/FeatureFlags/MixpanelFlagsBase.php index a2659a9..8ad7d20 100644 --- a/lib/FeatureFlags/MixpanelFlagsBase.php +++ b/lib/FeatureFlags/MixpanelFlagsBase.php @@ -5,26 +5,16 @@ require_once(dirname(__FILE__) . "/MixpanelSelectedVariant.php"); /** - * Sentinel return codes for the most recent evaluation attempt. The - * facade exposes these via lastFailureReason() so callers (including a - * future OpenFeature wrapper) can distinguish a missing flag from a - * missing context attribute from a no-match rollout. This addresses - * finding #1 in the SDK audit — every existing SDK collapses all three - * cases to "flag not found", which sends customers debugging the wrong - * thing. + * Shared HTTP / exposure-tracking plumbing for the local and remote + * feature-flag providers. When a getVariant call falls through to the + * caller's fallback, the reason is attached to the returned + * SelectedVariant via its `fallbackReason` field (see + * FeatureFlags_MixpanelSelectedVariant::REASON_*) — addressing audit + * finding #1 (every other Mixpanel SDK collapses three distinct + * failure modes into "flag not found"). */ abstract class FeatureFlags_MixpanelFlagsBase extends Base_MixpanelBase { - const REASON_OK = 'OK'; - const REASON_FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'; - const REASON_MISSING_CONTEXT_KEY = 'MISSING_CONTEXT_KEY'; - const REASON_NO_ROLLOUT_MATCH = 'NO_ROLLOUT_MATCH'; - const REASON_BACKEND_ERROR = 'BACKEND_ERROR'; - // Local-only: getVariant called before loadDefinitions() completed - // successfully. Distinguishes "we haven't fetched yet" from - // "fetched but this flag isn't defined". - const REASON_NOT_READY = 'NOT_READY'; - /** @var string */ protected $_token; @@ -40,9 +30,6 @@ abstract class FeatureFlags_MixpanelFlagsBase extends Base_MixpanelBase { /** @var int seconds */ protected $_requestTimeout; - /** @var string most recent evaluation outcome */ - protected $_lastFailureReason = self::REASON_OK; - public function __construct($token, $version, $tracker, array $options) { parent::__construct($options); $this->_token = $token; @@ -64,11 +51,6 @@ public function __construct($token, $version, $tracker, array $options) { $this->_requestTimeout = isset($flagsOpts['request_timeout_in_seconds']) ? (int) $flagsOpts['request_timeout_in_seconds'] : 10; } - /** @return string one of the REASON_* constants */ - public function lastFailureReason() { - return $this->_lastFailureReason; - } - /** Release any held resources. Subclasses override to close cURL handles. */ public function shutdown() { // default: nothing held diff --git a/lib/FeatureFlags/MixpanelLocalFlags.php b/lib/FeatureFlags/MixpanelLocalFlags.php index 2c52fe9..57f54c9 100644 --- a/lib/FeatureFlags/MixpanelLocalFlags.php +++ b/lib/FeatureFlags/MixpanelLocalFlags.php @@ -86,30 +86,27 @@ public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallb if (!$this->_ready) { // Distinguish "definitions never loaded" from "definitions - // loaded but flag not present" — the audit-driven reason - // enum is the seam a future OpenFeature wrapper uses. - $this->_lastFailureReason = self::REASON_NOT_READY; + // loaded but flag not present" — the per-variant reason is + // the seam a future OpenFeature wrapper uses. $this->_handleError( 'mixpanel-flags', "getVariant called before loadDefinitions() succeeded; call loadDefinitions() first." ); - return $fallback; + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_NOT_READY); } if (!isset($this->_definitions[$flagKey])) { - $this->_lastFailureReason = self::REASON_FLAG_NOT_FOUND; - return $fallback; + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND); } $flag = $this->_definitions[$flagKey]; $bucketingKey = isset($flag['context']) ? $flag['context'] : 'distinct_id'; if (!isset($context[$bucketingKey]) || $context[$bucketingKey] === '' || $context[$bucketingKey] === null) { - $this->_lastFailureReason = self::REASON_MISSING_CONTEXT_KEY; $this->_handleError( 'mixpanel-flags', "Flag '{$flagKey}' requires context key '{$bucketingKey}' which was not supplied" ); - return $fallback; + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_MISSING_CONTEXT_KEY); } $contextValue = (string) $context[$bucketingKey]; @@ -124,12 +121,9 @@ public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallb } if ($selected === null) { - $this->_lastFailureReason = self::REASON_NO_ROLLOUT_MATCH; - return $fallback; + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_NO_ROLLOUT_MATCH); } - $this->_lastFailureReason = self::REASON_OK; - if ($reportExposure) { $latencyMs = (microtime(true) - $startTime) * 1000.0; $this->_trackExposure($flagKey, $selected, $context, 'local', $latencyMs); diff --git a/lib/FeatureFlags/MixpanelRemoteFlags.php b/lib/FeatureFlags/MixpanelRemoteFlags.php index 7a18cc0..358ed70 100644 --- a/lib/FeatureFlags/MixpanelRemoteFlags.php +++ b/lib/FeatureFlags/MixpanelRemoteFlags.php @@ -27,22 +27,19 @@ public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallb $flags = $this->_fetchFlags($context, $flagKey); } catch (Exception $e) { // Audit finding #7: don't silently swallow backend errors. - // Surface to error_callback, mark the failure reason, and - // return fallback so a future OF wrapper can translate to - // GENERAL instead of FLAG_NOT_FOUND. - $this->_lastFailureReason = self::REASON_BACKEND_ERROR; + // Surface to error_callback, tag the fallback so a future + // OF wrapper can translate to GENERAL instead of + // FLAG_NOT_FOUND. $this->_handleError($e->getCode(), 'Remote flag fetch failed: ' . $e->getMessage()); - return $fallback; + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_BACKEND_ERROR); } $endTime = microtime(true); if (!isset($flags[$flagKey])) { - $this->_lastFailureReason = self::REASON_FLAG_NOT_FOUND; - return $fallback; + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND); } $selected = FeatureFlags_MixpanelSelectedVariant::fromArray($flags[$flagKey]); - $this->_lastFailureReason = self::REASON_OK; if ($reportExposure) { // Pass start/end so the exposure event carries @@ -59,7 +56,6 @@ public function getAllVariants(array $context) { try { $flags = $this->_fetchFlags($context, null); } catch (Exception $e) { - $this->_lastFailureReason = self::REASON_BACKEND_ERROR; $this->_handleError($e->getCode(), 'Remote flag fetch failed: ' . $e->getMessage()); return array(); } @@ -68,7 +64,6 @@ public function getAllVariants(array $context) { foreach ($flags as $key => $payload) { $out[$key] = FeatureFlags_MixpanelSelectedVariant::fromArray($payload); } - $this->_lastFailureReason = self::REASON_OK; return $out; } diff --git a/lib/FeatureFlags/MixpanelSelectedVariant.php b/lib/FeatureFlags/MixpanelSelectedVariant.php index 1e2f887..6b1719f 100644 --- a/lib/FeatureFlags/MixpanelSelectedVariant.php +++ b/lib/FeatureFlags/MixpanelSelectedVariant.php @@ -7,13 +7,25 @@ * downstream wrappers (a future OpenFeature provider) and analytics * tooling can rely on the same field names across languages. * - * The `experiment_id`, `is_experiment_active`, and `is_qa_tester` - * fields are kept available so a future OpenFeature wrapper can - * forward them as `flag_metadata` — addressing finding "Design C" in - * the audit (other wrappers throw this metadata away). + * The `experimentId`, `isExperimentActive`, and `isQaTester` fields are + * kept available so a future OpenFeature wrapper can forward them as + * `flag_metadata` — addressing finding "Design C" in the cross-SDK + * audit (other wrappers throw this metadata away). + * + * `fallbackReason` is `null` when evaluation succeeded; when the SDK + * returns the fallback you passed in, it's set to one of the REASON_* + * constants below so the caller (or a future OpenFeature wrapper) can + * distinguish flag-not-found from missing-context-key from no-rollout- + * match etc. — addressing audit finding #1. */ class FeatureFlags_MixpanelSelectedVariant { + const REASON_FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'; + const REASON_MISSING_CONTEXT_KEY = 'MISSING_CONTEXT_KEY'; + const REASON_NO_ROLLOUT_MATCH = 'NO_ROLLOUT_MATCH'; + const REASON_BACKEND_ERROR = 'BACKEND_ERROR'; + const REASON_NOT_READY = 'NOT_READY'; + /** @var string|null variant key — null when this instance is a fallback */ public $variantKey; @@ -29,18 +41,23 @@ class FeatureFlags_MixpanelSelectedVariant { /** @var bool|null */ public $isQaTester; + /** @var string|null null on success; one of the REASON_* constants when the fallback was returned */ + public $fallbackReason; + public function __construct( $variantKey = null, $variantValue = null, $experimentId = null, $isExperimentActive = null, - $isQaTester = null + $isQaTester = null, + $fallbackReason = null ) { $this->variantKey = $variantKey; $this->variantValue = $variantValue; $this->experimentId = $experimentId; $this->isExperimentActive = $isExperimentActive; $this->isQaTester = $isQaTester; + $this->fallbackReason = $fallbackReason; } /** @@ -60,6 +77,20 @@ public static function fromArray(array $data) { ); } + /** + * Return a copy of this variant with the supplied fallbackReason + * set. Used by the providers to tag the caller's fallback without + * mutating their object. + * + * @param string $reason one of the REASON_* constants + * @return FeatureFlags_MixpanelSelectedVariant + */ + public function withFallbackReason($reason) { + $clone = clone $this; + $clone->fallbackReason = $reason; + return $clone; + } + /** * @return array */ @@ -70,6 +101,7 @@ public function toArray() { 'experiment_id' => $this->experimentId, 'is_experiment_active' => $this->isExperimentActive, 'is_qa_tester' => $this->isQaTester, + 'fallback_reason' => $this->fallbackReason, ); } } diff --git a/test/FeatureFlags/MixpanelLocalFlagsTest.php b/test/FeatureFlags/MixpanelLocalFlagsTest.php index cad7858..ba22a2c 100644 --- a/test/FeatureFlags/MixpanelLocalFlagsTest.php +++ b/test/FeatureFlags/MixpanelLocalFlagsTest.php @@ -85,11 +85,13 @@ public function testReturnsFallbackAndSetsReasonWhenFlagMissing() { $this->_provider->setDefinitionsForTest(array()); $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); $result = $this->_provider->getVariant('unknown', $fallback, array('distinct_id' => 'u1')); - $this->assertSame($fallback, $result); + $this->assertEquals('fallback', $result->variantValue); $this->assertEquals( - FeatureFlags_MixpanelFlagsBase::REASON_FLAG_NOT_FOUND, - $this->_provider->lastFailureReason() + FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND, + $result->fallbackReason ); + // The caller's fallback object must not be mutated — we return a clone. + $this->assertNull($fallback->fallbackReason); } public function testGetVariantBeforeLoadReturnsNotReady() { @@ -99,10 +101,10 @@ public function testGetVariantBeforeLoadReturnsNotReady() { )); $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); $result = $fresh->getVariant('any-flag', $fallback, array('distinct_id' => 'u1')); - $this->assertSame($fallback, $result); + $this->assertEquals('fb', $result->variantValue); $this->assertEquals( - FeatureFlags_MixpanelFlagsBase::REASON_NOT_READY, - $fresh->lastFailureReason() + FeatureFlags_MixpanelSelectedVariant::REASON_NOT_READY, + $result->fallbackReason ); } @@ -113,10 +115,10 @@ public function testReturnsFallbackAndSetsReasonWhenContextMissing() { $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); // No distinct_id in context, but the flag's bucketing key IS distinct_id. $result = $this->_provider->getVariant('my-flag', $fallback, array('email' => 'x@y.com')); - $this->assertSame($fallback, $result); + $this->assertEquals('fallback', $result->variantValue); $this->assertEquals( - FeatureFlags_MixpanelFlagsBase::REASON_MISSING_CONTEXT_KEY, - $this->_provider->lastFailureReason() + FeatureFlags_MixpanelSelectedVariant::REASON_MISSING_CONTEXT_KEY, + $result->fallbackReason ); } @@ -130,7 +132,8 @@ public function testReturnsVariantOnSuccessfulEval() { $this->assertSame(true, $result->variantValue); $this->assertEquals('exp-my-flag', $result->experimentId); $this->assertTrue($result->isExperimentActive); - $this->assertEquals(FeatureFlags_MixpanelFlagsBase::REASON_OK, $this->_provider->lastFailureReason()); + // null fallbackReason means evaluation succeeded — no fallback used. + $this->assertNull($result->fallbackReason); } public function testTracksExposureByDefault() { @@ -172,10 +175,10 @@ public function testReturnsFallbackWhenRolloutIsZero() { )); $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); $result = $this->_provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); - $this->assertSame($fallback, $result); + $this->assertEquals('fallback', $result->variantValue); $this->assertEquals( - FeatureFlags_MixpanelFlagsBase::REASON_NO_ROLLOUT_MATCH, - $this->_provider->lastFailureReason() + FeatureFlags_MixpanelSelectedVariant::REASON_NO_ROLLOUT_MATCH, + $result->fallbackReason ); } @@ -255,10 +258,10 @@ public function testRuntimeRuleMissingCustomPropertiesIsNotMatch() { $fallback = new FeatureFlags_MixpanelSelectedVariant(null, false); $result = $this->_provider->getVariant('rt-flag', $fallback, array('distinct_id' => 'u1'), false); - $this->assertSame($fallback, $result); + $this->assertSame(false, $result->variantValue); $this->assertEquals( - FeatureFlags_MixpanelFlagsBase::REASON_NO_ROLLOUT_MATCH, - $this->_provider->lastFailureReason() + FeatureFlags_MixpanelSelectedVariant::REASON_NO_ROLLOUT_MATCH, + $result->fallbackReason ); } diff --git a/test/FeatureFlags/MixpanelRemoteFlagsTest.php b/test/FeatureFlags/MixpanelRemoteFlagsTest.php index 93b5fba..9fc4ad7 100644 --- a/test/FeatureFlags/MixpanelRemoteFlagsTest.php +++ b/test/FeatureFlags/MixpanelRemoteFlagsTest.php @@ -92,10 +92,10 @@ public function testFlagMissingInResponseSetsFlagNotFoundReason() { )); $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); $variant = $this->_provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); - $this->assertSame($fallback, $variant); + $this->assertEquals('fb', $variant->variantValue); $this->assertEquals( - FeatureFlags_MixpanelFlagsBase::REASON_FLAG_NOT_FOUND, - $this->_provider->lastFailureReason() + FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND, + $variant->fallbackReason ); } @@ -121,10 +121,10 @@ public function testBackendErrorIsSurfacedDistinctlyFromFlagNotFound() { $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); $variant = $provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); - $this->assertSame($fallback, $variant); + $this->assertEquals('fb', $variant->variantValue); $this->assertEquals( - FeatureFlags_MixpanelFlagsBase::REASON_BACKEND_ERROR, - $provider->lastFailureReason() + FeatureFlags_MixpanelSelectedVariant::REASON_BACKEND_ERROR, + $variant->fallbackReason ); $this->assertCount(1, $errors); $this->assertStringContainsString('simulated HTTP 500', $errors[0]); From 6f8a368bf73ba6228eb6eb3011bb8685582f7e8b Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 24 Jun 2026 16:23:05 -0400 Subject: [PATCH 09/20] =?UTF-8?q?chore:=20drop=20CHANGELOG=20edits=20?= =?UTF-8?q?=E2=80=94=20changelog=20is=20generated=20at=20release=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6608d9..56af575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,5 @@ # Changelog -## Unreleased - -- Added feature flag support (local and remote evaluation) via `$mp->flags` -- Bumped PHP minimum to 7.2 and PHPUnit dev dep to ^7.5 || ^8.5 || ^9.5 -- Added two composer dependencies (only pulled in when this version is installed): - - `jwadhams/json-logic-php` — for runtime rule evaluation in local mode - - `symfony/polyfill-mbstring` — Unicode case folding for runtime rules; no-op when `ext-mbstring` is present -- No new PHP extension requirements: FNV-1a bucketing uses PHP's built-in `hash('fnv1a64', …)` from core `ext-hash`, and case folding falls through to the polyfill on hosts without `ext-mbstring` - ## [2.11.0](https://github.com/mixpanel/mixpanel-php/tree/2.11.0) (2026-05-13) - Fix identify regex for $anon_id From 764acc3a7a9b5adf3abcf675b93ed4adf06bd284 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 24 Jun 2026 16:23:35 -0400 Subject: [PATCH 10/20] =?UTF-8?q?chore:=20drop=20README=20edits=20?= =?UTF-8?q?=E2=80=94=20docs=20live=20on=20the=20official=20docs=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- README.md | 68 ------------------------------------------------------- 1 file changed, 68 deletions(-) diff --git a/README.md b/README.md index 66cb99b..b6ffa8f 100644 --- a/README.md +++ b/README.md @@ -68,74 +68,6 @@ $mp->people->set(12345, array( )); ``` -Feature Flags -------------- - -Feature flags let you ship code that is dark to most users and roll it out to a -configurable percentage of traffic, optionally gated by per-user runtime rules. -The PHP SDK supports both **remote evaluation** (the Mixpanel API decides the -variant for each call) and **local evaluation** (the SDK fetches definitions -once per process and evaluates in-process). - -> **Heads up on `Mixpanel::getInstance()`.** The singleton caches per-token and -> **ignores `$options` on subsequent calls for the same token**. If any earlier -> code path constructs the instance without `flags`, a later -> `Mixpanel::getInstance($token, ['flags' => ...])` will return the cached -> instance and `$mp->flags` will be `null`. Either always pass the flags config -> at the first call site, or use explicit construction -> (`new Mixpanel($token, ['flags' => ...])`) to bypass the singleton entirely. - -```php -$mp = Mixpanel::getInstance("MIXPANEL_PROJECT_TOKEN", array( - "flags" => array( - // Mode accepts the MODE_* class constants or the raw strings - // "remote" / "local" — both are equivalent. - "mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE, - ), -)); - -// Always supply the explicit bucketing-key attribute the flag was configured -// against, NOT just a generic "targeting key". For flags bucketed on -// distinct_id alone you can pass only distinct_id; for flags bucketed on -// device_id or a custom group key, pass that attribute AND distinct_id (the -// distinct_id is required to attach the exposure event to a profile). -$context = array( - "distinct_id" => "user-12345", - "device_id" => "abcdef-12345", - "custom_properties" => array("email" => "alice@example.com", "plan" => "pro"), -); - -if ($mp->flags->isEnabled("new-checkout", $context)) { - // ... -} - -$theme = $mp->flags->getVariantValue("ui-theme", "light", $context); -$variant = $mp->flags->getVariant( - "experiment-pricing", - new FeatureFlags_MixpanelSelectedVariant(null, "control"), - $context -); -``` - -In local mode, definitions are loaded explicitly — PHP's request-per-process -model means we deliberately do not spawn background polling threads: - -```php -$mp = Mixpanel::getInstance("TOKEN", array( - "flags" => array("mode" => FeatureFlags_MixpanelFlags::MODE_LOCAL), -)); -$mp->flags->loadDefinitions(); // fetch once per process -$enabled = $mp->flags->isEnabled("my-flag", $context); -``` - -Every `getVariant()` call returns a `FeatureFlags_MixpanelSelectedVariant` -with a `fallbackReason` field — `null` on success, or one of -`REASON_FLAG_NOT_FOUND` / `MISSING_CONTEXT_KEY` / `NO_ROLLOUT_MATCH` / -`BACKEND_ERROR` / `NOT_READY` when the SDK returned the fallback you passed -in. Useful when debugging or building higher-level wrappers. - -See `examples/feature_flags.php` for a full walk-through. - Production Notes ------------- By default, data is sent using ssl over cURL. This works fine when you're tracking a small number of events or aren't concerned with the potentially blocking nature of the PHP cURL calls. However, this isn't very efficient when you're sending hundreds of events (such as in batch processing). Our library comes packaged with an easy way to use a persistent socket connection for much more efficient writes. To enable the persistent socket, simply pass `'consumer' => 'socket'` as an entry in the `$options` array when you instantiate the Mixpanel class. Additionally, you can contribute your own persistence implementation by creating a custom Consumer. From 94ca40eeb77896c357536cb97e9e881a393d5e30 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 24 Jun 2026 16:24:31 -0400 Subject: [PATCH 11/20] =?UTF-8?q?chore:=20drop=20example=20file=20?= =?UTF-8?q?=E2=80=94=20examples=20live=20alongside=20the=20docs=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- examples/feature_flags.php | 80 -------------------------------------- 1 file changed, 80 deletions(-) delete mode 100644 examples/feature_flags.php diff --git a/examples/feature_flags.php b/examples/feature_flags.php deleted file mode 100644 index 5a9c7ba..0000000 --- a/examples/feature_flags.php +++ /dev/null @@ -1,80 +0,0 @@ - array( - // Either the MODE_* constants or the raw strings 'local' / - // 'remote' are accepted. - "mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE, - ), -)); - -// IMPORTANT: when a flag uses a non-default Variant Assignment Key -// (e.g., device_id or a custom group key), supply BOTH that key AND -// distinct_id in the context — otherwise the SDK can't tie the -// exposure event back to a profile. -$context = array( - "distinct_id" => "user-12345", - "device_id" => "abcdef-12345", // for flags bucketed on device_id - "custom_properties" => array( - "email" => "alice@example.com", - "plan" => "pro", - ), -); - -// Boolean-style probe. -if ($mp->flags->isEnabled("new-checkout", $context)) { - echo "new-checkout is enabled\n"; -} else { - // To know *why* we got the fallback, use getVariant() instead — - // its returned SelectedVariant carries a fallbackReason that's null - // on success and one of the REASON_* constants when the fallback - // was returned. - $variant = $mp->flags->getVariant( - "new-checkout", - new FeatureFlags_MixpanelSelectedVariant(null, false), - $context, - false // don't double-count the exposure - ); - echo "new-checkout fell back; reason: " . $variant->fallbackReason . "\n"; -} - -// Variant value with a typed fallback. -$theme = $mp->flags->getVariantValue("ui-theme", "light", $context); -echo "theme = $theme\n"; - -// Full variant for richer reporting. -$variant = $mp->flags->getVariant( - "experiment-pricing", - new FeatureFlags_MixpanelSelectedVariant(null, "control"), - $context -); -printf("experiment-pricing => variant=%s value=%s exp_id=%s\n", - $variant->variantKey, - json_encode($variant->variantValue), - $variant->experimentId -); - -// Bulk evaluation. trackExposure() can be called per-flag after the -// caller actually consumes the value. -$all = $mp->flags->getAllVariants($context); -foreach ($all as $key => $v) { - echo "[$key] {$v->variantKey} = " . json_encode($v->variantValue) . "\n"; -} - -// Local mode usage: -// -// $mp = Mixpanel::getInstance("MY_TOKEN", array( -// "flags" => array("mode" => FeatureFlags_MixpanelFlags::MODE_LOCAL), -// )); -// $mp->flags->loadDefinitions(); // fetch once per process -// $variant = $mp->flags->getVariant("my-flag", $fallback, $context); -// -// In long-running CLI workers you can call loadDefinitions() on -// whatever schedule fits (e.g., every N minutes). The PHP SDK does not -// spawn background polling threads — request-per-process FPM/Apache -// deployments don't have a place to host them. - -$mp->flags->shutdown(); -$mp->flush(); From e6dc14e4744193bafecc981faf8102c94d0c32e6 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 24 Jun 2026 16:34:15 -0400 Subject: [PATCH 12/20] refactor(flags): resolve lib_version dynamically via Composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded `Mixpanel::VERSION = '2.11.0'` constant with a static lookup against `\Composer\InstalledVersions` so the `lib_version` query param sent on flag HTTP requests stays in sync with the released tag without any release-time bumping. This matches the prepare-release.yml philosophy ("composer.json intentionally has NO version field — Packagist reads from the git tag") and keeps PHP cross-SDK aligned with Python, Ruby, Go, and Node, all of which send their respective library version on flag requests. Composer 2.x bundles `\Composer\InstalledVersions` in every install, so no new dep is required. Falls back to "unknown" on the off chance the package is loaded outside a Composer-managed environment. Co-Authored-By: Claude Opus 4.7 --- lib/Mixpanel.php | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/Mixpanel.php b/lib/Mixpanel.php index 0f3657b..34fd0ba 100644 --- a/lib/Mixpanel.php +++ b/lib/Mixpanel.php @@ -111,9 +111,29 @@ class Mixpanel extends Base_MixpanelBase { /** - * The library version, sent as lib_version on every request. + * Resolve the installed SDK version from Composer's runtime API. + * Used to populate the `lib_version` query param on feature-flag + * HTTP requests (matching Python/Ruby/Go/Java/Node). + * + * Composer 2.x ships `\Composer\InstalledVersions` in every install + * and returns the tag the package was installed from — so this + * stays accurate without any release-time bumping. Falls back to + * "unknown" on the off chance the package was loaded outside of a + * Composer-managed environment. */ - const VERSION = '2.11.0'; + private static function _resolveLibVersion() { + if (class_exists('\Composer\InstalledVersions')) { + try { + $version = \Composer\InstalledVersions::getVersion('mixpanel/mixpanel-php'); + if ($version !== null && $version !== '') { + return $version; + } + } catch (\Throwable $e) { + // fall through to "unknown" + } + } + return 'unknown'; + } /** @@ -172,7 +192,7 @@ public function __construct($token, $options = array()) { $properties['distinct_id'] = $distinctId; $events->track($eventName, $properties); }; - $this->flags = new FeatureFlags_MixpanelFlags($token, self::VERSION, $tracker, $options); + $this->flags = new FeatureFlags_MixpanelFlags($token, self::_resolveLibVersion(), $tracker, $options); } } From cebc4a7ae8adb95a1ebd802872f378e9fa112b50 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 25 Jun 2026 11:32:04 -0400 Subject: [PATCH 13/20] docs(readme): document feature flags, drop dead Travis badge, fix typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removes the broken Travis CI badge (travis-ci.org has been down for OSS projects since 2021). - Fixes a long-standing typo ("checkout out" → "check out"). - Adds a Feature Flags section covering the full public API surface: config options, evaluation methods, SelectedVariant fields, the fallbackReason discriminator, local-mode lifecycle, the getInstance() singleton gotcha, and pointers to the docs site for the deeper guide. Co-Authored-By: Claude Opus 4.7 --- README.md | 144 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b6ffa8f..f3e8068 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Mixpanel PHP Library [![Build Status](https://travis-ci.org/mixpanel/mixpanel-php.svg)](https://travis-ci.org/mixpanel/mixpanel-php) +Mixpanel PHP Library ============ ##### _May 13, 2026_ - [2.11.0](https://github.com/mixpanel/mixpanel-php/releases/tag/2.11.0) @@ -68,6 +68,146 @@ $mp->people->set(12345, array( )); ``` +Feature Flags +------------- + +`$mp->flags` evaluates Mixpanel feature flags either against the API on every +call (remote mode) or in-process after fetching definitions once (local mode). +Opt in by passing a `flags` config block when constructing the SDK: + +```php +$mp = Mixpanel::getInstance("MIXPANEL_PROJECT_TOKEN", array( + "flags" => array( + "mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE, // or MODE_LOCAL + ), +)); + +$context = array( + "distinct_id" => "user-12345", + // Include any custom bucketing-key attributes the flag was configured + // against (e.g., device_id) alongside distinct_id. + "device_id" => "abcdef-12345", + "custom_properties" => array("email" => "alice@example.com", "plan" => "pro"), +); + +if ($mp->flags->isEnabled("new-checkout", $context)) { + // ... +} +``` + +`isEnabled()` is the boolean shortcut. For typed values or full variant +metadata, use `getVariantValue()` or `getVariant()`: + +```php +$theme = $mp->flags->getVariantValue("ui-theme", "light", $context); + +$variant = $mp->flags->getVariant( + "experiment-pricing", + new FeatureFlags_MixpanelSelectedVariant(null, "control"), + $context +); +echo $variant->variantKey; // "treatment-a" / null +echo $variant->variantValue; // mixed +echo $variant->experimentId; // string|null +echo $variant->fallbackReason; // null on success; REASON_* if the SDK fell back +``` + +**Local mode** requires one explicit fetch of the definitions before evaluation +(PHP's request-per-process model doesn't allow background polling like the +Python/Ruby/Go/Node SDKs do): + +```php +$mp = Mixpanel::getInstance("TOKEN", array( + "flags" => array("mode" => FeatureFlags_MixpanelFlags::MODE_LOCAL), +)); +$mp->flags->loadDefinitions(); // fetch once +$enabled = $mp->flags->isEnabled("my-flag", $context); // in-process eval +``` + +For long-running CLI workers, re-fetch on whatever cadence fits: + +```php +$lastRefresh = time(); +while ($job = $queue->next()) { + if (time() - $lastRefresh >= 60) { + if ($mp->flags->loadDefinitions()) $lastRefresh = time(); + } + processJob($job, $mp); +} +``` + +### Configuration + +| Option | Default | Description | +| --- | --- | --- | +| `mode` | `"remote"` | `MODE_LOCAL` or `MODE_REMOTE` (raw strings work too). | +| `api_host` | inherits top-level `host`, then `"api.mixpanel.com"` | EU customers set `"api-eu.mixpanel.com"`; India `"api-in.mixpanel.com"`. | +| `request_timeout_in_seconds` | `10` | Total budget per flags HTTP call (applied to connect + read). | + +The top-level `error_callback` option (shared with the event consumers) also +receives flag-side errors — backend failures, definition-fetch failures, and +warnings about missing `distinct_id` when an exposure can't be attached. + +### Public API at a glance + +```php +// Lifecycle (no-ops/sentinels in remote mode) +$mp->flags->loadDefinitions() : bool // local: fetch /flags/definitions +$mp->flags->areFlagsReady() : bool // local: ready check +$mp->flags->lastSyncedAt() : int|null // local: unix ts of last sync +$mp->flags->getMode() : string // "local" or "remote" +$mp->flags->shutdown() : void + +// Evaluation +$mp->flags->isEnabled($flagKey, $context) : bool +$mp->flags->getVariantValue($flagKey, $fallbackValue, $context) : mixed +$mp->flags->getVariant($flagKey, $fallback, $context, $reportExposure = true) + : FeatureFlags_MixpanelSelectedVariant +$mp->flags->getAllVariants($context) + : array +$mp->flags->trackExposure($flagKey, $variant, $context) : void + +// SelectedVariant fields +$variant->variantKey // string|null +$variant->variantValue // mixed +$variant->experimentId // string|null +$variant->isExperimentActive // bool|null +$variant->isQaTester // bool|null +$variant->fallbackReason // null on success; REASON_* on fallback + +// Fallback reasons (on FeatureFlags_MixpanelSelectedVariant) +REASON_FLAG_NOT_FOUND // flag key doesn't exist +REASON_MISSING_CONTEXT_KEY // context lacks the flag's bucketing attribute +REASON_NO_ROLLOUT_MATCH // flag exists, no rollout matched +REASON_BACKEND_ERROR // remote: HTTP transport / status failure +REASON_NOT_READY // local: getVariant called before loadDefinitions +``` + +`isEnabled()` returns `true` only when the resolved variant value is literal +`bool(true)` — strings and other truthy values resolve to `false`. Use +`getVariantValue()` if your flag carries a non-boolean value. + +`getAllVariants()` does NOT auto-fire exposure events (bulk exposure would +skew analytics for flags the caller never actually reads). Pair it with +`trackExposure()` per flag you consume. + +### Common gotcha + +`Mixpanel::getInstance()` caches per-token and **ignores `$options` on +subsequent calls**. If anything in your app calls `Mixpanel::getInstance($token)` +before your flag-aware code does, the cached instance's `$mp->flags` will be +`null`. Two safe patterns: + +```php +// Always pass flags config at the first call site +Mixpanel::getInstance("TOKEN", array("flags" => array("mode" => "remote"))); + +// Or bypass the singleton entirely +$mp = new Mixpanel("TOKEN", array("flags" => array("mode" => "remote"))); +``` + +Full reference docs live on the Mixpanel docs site. + Production Notes ------------- By default, data is sent using ssl over cURL. This works fine when you're tracking a small number of events or aren't concerned with the potentially blocking nature of the PHP cURL calls. However, this isn't very efficient when you're sending hundreds of events (such as in batch processing). Our library comes packaged with an easy way to use a persistent socket connection for much more efficient writes. To enable the persistent socket, simply pass `'consumer' => 'socket'` as an entry in the `$options` array when you instantiate the Mixpanel class. Additionally, you can contribute your own persistence implementation by creating a custom Consumer. @@ -82,7 +222,7 @@ Documentation * Reference Docs * Full API Reference -For further examples and options checkout out the "examples" folder +For further examples and options check out the "examples" folder. Changelog ------------- From 71f740f132d34d2271b62ea2528739523c7d05be Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 25 Jun 2026 11:40:26 -0400 Subject: [PATCH 14/20] =?UTF-8?q?revert(readme):=20drop=20feature=20flags?= =?UTF-8?q?=20section=20=E2=80=94=20keep=20docs=20on=20the=20docs=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mistakenly added the FF API surface to the README in cebc4a7. Travis badge removal and typo fix from that commit stay; the feature-flags reference docs belong on the official docs site, not in-repo. Co-Authored-By: Claude Opus 4.7 --- README.md | 140 ------------------------------------------------------ 1 file changed, 140 deletions(-) diff --git a/README.md b/README.md index f3e8068..3afbae0 100644 --- a/README.md +++ b/README.md @@ -68,146 +68,6 @@ $mp->people->set(12345, array( )); ``` -Feature Flags -------------- - -`$mp->flags` evaluates Mixpanel feature flags either against the API on every -call (remote mode) or in-process after fetching definitions once (local mode). -Opt in by passing a `flags` config block when constructing the SDK: - -```php -$mp = Mixpanel::getInstance("MIXPANEL_PROJECT_TOKEN", array( - "flags" => array( - "mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE, // or MODE_LOCAL - ), -)); - -$context = array( - "distinct_id" => "user-12345", - // Include any custom bucketing-key attributes the flag was configured - // against (e.g., device_id) alongside distinct_id. - "device_id" => "abcdef-12345", - "custom_properties" => array("email" => "alice@example.com", "plan" => "pro"), -); - -if ($mp->flags->isEnabled("new-checkout", $context)) { - // ... -} -``` - -`isEnabled()` is the boolean shortcut. For typed values or full variant -metadata, use `getVariantValue()` or `getVariant()`: - -```php -$theme = $mp->flags->getVariantValue("ui-theme", "light", $context); - -$variant = $mp->flags->getVariant( - "experiment-pricing", - new FeatureFlags_MixpanelSelectedVariant(null, "control"), - $context -); -echo $variant->variantKey; // "treatment-a" / null -echo $variant->variantValue; // mixed -echo $variant->experimentId; // string|null -echo $variant->fallbackReason; // null on success; REASON_* if the SDK fell back -``` - -**Local mode** requires one explicit fetch of the definitions before evaluation -(PHP's request-per-process model doesn't allow background polling like the -Python/Ruby/Go/Node SDKs do): - -```php -$mp = Mixpanel::getInstance("TOKEN", array( - "flags" => array("mode" => FeatureFlags_MixpanelFlags::MODE_LOCAL), -)); -$mp->flags->loadDefinitions(); // fetch once -$enabled = $mp->flags->isEnabled("my-flag", $context); // in-process eval -``` - -For long-running CLI workers, re-fetch on whatever cadence fits: - -```php -$lastRefresh = time(); -while ($job = $queue->next()) { - if (time() - $lastRefresh >= 60) { - if ($mp->flags->loadDefinitions()) $lastRefresh = time(); - } - processJob($job, $mp); -} -``` - -### Configuration - -| Option | Default | Description | -| --- | --- | --- | -| `mode` | `"remote"` | `MODE_LOCAL` or `MODE_REMOTE` (raw strings work too). | -| `api_host` | inherits top-level `host`, then `"api.mixpanel.com"` | EU customers set `"api-eu.mixpanel.com"`; India `"api-in.mixpanel.com"`. | -| `request_timeout_in_seconds` | `10` | Total budget per flags HTTP call (applied to connect + read). | - -The top-level `error_callback` option (shared with the event consumers) also -receives flag-side errors — backend failures, definition-fetch failures, and -warnings about missing `distinct_id` when an exposure can't be attached. - -### Public API at a glance - -```php -// Lifecycle (no-ops/sentinels in remote mode) -$mp->flags->loadDefinitions() : bool // local: fetch /flags/definitions -$mp->flags->areFlagsReady() : bool // local: ready check -$mp->flags->lastSyncedAt() : int|null // local: unix ts of last sync -$mp->flags->getMode() : string // "local" or "remote" -$mp->flags->shutdown() : void - -// Evaluation -$mp->flags->isEnabled($flagKey, $context) : bool -$mp->flags->getVariantValue($flagKey, $fallbackValue, $context) : mixed -$mp->flags->getVariant($flagKey, $fallback, $context, $reportExposure = true) - : FeatureFlags_MixpanelSelectedVariant -$mp->flags->getAllVariants($context) - : array -$mp->flags->trackExposure($flagKey, $variant, $context) : void - -// SelectedVariant fields -$variant->variantKey // string|null -$variant->variantValue // mixed -$variant->experimentId // string|null -$variant->isExperimentActive // bool|null -$variant->isQaTester // bool|null -$variant->fallbackReason // null on success; REASON_* on fallback - -// Fallback reasons (on FeatureFlags_MixpanelSelectedVariant) -REASON_FLAG_NOT_FOUND // flag key doesn't exist -REASON_MISSING_CONTEXT_KEY // context lacks the flag's bucketing attribute -REASON_NO_ROLLOUT_MATCH // flag exists, no rollout matched -REASON_BACKEND_ERROR // remote: HTTP transport / status failure -REASON_NOT_READY // local: getVariant called before loadDefinitions -``` - -`isEnabled()` returns `true` only when the resolved variant value is literal -`bool(true)` — strings and other truthy values resolve to `false`. Use -`getVariantValue()` if your flag carries a non-boolean value. - -`getAllVariants()` does NOT auto-fire exposure events (bulk exposure would -skew analytics for flags the caller never actually reads). Pair it with -`trackExposure()` per flag you consume. - -### Common gotcha - -`Mixpanel::getInstance()` caches per-token and **ignores `$options` on -subsequent calls**. If anything in your app calls `Mixpanel::getInstance($token)` -before your flag-aware code does, the cached instance's `$mp->flags` will be -`null`. Two safe patterns: - -```php -// Always pass flags config at the first call site -Mixpanel::getInstance("TOKEN", array("flags" => array("mode" => "remote"))); - -// Or bypass the singleton entirely -$mp = new Mixpanel("TOKEN", array("flags" => array("mode" => "remote"))); -``` - -Full reference docs live on the Mixpanel docs site. - Production Notes ------------- By default, data is sent using ssl over cURL. This works fine when you're tracking a small number of events or aren't concerned with the potentially blocking nature of the PHP cURL calls. However, this isn't very efficient when you're sending hundreds of events (such as in batch processing). Our library comes packaged with an easy way to use a persistent socket connection for much more efficient writes. To enable the persistent socket, simply pass `'consumer' => 'socket'` as an entry in the `$options` array when you instantiate the Mixpanel class. Additionally, you can contribute your own persistence implementation by creating a custom Consumer. From 8ab9127c56b9ee2cf5429976e7ca6a8fa224d814 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 25 Jun 2026 11:45:51 -0400 Subject: [PATCH 15/20] ci: run PHPUnit across the supported PHP version matrix Adds a tests workflow that runs `composer run-script unit-tests` on PHP 7.2 / 7.4 / 8.1 / 8.3 / 8.4 on every PR and every push to master. Pins action SHAs per the existing prepare-release.yml convention. The 7.2 / 7.4 matrix entries enforce the `>=7.2` floor declared in composer.json; the 8.x entries cover every currently-supported PHP release. Concurrency cancels in-flight PR runs on new commits but lets master-push runs always finish. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/tests.yml | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c52ff29 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,56 @@ +name: Tests + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +concurrency: + # Cancel in-flight runs for the same PR when a new commit lands; let + # master pushes always finish so we keep a complete trunk history. + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + phpunit: + name: "PHPUnit (PHP ${{ matrix.php }})" + runs-on: ubuntu-latest + strategy: + # Surface every failing version on every run, not just the first. + fail-fast: false + matrix: + # Floor matches the `>=7.2` declared in composer.json. The + # 7.2 / 7.4 entries enforce the promise; 8.x covers every + # currently-supported PHP release. + php: ['7.2', '7.4', '8.1', '8.3', '8.4'] + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Resolve Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer packages + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + restore-keys: | + php-${{ matrix.php }}-composer- + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: Run PHPUnit + run: composer run-script unit-tests From c34b56c766367858293dd8f5e89a074535637dcd Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 25 Jun 2026 13:35:33 -0400 Subject: [PATCH 16/20] ci: bump action SHAs to Node 24 runtime, drop vulnerable PHPUnit range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two warnings surfaced by the first CI run on PR #86: 1. Every job emitted a deprecation warning that the pinned action versions (actions/checkout@v4.2.2, actions/cache@v4.2.0, shivammathur/setup-php@v2.31.1) target the Node 20 runtime, which GitHub deprecated in September 2025. Bumped to: - actions/checkout v7.0.0 - actions/cache v6.0.0 - shivammathur/setup-php 2.37.2 2. Dependabot flagged PHPUnit < 8.5.52 (high-severity unsafe deserialization in PHPT code coverage handling). The previous constraint `^7.5 || ^8.5 || ^9.5` allowed the vulnerable range. Tightened to `^8.5.52 || ^9.5` — still covers every PHP version in the CI matrix (8.5 supports 7.2+, 9.5 supports 7.3+). Co-Authored-By: Claude Opus 4.7 --- .github/workflows/tests.yml | 6 +++--- composer.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c52ff29..349c266 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,10 +28,10 @@ jobs: php: ['7.2', '7.4', '8.1', '8.3', '8.4'] steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v7.0.0 - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 + uses: shivammathur/setup-php@2282b6a082fc605c8320908a4cca3a5d1ca6c6fe # 2.37.2 with: php-version: ${{ matrix.php }} coverage: none @@ -42,7 +42,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" - name: Cache Composer packages - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v6.0.0 with: path: ${{ steps.composer-cache.outputs.dir }} key: php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} diff --git a/composer.json b/composer.json index 87ddcce..04e602d 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "symfony/polyfill-mbstring": "^1.27" }, "require-dev": { - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + "phpunit/phpunit": "^8.5.52 || ^9.5" }, "autoload": { "files": ["lib/Mixpanel.php"] From d4abfff8ee971caa3576a66f9fa232f76c5a255e Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 29 Jun 2026 11:12:02 -0400 Subject: [PATCH 17/20] fix(flags): tag fallback_reason so OpenFeature can distinguish causes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SelectedVariant now carries two source fields: `variant_source` (local | remote | fallback) and `fallback_reason` (FLAG_NOT_FOUND | MISSING_CONTEXT_KEY | NO_ROLLOUT_MATCH | BACKEND_ERROR | NOT_READY, set only when source is fallback). Three behaviorally distinct outcomes — flag-not-found, no-rollout-match, and missing-context-key — previously all returned the bare fallback. The OpenFeature wrapper collapsed them to FLAG_NOT_FOUND, sending callers chasing the flag name when the real cause was usually a rule miss or absent context. The wrapper now dispatches on fallback_reason and maps each to the spec-correct OpenFeature response. Most notably, NO_ROLLOUT_MATCH becomes `reason: DEFAULT` with no error code instead of FLAG_NOT_FOUND. Constant names align with mixpanel-php for consistency across SDKs. Linear: SDK-79 Co-Authored-By: Claude Opus 4.7 --- lib/FeatureFlags/MixpanelLocalFlags.php | 2 +- lib/FeatureFlags/MixpanelRemoteFlags.php | 6 +- lib/FeatureFlags/MixpanelSelectedVariant.php | 60 ++++++++++++++----- test/FeatureFlags/MixpanelLocalFlagsTest.php | 9 ++- test/FeatureFlags/MixpanelRemoteFlagsTest.php | 6 ++ 5 files changed, 64 insertions(+), 19 deletions(-) diff --git a/lib/FeatureFlags/MixpanelLocalFlags.php b/lib/FeatureFlags/MixpanelLocalFlags.php index 57f54c9..bec5d4c 100644 --- a/lib/FeatureFlags/MixpanelLocalFlags.php +++ b/lib/FeatureFlags/MixpanelLocalFlags.php @@ -129,7 +129,7 @@ public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallb $this->_trackExposure($flagKey, $selected, $context, 'local', $latencyMs); } - return $selected; + return $selected->withSource(FeatureFlags_MixpanelSelectedVariant::SOURCE_LOCAL); } public function getAllVariants(array $context) { diff --git a/lib/FeatureFlags/MixpanelRemoteFlags.php b/lib/FeatureFlags/MixpanelRemoteFlags.php index 358ed70..795755d 100644 --- a/lib/FeatureFlags/MixpanelRemoteFlags.php +++ b/lib/FeatureFlags/MixpanelRemoteFlags.php @@ -39,7 +39,8 @@ public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallb return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND); } - $selected = FeatureFlags_MixpanelSelectedVariant::fromArray($flags[$flagKey]); + $selected = FeatureFlags_MixpanelSelectedVariant::fromArray($flags[$flagKey]) + ->withSource(FeatureFlags_MixpanelSelectedVariant::SOURCE_REMOTE); if ($reportExposure) { // Pass start/end so the exposure event carries @@ -62,7 +63,8 @@ public function getAllVariants(array $context) { $out = array(); foreach ($flags as $key => $payload) { - $out[$key] = FeatureFlags_MixpanelSelectedVariant::fromArray($payload); + $out[$key] = FeatureFlags_MixpanelSelectedVariant::fromArray($payload) + ->withSource(FeatureFlags_MixpanelSelectedVariant::SOURCE_REMOTE); } return $out; } diff --git a/lib/FeatureFlags/MixpanelSelectedVariant.php b/lib/FeatureFlags/MixpanelSelectedVariant.php index 6b1719f..bd988a1 100644 --- a/lib/FeatureFlags/MixpanelSelectedVariant.php +++ b/lib/FeatureFlags/MixpanelSelectedVariant.php @@ -3,23 +3,30 @@ /** * A feature-flag variant after evaluation. * - * Matches the shape used by the Python, Ruby, Go, and Java SDKs so that - * downstream wrappers (a future OpenFeature provider) and analytics - * tooling can rely on the same field names across languages. + * Matches the shape used by the Python, Ruby, Go, Java, and Node SDKs so + * that downstream wrappers (the OpenFeature provider) and analytics tooling + * can rely on the same field names across languages. * * The `experimentId`, `isExperimentActive`, and `isQaTester` fields are * kept available so a future OpenFeature wrapper can forward them as - * `flag_metadata` — addressing finding "Design C" in the cross-SDK - * audit (other wrappers throw this metadata away). + * `flag_metadata` — addressing finding "Design C" in the cross-SDK audit + * (other wrappers throw this metadata away). * - * `fallbackReason` is `null` when evaluation succeeded; when the SDK - * returns the fallback you passed in, it's set to one of the REASON_* - * constants below so the caller (or a future OpenFeature wrapper) can - * distinguish flag-not-found from missing-context-key from no-rollout- - * match etc. — addressing audit finding #1. + * Two fields describe the result's provenance: + * - `variantSource` is always set: `local` (local rule evaluation), + * `remote` (server-side /flags response), or `fallback` (developer + * fallback returned because the SDK had no value to serve). + * - `fallbackReason` is `null` on success; when `variantSource === 'fallback'` + * it's set to one of the REASON_* constants below so the OpenFeature + * wrapper can map each reason to the spec-correct error code instead of + * collapsing every fallback to FLAG_NOT_FOUND (audit finding #1). */ class FeatureFlags_MixpanelSelectedVariant { + const SOURCE_LOCAL = 'local'; + const SOURCE_REMOTE = 'remote'; + const SOURCE_FALLBACK = 'fallback'; + const REASON_FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'; const REASON_MISSING_CONTEXT_KEY = 'MISSING_CONTEXT_KEY'; const REASON_NO_ROLLOUT_MATCH = 'NO_ROLLOUT_MATCH'; @@ -41,7 +48,10 @@ class FeatureFlags_MixpanelSelectedVariant { /** @var bool|null */ public $isQaTester; - /** @var string|null null on success; one of the REASON_* constants when the fallback was returned */ + /** @var string|null one of SOURCE_*; set by the providers on every returned variant */ + public $variantSource; + + /** @var string|null null on success; one of the REASON_* constants when variantSource === SOURCE_FALLBACK */ public $fallbackReason; public function __construct( @@ -50,7 +60,8 @@ public function __construct( $experimentId = null, $isExperimentActive = null, $isQaTester = null, - $fallbackReason = null + $fallbackReason = null, + $variantSource = null ) { $this->variantKey = $variantKey; $this->variantValue = $variantValue; @@ -58,6 +69,7 @@ public function __construct( $this->isExperimentActive = $isExperimentActive; $this->isQaTester = $isQaTester; $this->fallbackReason = $fallbackReason; + $this->variantSource = $variantSource; } /** @@ -78,15 +90,32 @@ public static function fromArray(array $data) { } /** - * Return a copy of this variant with the supplied fallbackReason - * set. Used by the providers to tag the caller's fallback without - * mutating their object. + * Return a copy of this variant with the given source. Clears + * fallbackReason — use {@link withFallbackReason} when returning a + * fallback. + * + * @param string $source one of the SOURCE_* constants + * @return FeatureFlags_MixpanelSelectedVariant + */ + public function withSource($source) { + $clone = clone $this; + $clone->variantSource = $source; + $clone->fallbackReason = null; + return $clone; + } + + /** + * Return a copy of this variant tagged as a fallback with the given + * reason. Sets `variantSource` to SOURCE_FALLBACK and `fallbackReason` + * to the supplied REASON_* constant. Used by the providers to tag the + * caller's fallback without mutating their object. * * @param string $reason one of the REASON_* constants * @return FeatureFlags_MixpanelSelectedVariant */ public function withFallbackReason($reason) { $clone = clone $this; + $clone->variantSource = self::SOURCE_FALLBACK; $clone->fallbackReason = $reason; return $clone; } @@ -101,6 +130,7 @@ public function toArray() { 'experiment_id' => $this->experimentId, 'is_experiment_active' => $this->isExperimentActive, 'is_qa_tester' => $this->isQaTester, + 'variant_source' => $this->variantSource, 'fallback_reason' => $this->fallbackReason, ); } diff --git a/test/FeatureFlags/MixpanelLocalFlagsTest.php b/test/FeatureFlags/MixpanelLocalFlagsTest.php index ba22a2c..1d25b73 100644 --- a/test/FeatureFlags/MixpanelLocalFlagsTest.php +++ b/test/FeatureFlags/MixpanelLocalFlagsTest.php @@ -86,12 +86,17 @@ public function testReturnsFallbackAndSetsReasonWhenFlagMissing() { $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); $result = $this->_provider->getVariant('unknown', $fallback, array('distinct_id' => 'u1')); $this->assertEquals('fallback', $result->variantValue); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::SOURCE_FALLBACK, + $result->variantSource + ); $this->assertEquals( FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND, $result->fallbackReason ); // The caller's fallback object must not be mutated — we return a clone. $this->assertNull($fallback->fallbackReason); + $this->assertNull($fallback->variantSource); } public function testGetVariantBeforeLoadReturnsNotReady() { @@ -132,7 +137,9 @@ public function testReturnsVariantOnSuccessfulEval() { $this->assertSame(true, $result->variantValue); $this->assertEquals('exp-my-flag', $result->experimentId); $this->assertTrue($result->isExperimentActive); - // null fallbackReason means evaluation succeeded — no fallback used. + // variantSource=local marks a real local-eval match. null fallbackReason + // means evaluation succeeded — no fallback used. + $this->assertEquals(FeatureFlags_MixpanelSelectedVariant::SOURCE_LOCAL, $result->variantSource); $this->assertNull($result->fallbackReason); } diff --git a/test/FeatureFlags/MixpanelRemoteFlagsTest.php b/test/FeatureFlags/MixpanelRemoteFlagsTest.php index 9fc4ad7..346cd16 100644 --- a/test/FeatureFlags/MixpanelRemoteFlagsTest.php +++ b/test/FeatureFlags/MixpanelRemoteFlagsTest.php @@ -56,6 +56,8 @@ public function testGetVariantSendsContextAndFlagKey() { $this->assertEquals('on', $variant->variantKey); $this->assertSame(true, $variant->variantValue); + $this->assertEquals(FeatureFlags_MixpanelSelectedVariant::SOURCE_REMOTE, $variant->variantSource); + $this->assertNull($variant->fallbackReason); $this->assertEquals('/flags', $this->_provider->lastRequest['path']); $this->assertEquals('my-flag', $this->_provider->lastRequest['query']['flag_key']); $this->assertEquals(json_encode($context), $this->_provider->lastRequest['query']['context']); @@ -93,6 +95,10 @@ public function testFlagMissingInResponseSetsFlagNotFoundReason() { $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); $variant = $this->_provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); $this->assertEquals('fb', $variant->variantValue); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::SOURCE_FALLBACK, + $variant->variantSource + ); $this->assertEquals( FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND, $variant->fallbackReason From f4fafd323d304fa31657e4edfde7b5b132240e57 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 1 Jul 2026 11:05:03 -0400 Subject: [PATCH 18/20] fix(flags): address review feedback (mode validation, hardening, hygiene) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate the flags 'mode' option and throw InvalidArgumentException on unknown values. A typo like 'lcoal' used to silently fall through to remote, which is a nasty debug trap. - Replace the public static _compareVariantKeys usort callback with an anonymous function so the sort helper doesn't leak into the class's public API surface. - Swap Content-Type: application/json for Accept: application/json on GET requests — the header describes what we accept, not a request body we don't send. - Wrap MixpanelFlags::__destruct in try/catch(Throwable) so a shutdown throw during fatal-error cleanup can't mask the original error. - Log via _handleError when a rollout has a runtime rule but the context lacks custom_properties. Behavior unchanged (rollout still skips), but callers debugging REASON_NO_ROLLOUT_MATCH can now see why. - Clarify (via comments + a new regression test) the lockstep invariant between lowercaseLeafNodes and lowercaseKeysAndValues — if either drifts, JSON-Logic 'var' lookups silently miss and every runtime-rule flag falls back. - Explain the intentional null vs '' asymmetry in _assignedRollout / _assignedVariant $hashSalt defaults (different salt formulas, not a cosmetic mistake). Co-Authored-By: Claude Opus 4.7 --- lib/FeatureFlags/MixpanelFlags.php | 22 +++++++++++++-- lib/FeatureFlags/MixpanelFlagsBase.php | 3 ++- lib/FeatureFlags/MixpanelFlagsUtils.php | 8 ++++++ lib/FeatureFlags/MixpanelLocalFlags.php | 28 +++++++++++++++----- test/FeatureFlags/MixpanelFlagsTest.php | 16 +++++++++++ test/FeatureFlags/MixpanelFlagsUtilsTest.php | 20 ++++++++++++++ 6 files changed, 87 insertions(+), 10 deletions(-) diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php index 4695239..e9fc5bd 100644 --- a/lib/FeatureFlags/MixpanelFlags.php +++ b/lib/FeatureFlags/MixpanelFlags.php @@ -40,7 +40,19 @@ public function __construct($token, $version, $tracker, array $options) { // symfony/polyfill-mbstring (composer dep), HTTP uses the // existing CurlConsumer which already runtime-checks ext-curl. $flagsOpts = isset($options['flags']) && is_array($options['flags']) ? $options['flags'] : array(); - $this->_mode = isset($flagsOpts['mode']) ? strtolower((string) $flagsOpts['mode']) : self::MODE_REMOTE; + if (isset($flagsOpts['mode'])) { + $requested = strtolower((string) $flagsOpts['mode']); + if ($requested !== self::MODE_LOCAL && $requested !== self::MODE_REMOTE) { + // Fail loudly on typos ('lcoal' -> silently falls through to remote is a debug trap). + throw new InvalidArgumentException( + "Invalid flags 'mode' option: " . var_export($flagsOpts['mode'], true) . + ". Expected '" . self::MODE_LOCAL . "' or '" . self::MODE_REMOTE . "'." + ); + } + $this->_mode = $requested; + } else { + $this->_mode = self::MODE_REMOTE; + } if ($this->_mode === self::MODE_LOCAL) { $this->_provider = new FeatureFlags_MixpanelLocalFlags($token, $version, $tracker, $options); @@ -50,7 +62,13 @@ public function __construct($token, $version, $tracker, array $options) { } public function __destruct() { - $this->shutdown(); + try { + $this->shutdown(); + } catch (\Throwable $t) { + // Swallow: destructors run during shutdown/fatal-error paths where + // throwing could mask the original error or hit a partially-torn-down + // interpreter state. + } } /** @return string 'local' or 'remote' */ diff --git a/lib/FeatureFlags/MixpanelFlagsBase.php b/lib/FeatureFlags/MixpanelFlagsBase.php index 8ad7d20..7928c5a 100644 --- a/lib/FeatureFlags/MixpanelFlagsBase.php +++ b/lib/FeatureFlags/MixpanelFlagsBase.php @@ -75,7 +75,8 @@ protected function _httpGet($path, array $query = array()) { $url = 'https://' . $this->_apiHost . $path . '?' . http_build_query($params); $headers = array( - 'Content-Type: application/json', + // GET requests have no body — describe what we accept, not what we're sending. + 'Accept: application/json', 'X-Scheme: https', 'X-Forwarded-Proto: https', 'traceparent: ' . FeatureFlags_MixpanelFlagsUtils::generateTraceparent(), diff --git a/lib/FeatureFlags/MixpanelFlagsUtils.php b/lib/FeatureFlags/MixpanelFlagsUtils.php index 79e7194..2ef1376 100644 --- a/lib/FeatureFlags/MixpanelFlagsUtils.php +++ b/lib/FeatureFlags/MixpanelFlagsUtils.php @@ -74,6 +74,14 @@ public static function commonQueryParams($token, $version) { * comparisons against context values are case-insensitive without * mangling operator names like "in" or "==". * + * INVARIANT: this MUST be applied to the rule in lockstep with + * {@link self::lowercaseKeysAndValues} on the runtime parameters. + * `lowercaseLeafNodes` lowercases the operand values inside + * `{"var": "Email"}` to `"email"`; if the parameter keys aren't + * also lowercased, JSON-Logic's `var` lookup misses and every + * runtime-rule flag silently falls back. The two functions live + * together and must stay in sync. + * * @param mixed $value * @return mixed */ diff --git a/lib/FeatureFlags/MixpanelLocalFlags.php b/lib/FeatureFlags/MixpanelLocalFlags.php index bec5d4c..fdf637d 100644 --- a/lib/FeatureFlags/MixpanelLocalFlags.php +++ b/lib/FeatureFlags/MixpanelLocalFlags.php @@ -50,7 +50,11 @@ public function loadDefinitions() { } if (isset($flag['ruleset']['variants']) && is_array($flag['ruleset']['variants'])) { // Sort variants by key for deterministic bucket assignment. - usort($flag['ruleset']['variants'], array(__CLASS__, '_compareVariantKeys')); + usort($flag['ruleset']['variants'], function ($a, $b) { + $ak = isset($a['key']) ? (string) $a['key'] : ''; + $bk = isset($b['key']) ? (string) $b['key'] : ''; + return strcmp($ak, $bk); + }); } $byKey[$flag['key']] = $flag; } @@ -60,12 +64,6 @@ public function loadDefinitions() { return true; } - public static function _compareVariantKeys($a, $b) { - $ak = isset($a['key']) ? (string) $a['key'] : ''; - $bk = isset($b['key']) ? (string) $b['key'] : ''; - return strcmp($ak, $bk); - } - /** @return bool true once loadDefinitions has succeeded at least once */ public function areFlagsReady() { return $this->_ready; @@ -186,6 +184,9 @@ private function _assignedRollout(array $flag, $contextValue, array $context) { return null; } $flagKey = isset($flag['key']) ? $flag['key'] : ''; + // Default to null (not '') so the branch below can distinguish + // "flag declared a salt" from "flag has no salt at all" — the two + // pick completely different salt formulas. $hashSalt = isset($flag['hash_salt']) ? $flag['hash_salt'] : null; foreach ($flag['ruleset']['rollout'] as $index => $rollout) { @@ -212,6 +213,8 @@ private function _assignedVariant(array $flag, $contextValue, $flagKey, array $r } } + // Default to '' (not null) — this codepath just concatenates and never + // branches on presence, so the empty string collapses cleanly. $hashSalt = isset($flag['hash_salt']) ? $flag['hash_salt'] : ''; $salt = $flagKey . $hashSalt . 'variant'; $variantHash = FeatureFlags_MixpanelFlagsUtils::normalizedHash($contextValue, $salt); @@ -253,6 +256,13 @@ private function _runtimeRulesSatisfied(array $rollout, array $context) { if (isset($rollout['runtime_evaluation_rule']) && $rollout['runtime_evaluation_rule']) { $params = $this->_runtimeParameters($context); if ($params === null) { + // Not an error — the rollout just doesn't match — but log so + // callers debugging "why did I fall through to REASON_NO_ROLLOUT_MATCH" + // can see the missing custom_properties is why. + $this->_handleError( + 0, + 'Runtime rule present but custom_properties missing from context; rollout skipped.' + ); return false; } try { @@ -279,6 +289,10 @@ private function _runtimeRulesSatisfied(array $rollout, array $context) { private function _legacyRuntimeRuleSatisfied(array $definition, array $context) { $params = $this->_runtimeParameters($context); if ($params === null) { + $this->_handleError( + 0, + 'Legacy runtime rule present but custom_properties missing from context; rollout skipped.' + ); return false; } foreach ($definition as $key => $expectedValue) { diff --git a/test/FeatureFlags/MixpanelFlagsTest.php b/test/FeatureFlags/MixpanelFlagsTest.php index d6bbe7c..d09cff8 100644 --- a/test/FeatureFlags/MixpanelFlagsTest.php +++ b/test/FeatureFlags/MixpanelFlagsTest.php @@ -67,6 +67,22 @@ public function testModeAcceptsConstantOrStringLiteral() { $this->assertInstanceOf('FeatureFlags_MixpanelLocalFlags', $viaLiteral->flags->getProvider()); } + public function testInvalidModeThrows() { + // Typos like 'lcoal' used to silently fall through to remote — a debug trap + // when the intent was local. Fail loudly instead. + $this->setExpectedExceptionCompat('InvalidArgumentException'); + new Mixpanel('token', array('flags' => array('mode' => 'lcoal'))); + } + + /** Shim: setExpectedException is deprecated on 8.x+, expectException is the modern form. */ + private function setExpectedExceptionCompat($class) { + if (method_exists($this, 'expectException')) { + $this->expectException($class); + } else { + $this->setExpectedException($class); + } + } + public function testFlagsApiHostInheritsFromTopLevelHost() { // If the caller already pointed the SDK at a regional or mock // endpoint via the top-level `host` option, the flags module diff --git a/test/FeatureFlags/MixpanelFlagsUtilsTest.php b/test/FeatureFlags/MixpanelFlagsUtilsTest.php index c3f4e7f..3296018 100644 --- a/test/FeatureFlags/MixpanelFlagsUtilsTest.php +++ b/test/FeatureFlags/MixpanelFlagsUtilsTest.php @@ -69,4 +69,24 @@ public function testLowercaseKeysAndValues() { // Non-string values pass through unchanged. $this->assertSame(5, $out['count']); } + + /** + * Locks the invariant documented on {@link FeatureFlags_MixpanelFlagsUtils::lowercaseLeafNodes}: + * the rule side ({@code lowercaseLeafNodes}) and the parameter side + * ({@code lowercaseKeysAndValues}) must resolve the same var name to + * the same casefolded key. If someone changes one function without the + * other, JSON-Logic's `var` lookup silently misses and every + * runtime-rule flag falls back — this test would catch that. + */ + public function testLeafNodesAndKeysAndValuesUseCompatibleCasefolding() { + $rule = array('==' => array(array('var' => 'Email'), 'Alice@Example.COM')); + $params = array('Email' => 'Alice@Example.COM'); + + $normalizedRule = FeatureFlags_MixpanelFlagsUtils::lowercaseLeafNodes($rule); + $normalizedParams = FeatureFlags_MixpanelFlagsUtils::lowercaseKeysAndValues($params); + + $varName = $normalizedRule['=='][0]['var']; + $this->assertArrayHasKey($varName, $normalizedParams); + $this->assertSame($normalizedRule['=='][1], $normalizedParams[$varName]); + } } From 4af078e4ec06f5dc3367cf7eccfa86482267ea2f Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 1 Jul 2026 11:50:49 -0400 Subject: [PATCH 19/20] refactor(flags): bump PHP min to 8.1 and strongly type the feature-flag surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP 8.1 is already end-of-life, so it's safe as a floor. Adds real PHP types (properties, params, returns, declare(strict_types=1)) throughout lib/FeatureFlags/* and typs the $flags property on the main Mixpanel class. Public API surface is unchanged — same method names, same signatures, same defaults — but every internal call site now has IDE/analyzer-visible types. - composer.json: >=7.2 -> >=8.1 - .github/workflows/tests.yml: matrix pruned to 8.1, 8.3, 8.4 - Test harness override in MixpanelRemoteFlagsTest updated to match the newly-typed base signature - Removed now-dead (bool) $reportExposure casts in local + remote providers, and the dead $_provider !== null guard in shutdown() Co-Authored-By: Claude Opus 4.7 --- .github/workflows/tests.yml | 6 +- composer.json | 2 +- lib/FeatureFlags/MixpanelFlags.php | 59 ++++++------- lib/FeatureFlags/MixpanelFlagsBase.php | 84 ++++++++++--------- lib/FeatureFlags/MixpanelFlagsUtils.php | 28 ++----- lib/FeatureFlags/MixpanelLocalFlags.php | 51 +++++------ lib/FeatureFlags/MixpanelRemoteFlags.php | 18 ++-- lib/FeatureFlags/MixpanelSelectedVariant.php | 65 +++++++------- lib/Mixpanel.php | 3 +- test/FeatureFlags/MixpanelRemoteFlagsTest.php | 2 +- 10 files changed, 154 insertions(+), 164 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 349c266..f48098b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,10 +22,10 @@ jobs: # Surface every failing version on every run, not just the first. fail-fast: false matrix: - # Floor matches the `>=7.2` declared in composer.json. The - # 7.2 / 7.4 entries enforce the promise; 8.x covers every + # Floor matches the `>=8.1` declared in composer.json. The + # 8.1 entry enforces the promise; later minors cover every # currently-supported PHP release. - php: ['7.2', '7.4', '8.1', '8.3', '8.4'] + php: ['8.1', '8.3', '8.4'] steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v7.0.0 diff --git a/composer.json b/composer.json index 04e602d..7132b3a 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ } ], "require": { - "php": ">=7.2", + "php": ">=8.1", "jwadhams/json-logic-php": "^1.5", "symfony/polyfill-mbstring": "^1.27" }, diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php index e9fc5bd..fe61dad 100644 --- a/lib/FeatureFlags/MixpanelFlags.php +++ b/lib/FeatureFlags/MixpanelFlags.php @@ -1,5 +1,7 @@ _mode; } - /** @return FeatureFlags_MixpanelFlagsBase */ - public function getProvider() { + public function getProvider(): FeatureFlags_MixpanelFlagsBase { return $this->_provider; } /** * Fetch flag definitions from the server. Local mode only; no-op * (returns true) in remote mode. - * - * @return bool true on success */ - public function loadDefinitions() { + public function loadDefinitions(): bool { if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { return $this->_provider->loadDefinitions(); } return true; } - /** @return bool */ - public function areFlagsReady() { + public function areFlagsReady(): bool { if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { return $this->_provider->areFlagsReady(); } return true; } - /** @return int|null */ - public function lastSyncedAt() { + public function lastSyncedAt(): ?int { if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { return $this->_provider->lastSyncedAt(); } return null; } - public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = true) { + public function getVariant( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $fallback, + array $context, + bool $reportExposure = true + ): FeatureFlags_MixpanelSelectedVariant { return $this->_provider->getVariant($flagKey, $fallback, $context, $reportExposure); } - public function getVariantValue($flagKey, $fallbackValue, array $context) { + public function getVariantValue(string $flagKey, mixed $fallbackValue, array $context): mixed { return $this->_provider->getVariantValue($flagKey, $fallbackValue, $context); } - public function isEnabled($flagKey, array $context) { + public function isEnabled(string $flagKey, array $context): bool { return $this->_provider->isEnabled($flagKey, $context); } - public function getAllVariants(array $context) { + public function getAllVariants(array $context): array { return $this->_provider->getAllVariants($context); } - public function trackExposure($flagKey, FeatureFlags_MixpanelSelectedVariant $variant, array $context) { + public function trackExposure( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $variant, + array $context + ): void { $this->_provider->trackExposure($flagKey, $variant, $context); } - public function shutdown() { - if ($this->_provider !== null) { - $this->_provider->shutdown(); - } + public function shutdown(): void { + $this->_provider->shutdown(); } } diff --git a/lib/FeatureFlags/MixpanelFlagsBase.php b/lib/FeatureFlags/MixpanelFlagsBase.php index 7928c5a..fb29cff 100644 --- a/lib/FeatureFlags/MixpanelFlagsBase.php +++ b/lib/FeatureFlags/MixpanelFlagsBase.php @@ -1,5 +1,7 @@ track($eventName, $properties + ['distinct_id' => $distinctId]) */ + // Note: 'callable' is not a valid PHP property type. Kept untyped with phpdoc. protected $_tracker; - /** @var string */ - protected $_apiHost; + protected string $_apiHost; - /** @var int seconds */ - protected $_requestTimeout; + /** Seconds. */ + protected int $_requestTimeout; - public function __construct($token, $version, $tracker, array $options) { + public function __construct(string $token, string $version, callable $tracker, array $options) { parent::__construct($options); $this->_token = $token; $this->_version = $version; @@ -42,9 +42,9 @@ public function __construct($token, $version, $tracker, array $options) { // This means EU/India endpoints or local mocks only need to be // configured once at the SDK level. if (isset($flagsOpts['api_host'])) { - $this->_apiHost = $flagsOpts['api_host']; + $this->_apiHost = (string) $flagsOpts['api_host']; } elseif (isset($options['host'])) { - $this->_apiHost = $options['host']; + $this->_apiHost = (string) $options['host']; } else { $this->_apiHost = 'api.mixpanel.com'; } @@ -52,7 +52,7 @@ public function __construct($token, $version, $tracker, array $options) { } /** Release any held resources. Subclasses override to close cURL handles. */ - public function shutdown() { + public function shutdown(): void { // default: nothing held } @@ -67,7 +67,7 @@ public function shutdown() { * @return array decoded JSON * @throws Exception on HTTP non-2xx or cURL transport error */ - protected function _httpGet($path, array $query = array()) { + protected function _httpGet(string $path, array $query = array()): array { $params = array_merge( FeatureFlags_MixpanelFlagsUtils::commonQueryParams($this->_token, $this->_version), $query @@ -139,13 +139,13 @@ protected function _httpGet($path, array $query = array()) { * @return array */ protected function _buildExposureProperties( - $flagKey, + string $flagKey, FeatureFlags_MixpanelSelectedVariant $variant, - $evaluationMode, - $latencyMs = null, - $startTime = null, - $endTime = null - ) { + string $evaluationMode, + ?float $latencyMs = null, + ?float $startTime = null, + ?float $endTime = null + ): array { $properties = array( 'Experiment name' => $flagKey, 'Variant name' => $variant->variantKey, @@ -174,7 +174,7 @@ protected function _buildExposureProperties( * `datetime.now().isoformat()` output shape so cross-SDK analytics * keyed on these properties parse consistently. */ - private static function _formatIsoMicrotime($microtime) { + private static function _formatIsoMicrotime(float $microtime): string { $seconds = (int) floor($microtime); $micros = (int) round(($microtime - $seconds) * 1000000); if ($micros >= 1000000) { @@ -200,14 +200,14 @@ private static function _formatIsoMicrotime($microtime) { * @param float|null $latencyMs */ protected function _trackExposure( - $flagKey, + string $flagKey, FeatureFlags_MixpanelSelectedVariant $variant, array $context, - $evaluationMode, - $latencyMs = null, - $startTime = null, - $endTime = null - ) { + string $evaluationMode, + ?float $latencyMs = null, + ?float $startTime = null, + ?float $endTime = null + ): void { if (!isset($context['distinct_id']) || $context['distinct_id'] === '' || $context['distinct_id'] === null) { // Don't drop silently — surface to the error_callback so the // caller learns why their exposure analytics are empty @@ -234,10 +234,12 @@ protected function _trackExposure( * Forward an error to the user-supplied error_callback if one was * configured (matches the existing AbstractConsumer convention). * - * @param mixed $code - * @param string $message + * `$code` is a union because callers pass HTTP status codes (int), + * literal string codes ('mixpanel-flags'), Throwable::getCode() + * (which is int on Exception but string on PDOException), or 0/null + * placeholders. */ - protected function _handleError($code, $message) { + protected function _handleError(int|string|null $code, string $message): void { if (isset($this->_options['error_callback']) && is_callable($this->_options['error_callback'])) { call_user_func($this->_options['error_callback'], $code, $message); } elseif ($this->_debug()) { @@ -245,15 +247,20 @@ protected function _handleError($code, $message) { } } - abstract public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = true); + abstract public function getVariant( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $fallback, + array $context, + bool $reportExposure = true + ): FeatureFlags_MixpanelSelectedVariant; - public function getVariantValue($flagKey, $fallbackValue, array $context) { + public function getVariantValue(string $flagKey, mixed $fallbackValue, array $context): mixed { $fallback = new FeatureFlags_MixpanelSelectedVariant(null, $fallbackValue); $variant = $this->getVariant($flagKey, $fallback, $context); return $variant->variantValue; } - public function isEnabled($flagKey, array $context) { + public function isEnabled(string $flagKey, array $context): bool { return $this->getVariantValue($flagKey, false, $context) === true; } @@ -261,16 +268,15 @@ public function isEnabled($flagKey, array $context) { * Manually track exposure for a previously evaluated variant. Used * with getAllVariants() so callers can record exposure only for the * flags they actually consume. - * - * @param string $flagKey - * @param FeatureFlags_MixpanelSelectedVariant $variant - * @param array $context */ - public function trackExposure($flagKey, FeatureFlags_MixpanelSelectedVariant $variant, array $context) { + public function trackExposure( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $variant, + array $context + ): void { $mode = $this->_evaluationMode(); $this->_trackExposure($flagKey, $variant, $context, $mode); } - /** @return string */ - abstract protected function _evaluationMode(); + abstract protected function _evaluationMode(): string; } diff --git a/lib/FeatureFlags/MixpanelFlagsUtils.php b/lib/FeatureFlags/MixpanelFlagsUtils.php index 2ef1376..3521831 100644 --- a/lib/FeatureFlags/MixpanelFlagsUtils.php +++ b/lib/FeatureFlags/MixpanelFlagsUtils.php @@ -1,5 +1,7 @@ -<16 hex>-01. * The values are random per call; their only purpose is to give the * Mixpanel server a correlation id for the request. - * - * @return string */ - public static function generateTraceparent() { + public static function generateTraceparent(): string { $traceId = bin2hex(random_bytes(16)); $spanId = bin2hex(random_bytes(8)); return '00-' . $traceId . '-' . $spanId . '-01'; @@ -54,12 +50,8 @@ public static function generateTraceparent() { * The mp_lib / lib_version / token tuple that every flags request * sends. lib_version is read from a constant on the main Mixpanel * class so it tracks the package release. - * - * @param string $token - * @param string $version - * @return array */ - public static function commonQueryParams($token, $version) { + public static function commonQueryParams(string $token, string $version): array { return array( 'mp_lib' => 'php', 'lib_version' => $version, @@ -81,11 +73,8 @@ public static function commonQueryParams($token, $version) { * also lowercased, JSON-Logic's `var` lookup misses and every * runtime-rule flag silently falls back. The two functions live * together and must stay in sync. - * - * @param mixed $value - * @return mixed */ - public static function lowercaseLeafNodes($value) { + public static function lowercaseLeafNodes(mixed $value): mixed { if (is_string($value)) { return mb_strtolower($value, 'UTF-8'); } @@ -103,11 +92,8 @@ public static function lowercaseLeafNodes($value) { * Recursively casefold both keys and string values. Used on the * runtime-parameters side (custom_properties) so that user-supplied * "Email"/"EMAIL"/"email" all collide with the same rule operand. - * - * @param mixed $value - * @return mixed */ - public static function lowercaseKeysAndValues($value) { + public static function lowercaseKeysAndValues(mixed $value): mixed { if (is_string($value)) { return mb_strtolower($value, 'UTF-8'); } diff --git a/lib/FeatureFlags/MixpanelLocalFlags.php b/lib/FeatureFlags/MixpanelLocalFlags.php index fdf637d..f115ebf 100644 --- a/lib/FeatureFlags/MixpanelLocalFlags.php +++ b/lib/FeatureFlags/MixpanelLocalFlags.php @@ -1,5 +1,7 @@ flag definition (decoded JSON) */ - private $_definitions = array(); + /** @var array map of flag_key => flag definition (decoded JSON) */ + private array $_definitions = array(); - /** @var bool */ - private $_ready = false; + private bool $_ready = false; - /** @var int|null unix timestamp of last successful loadDefinitions */ - private $_lastSyncedAt = null; + /** Unix timestamp of last successful loadDefinitions. */ + private ?int $_lastSyncedAt = null; /** * Fetch the latest flag definitions from Mixpanel. Throws nothing * on transport failure — the error is routed to error_callback so * the existing definitions (if any) keep working until the next * successful call. Returns true on success. - * - * @return bool */ - public function loadDefinitions() { + public function loadDefinitions(): bool { try { $response = $this->_httpGet(self::DEFINITIONS_PATH); } catch (Exception $e) { @@ -64,22 +63,26 @@ public function loadDefinitions() { return true; } - /** @return bool true once loadDefinitions has succeeded at least once */ - public function areFlagsReady() { + /** True once loadDefinitions has succeeded at least once. */ + public function areFlagsReady(): bool { return $this->_ready; } - /** @return int|null unix timestamp of most recent successful sync */ - public function lastSyncedAt() { + /** Unix timestamp of most recent successful sync. */ + public function lastSyncedAt(): ?int { return $this->_lastSyncedAt; } - protected function _evaluationMode() { + protected function _evaluationMode(): string { return 'local'; } - public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallback, array $context, $reportExposure = true) { - $reportExposure = (bool) $reportExposure; + public function getVariant( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $fallback, + array $context, + bool $reportExposure = true + ): FeatureFlags_MixpanelSelectedVariant { $startTime = microtime(true); if (!$this->_ready) { @@ -130,7 +133,7 @@ public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallb return $selected->withSource(FeatureFlags_MixpanelSelectedVariant::SOURCE_LOCAL); } - public function getAllVariants(array $context) { + public function getAllVariants(array $context): array { $out = array(); foreach ($this->_definitions as $flagKey => $_def) { $fallback = new FeatureFlags_MixpanelSelectedVariant(null, null); @@ -142,7 +145,7 @@ public function getAllVariants(array $context) { return $out; } - private function _overrideForTestUser(array $flag, array $context) { + private function _overrideForTestUser(array $flag, array $context): ?FeatureFlags_MixpanelSelectedVariant { if (!isset($flag['ruleset']['test']['users']) || !is_array($flag['ruleset']['test']['users'])) { return null; } @@ -157,7 +160,7 @@ private function _overrideForTestUser(array $flag, array $context) { return $this->_matchingVariant($users[$distinctId], $flag, /* isQaTester */ true); } - private function _matchingVariant($variantKey, array $flag, $isQaTester = false) { + private function _matchingVariant(string $variantKey, array $flag, bool $isQaTester = false): ?FeatureFlags_MixpanelSelectedVariant { if (!isset($flag['ruleset']['variants']) || !is_array($flag['ruleset']['variants'])) { return null; } @@ -179,7 +182,7 @@ private function _matchingVariant($variantKey, array $flag, $isQaTester = false) return null; } - private function _assignedRollout(array $flag, $contextValue, array $context) { + private function _assignedRollout(array $flag, string $contextValue, array $context): ?array { if (!isset($flag['ruleset']['rollout']) || !is_array($flag['ruleset']['rollout'])) { return null; } @@ -205,7 +208,7 @@ private function _assignedRollout(array $flag, $contextValue, array $context) { return null; } - private function _assignedVariant(array $flag, $contextValue, $flagKey, array $rollout) { + private function _assignedVariant(array $flag, string $contextValue, string $flagKey, array $rollout): ?FeatureFlags_MixpanelSelectedVariant { if (isset($rollout['variant_override']['key'])) { $override = $this->_matchingVariant($rollout['variant_override']['key'], $flag); if ($override !== null) { @@ -252,7 +255,7 @@ private function _assignedVariant(array $flag, $contextValue, $flagKey, array $r ); } - private function _runtimeRulesSatisfied(array $rollout, array $context) { + private function _runtimeRulesSatisfied(array $rollout, array $context): bool { if (isset($rollout['runtime_evaluation_rule']) && $rollout['runtime_evaluation_rule']) { $params = $this->_runtimeParameters($context); if ($params === null) { @@ -286,7 +289,7 @@ private function _runtimeRulesSatisfied(array $rollout, array $context) { return true; } - private function _legacyRuntimeRuleSatisfied(array $definition, array $context) { + private function _legacyRuntimeRuleSatisfied(array $definition, array $context): bool { $params = $this->_runtimeParameters($context); if ($params === null) { $this->_handleError( @@ -315,7 +318,7 @@ private function _legacyRuntimeRuleSatisfied(array $definition, array $context) return true; } - private function _runtimeParameters(array $context) { + private function _runtimeParameters(array $context): ?array { if (!isset($context['custom_properties']) || !is_array($context['custom_properties'])) { return null; } diff --git a/lib/FeatureFlags/MixpanelRemoteFlags.php b/lib/FeatureFlags/MixpanelRemoteFlags.php index 795755d..e683dc4 100644 --- a/lib/FeatureFlags/MixpanelRemoteFlags.php +++ b/lib/FeatureFlags/MixpanelRemoteFlags.php @@ -1,5 +1,7 @@ _fetchFlags($context, $flagKey); @@ -53,7 +58,7 @@ public function getVariant($flagKey, FeatureFlags_MixpanelSelectedVariant $fallb return $selected; } - public function getAllVariants(array $context) { + public function getAllVariants(array $context): array { try { $flags = $this->_fetchFlags($context, null); } catch (Exception $e) { @@ -70,11 +75,10 @@ public function getAllVariants(array $context) { } /** - * @param array $context * @param string|null $flagKey when set, the server scopes the response to that one flag * @return array map of flag_key => variant payload */ - private function _fetchFlags(array $context, $flagKey) { + private function _fetchFlags(array $context, ?string $flagKey): array { $query = array( // The Python and Ruby SDKs URL-encode the context JSON // before placing it in the query string. http_build_query diff --git a/lib/FeatureFlags/MixpanelSelectedVariant.php b/lib/FeatureFlags/MixpanelSelectedVariant.php index bd988a1..1b661d4 100644 --- a/lib/FeatureFlags/MixpanelSelectedVariant.php +++ b/lib/FeatureFlags/MixpanelSelectedVariant.php @@ -1,5 +1,7 @@ variantKey = $variantKey; $this->variantValue = $variantValue; @@ -75,17 +74,14 @@ public function __construct( /** * Build a SelectedVariant from the JSON shape returned by the * /flags remote endpoint or stored inside a flag definition. - * - * @param array $data - * @return FeatureFlags_MixpanelSelectedVariant */ - public static function fromArray(array $data) { + public static function fromArray(array $data): self { return new self( - isset($data['variant_key']) ? $data['variant_key'] : null, + isset($data['variant_key']) ? (string) $data['variant_key'] : null, isset($data['variant_value']) ? $data['variant_value'] : null, - isset($data['experiment_id']) ? $data['experiment_id'] : null, - isset($data['is_experiment_active']) ? $data['is_experiment_active'] : null, - isset($data['is_qa_tester']) ? $data['is_qa_tester'] : null + isset($data['experiment_id']) ? (string) $data['experiment_id'] : null, + isset($data['is_experiment_active']) ? (bool) $data['is_experiment_active'] : null, + isset($data['is_qa_tester']) ? (bool) $data['is_qa_tester'] : null ); } @@ -95,9 +91,8 @@ public static function fromArray(array $data) { * fallback. * * @param string $source one of the SOURCE_* constants - * @return FeatureFlags_MixpanelSelectedVariant */ - public function withSource($source) { + public function withSource(string $source): self { $clone = clone $this; $clone->variantSource = $source; $clone->fallbackReason = null; @@ -111,19 +106,15 @@ public function withSource($source) { * caller's fallback without mutating their object. * * @param string $reason one of the REASON_* constants - * @return FeatureFlags_MixpanelSelectedVariant */ - public function withFallbackReason($reason) { + public function withFallbackReason(string $reason): self { $clone = clone $this; $clone->variantSource = self::SOURCE_FALLBACK; $clone->fallbackReason = $reason; return $clone; } - /** - * @return array - */ - public function toArray() { + public function toArray(): array { return array( 'variant_key' => $this->variantKey, 'variant_value' => $this->variantValue, diff --git a/lib/Mixpanel.php b/lib/Mixpanel.php index 34fd0ba..621311c 100644 --- a/lib/Mixpanel.php +++ b/lib/Mixpanel.php @@ -160,9 +160,8 @@ private static function _resolveLibVersion() { * An instance of the MixpanelFlags facade, present only when the * caller passed an 'flags' entry in $options. Use it for * `$mp->flags->isEnabled(...)` and similar calls. - * @var FeatureFlags_MixpanelFlags|null */ - public $flags; + public ?FeatureFlags_MixpanelFlags $flags = null; /** diff --git a/test/FeatureFlags/MixpanelRemoteFlagsTest.php b/test/FeatureFlags/MixpanelRemoteFlagsTest.php index 346cd16..30d9c17 100644 --- a/test/FeatureFlags/MixpanelRemoteFlagsTest.php +++ b/test/FeatureFlags/MixpanelRemoteFlagsTest.php @@ -15,7 +15,7 @@ class _TestableRemoteFlags extends FeatureFlags_MixpanelRemoteFlags { /** @var string|null exception message to throw instead of returning a response */ public $nextError = null; - protected function _httpGet($path, array $query = array()) { + protected function _httpGet(string $path, array $query = array()): array { $this->lastRequest = array('path' => $path, 'query' => $query); if ($this->nextError !== null) { throw new Exception($this->nextError); From 8d58a16ae6f9586ddb964d05da8e0bbcdaa0f1d4 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 2 Jul 2026 15:56:02 -0400 Subject: [PATCH 20/20] fix(flags): detect json_encode failure + guard curl_init + document isEnabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Greptile findings on the feature-flags PR: 1. P1: MixpanelRemoteFlags._fetchFlags used to pass json_encode($context) straight into http_build_query. On non-UTF-8 strings or circular refs json_encode returns false, http_build_query coerced it to "", and the server received context= — silently returning nothing and giving the caller REASON_FLAG_NOT_FOUND with no hint that serialization was the real cause. Now we check the return value and throw so getVariant surfaces REASON_BACKEND_ERROR via error_callback. 2. P2: MixpanelFlagsBase._httpGet called curl_init() directly. AbstractConsumer already guards this. On minimal PHP builds without ext-curl (some Alpine images) curl_init fatals with no hint. Added a function_exists('curl_init') guard throwing RuntimeException. 3. P2: isEnabled uses strict === true. Documented that this is intentional — non-boolean truthy values (1, "true", "on", ...) signal a type mismatch on a Feature Gate flag and we fail closed, matching Node/Ruby/Python/Go isEnabled semantics. New test testJsonEncodeFailureSurfacesAsBackendError locks in the P1 behavior (invalid UTF-8 → BACKEND_ERROR + error_callback, no HTTP call). All 39 flag tests pass locally. --- lib/FeatureFlags/MixpanelFlagsBase.php | 19 +++++++++++ lib/FeatureFlags/MixpanelRemoteFlags.php | 20 ++++++++--- test/FeatureFlags/MixpanelRemoteFlagsTest.php | 34 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/lib/FeatureFlags/MixpanelFlagsBase.php b/lib/FeatureFlags/MixpanelFlagsBase.php index fb29cff..ed83750 100644 --- a/lib/FeatureFlags/MixpanelFlagsBase.php +++ b/lib/FeatureFlags/MixpanelFlagsBase.php @@ -68,6 +68,16 @@ public function shutdown(): void { * @throws Exception on HTTP non-2xx or cURL transport error */ protected function _httpGet(string $path, array $query = array()): array { + // Match the guard AbstractConsumer already applies. On minimal + // PHP builds without ext-curl (some Alpine images, custom + // builds) curl_init would fatal with an unresolved function + // and give no hint that curl is what's missing. + if (!function_exists('curl_init')) { + throw new RuntimeException( + 'Mixpanel feature flags require the PHP curl extension (ext-curl), which is not loaded.' + ); + } + $params = array_merge( FeatureFlags_MixpanelFlagsUtils::commonQueryParams($this->_token, $this->_version), $query @@ -260,6 +270,15 @@ public function getVariantValue(string $flagKey, mixed $fallbackValue, array $co return $variant->variantValue; } + /** + * Returns true only when the variant value is the strict boolean + * `true`. Non-boolean truthy values (`1`, `"true"`, `"on"`, ...) + * intentionally return false — they signal a type mismatch on a + * flag that was expected to be a Mixpanel Feature Gate, and we + * fail closed rather than accept an ambiguous "on" signal. + * Matches the strict semantics of isEnabled in the Node, Ruby, + * Python, and Go SDKs. + */ public function isEnabled(string $flagKey, array $context): bool { return $this->getVariantValue($flagKey, false, $context) === true; } diff --git a/lib/FeatureFlags/MixpanelRemoteFlags.php b/lib/FeatureFlags/MixpanelRemoteFlags.php index e683dc4..6661ed9 100644 --- a/lib/FeatureFlags/MixpanelRemoteFlags.php +++ b/lib/FeatureFlags/MixpanelRemoteFlags.php @@ -79,12 +79,22 @@ public function getAllVariants(array $context): array { * @return array map of flag_key => variant payload */ private function _fetchFlags(array $context, ?string $flagKey): array { + // The Python and Ruby SDKs URL-encode the context JSON before + // placing it in the query string. http_build_query would do + // that for us, but we pre-encode to JSON first so the server + // sees the expected JSON shape. + $encodedContext = json_encode($context); + if ($encodedContext === false) { + // json_encode returns false on non-UTF-8 strings, circular + // references, etc. http_build_query would coerce that to + // an empty string and the server would silently see + // context=, so surface the real cause instead. + throw new Exception( + 'Mixpanel flags context could not be JSON-encoded: ' . json_last_error_msg() + ); + } $query = array( - // The Python and Ruby SDKs URL-encode the context JSON - // before placing it in the query string. http_build_query - // would do that for us, but we pre-encode to JSON first so - // the server sees the expected JSON shape. - 'context' => json_encode($context), + 'context' => $encodedContext, ); if ($flagKey !== null) { $query['flag_key'] = $flagKey; diff --git a/test/FeatureFlags/MixpanelRemoteFlagsTest.php b/test/FeatureFlags/MixpanelRemoteFlagsTest.php index 30d9c17..8501c6f 100644 --- a/test/FeatureFlags/MixpanelRemoteFlagsTest.php +++ b/test/FeatureFlags/MixpanelRemoteFlagsTest.php @@ -149,6 +149,40 @@ public function testReportExposureFalseSkipsTracking() { $this->assertCount(0, $this->_captured); } + public function testJsonEncodeFailureSurfacesAsBackendError() { + // Non-UTF-8 bytes in the context make json_encode return false. + // Previously http_build_query silently coerced that to an empty + // string, the server received context= and returned a null + // response, and the caller got REASON_FLAG_NOT_FOUND — with no + // hint that the real cause was serialization. + $errors = array(); + $errorCallback = function ($code, $message) use (&$errors) { + $errors[] = $message; + }; + $provider = new _TestableRemoteFlags( + 'token', + '2.11.0', + function () {}, + array( + 'error_callback' => $errorCallback, + 'flags' => array('mode' => 'remote'), + ) + ); + + $context = array('distinct_id' => 'u1', 'bad' => "\xB1\x31"); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); + $variant = $provider->getVariant('my-flag', $fallback, $context); + + $this->assertNull($provider->lastRequest, 'HTTP call should not have been attempted'); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::REASON_BACKEND_ERROR, + $variant->fallbackReason + ); + $this->assertEquals('fb', $variant->variantValue); + $this->assertCount(1, $errors); + $this->assertStringContainsString('JSON-encoded', $errors[0]); + } + public function testGetAllVariantsOmitsFlagKeyQueryParam() { $this->_provider->nextResponse = array('flags' => array( 'a' => array('variant_key' => 'on', 'variant_value' => 1),