Skip to content

feat(analytics): add feature flag support (local + remote evaluation) - #86

Open
tylerjroach wants to merge 20 commits into
masterfrom
feature-flags-support
Open

feat(analytics): add feature flag support (local + remote evaluation)#86
tylerjroach wants to merge 20 commits into
masterfrom
feature-flags-support

Conversation

@tylerjroach

@tylerjroach tylerjroach commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Mixpanel feature-flag support to the PHP SDK via a new $mp->flags facade — both local evaluation (one-shot fetch of definitions, then in-process FNV-1a bucketing + JSON Logic runtime rules) and remote evaluation (HTTP /flags on every call). API surface aligns with the Python / Ruby / Go / Java / Node server SDKs so cross-language behavior is consistent.

$mp = Mixpanel::getInstance("TOKEN", ["flags" => ["mode" => FeatureFlags_MixpanelFlags::MODE_REMOTE]]);
if ($mp->flags->isEnabled("new-checkout", ["distinct_id" => "u-123"])) { ... }

PHP-specific design notes:

  • No background polling thread. PHP has no native threading and FPM/Apache processes don't survive past a single request. Long-lived CLI workers call loadDefinitions() on whatever cadence they need.
  • Exposure events flow through the existing event queue, so they're naturally batched / flushed at end-of-request — evaluation never blocks on a /track HTTP call.
  • fallbackReason on the returned SelectedVariant distinguishes the five ways an eval can fall back (FLAG_NOT_FOUND, MISSING_CONTEXT_KEY, NO_ROLLOUT_MATCH, BACKEND_ERROR, NOT_READY) — null on success.

Public API surface (strongly typed, PHP 8.1+)

The whole feature-flag implementation ships with real PHP types — typed properties, typed parameters, typed returns, declare(strict_types=1) in every file. IDE autocomplete and static analyzers get accurate types; latent type-mismatch bugs surface at parse/runtime instead of silently coercing.

// Configuration (in $options to Mixpanel constructor)
[
    "flags" => [
        "mode"                       => FeatureFlags_MixpanelFlags::MODE_REMOTE
                                        | FeatureFlags_MixpanelFlags::MODE_LOCAL,  // default: MODE_REMOTE
        "api_host"                   => string,  // default: inherits "host" then "api.mixpanel.com"
        "request_timeout_in_seconds" => int,     // default: 10 (applied to connect + read)
    ],
]

// On the Mixpanel class
public ?FeatureFlags_MixpanelFlags $flags = null;

// Lifecycle (no-ops/sentinels in remote mode)
public function loadDefinitions(): bool                                            // local: fetch /flags/definitions
public function areFlagsReady(): bool                                              // local: ready check
public function lastSyncedAt(): ?int                                               // local: unix ts of last sync
public function getMode(): string                                                  // 'local' or 'remote'
public function getProvider(): FeatureFlags_MixpanelFlagsBase
public function shutdown(): void

// Evaluation
public function isEnabled(string $flagKey, array $context): bool
public function getVariantValue(string $flagKey, mixed $fallbackValue, array $context): mixed
public function getVariant(
    string $flagKey,
    FeatureFlags_MixpanelSelectedVariant $fallback,
    array $context,
    bool $reportExposure = true
): FeatureFlags_MixpanelSelectedVariant
public function getAllVariants(array $context): array   // array<string, FeatureFlags_MixpanelSelectedVariant>
public function trackExposure(
    string $flagKey,
    FeatureFlags_MixpanelSelectedVariant $variant,
    array $context
): void

// SelectedVariant fields (typed properties)
public ?string $variantKey
public mixed   $variantValue
public ?string $experimentId
public ?bool   $isExperimentActive
public ?bool   $isQaTester
public ?string $variantSource      // SOURCE_LOCAL | SOURCE_REMOTE | SOURCE_FALLBACK
public ?string $fallbackReason     // null on success; REASON_* on fallback

// Fallback reasons (constants 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

