From 3d2b5965bc8173ab007d74cf082e3c9cc8652a6f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:54:28 +0000 Subject: [PATCH 1/3] deps: update dependency flagsmith/sdk-spec to v0.7.0 --- .github/workflows/sdk-spec.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 3115820..0a742d9 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0 with: - spec-ref: v0.3.0 + spec-ref: v0.7.0 language: php From ed02408acca67273349945a4ca45182a847bc785 Mon Sep 17 00:00:00 2001 From: "flagsmithsmith[bot]" <346993984+flagsmithsmith[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:30:58 +0000 Subject: [PATCH 2/3] feat(Experimentation): Get experiment flags, and track events and exposures Implements the events steps of SDK spec v0.6.0. - `Flagsmith::getExperimentFlag()` gets an identity's flag and records a `$flag_exposure` event for the variant served, when the identity is enrolled in a running experiment on the feature. - `Flagsmith::trackEvent()` and `Flagsmith::trackExposureEvent()` record custom and exposure events; `Flagsmith::flushEvents()` sends them now. - `EventProcessor` buffers events, deduplicates exposures, and posts them to the Flagsmith events API on flush, when full, after the flush interval, or when destroyed. Events are opt-in via `enableEvents`. - `Flag` exposes the `variant` and `experiment` the identities API returns. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 0dad745e0404c938b3da696952f502776912faaf) --- sdk-spec/steps.php | 40 +++- src/Flagsmith.php | 151 ++++++++++++++- src/Models/Experiment.php | 92 +++++++++ src/Models/Flag.php | 44 ++++- src/Utils/EventProcessor.php | 173 +++++++++++++++++ src/Utils/UserAgent.php | 14 +- tests/Data/identities_in_experiment.json | 75 ++++++++ tests/EventProcessorTest.php | 183 ++++++++++++++++++ tests/FlagsmithClientTest.php | 227 +++++++++++++++++++++++ 9 files changed, 991 insertions(+), 8 deletions(-) create mode 100644 src/Models/Experiment.php create mode 100644 src/Utils/EventProcessor.php create mode 100644 tests/Data/identities_in_experiment.json create mode 100644 tests/EventProcessorTest.php diff --git a/sdk-spec/steps.php b/sdk-spec/steps.php index 9b2812b..8527511 100644 --- a/sdk-spec/steps.php +++ b/sdk-spec/steps.php @@ -9,14 +9,17 @@ declare(strict_types=1); use Flagsmith\Flagsmith; +use Flagsmith\Models\Flag; use Flagsmith\Models\Flags; +function asSpecFlag(Flag $flag): array +{ + return ['feature' => $flag->getFeatureName(), 'enabled' => $flag->getEnabled(), 'value' => $flag->getValue()]; +} + function asSpecFlags(Flags $flags): array { - return array_map( - fn ($flag) => ['feature' => $flag->getFeatureName(), 'enabled' => $flag->getEnabled(), 'value' => $flag->getValue()], - $flags->allFlags(), - ); + return array_map(asSpecFlag(...), $flags->allFlags()); } // Create the SDK client @@ -25,6 +28,12 @@ function createClient(string $environmentKey, string $apiUrl): Flagsmith return new Flagsmith(apiKey: $environmentKey, host: $apiUrl); } +// Create the SDK client with events enabled +function createClientWithEvents(string $environmentKey, string $apiUrl, string $eventsApiUrl): Flagsmith +{ + return new Flagsmith(apiKey: $environmentKey, host: $apiUrl, enableEvents: true, eventsApiUrl: $eventsApiUrl); +} + // When the application gets the environment flags function getEnvironmentFlags(Flagsmith $flagsmith): array { @@ -36,3 +45,26 @@ function getIdentityFlags(Flagsmith $flagsmith, string $identifier, object $trai { return asSpecFlags($flagsmith->getIdentityFlags($identifier, $traits)); } + +// When the application gets the experiment flag "{feature}" for identity "{identifier}" with traits: +function getExperimentFlag(Flagsmith $flagsmith, string $feature, string $identifier, object $traits): array +{ + $flag = $flagsmith->getExperimentFlag($feature, $identifier, $traits); + $flagsmith->flushEvents(); // Events are sent when a PHP request ends, which the harness's long-lived process never does. + + return $flag instanceof Flag ? [asSpecFlag($flag)] : []; +} + +// When the application tracks event "{event}" for identity "{identifier}" with value {value} and metadata: +function trackEvent(Flagsmith $flagsmith, string $event, string $identifier, string|int|float|bool|null $value, array $metadata): void +{ + $flagsmith->trackEvent($event, identifier: $identifier, value: $value, metadata: $metadata); + $flagsmith->flushEvents(); // Events are sent when a PHP request ends, which the harness's long-lived process never does. +} + +// When the application tracks an exposure of feature "{feature}" for identity "{identifier}" with value {value} and metadata: +function trackExposureEvent(Flagsmith $flagsmith, string $feature, string $identifier, string|int|float|bool|null $value, array $metadata): void +{ + $flagsmith->trackExposureEvent($feature, identifier: $identifier, value: $value, metadata: $metadata); + $flagsmith->flushEvents(); // Events are sent when a PHP request ends, which the harness's long-lived process never does. +} diff --git a/src/Flagsmith.php b/src/Flagsmith.php index 3458cdd..6418e00 100644 --- a/src/Flagsmith.php +++ b/src/Flagsmith.php @@ -8,10 +8,13 @@ use Flagsmith\Exceptions\FlagsmithAPIError; use Flagsmith\Exceptions\FlagsmithClientError; use Flagsmith\Exceptions\FlagsmithThrowable; +use Flagsmith\Models\Flag; +use Flagsmith\Models\BaseFlag; use Flagsmith\Models\Flags; use Flagsmith\Models\Segment; use Flagsmith\Offline\IOfflineHandler; use Flagsmith\Utils\AnalyticsProcessor; +use Flagsmith\Utils\EventProcessor; use Flagsmith\Utils\IdentitiesGenerator; use Flagsmith\Utils\Mappers; use Flagsmith\Utils\Retry; @@ -36,6 +39,7 @@ class Flagsmith private bool $enableLocalEvaluation = false; private Retry $retries; private ?AnalyticsProcessor $analyticsProcessor = null; + private ?EventProcessor $eventProcessor = null; private ?\Closure $defaultFlagHandler = null; private ClientInterface $client; private RequestFactoryInterface $requestFactory; @@ -65,12 +69,18 @@ public function __construct( bool $enableAnalytics = false, ?\Closure $defaultFlagHandler = null, bool $offlineMode = false, - ?IOfflineHandler $offlineHandler = null + ?IOfflineHandler $offlineHandler = null, + bool $enableEvents = false, + ?string $eventsApiUrl = null ) { if ($offlineMode and is_null($offlineHandler)) { throw new ValueError('offlineHandler must be provided to use offline mode.'); } + if ($offlineMode and $enableEvents) { + throw new ValueError('Cannot use events in offline mode.'); + } + if (!is_null($offlineHandler) and !is_null($defaultFlagHandler)) { throw new ValueError('Cannot use both defaultFlagHandler and offlineHandler.'); } @@ -96,6 +106,7 @@ public function __construct( $this->enableLocalEvaluation = !is_null($environmentTtl); $this->retries = $retries ?? new Retry(3); $this->analyticsProcessor = $enableAnalytics ? new AnalyticsProcessor($apiKey, $host) : null; + $this->eventProcessor = $enableEvents ? new EventProcessor($apiKey, $eventsApiUrl) : null; $this->defaultFlagHandler = $defaultFlagHandler ?? $this->defaultFlagHandler; //We default to using Guzzle for the HTTP client (as this is how it worked in 1.0) @@ -152,6 +163,16 @@ public function withAnalytics(AnalyticsProcessor $analytics): self return $this->with('analyticsProcessor', $analytics); } + /** + * Build with an event processor, enabling events. + * @param EventProcessor $eventProcessor + * @return Flagsmith + */ + public function withEventProcessor(EventProcessor $eventProcessor): self + { + return $this->with('eventProcessor', $eventProcessor); + } + /** * Build with default flag handler. * @param \Closure $defaultFlagHandler @@ -342,6 +363,115 @@ public function getIdentityFlags(string $identifier, ?object $traits = null, ?bo return $this->getIdentityFlagsFromApi($identifier, $traits, $transient); } + /** + * Get a flag for an identity, and record the identity's exposure to it. + * + * The exposure is a `$flag_exposure` event whose value is the variant the identity was served. It + * is only recorded when the flag exists, is enabled, and the identity is enrolled in a running + * experiment on the feature with a variant, so that experiment data stays clean. + * + * @param string $featureName + * @param string $identifier + * @param object|null $traits + * @return BaseFlag + * + * @throws ValueError if events are not enabled + * @throws FlagsmithThrowable + */ + public function getExperimentFlag(string $featureName, string $identifier, ?object $traits = null): BaseFlag + { + if (is_null($this->eventProcessor)) { + throw new ValueError('Events must be enabled to use experiment flags.'); + } + + $flag = $this->getIdentityFlags($identifier, $traits)->getFlag($featureName); + $experiment = $flag instanceof Flag ? $flag->getExperiment() : null; + if ($flag->getEnabled() && $experiment?->getInExperiment() && !is_null($flag->getVariant())) { + $this->trackExposureEvent( + featureName: $featureName, + identifier: $identifier, + value: $flag->getVariant(), + traits: $traits, + metadata: ['experiment_id' => $experiment->getId()], + ); + } + + return $flag; + } + + /** + * Record a custom event, e.g. a conversion to reconcile with experiment exposures. + * + * @param string $event the event name. Names starting with `$` are reserved for Flagsmith. + * @param string|null $identifier + * @param string|int|float|bool|null $value + * @param object|null $traits + * @param array|null $metadata + * @return void + * + * @throws ValueError if events are not enabled, or the event name is reserved + */ + public function trackEvent( + string $event, + ?string $identifier = null, + string|int|float|bool|null $value = null, + ?object $traits = null, + ?array $metadata = null + ): void { + if (is_null($this->eventProcessor)) { + throw new ValueError('Events must be enabled to track events.'); + } + if (str_starts_with($event, '$') && $event !== EventProcessor::FLAG_EXPOSURE_EVENT) { + throw new ValueError("Event name \"{$event}\" uses the reserved \"$\" prefix but is not a known system event."); + } + + $this->eventProcessor->trackEvent($event, $identifier, $value, $this->resolveTraitValues($traits), $metadata); + } + + /** + * Record a `$flag_exposure` event for a feature evaluated elsewhere than `getExperimentFlag`. + * + * @param string $featureName + * @param string $identifier + * @param string|int|float|bool|null $value the variant the identity was served + * @param object|null $traits + * @param array|null $metadata + * @return void + * + * @throws ValueError if events are not enabled + */ + public function trackExposureEvent( + string $featureName, + string $identifier, + string|int|float|bool|null $value = null, + ?object $traits = null, + ?array $metadata = null + ): void { + if (is_null($this->eventProcessor)) { + throw new ValueError('Events must be enabled to track exposure events.'); + } + if ($identifier === '') { + throw new ValueError('An exposure requires an identifier to reconcile with conversion events.'); + } + + $this->eventProcessor->trackExposureEvent( + $featureName, + $identifier, + $value, + $this->resolveTraitValues($traits), + $metadata, + ); + } + + /** + * Send all buffered events to the Flagsmith events API now. Does nothing if events are not enabled. + * @return void + */ + public function flushEvents(): void + { + $this->eventProcessor?->flush(); + } + /** * Get a list of segments that the given identity is in. * @param string $identifier a unique identifier for the identity in the current @@ -520,6 +650,25 @@ private function getIdentityFlagsFromApi(string $identifier, ?object $traits, ?b } } + /** + * Map traits, including transient ones given as objects, to their values. + * @param object|null $traits + * @return array|null + */ + private function resolveTraitValues(?object $traits): ?array + { + if (is_null($traits)) { + return null; + } + + $values = []; + foreach ($traits as $key => $value) { + $values[$key] = is_object($value) ? $value->value : $value; + } + + return $values; + } + /** * Call the API and cache the response (If Caching is Enabled) * diff --git a/src/Models/Experiment.php b/src/Models/Experiment.php new file mode 100644 index 0000000..ddae6ea --- /dev/null +++ b/src/Models/Experiment.php @@ -0,0 +1,92 @@ +id; + } + + /** + * Build with ID. + * @param int $id + * @return Experiment + */ + public function withId(int $id): self + { + return $this->with('id', $id); + } + + /** + * Get the name of the experiment. + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Build with Name. + * @param string $name + * @return Experiment + */ + public function withName(string $name): self + { + return $this->with('name', $name); + } + + /** + * Whether the identity is enrolled in the experiment. + * @return bool + */ + public function getInExperiment(): bool + { + return $this->in_experiment; + } + + /** + * Build with whether the identity is enrolled in the experiment. + * @param bool $inExperiment + * @return Experiment + */ + public function withInExperiment(bool $inExperiment): self + { + return $this->with('in_experiment', $inExperiment); + } + + /** + * Build experiment from a flag's API metadata, if it carries one. + * @param object|null $metadata + * @return Experiment|null + */ + public static function fromApiMetadata(?object $metadata): ?self + { + $experiment = $metadata->experiment ?? null; + if ($experiment === null) { + return null; + } + + return (new self()) + ->withId($experiment->id) + ->withName($experiment->name) + ->withInExperiment($experiment->in_experiment); + } +} diff --git a/src/Models/Flag.php b/src/Models/Flag.php index d36122a..aaf58c3 100644 --- a/src/Models/Flag.php +++ b/src/Models/Flag.php @@ -8,6 +8,8 @@ class Flag extends BaseFlag { public int $feature_id; public string $feature_name; + public ?string $variant = null; + public ?Experiment $experiment = null; /** * Get the Feature Name @@ -47,6 +49,44 @@ public function withFeatureId(int $featureId): self return $this->with('feature_id', $featureId); } + /** + * Get the key of the multivariate option the flag was served, if any. + * @return string|null + */ + public function getVariant(): ?string + { + return $this->variant; + } + + /** + * Build with the variant key. + * @param string|null $variant + * @return Flag + */ + public function withVariant(?string $variant): self + { + return $this->with('variant', $variant); + } + + /** + * Get the running experiment the flag was evaluated under, if any. + * @return Experiment|null + */ + public function getExperiment(): ?Experiment + { + return $this->experiment; + } + + /** + * Build with the experiment. + * @param Experiment|null $experiment + * @return Flag + */ + public function withExperiment(?Experiment $experiment): self + { + return $this->with('experiment', $experiment); + } + /** * Build flag from dict. * @param object $flagDict @@ -58,6 +98,8 @@ public static function fromApiFlag(object $flagDict): self ->withFeatureId($flagDict->feature->id) ->withFeatureName($flagDict->feature->name) ->withEnabled($flagDict->enabled) - ->withValue($flagDict->feature_state_value); + ->withValue($flagDict->feature_state_value) + ->withVariant($flagDict->variant ?? null) + ->withExperiment(Experiment::fromApiMetadata($flagDict->metadata ?? null)); } } diff --git a/src/Utils/EventProcessor.php b/src/Utils/EventProcessor.php new file mode 100644 index 0000000..6808a2e --- /dev/null +++ b/src/Utils/EventProcessor.php @@ -0,0 +1,173 @@ +> */ + public array $events = []; + /** @var array */ + private array $bufferedExposures = []; + private ClientInterface $client; + private RequestFactoryInterface $requestFactory; + private StreamFactoryInterface $streamFactory; + + public function __construct( + string $environmentKey, + ?string $eventsApiUrl = null, + int $maxBuffer = self::DEFAULT_MAX_BUFFER, + int $flushIntervalSeconds = self::DEFAULT_FLUSH_INTERVAL_SECONDS, + int $timeout = 3, + ?ClientInterface $client = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null + ) { + $this->eventsEndpoint = rtrim($eventsApiUrl ?? self::DEFAULT_EVENTS_API_URL, '/') . '/' . self::EVENTS_ENDPOINT; + $this->environmentKey = $environmentKey; + $this->maxBuffer = $maxBuffer; + $this->flushIntervalSeconds = $flushIntervalSeconds; + $this->timeout = $timeout; + $this->lastFlushed = time(); + + $this->client = $client ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function __destruct() + { + $this->flush(); + } + + /** + * Buffer a custom event. + * @param string $event + * @param string|null $identifier + * @param string|int|float|bool|null $value + * @param array|null $traits + * @param array|null $metadata + * @return void + */ + public function trackEvent( + string $event, + ?string $identifier = null, + string|int|float|bool|null $value = null, + ?array $traits = null, + ?array $metadata = null + ): void { + $this->bufferEvent($event, null, $identifier, $value, $traits, $metadata); + } + + /** + * Buffer a `$flag_exposure` event. An exposure equal to one already buffered is discarded. + * @param string $featureName + * @param string $identifier + * @param string|int|float|bool|null $value + * @param array|null $traits + * @param array|null $metadata + * @return void + */ + public function trackExposureEvent( + string $featureName, + string $identifier, + string|int|float|bool|null $value = null, + ?array $traits = null, + ?array $metadata = null + ): void { + $this->bufferEvent(self::FLAG_EXPOSURE_EVENT, $featureName, $identifier, $value, $traits, $metadata); + } + + /** + * Post all buffered events to the Flagsmith events API. + * @return void + */ + public function flush(): void + { + $this->lastFlushed = time(); + if (empty($this->events)) { + return; + } + + $events = $this->events; + $this->events = []; + $this->bufferedExposures = []; + + $request = $this->requestFactory + ->createRequest('POST', $this->eventsEndpoint) + ->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withHeader('X-Environment-Key', $this->environmentKey) + ->withHeader('Flagsmith-SDK-User-Agent', UserAgent::get()) + ->withHeader('User-Agent', UserAgent::get()); + + try { + $request = $request->withBody( + $this->streamFactory->createStream(json_encode(['events' => $events], JSON_THROW_ON_ERROR)) + ); + $this->client->sendRequest($request); + } catch (\Exception $e) { + // Recording events must never fail the application: the batch is dropped. + } + } + + private function bufferEvent( + string $event, + ?string $featureName, + ?string $identifier, + string|int|float|bool|null $value, + ?array $traits, + ?array $metadata + ): void { + $value = is_null($value) || is_string($value) ? $value : json_encode($value); + $metadata = array_merge($metadata ?? [], ['sdk_version' => UserAgent::getVersion()]); + + if ($event === self::FLAG_EXPOSURE_EVENT) { + $exposure = json_encode([$featureName, $identifier, $value, $metadata['experiment_id'] ?? null]); + if (isset($this->bufferedExposures[$exposure])) { + return; + } + $this->bufferedExposures[$exposure] = true; + } + + $this->events[] = [ + 'event' => $event, + 'feature_name' => $featureName, + 'identifier' => $identifier, + 'value' => $value, + 'traits' => empty($traits) ? null : $traits, + 'metadata' => $metadata, + 'timestamp' => (int) floor(microtime(true) * 1000), + ]; + + if (count($this->events) >= $this->maxBuffer || (time() - $this->lastFlushed) >= $this->flushIntervalSeconds) { + $this->flush(); + } + } +} diff --git a/src/Utils/UserAgent.php b/src/Utils/UserAgent.php index 32bde83..bd54a65 100644 --- a/src/Utils/UserAgent.php +++ b/src/Utils/UserAgent.php @@ -13,6 +13,16 @@ class UserAgent * @return string The user agent string in the format "flagsmith-php-sdk/{version}" */ public static function get(): string + { + return 'flagsmith-php-sdk/' . self::getVersion(); + } + + /** + * Get the SDK version. + * + * @return string The version from composer.json, or "unknown" + */ + public static function getVersion(): string { try { $composerPath = __DIR__ . '/../../composer.json'; @@ -21,12 +31,12 @@ public static function get(): string $version = $data['version'] ?? null; if ($version) { - return "flagsmith-php-sdk/{$version}"; + return $version; } } catch (\Exception $e) { // Silently fall through to default } - return 'flagsmith-php-sdk/unknown'; + return 'unknown'; } } diff --git a/tests/Data/identities_in_experiment.json b/tests/Data/identities_in_experiment.json new file mode 100644 index 0000000..9b0a0b7 --- /dev/null +++ b/tests/Data/identities_in_experiment.json @@ -0,0 +1,75 @@ +{ + "traits": [ + { + "id": 1, + "trait_key": "plan", + "trait_value": "premium" + } + ], + "flags": [ + { + "id": 1, + "feature": { + "id": 1, + "name": "banner", + "type": "MULTIVARIATE" + }, + "feature_state_value": "hello treatment", + "enabled": true, + "variant": "treatment", + "metadata": { + "experiment": { + "id": 7, + "name": "banner experiment", + "in_experiment": true + } + } + }, + { + "id": 2, + "feature": { + "id": 2, + "name": "checkout", + "type": "MULTIVARIATE" + }, + "feature_state_value": "control", + "enabled": true, + "variant": null, + "metadata": { + "experiment": { + "id": 8, + "name": "checkout experiment", + "in_experiment": false + } + } + }, + { + "id": 3, + "feature": { + "id": 3, + "name": "paused_banner", + "type": "MULTIVARIATE" + }, + "feature_state_value": "hello treatment", + "enabled": false, + "variant": "treatment", + "metadata": { + "experiment": { + "id": 9, + "name": "paused banner experiment", + "in_experiment": true + } + } + }, + { + "id": 4, + "feature": { + "id": 4, + "name": "footer", + "type": "STANDARD" + }, + "feature_state_value": "hello", + "enabled": true + } + ] +} diff --git a/tests/EventProcessorTest.php b/tests/EventProcessorTest.php new file mode 100644 index 0000000..2783a83 --- /dev/null +++ b/tests/EventProcessorTest.php @@ -0,0 +1,183 @@ +createMock(ClientInterface::class); + $client->method('sendRequest') + ->willReturnCallback(function ($request) use ($failure) { + $this->requests[] = $request; + if ($failure) { + throw $failure; + } + return new Response(202); + }); + + return $client; + } + + private function sentEvents(): array + { + return array_merge(...array_map( + fn ($request) => json_decode((string) $request->getBody(), true)['events'], + $this->requests, + )); + } + + public function testTrackedEventIsSentToTheEventsApi() + { + // Given + $processor = new EventProcessor('env-key', 'http://events/', client: $this->eventsApiClient()); + $composerData = json_decode(file_get_contents(__DIR__ . '/../composer.json'), true); + + // When + $processor->trackEvent('checkout_completed', 'user_123', 42, ['plan' => 'premium'], ['currency' => 'GBP']); + $processor->flush(); + + // Then + $this->assertCount(1, $this->requests); + $request = $this->requests[0]; + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals('http://events/v1/events', (string) $request->getUri()); + $this->assertEquals('env-key', $request->getHeaderLine('X-Environment-Key')); + $this->assertEquals("flagsmith-php-sdk/{$composerData['version']}", $request->getHeaderLine('Flagsmith-SDK-User-Agent')); + $event = $this->sentEvents()[0]; + $this->assertGreaterThan(0, $event['timestamp']); + unset($event['timestamp']); + $this->assertEquals([ + 'event' => 'checkout_completed', + 'feature_name' => null, + 'identifier' => 'user_123', + 'value' => '42', + 'traits' => ['plan' => 'premium'], + 'metadata' => ['currency' => 'GBP', 'sdk_version' => $composerData['version']], + ], $event); + } + + public function testEventsAreSentToTheDefaultEventsApi() + { + // Given + $processor = new EventProcessor('env-key', client: $this->eventsApiClient()); + + // When + $processor->trackEvent('signed_up'); + $processor->flush(); + + // Then + $this->assertEquals('https://events.api.flagsmith.com/v1/events', (string) $this->requests[0]->getUri()); + } + + public function testEventValuesAreSentAsText() + { + // Given + $processor = new EventProcessor('env-key', client: $this->eventsApiClient()); + + // When + $processor->trackEvent('purchase', value: 'basket'); + $processor->trackEvent('purchase', value: 9.99); + $processor->trackEvent('purchase', value: true); + $processor->trackEvent('purchase'); + $processor->flush(); + + // Then + $this->assertEquals(['basket', '9.99', 'true', null], array_column($this->sentEvents(), 'value')); + } + + public function testRepeatedExposuresAreSentOnce() + { + // Given + $processor = new EventProcessor('env-key', client: $this->eventsApiClient()); + + // When + $processor->trackExposureEvent('banner', 'user_123', 'treatment', metadata: ['experiment_id' => 1]); + $processor->trackExposureEvent('banner', 'user_123', 'treatment', metadata: ['experiment_id' => 1]); + $processor->trackExposureEvent('banner', 'user_123', 'control', metadata: ['experiment_id' => 1]); + $processor->flush(); + $processor->trackExposureEvent('banner', 'user_123', 'treatment', metadata: ['experiment_id' => 1]); + $processor->flush(); + + // Then + $events = $this->sentEvents(); + $this->assertEquals(['$flag_exposure', '$flag_exposure', '$flag_exposure'], array_column($events, 'event')); + $this->assertEquals(['banner', 'banner', 'banner'], array_column($events, 'feature_name')); + $this->assertEquals(['treatment', 'control', 'treatment'], array_column($events, 'value')); + } + + public function testEventsAreSentOnceTheBufferIsFull() + { + // Given + $processor = new EventProcessor('env-key', maxBuffer: 2, client: $this->eventsApiClient()); + + // When + $processor->trackEvent('first'); + $processor->trackEvent('second'); + + // Then + $this->assertEquals(['first', 'second'], array_column($this->sentEvents(), 'event')); + } + + public function testEventsAreSentOnceTheFlushIntervalHasElapsed() + { + // Given + $processor = new EventProcessor('env-key', flushIntervalSeconds: 0, client: $this->eventsApiClient()); + + // When + $processor->trackEvent('first'); + + // Then + $this->assertEquals(['first'], array_column($this->sentEvents(), 'event')); + } + + public function testBufferedEventsAreSentWhenTheProcessorIsDestroyed() + { + // Given + $processor = new EventProcessor('env-key', client: $this->eventsApiClient()); + $processor->trackEvent('first'); + + // When + unset($processor); + + // Then + $this->assertEquals(['first'], array_column($this->sentEvents(), 'event')); + } + + public function testNothingIsSentWithoutEvents() + { + // Given + $processor = new EventProcessor('env-key', client: $this->eventsApiClient()); + + // When + $processor->flush(); + + // Then + $this->assertEmpty($this->requests); + } + + public function testEventsThatCannotBeSentAreDroppedWithoutFailingTheApplication() + { + // Given + $processor = new EventProcessor( + 'env-key', + client: $this->eventsApiClient(new ConnectException('Connection refused', new Request('POST', 'http://events/'))), + ); + $processor->trackEvent('first'); + + // When + $processor->flush(); + $processor->flush(); + + // Then + $this->assertCount(1, $this->requests); + $this->assertEmpty($processor->events); + } +} diff --git a/tests/FlagsmithClientTest.php b/tests/FlagsmithClientTest.php index dfa4893..c08e4b0 100644 --- a/tests/FlagsmithClientTest.php +++ b/tests/FlagsmithClientTest.php @@ -4,6 +4,7 @@ use Flagsmith\Exceptions\FlagsmithAPIError; use Flagsmith\Flagsmith; use Flagsmith\Models\DefaultFlag; +use Flagsmith\Utils\EventProcessor; use FlagsmithTest\ClientFixtures; use FlagsmithTest\Offline\FakeOfflineHandler; use GuzzleHttp\Psr7\Response; @@ -499,4 +500,230 @@ public function testGetEnvironmentFlagsOmitsSegmentsFromEvaluation() $this->assertFalse($identityFlag->enabled); $this->assertEquals('segment-override-value', $identityFlag->value); } + + private function identityInExperimentClient(): ClientInterface + { + $handlerBuilder = ClientFixtures::getHandlerBuilder(); + $handlerBuilder->addRoute( + ClientFixtures::getRouteBuilder()->new() + ->withMethod('POST') + ->withPath('/api/v1/identities/') + ->withFileResponse(__DIR__ . '/Data/identities_in_experiment.json') + ->build() + ); + + return ClientFixtures::getMockClient($handlerBuilder, false); + } + + private function eventsApiClient(array &$events): ClientInterface + { + $client = $this->createMock(ClientInterface::class); + $client->method('sendRequest') + ->willReturnCallback(function ($request) use (&$events) { + array_push($events, ...json_decode((string) $request->getBody(), true)['events']); + return new Response(202); + }); + + return $client; + } + + public function testGetExperimentFlagRecordsTheExposureOfAnIdentityInAnExperiment() + { + // Given + $events = []; + $flagsmith = (new Flagsmith('api_key')) + ->withClient($this->identityInExperimentClient()) + ->withEventProcessor(new EventProcessor('api_key', client: $this->eventsApiClient($events))); + + // When + $flag = $flagsmith->getExperimentFlag('banner', 'user_123', (object) ['plan' => 'premium']); + $flagsmith->flushEvents(); + + // Then + $this->assertTrue($flag->getEnabled()); + $this->assertEquals('hello treatment', $flag->getValue()); + $this->assertEquals('treatment', $flag->getVariant()); + $this->assertEquals(7, $flag->getExperiment()->getId()); + $this->assertEquals('banner experiment', $flag->getExperiment()->getName()); + $this->assertTrue($flag->getExperiment()->getInExperiment()); + $this->assertCount(1, $events); + $this->assertEquals('$flag_exposure', $events[0]['event']); + $this->assertEquals('banner', $events[0]['feature_name']); + $this->assertEquals('user_123', $events[0]['identifier']); + $this->assertEquals('treatment', $events[0]['value']); + $this->assertEquals(['plan' => 'premium'], $events[0]['traits']); + $this->assertEquals(7, $events[0]['metadata']['experiment_id']); + } + + public static function flagsNotExposedProvider(): array + { + return [ + 'identity not enrolled in the experiment' => ['checkout'], + 'flag disabled' => ['paused_banner'], + 'feature without an experiment' => ['footer'], + ]; + } + + /** + * @dataProvider flagsNotExposedProvider + */ + public function testGetExperimentFlagDoesNotRecordAnExposureOutsideARunningExperiment(string $featureName) + { + // Given + $events = []; + $flagsmith = (new Flagsmith('api_key')) + ->withClient($this->identityInExperimentClient()) + ->withEventProcessor(new EventProcessor('api_key', client: $this->eventsApiClient($events))); + + // When + $flag = $flagsmith->getExperimentFlag($featureName, 'user_123'); + $flagsmith->flushEvents(); + + // Then + $this->assertEquals($featureName, $flag->getFeatureName()); + $this->assertEmpty($events); + } + + public function testGetExperimentFlagDoesNotRecordAnExposureForADefaultFlag() + { + // Given + $events = []; + $defaultFlag = (new DefaultFlag())->withEnabled(true)->withValue('default'); + $flagsmith = (new Flagsmith('api_key', defaultFlagHandler: fn (string $featureName) => $defaultFlag)) + ->withClient($this->identityInExperimentClient()) + ->withEventProcessor(new EventProcessor('api_key', client: $this->eventsApiClient($events))); + + // When + $flag = $flagsmith->getExperimentFlag('unknown_feature', 'user_123'); + $flagsmith->flushEvents(); + + // Then + $this->assertSame($defaultFlag, $flag); + $this->assertEmpty($events); + } + + public function testTrackEventSendsTheEventWithTraitValues() + { + // Given + $events = []; + $flagsmith = (new Flagsmith('api_key')) + ->withEventProcessor(new EventProcessor('api_key', client: $this->eventsApiClient($events))); + $traits = (object) ['plan' => 'premium', 'session' => (object) ['value' => 'abc', 'transient' => true]]; + + // When + $flagsmith->trackEvent('checkout_completed', 'user_123', 42, $traits, ['currency' => 'GBP']); + $flagsmith->flushEvents(); + + // Then + $this->assertCount(1, $events); + $this->assertEquals('checkout_completed', $events[0]['event']); + $this->assertEquals('user_123', $events[0]['identifier']); + $this->assertEquals('42', $events[0]['value']); + $this->assertEquals(['plan' => 'premium', 'session' => 'abc'], $events[0]['traits']); + $this->assertEquals('GBP', $events[0]['metadata']['currency']); + } + + public function testTrackEventRejectsReservedEventNames() + { + // Given + $flagsmith = (new Flagsmith('api_key')) + ->withEventProcessor(new EventProcessor('api_key', client: $this->createMock(ClientInterface::class))); + + $this->expectException(ValueError::class); + $this->expectExceptionMessage('Event name "$purchase" uses the reserved "$" prefix but is not a known system event.'); + + // When + $flagsmith->trackEvent('$purchase'); + } + + public function testTrackExposureEventSendsAFlagExposure() + { + // Given + $events = []; + $flagsmith = (new Flagsmith('api_key')) + ->withEventProcessor(new EventProcessor('api_key', client: $this->eventsApiClient($events))); + + // When + $flagsmith->trackExposureEvent('banner', 'user_123', 'control', metadata: ['experiment_id' => 1]); + $flagsmith->flushEvents(); + + // Then + $this->assertCount(1, $events); + $this->assertEquals('$flag_exposure', $events[0]['event']); + $this->assertEquals('banner', $events[0]['feature_name']); + $this->assertEquals('user_123', $events[0]['identifier']); + $this->assertEquals('control', $events[0]['value']); + $this->assertEquals(1, $events[0]['metadata']['experiment_id']); + } + + public function testTrackExposureEventRequiresAnIdentifier() + { + // Given + $flagsmith = (new Flagsmith('api_key')) + ->withEventProcessor(new EventProcessor('api_key', client: $this->createMock(ClientInterface::class))); + + $this->expectException(ValueError::class); + $this->expectExceptionMessage('An exposure requires an identifier to reconcile with conversion events.'); + + // When + $flagsmith->trackExposureEvent('banner', '', 'control'); + } + + public static function eventMethodsProvider(): array + { + return [ + 'getExperimentFlag' => [fn (Flagsmith $flagsmith) => $flagsmith->getExperimentFlag('banner', 'user_123'), 'Events must be enabled to use experiment flags.'], + 'trackEvent' => [fn (Flagsmith $flagsmith) => $flagsmith->trackEvent('purchase'), 'Events must be enabled to track events.'], + 'trackExposureEvent' => [fn (Flagsmith $flagsmith) => $flagsmith->trackExposureEvent('banner', 'user_123'), 'Events must be enabled to track exposure events.'], + ]; + } + + /** + * @dataProvider eventMethodsProvider + */ + public function testEventsMustBeEnabledToRecordEvents(\Closure $recordEvent, string $message) + { + // Given + $flagsmith = new Flagsmith('api_key'); + + $this->expectException(ValueError::class); + $this->expectExceptionMessage($message); + + // When + $recordEvent($flagsmith); + } + + public function testFlushEventsDoesNothingWithoutEvents() + { + // Given + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never())->method('sendRequest'); + $flagsmith = (new Flagsmith('api_key'))->withClient($client); + + // When / Then + $flagsmith->flushEvents(); + } + + public function testEventsCanBeEnabledWhenCreatingTheClient() + { + // Given + $flagsmith = new Flagsmith('api_key', enableEvents: true, eventsApiUrl: 'http://127.0.0.1:1/'); + + // When + $flagsmith->trackEvent('purchase'); + $flagsmith->flushEvents(); + + // Then + $this->addToAssertionCount(1); + } + + public function testCannotUseEventsInOfflineMode() + { + // Given + $this->expectException(ValueError::class); + $this->expectExceptionMessage('Cannot use events in offline mode.'); + + // When + new Flagsmith(offlineMode: true, offlineHandler: new FakeOfflineHandler(), enableEvents: true); + } } From 1d36396365bd9baaae71a60eb17ccc6b5883e259 Mon Sep 17 00:00:00 2001 From: "flagsmithsmith[bot]" <346993984+flagsmithsmith[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:24:42 +0000 Subject: [PATCH 3/3] fix(Experimentation): Address review feedback on events - Reject `withEventProcessor()` on offline instances, as the constructor already does for `enableEvents`. - Remove `EventProcessor`'s unused `$timeout` parameter: PSR-18 has no per-request timeout, so document passing a client configured with one. - Read the SDK version from composer.json once per process. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit c3b34740a7ca503063b955ffa81fe32fb8e7857d) --- src/Flagsmith.php | 5 +++++ src/Utils/EventProcessor.php | 5 ++--- src/Utils/UserAgent.php | 10 +++++++++- tests/FlagsmithClientTest.php | 11 +++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/Flagsmith.php b/src/Flagsmith.php index 6418e00..56118cf 100644 --- a/src/Flagsmith.php +++ b/src/Flagsmith.php @@ -167,9 +167,14 @@ public function withAnalytics(AnalyticsProcessor $analytics): self * Build with an event processor, enabling events. * @param EventProcessor $eventProcessor * @return Flagsmith + * @throws ValueError */ public function withEventProcessor(EventProcessor $eventProcessor): self { + if ($this->offlineMode) { + throw new ValueError('Cannot use events in offline mode.'); + } + return $this->with('eventProcessor', $eventProcessor); } diff --git a/src/Utils/EventProcessor.php b/src/Utils/EventProcessor.php index 6808a2e..61ac36a 100644 --- a/src/Utils/EventProcessor.php +++ b/src/Utils/EventProcessor.php @@ -16,6 +16,8 @@ * tracked after the flush interval has elapsed. * * Recording an event never fails the calling application: a batch that cannot be posted is dropped. + * Flushing sends the request synchronously, and PSR-18 has no per-request timeout, so a flush waits + * as long as the HTTP client allows. Pass a `$client` configured with a short timeout to bound it. */ class EventProcessor { @@ -30,7 +32,6 @@ class EventProcessor private string $environmentKey; private int $maxBuffer; private int $flushIntervalSeconds; - private int $timeout; private int $lastFlushed; /** @var array> */ public array $events = []; @@ -45,7 +46,6 @@ public function __construct( ?string $eventsApiUrl = null, int $maxBuffer = self::DEFAULT_MAX_BUFFER, int $flushIntervalSeconds = self::DEFAULT_FLUSH_INTERVAL_SECONDS, - int $timeout = 3, ?ClientInterface $client = null, ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null @@ -54,7 +54,6 @@ public function __construct( $this->environmentKey = $environmentKey; $this->maxBuffer = $maxBuffer; $this->flushIntervalSeconds = $flushIntervalSeconds; - $this->timeout = $timeout; $this->lastFlushed = time(); $this->client = $client ?? Psr18ClientDiscovery::find(); diff --git a/src/Utils/UserAgent.php b/src/Utils/UserAgent.php index bd54a65..3bd3f77 100644 --- a/src/Utils/UserAgent.php +++ b/src/Utils/UserAgent.php @@ -7,6 +7,8 @@ */ class UserAgent { + private static ?string $version = null; + /** * Get the user agent string for the SDK. * @@ -23,11 +25,17 @@ public static function get(): string * @return string The version from composer.json, or "unknown" */ public static function getVersion(): string + { + // The version cannot change within a process, so composer.json is read at most once. + return self::$version ??= self::readVersion(); + } + + private static function readVersion(): string { try { $composerPath = __DIR__ . '/../../composer.json'; $content = file_get_contents($composerPath); - $data = json_decode($content, true); + $data = $content === false ? null : json_decode($content, true); $version = $data['version'] ?? null; if ($version) { diff --git a/tests/FlagsmithClientTest.php b/tests/FlagsmithClientTest.php index c08e4b0..8cfc855 100644 --- a/tests/FlagsmithClientTest.php +++ b/tests/FlagsmithClientTest.php @@ -726,4 +726,15 @@ public function testCannotUseEventsInOfflineMode() // When new Flagsmith(offlineMode: true, offlineHandler: new FakeOfflineHandler(), enableEvents: true); } + + public function testCannotAddEventProcessorInOfflineMode() + { + // Given + $flagsmith = new Flagsmith(offlineMode: true, offlineHandler: new FakeOfflineHandler()); + $this->expectException(ValueError::class); + $this->expectExceptionMessage('Cannot use events in offline mode.'); + + // When + $flagsmith->withEventProcessor(new EventProcessor('api_key', client: $this->createMock(ClientInterface::class))); + } }