Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/sdk-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 36 additions & 4 deletions sdk-spec/steps.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
{
Expand All @@ -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.
}
156 changes: 155 additions & 1 deletion src/Flagsmith.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.');
}
Expand All @@ -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)
Expand Down Expand Up @@ -152,6 +163,21 @@ public function withAnalytics(AnalyticsProcessor $analytics): self
return $this->with('analyticsProcessor', $analytics);
}

/**
* 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);
}

/**
* Build with default flag handler.
* @param \Closure $defaultFlagHandler
Expand Down Expand Up @@ -342,6 +368,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<string, mixed>|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<string, mixed>|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
Expand Down Expand Up @@ -520,6 +655,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<string, mixed>|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)
*
Expand Down
92 changes: 92 additions & 0 deletions src/Models/Experiment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

namespace Flagsmith\Models;

use Flagsmith\Concerns\HasWith;

/**
* The running experiment a flag was evaluated under. Only remote identity evaluations carry it.
*/
class Experiment
{
use HasWith;

public int $id;
public string $name;
public bool $in_experiment;

/**
* Get the ID.
* @return int
*/
public function getId(): int
{
return $this->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);
}
}
Loading
Loading