Dependency / version changes

  • PHP minimum bumped from >=5.0 to >=8.1. PHP 8.1 is already end-of-life at the time of this change, so it's the natural floor. Enables typed properties (7.4+), mixed (8.0), union types (8.0), and declare(strict_types=1) throughout the feature-flag implementation.
  • Two new runtime deps (both tiny, pure-PHP packages):
    • jwadhams/json-logic-php ^1.5 — runtime-rule evaluation in local mode.
    • symfony/polyfill-mbstring ^1.27 — Unicode case folding for runtime rules; no-op pass-through when ext-mbstring is present.
  • No new PHP extension requirements. FNV-1a bucketing uses PHP's built-in hash('fnv1a64', …) from core ext-hash (bundled in every PHP install). Tracking-only customers on PHP 8.1+ upgrading from 2.11.0 see zero install-time impact — no RUN docker-php-ext-install layers required on minimal Docker images.
  • lib_version query param on flag HTTP requests resolves dynamically via \Composer\InstalledVersions — no hardcoded version constant to bump at release time. Matches prepare-release.yml's philosophy ("composer.json intentionally has NO version field — Packagist reads from the git tag").
  • PHPUnit dev dep is ^8.5.52 || ^9.5; dropped abandoned phpdocumentor/phpdocumentor 2.9.* (security advisories on its symfony/validator transitive dep).
  • Updated existing tests for the PHPUnit baseline (PHPUnit\Framework\TestCase, : void setUp/tearDown, assertString*ContainsString).
  • Fixed one pre-existing test bug surfaced by the PHPUnit upgrade: MixpanelGroupsProducerTest::testRemove was asserting $unset when the SDK correctly emits $remove.
  • CI matrix (.github/workflows/tests.yml) runs PHPUnit against 8.1, 8.3, 8.4 — the supported PHP versions above the new floor.

Test plan

  • composer install && composer run-script unit-tests83 unit tests pass, 279 assertions. Coverage includes FNV-1a canonical RFC vectors (cross-language bucketing parity), runtime-rule eval (case-folding semantics), every fallback-reason path, exposure event shape, ISO start/complete timestamps in remote-mode exposure, nested numeric preservation, custom-bucketing-key warning behavior, api_host inheritance from top-level host, MODE_* constant equivalence.
  • php -l clean on every modified file after the typing pass.
  • End-to-end against the live integration test project via the sdk-testing harness — 42 integration tests pass (19 runtime-rule cases × local + remote modes, plus 4 custom-context exposure tests).
  • Security review — no HIGH/MEDIUM findings.

What's not in this PR

  • README, CHANGELOG, examples/ — feature-flag docs live on the official docs site; CHANGELOG is generated at release time by prepare-release.yml.
  • OpenFeature provider package — the fallbackReason discriminator, the variant-metadata fields on SelectedVariant, and the per-call $reportExposure parameter are hooks intentionally exposed so a mixpanel-openfeature-php provider package can bind directly without redesigning the core. Tracked as follow-up.

Trying the PR before release

Customers can install directly from this branch by adding to their composer.json:

```json
{
"repositories": [
{"type": "vcs", "url": "https://github.com/mixpanel/mixpanel-php"}
],
"require": {
"mixpanel/mixpanel-php": "dev-feature-flags-support"
},
"minimum-stability": "dev",
"prefer-stable": true
}
```

