diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..f48098b
--- /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 `>=8.1` declared in composer.json. The
+ # 8.1 entry enforces the promise; later minors cover every
+ # currently-supported PHP release.
+ php: ['8.1', '8.3', '8.4']
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v7.0.0
+
+ - name: Setup PHP ${{ matrix.php }}
+ uses: shivammathur/setup-php@2282b6a082fc605c8320908a4cca3a5d1ca6c6fe # 2.37.2
+ 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@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v6.0.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
diff --git a/README.md b/README.md
index b6ffa8f..3afbae0 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-Mixpanel PHP Library [](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)
@@ -82,7 +82,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
-------------
diff --git a/composer.json b/composer.json
index 5d43e7a..7132b3a 100644
--- a/composer.json
+++ b/composer.json
@@ -17,11 +17,12 @@
}
],
"require": {
- "php": ">=5.0"
+ "php": ">=8.1",
+ "jwadhams/json-logic-php": "^1.5",
+ "symfony/polyfill-mbstring": "^1.27"
},
"require-dev": {
- "phpunit/phpunit": "5.6.*",
- "phpdocumentor/phpdocumentor": "2.9.*"
+ "phpunit/phpunit": "^8.5.52 || ^9.5"
},
"autoload": {
"files": ["lib/Mixpanel.php"]
diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php
new file mode 100644
index 0000000..fe61dad
--- /dev/null
+++ b/lib/FeatureFlags/MixpanelFlags.php
@@ -0,0 +1,139 @@
+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' => FeatureFlags_MixpanelFlags::MODE_REMOTE),
+ * ));
+ * $enabled = $mp->flags->isEnabled('my-flag', array(
+ * 'distinct_id' => 'user-123',
+ * ));
+ */
+class FeatureFlags_MixpanelFlags {
+
+ /**
+ * Evaluation mode values for the `mode` config key. 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';
+
+ private FeatureFlags_MixpanelFlagsBase $_provider;
+
+ /** One of the MODE_* constants. */
+ private string $_mode;
+
+ public function __construct(string $token, string $version, callable $tracker, array $options) {
+ // 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();
+ 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);
+ } else {
+ $this->_provider = new FeatureFlags_MixpanelRemoteFlags($token, $version, $tracker, $options);
+ }
+ }
+
+ public function __destruct() {
+ 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.
+ }
+ }
+
+ public function getMode(): string {
+ return $this->_mode;
+ }
+
+ public function getProvider(): FeatureFlags_MixpanelFlagsBase {
+ return $this->_provider;
+ }
+
+ /**
+ * Fetch flag definitions from the server. Local mode only; no-op
+ * (returns true) in remote mode.
+ */
+ public function loadDefinitions(): bool {
+ if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) {
+ return $this->_provider->loadDefinitions();
+ }
+ return true;
+ }
+
+ public function areFlagsReady(): bool {
+ if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) {
+ return $this->_provider->areFlagsReady();
+ }
+ return true;
+ }
+
+ public function lastSyncedAt(): ?int {
+ if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) {
+ return $this->_provider->lastSyncedAt();
+ }
+ return null;
+ }
+
+ 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(string $flagKey, mixed $fallbackValue, array $context): mixed {
+ return $this->_provider->getVariantValue($flagKey, $fallbackValue, $context);
+ }
+
+ public function isEnabled(string $flagKey, array $context): bool {
+ return $this->_provider->isEnabled($flagKey, $context);
+ }
+
+ public function getAllVariants(array $context): array {
+ return $this->_provider->getAllVariants($context);
+ }
+
+ public function trackExposure(
+ string $flagKey,
+ FeatureFlags_MixpanelSelectedVariant $variant,
+ array $context
+ ): void {
+ $this->_provider->trackExposure($flagKey, $variant, $context);
+ }
+
+ public function shutdown(): void {
+ $this->_provider->shutdown();
+ }
+}
diff --git a/lib/FeatureFlags/MixpanelFlagsBase.php b/lib/FeatureFlags/MixpanelFlagsBase.php
new file mode 100644
index 0000000..ed83750
--- /dev/null
+++ b/lib/FeatureFlags/MixpanelFlagsBase.php
@@ -0,0 +1,301 @@
+track($eventName, $properties + ['distinct_id' => $distinctId]) */
+ // Note: 'callable' is not a valid PHP property type. Kept untyped with phpdoc.
+ protected $_tracker;
+
+ protected string $_apiHost;
+
+ /** Seconds. */
+ protected int $_requestTimeout;
+
+ public function __construct(string $token, string $version, callable $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 = (string) $flagsOpts['api_host'];
+ } elseif (isset($options['host'])) {
+ $this->_apiHost = (string) $options['host'];
+ } else {
+ $this->_apiHost = 'api.mixpanel.com';
+ }
+ $this->_requestTimeout = isset($flagsOpts['request_timeout_in_seconds']) ? (int) $flagsOpts['request_timeout_in_seconds'] : 10;
+ }
+
+ /** Release any held resources. Subclasses override to close cURL handles. */
+ public function shutdown(): void {
+ // 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(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
+ );
+ $url = 'https://' . $this->_apiHost . $path . '?' . http_build_query($params);
+
+ $headers = array(
+ // 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(),
+ // 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);
+ // 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);
+ $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.
+ *
+ * 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 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(
+ string $flagKey,
+ FeatureFlags_MixpanelSelectedVariant $variant,
+ string $evaluationMode,
+ ?float $latencyMs = null,
+ ?float $startTime = null,
+ ?float $endTime = null
+ ): array {
+ $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 ($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(float $microtime): string {
+ $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
+ * 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(
+ string $flagKey,
+ FeatureFlags_MixpanelSelectedVariant $variant,
+ array $context,
+ 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
+ // (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, $startTime, $endTime
+ );
+
+ 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).
+ *
+ * `$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(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()) {
+ $this->_log('[flags] ' . $message);
+ }
+ }
+
+ abstract public function getVariant(
+ string $flagKey,
+ FeatureFlags_MixpanelSelectedVariant $fallback,
+ array $context,
+ bool $reportExposure = true
+ ): FeatureFlags_MixpanelSelectedVariant;
+
+ 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;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Manually track exposure for a previously evaluated variant. Used
+ * with getAllVariants() so callers can record exposure only for the
+ * flags they actually consume.
+ */
+ public function trackExposure(
+ string $flagKey,
+ FeatureFlags_MixpanelSelectedVariant $variant,
+ array $context
+ ): void {
+ $mode = $this->_evaluationMode();
+ $this->_trackExposure($flagKey, $variant, $context, $mode);
+ }
+
+ abstract protected function _evaluationMode(): string;
+}
diff --git a/lib/FeatureFlags/MixpanelFlagsUtils.php b/lib/FeatureFlags/MixpanelFlagsUtils.php
new file mode 100644
index 0000000..3521831
--- /dev/null
+++ b/lib/FeatureFlags/MixpanelFlagsUtils.php
@@ -0,0 +1,110 @@
+-<16 hex>-01.
+ * The values are random per call; their only purpose is to give the
+ * Mixpanel server a correlation id for the request.
+ */
+ public static function generateTraceparent(): string {
+ $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.
+ */
+ public static function commonQueryParams(string $token, string $version): array {
+ 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 "==".
+ *
+ * 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.
+ */
+ public static function lowercaseLeafNodes(mixed $value): mixed {
+ 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.
+ */
+ public static function lowercaseKeysAndValues(mixed $value): mixed {
+ 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..f115ebf
--- /dev/null
+++ b/lib/FeatureFlags/MixpanelLocalFlags.php
@@ -0,0 +1,327 @@
+ map of flag_key => flag definition (decoded JSON) */
+ private array $_definitions = array();
+
+ private bool $_ready = false;
+
+ /** 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.
+ */
+ public function loadDefinitions(): bool {
+ 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'], 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;
+ }
+ $this->_definitions = $byKey;
+ $this->_ready = true;
+ $this->_lastSyncedAt = time();
+ return true;
+ }
+
+ /** True once loadDefinitions has succeeded at least once. */
+ public function areFlagsReady(): bool {
+ return $this->_ready;
+ }
+
+ /** Unix timestamp of most recent successful sync. */
+ public function lastSyncedAt(): ?int {
+ return $this->_lastSyncedAt;
+ }
+
+ protected function _evaluationMode(): string {
+ return 'local';
+ }
+
+ public function getVariant(
+ string $flagKey,
+ FeatureFlags_MixpanelSelectedVariant $fallback,
+ array $context,
+ bool $reportExposure = true
+ ): FeatureFlags_MixpanelSelectedVariant {
+ $startTime = microtime(true);
+
+ if (!$this->_ready) {
+ // Distinguish "definitions never loaded" from "definitions
+ // 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->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_NOT_READY);
+ }
+
+ if (!isset($this->_definitions[$flagKey])) {
+ 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->_handleError(
+ 'mixpanel-flags',
+ "Flag '{$flagKey}' requires context key '{$bucketingKey}' which was not supplied"
+ );
+ return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_MISSING_CONTEXT_KEY);
+ }
+ $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) {
+ return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_NO_ROLLOUT_MATCH);
+ }
+
+ if ($reportExposure) {
+ $latencyMs = (microtime(true) - $startTime) * 1000.0;
+ $this->_trackExposure($flagKey, $selected, $context, 'local', $latencyMs);
+ }
+
+ return $selected->withSource(FeatureFlags_MixpanelSelectedVariant::SOURCE_LOCAL);
+ }
+
+ public function getAllVariants(array $context): array {
+ $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): ?FeatureFlags_MixpanelSelectedVariant {
+ 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(string $variantKey, array $flag, bool $isQaTester = false): ?FeatureFlags_MixpanelSelectedVariant {
+ 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, string $contextValue, array $context): ?array {
+ if (!isset($flag['ruleset']['rollout']) || !is_array($flag['ruleset']['rollout'])) {
+ 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) {
+ 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, 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) {
+ return $override;
+ }
+ }
+
+ // 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);
+
+ $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): bool {
+ 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 {
+ $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): bool {
+ $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) {
+ 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): ?array {
+ 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..6661ed9
--- /dev/null
+++ b/lib/FeatureFlags/MixpanelRemoteFlags.php
@@ -0,0 +1,108 @@
+ 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(): string {
+ return 'remote';
+ }
+
+ public function getVariant(
+ string $flagKey,
+ FeatureFlags_MixpanelSelectedVariant $fallback,
+ array $context,
+ bool $reportExposure = true
+ ): FeatureFlags_MixpanelSelectedVariant {
+ $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, 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->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_BACKEND_ERROR);
+ }
+ $endTime = microtime(true);
+
+ if (!isset($flags[$flagKey])) {
+ return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND);
+ }
+
+ $selected = FeatureFlags_MixpanelSelectedVariant::fromArray($flags[$flagKey])
+ ->withSource(FeatureFlags_MixpanelSelectedVariant::SOURCE_REMOTE);
+
+ if ($reportExposure) {
+ // 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;
+ }
+
+ public function getAllVariants(array $context): array {
+ try {
+ $flags = $this->_fetchFlags($context, null);
+ } catch (Exception $e) {
+ $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)
+ ->withSource(FeatureFlags_MixpanelSelectedVariant::SOURCE_REMOTE);
+ }
+ return $out;
+ }
+
+ /**
+ * @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, ?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(
+ 'context' => $encodedContext,
+ );
+ 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..1b661d4
--- /dev/null
+++ b/lib/FeatureFlags/MixpanelSelectedVariant.php
@@ -0,0 +1,128 @@
+variantKey = $variantKey;
+ $this->variantValue = $variantValue;
+ $this->experimentId = $experimentId;
+ $this->isExperimentActive = $isExperimentActive;
+ $this->isQaTester = $isQaTester;
+ $this->fallbackReason = $fallbackReason;
+ $this->variantSource = $variantSource;
+ }
+
+ /**
+ * Build a SelectedVariant from the JSON shape returned by the
+ * /flags remote endpoint or stored inside a flag definition.
+ */
+ public static function fromArray(array $data): self {
+ return new self(
+ isset($data['variant_key']) ? (string) $data['variant_key'] : null,
+ isset($data['variant_value']) ? $data['variant_value'] : 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
+ );
+ }
+
+ /**
+ * 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
+ */
+ public function withSource(string $source): self {
+ $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
+ */
+ public function withFallbackReason(string $reason): self {
+ $clone = clone $this;
+ $clone->variantSource = self::SOURCE_FALLBACK;
+ $clone->fallbackReason = $reason;
+ return $clone;
+ }
+
+ public function toArray(): array {
+ return array(
+ 'variant_key' => $this->variantKey,
+ 'variant_value' => $this->variantValue,
+ '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/lib/Mixpanel.php b/lib/Mixpanel.php
index 632bbd7..621311c 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,31 @@
*/
class Mixpanel extends Base_MixpanelBase {
+ /**
+ * 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.
+ */
+ 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';
+ }
+
/**
* An instance of the MixpanelPeople class (used to create/update profiles)
@@ -128,7 +154,14 @@ 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.
+ */
+ public ?FeatureFlags_MixpanelFlags $flags = null;
/**
@@ -136,7 +169,7 @@ class Mixpanel extends Base_MixpanelBase {
* @var Mixpanel[]
*/
private static $_instances = array();
-
+
/**
* Instantiates a new Mixpanel instance.
@@ -148,6 +181,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::_resolveLibVersion(), $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..d09cff8
--- /dev/null
+++ b/test/FeatureFlags/MixpanelFlagsTest.php
@@ -0,0 +1,116 @@
+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 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 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
+ // 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..3296018
--- /dev/null
+++ b/test/FeatureFlags/MixpanelFlagsUtilsTest.php
@@ -0,0 +1,92 @@
+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() {
+ $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']);
+ }
+
+ /**
+ * 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]);
+ }
+}
diff --git a/test/FeatureFlags/MixpanelLocalFlagsTest.php b/test/FeatureFlags/MixpanelLocalFlagsTest.php
new file mode 100644
index 0000000..1d25b73
--- /dev/null
+++ b/test/FeatureFlags/MixpanelLocalFlagsTest.php
@@ -0,0 +1,294 @@
+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->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() {
+ // 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->assertEquals('fb', $result->variantValue);
+ $this->assertEquals(
+ FeatureFlags_MixpanelSelectedVariant::REASON_NOT_READY,
+ $result->fallbackReason
+ );
+ }
+
+ 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->assertEquals('fallback', $result->variantValue);
+ $this->assertEquals(
+ FeatureFlags_MixpanelSelectedVariant::REASON_MISSING_CONTEXT_KEY,
+ $result->fallbackReason
+ );
+ }
+
+ 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);
+ // 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);
+ }
+
+ 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->assertEquals('fallback', $result->variantValue);
+ $this->assertEquals(
+ FeatureFlags_MixpanelSelectedVariant::REASON_NO_ROLLOUT_MATCH,
+ $result->fallbackReason
+ );
+ }
+
+ 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(false, $result->variantValue);
+ $this->assertEquals(
+ FeatureFlags_MixpanelSelectedVariant::REASON_NO_ROLLOUT_MATCH,
+ $result->fallbackReason
+ );
+ }
+
+ 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..8501c6f
--- /dev/null
+++ b/test/FeatureFlags/MixpanelRemoteFlagsTest.php
@@ -0,0 +1,193 @@
+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(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']);
+ }
+
+ 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']);
+ // 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() {
+ $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->assertEquals('fb', $variant->variantValue);
+ $this->assertEquals(
+ FeatureFlags_MixpanelSelectedVariant::SOURCE_FALLBACK,
+ $variant->variantSource
+ );
+ $this->assertEquals(
+ FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND,
+ $variant->fallbackReason
+ );
+ }
+
+ 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->assertEquals('fb', $variant->variantValue);
+ $this->assertEquals(
+ FeatureFlags_MixpanelSelectedVariant::REASON_BACKEND_ERROR,
+ $variant->fallbackReason
+ );
+ $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 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),
+ ));
+ $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();