Then composer update mixpanel/mixpanel-php. Pin to a SHA (dev-feature-flags-support#<sha>) for stability while testing.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@tylerjroach tylerjroach changed the title feat(flags): add feature flag support (local + remote evaluation) feat(analytics): add feature flag support (local + remote evaluation) Jun 22, 2026
tylerjroach and others added 11 commits June 23, 2026 11:35
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@tylerjroach
tylerjroach requested a review from efahk June 24, 2026 20:35
@tylerjroach
tylerjroach marked this pull request as ready for review June 25, 2026 15:23
@tylerjroach
tylerjroach requested review from a team and ketanmixpanel June 25, 2026 15:23
tylerjroach and others added 2 commits June 25, 2026 11:32
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@tylerjroach tylerjroach changed the title feat(analytics): add feature flag support (local + remote evaluation) feat(flags): add feature flag support (local + remote evaluation) Jun 25, 2026
@tylerjroach tylerjroach changed the title feat(flags): add feature flag support (local + remote evaluation) feat(analytics): add feature flag support (local + remote evaluation) Jun 25, 2026
tylerjroach and others added 2 commits June 25, 2026 11:45
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
tylerjroach and others added 2 commits June 29, 2026 12:00
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 <noreply@anthropic.com>
…ene)

- 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 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The new feature-flag implementation is well-structured with no blocking defects; all previously flagged issues have been addressed.

The json_encode failure path is now properly detected before the HTTP call, the curl_init guard is in place, and the strict isEnabled semantics are intentional and documented. The one remaining gap — _trackExposure catching Exception but not Error — is low-risk given the tracker is always the internally-defined closure.

lib/FeatureFlags/MixpanelFlagsBase.php — catch block in _trackExposure could be widened to match the Error-aware pattern used in MixpanelLocalFlags.

Important Files Changed

Filename Overview
lib/FeatureFlags/MixpanelFlagsBase.php Shared HTTP + exposure-tracking base; curl_init guard and json_encode failure handling both addressed; Exception-only catch in _trackExposure is inconsistent with the Error-aware pattern used in MixpanelLocalFlags.
lib/FeatureFlags/MixpanelLocalFlags.php FNV-1a bucketing + JSON Logic runtime evaluation; dead pre-loop $selected assignment at line 235 is cosmetic; core evaluation logic is correct.
lib/FeatureFlags/MixpanelRemoteFlags.php Previously flagged json_encode failure is now detected and surfaced as REASON_BACKEND_ERROR before the HTTP call; clean implementation.
lib/FeatureFlags/MixpanelFlags.php Clean facade delegating to local/remote providers; mode validation throws early on invalid input.
lib/FeatureFlags/MixpanelFlagsUtils.php FNV-1a 64 normalizedHash, traceparent generator, and case-folding helpers; byte-walk mod-100 is correct for 32-bit PHP compatibility.
lib/FeatureFlags/MixpanelSelectedVariant.php Well-typed value object with immutable withSource/withFallbackReason wither methods; all REASON_* constants are present.
lib/Mixpanel.php Flags facade wired lazily only when flags key is present; tracker closure routes exposure events through the existing event queue.
composer.json PHP floor bumped to 8.1; two new runtime deps (json-logic-php, polyfill-mbstring); PHPUnit upgraded; phpdocumentor dropped.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App
    participant MixpanelFlags
    participant LocalProvider as MixpanelLocalFlags
    participant RemoteProvider as MixpanelRemoteFlags
    participant FlagsAPI as Mixpanel /flags API
    participant EventQueue as Event Queue

    App->>MixpanelFlags: isEnabled("flag", context)
    MixpanelFlags->>LocalProvider: getVariant() [local mode]
    LocalProvider->>LocalProvider: FNV-1a hash + JSON Logic eval
    LocalProvider->>EventQueue: _trackExposure (async, batched)
    LocalProvider-->>App: SelectedVariant (SOURCE_LOCAL)

    App->>MixpanelFlags: isEnabled("flag", context)
    MixpanelFlags->>RemoteProvider: getVariant() [remote mode]
    RemoteProvider->>FlagsAPI: "GET /flags?context=JSON&flag_key=flag"
    FlagsAPI-->>RemoteProvider: "{flags: {...}}"
    RemoteProvider->>EventQueue: _trackExposure (async, batched)
    RemoteProvider-->>App: SelectedVariant (SOURCE_REMOTE)

    App->>MixpanelFlags: getAllVariants(context)
    MixpanelFlags->>RemoteProvider: getAllVariants()
    RemoteProvider->>FlagsAPI: "GET /flags?context=JSON"
    FlagsAPI-->>RemoteProvider: "{flags: {...}}"
    RemoteProvider-->>App: map of SelectedVariants (no auto-exposure)
    App->>MixpanelFlags: trackExposure("flag", variant, context)
    MixpanelFlags->>RemoteProvider: trackExposure()
    RemoteProvider->>EventQueue: _trackExposure
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant App
    participant MixpanelFlags
    participant LocalProvider as MixpanelLocalFlags
    participant RemoteProvider as MixpanelRemoteFlags
    participant FlagsAPI as Mixpanel /flags API
    participant EventQueue as Event Queue

    App->>MixpanelFlags: isEnabled("flag", context)
    MixpanelFlags->>LocalProvider: getVariant() [local mode]
    LocalProvider->>LocalProvider: FNV-1a hash + JSON Logic eval
    LocalProvider->>EventQueue: _trackExposure (async, batched)
    LocalProvider-->>App: SelectedVariant (SOURCE_LOCAL)

    App->>MixpanelFlags: isEnabled("flag", context)
    MixpanelFlags->>RemoteProvider: getVariant() [remote mode]
    RemoteProvider->>FlagsAPI: "GET /flags?context=JSON&flag_key=flag"
    FlagsAPI-->>RemoteProvider: "{flags: {...}}"
    RemoteProvider->>EventQueue: _trackExposure (async, batched)
    RemoteProvider-->>App: SelectedVariant (SOURCE_REMOTE)

    App->>MixpanelFlags: getAllVariants(context)
    MixpanelFlags->>RemoteProvider: getAllVariants()
    RemoteProvider->>FlagsAPI: "GET /flags?context=JSON"
    FlagsAPI-->>RemoteProvider: "{flags: {...}}"
    RemoteProvider-->>App: map of SelectedVariants (no auto-exposure)
    App->>MixpanelFlags: trackExposure("flag", variant, context)
    MixpanelFlags->>RemoteProvider: trackExposure()
    RemoteProvider->>EventQueue: _trackExposure
Loading

Reviews (3): Last reviewed commit: "fix(flags): detect json_encode failure +..." | Re-trigger Greptile

Comment thread lib/FeatureFlags/MixpanelRemoteFlags.php Outdated
Comment thread lib/FeatureFlags/MixpanelLocalFlags.php
Comment thread lib/FeatureFlags/MixpanelFlagsBase.php
Comment thread lib/FeatureFlags/MixpanelFlagsBase.php Outdated
tylerjroach and others added 2 commits July 1, 2026 11:50
…ag surface

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 <noreply@anthropic.com>
…sEnabled

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.
@tylerjroach

Copy link
Copy Markdown
Contributor Author

Pushed 8d58a16 addressing all three Greptile threads.

P1 — json_encode failure not detected (MixpanelRemoteFlags.php:87): now checks the return value and throws Exception('...could not be JSON-encoded: ' . json_last_error_msg()) before touching http_build_query. getVariant / getAllVariants already catch and route through _handleError + return REASON_BACKEND_ERROR, so the failure surfaces via the user's error_callback and via the fallback reason. New test testJsonEncodeFailureSurfacesAsBackendError uses invalid UTF-8 ("\xB1\x31") to trigger the path — asserts no HTTP call was attempted, fallback reason is REASON_BACKEND_ERROR, and error_callback got a message mentioning JSON encoding.

P2 — curl_init extension guard (MixpanelFlagsBase.php:102): added if (!function_exists('curl_init')) throw new RuntimeException(...) at the top of _httpGet, mirroring the guard AbstractConsumer uses.

P2 — isEnabled strict === true: kept the strict check and added a doc comment. The behavior is intentional and matches Node, Ruby, Python, and Go — non-boolean truthy values (1, "true", "on", ...) indicate a type mismatch on a flag that should be a Feature Gate, and we fail closed rather than accept an ambiguous "on" signal.

All 39 flag tests pass locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant