From bf2af89f36c6ad6e35d5b60ddfa20b81c6d49c90 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 25 Jun 2026 20:55:06 +0200 Subject: [PATCH 01/15] v3 --- Makefile | 9 +- README.md | 103 ++++++++++--- featurevisor | 144 +++++++++--------- src/DatafileReader.php | 33 ++++- src/Evaluate.php | 24 +-- src/EvaluateByBucketing.php | 42 +++--- src/Events.php | 5 +- src/Featurevisor.php | 281 ++++++++++++++++++++++++++++++------ src/HooksManager.php | 92 ------------ src/Logger.php | 5 + src/ModulesManager.php | 196 +++++++++++++++++++++++++ tests/EventsTest.php | 3 + tests/FeaturevisorTest.php | 178 ++++++++++++++++++++++- 13 files changed, 827 insertions(+), 288 deletions(-) delete mode 100644 src/HooksManager.php create mode 100644 src/ModulesManager.php diff --git a/Makefile b/Makefile index 9a99a62..03c2b47 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install test setup-monorepo update-monorepo test-example-project +.PHONY: install test setup-monorepo update-monorepo test-example-1 test-example-project install: composer install @@ -18,5 +18,8 @@ setup-monorepo: update-monorepo: (cd monorepo && git pull origin main) -test-example-project: - ./featurevisor test --projectDirectoryPath="./monorepo/examples/example-1" +test-example-1: + composer test + ./featurevisor test --projectDirectoryPath="/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1" --onlyFailures + +test-example-project: test-example-1 diff --git a/README.md b/README.md index ced31d0..8442ca7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Featurevisor PHP SDK -This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v2.x to PHP, providing a way to evaluate feature flags, variations, and variables in your PHP applications. +This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to PHP, providing a way to evaluate feature flags, variations, and variables in your PHP applications. -This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 projects and above. +This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 projects and v2 datafiles. ## Table of contents @@ -33,9 +33,10 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 proje - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) - [Evaluation details](#evaluation-details) -- [Hooks](#hooks) - - [Defining a hook](#defining-a-hook) - - [Registering hooks](#registering-hooks) +- [Diagnostics](#diagnostics) +- [Modules](#modules) + - [Defining a module](#defining-a-module) + - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) - [CLI usage](#cli-usage) @@ -336,6 +337,14 @@ You may also initialize the SDK without passing `datafile`, and set it later on: $f->setDatafile($datafileContent); ``` +In v3, `setDatafile($datafileContent)` merges the incoming datafile into the existing one by default. This is useful when you receive partial Target datafiles. + +To replace the stored datafile completely, pass `true` as the second argument: + +```php +$f->setDatafile($datafileContent, true); +``` + ### Updating datafile You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly. @@ -426,6 +435,7 @@ $unsubscribe = $f->on('datafile_set', function ($event) { $revision = $event['revision']; // new revision $previousRevision = $event['previousRevision']; $revisionChanged = $event['revisionChanged']; // true if revision has changed + $replaced = $event['replaced']; // true if datafile was replaced instead of merged // list of feature keys that have new updates, // and you should re-evaluate them @@ -500,20 +510,53 @@ And optionally these properties depending on whether you are evaluating a featur - `variableValue`: the variable value - `variableSchema`: the variable schema -## Hooks +## Diagnostics + +Diagnostics are structured SDK messages for initialization, datafile updates, module reports, and errors. You can subscribe to them at initialization time: + +```php +$f = Featurevisor::createInstance([ + 'onDiagnostic' => function (array $diagnostic) { + $level = $diagnostic['level']; + $code = $diagnostic['code'] ?? null; + $message = $diagnostic['message'] ?? null; + + // send to your own observability system + }, +]); +``` + +If `onDiagnostic` is not provided, diagnostics are written through the configured logger. Error-level diagnostics also emit the SDK `error` event. -Hooks allow you to intercept the evaluation process and customize it further as per your needs. +## Modules -### Defining a hook +Modules allow you to intercept the evaluation process, report diagnostics, and customize behavior further as per your needs. -A hook is a simple object with a unique required `name` and optional functions: +### Defining a module + +A module is a simple object with a unique required `name` and optional functions: ```php -$myCustomHook = [ +$myCustomModule = [ // only required property - 'name' => 'my-custom-hook', + 'name' => 'my-custom-module', + + // rest of the properties below are all optional per module - // rest of the properties below are all optional per hook + // setup receives a module API + 'setup' => function ($api) { + $revision = $api['getRevision'](); + + $unsubscribe = $api['onDiagnostic'](function (array $diagnostic) { + // observe diagnostics reported by other modules or the SDK + }); + + $api['reportDiagnostic']([ + 'level' => 'info', + 'code' => 'custom_module_ready', + 'message' => 'Custom module is ready', + ]); + }, // before evaluation 'before' => function ($options) { @@ -562,19 +605,24 @@ $myCustomHook = [ // return custom bucket value return $bucketValue; }, + + // cleanup + 'close' => function () { + // release module resources + }, ]; ``` -### Registering hooks +### Registering modules -You can register hooks at the time of SDK initialization: +You can register modules at the time of SDK initialization: ```php use Featurevisor\Featurevisor; $f = Featurevisor::createInstance([ - 'hooks' => [ - $myCustomHook + 'modules' => [ + $myCustomModule ], ]); ``` @@ -582,9 +630,12 @@ $f = Featurevisor::createInstance([ Or after initialization: ```php -$removeHook = $f->addHook($myCustomHook); +$removeModule = $f->addModule($myCustomModule); + +// $removeModule() -// $removeHook() +// or remove later by name +$f->removeModule('my-custom-module'); ``` ## Child instance @@ -630,7 +681,7 @@ Similar to parent SDK, child instances also support several additional methods: ## Close -Both primary and child instances support a `.close()` method, that removes forgotten event listeners (via `on` method) and cleans up any potential memory leaks. +Both primary and child instances support a `.close()` method. The primary instance also closes registered modules and removes diagnostic subscriptions. ```php $f->close(); @@ -656,12 +707,12 @@ $ vendor/bin/featurevisor test \ --quiet|verbose \ --onlyFailures \ --keyPattern="myFeatureKey" \ - --assertionPattern="#1" \ - --with-tags \ - --with-scopes + --assertionPattern="#1" ``` -If your assertions include `scope`, run tests with `--with-scopes` to evaluate against scoped datafiles generated on the fly via `npx featurevisor build --scope= --environment= --json`. +If assertions include `target`, the runner builds and selects the corresponding Target datafile automatically via `npx featurevisor build --target= --environment= --json`. + +Legacy `--with-scopes`, `--with-tags`, and schema-version flags are accepted for older scripts but ignored in v3. ### Benchmark @@ -710,6 +761,12 @@ $ composer install $ composer test ``` +To run the SDK against Featurevisor example-1 from the local monorepo checkout: + +``` +$ make test-example-1 +``` + ### Releasing - Manually create a new release on [GitHub](https://github.com/featurevisor/featurevisor-php/releases) diff --git a/featurevisor b/featurevisor index 58f763f..9372ed4 100755 --- a/featurevisor +++ b/featurevisor @@ -44,8 +44,10 @@ $cliOptions = [ 'variation' => parseCliOption($argv, 'variation'), 'verbose' => parseCliOption($argv, 'verbose'), 'inflate' => parseCliOption($argv, 'inflate'), + // Accepted for compatibility with older SDK runners, intentionally ignored in v3. 'withScopes' => parseCliOption($argv, 'withScopes'), 'withTags' => parseCliOption($argv, 'withTags'), + 'schemaVersion' => parseCliOption($argv, 'schemaVersion') ?? parseCliOption($argv, 'schema-version'), 'rootDirectoryPath' => $cwd, 'populateUuid' => array_reduce($argv, function($acc, $arg) { if (strpos($arg, '--populateUuid=') === 0) { @@ -87,21 +89,40 @@ function getSegments(string $featurevisorProjectPath): array { return $segmentsByKey; } +function getTargets(string $featurevisorProjectPath): array { + echo "Getting targets..." . PHP_EOL; + $targetsOutput = executeCommand("(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor list --targets --json)"); + $targets = json_decode($targetsOutput, true); + + if (!is_array($targets)) { + return []; + } + + $targetNames = []; + foreach ($targets as $target) { + if (is_string($target)) { + $targetNames[] = $target; + } elseif (is_array($target)) { + $name = $target['name'] ?? $target['key'] ?? null; + if (is_string($name)) { + $targetNames[] = $name; + } + } + } + + return array_values(array_unique($targetNames)); +} + function buildSingleDatafile( string $featurevisorProjectPath, - string $environment, - ?string $tag = null, - ?string $scope = null + $environment, + ?string $target = null ): array { $command = "(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor build --json"; $command .= " --environment=" . escapeshellarg($environment); - if ($tag) { - $command .= " --tag=" . escapeshellarg($tag); - } - - if ($scope) { - $command .= " --scope=" . escapeshellarg($scope); + if ($target) { + $command .= " --target=" . escapeshellarg($target); } $command .= ")"; @@ -112,45 +133,30 @@ function buildSingleDatafile( return is_array($decoded) ? $decoded : []; } -function buildDatafiles(string $featurevisorProjectPath, array $config, array $cliOptions): array { +function getTargetDatafileKey($environment, string $target): string { + $environmentKey = ($environment === false || $environment === null || $environment === '') + ? 'false' + : (string) $environment; + + return $environmentKey . '-target-' . $target; +} + +function buildDatafiles(string $featurevisorProjectPath, array $config, array $targets = []): array { $datafilesByKey = []; $environments = $config['environments'] ?? []; - $scopes = $config['scopes'] ?? []; - $tags = $config['tags'] ?? []; foreach ($environments as $environment) { echo "Building datafile for environment: $environment..." . PHP_EOL; $datafilesByKey[$environment] = buildSingleDatafile($featurevisorProjectPath, $environment); - if ($cliOptions['withScopes'] === true) { - foreach ($scopes as $scope) { - if (!isset($scope['name']) || !is_string($scope['name'])) { - continue; - } - $scopeName = $scope['name']; - echo "Building scoped datafile for environment: $environment, scope: $scopeName..." . PHP_EOL; - $datafilesByKey[$environment . '-scope-' . $scopeName] = buildSingleDatafile( - $featurevisorProjectPath, - $environment, - null, - $scopeName - ); - } - } - - if ($cliOptions['withTags'] === true) { - foreach ($tags as $tag) { - if (!is_string($tag)) { - continue; - } - echo "Building tagged datafile for environment: $environment, tag: $tag..." . PHP_EOL; - $datafilesByKey[$environment . '-tag-' . $tag] = buildSingleDatafile( - $featurevisorProjectPath, - $environment, - $tag - ); - } + foreach ($targets as $target) { + echo "Building target datafile for environment: $environment, target: $target..." . PHP_EOL; + $datafilesByKey[getTargetDatafileKey($environment, $target)] = buildSingleDatafile( + $featurevisorProjectPath, + $environment, + $target + ); } } @@ -171,6 +177,18 @@ function generateUuid(): string { return bin2hex(random_bytes(16)); } +function stringStartsWith(string $value, string $prefix): bool { + return substr($value, 0, strlen($prefix)) === $prefix; +} + +function stringEndsWith(string $value, string $suffix): bool { + if ($suffix === '') { + return true; + } + + return substr($value, -strlen($suffix)) === $suffix; +} + /** * Test */ @@ -224,8 +242,8 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a foreach (array_keys($assertion["expectedVariables"]) as $variableKey) { $expectedValue = $assertion["expectedVariables"][$variableKey]; if (is_string($expectedValue) && - (str_starts_with($expectedValue, '{') || str_starts_with($expectedValue, '[')) && - (str_ends_with($expectedValue, '}') || str_ends_with($expectedValue, ']'))) { + (stringStartsWith($expectedValue, '{') || stringStartsWith($expectedValue, '[')) && + (stringEndsWith($expectedValue, '}') || stringEndsWith($expectedValue, ']'))) { $expectedValue = json_decode($expectedValue, true); } $actualValue = $f->getVariable($featureKey, $variableKey, $context, [ @@ -350,7 +368,8 @@ function test(array $cliOptions) { $config = getConfig($featurevisorProjectPath); $environments = $config['environments']; $segmentsByKey = getSegments($featurevisorProjectPath); - $datafilesByKey = buildDatafiles($featurevisorProjectPath, $config, $cliOptions); + $targets = getTargets($featurevisorProjectPath); + $datafilesByKey = buildDatafiles($featurevisorProjectPath, $config, $targets); echo PHP_EOL; @@ -388,29 +407,10 @@ function test(array $cliOptions) { } else { $datafile = $datafilesByKey[$environment]; - if (isset($assertion["scope"])) { - $scopeDatafileKey = $environment . '-scope-' . $assertion["scope"]; - if ($cliOptions['withScopes'] === true && isset($datafilesByKey[$scopeDatafileKey])) { - $datafile = $datafilesByKey[$scopeDatafileKey]; - } elseif ($cliOptions['withScopes'] !== true) { - $scope = null; - foreach ($config['scopes'] ?? [] as $scopeCandidate) { - if (($scopeCandidate['name'] ?? null) === $assertion["scope"]) { - $scope = $scopeCandidate; - break; - } - } - - if ($scope && isset($scope['context']) && is_array($scope['context'])) { - $assertion['context'] = array_merge($scope['context'], $assertion['context'] ?? []); - } - } - } - - if (isset($assertion["tag"])) { - $tagDatafileKey = $environment . '-tag-' . $assertion["tag"]; - if ($cliOptions['withTags'] === true && isset($datafilesByKey[$tagDatafileKey])) { - $datafile = $datafilesByKey[$tagDatafileKey]; + if (isset($assertion["target"])) { + $targetDatafileKey = getTargetDatafileKey($environment, $assertion["target"]); + if (isset($datafilesByKey[$targetDatafileKey])) { + $datafile = $datafilesByKey[$targetDatafileKey]; } } @@ -420,9 +420,9 @@ function test(array $cliOptions) { 'level' => $level, ]), 'sticky' => $assertion['sticky'] ?? [], - 'hooks' => [ + 'modules' => [ [ - 'name' => 'tester-hook', + 'name' => 'test-module', 'bucketValue' => function ($options) use ($assertion) { if (isset($assertion["at"])) { return $assertion["at"] * 1000; @@ -493,10 +493,10 @@ function benchmark(array $cliOptions) { $context = $cliOptions['context'] ? json_decode($cliOptions['context'], true) : []; $level = getLoggerLevel($cliOptions); - $datafilesByEnvironment = buildDatafiles($featurevisorProjectPath, [$cliOptions['environment']]); + $datafile = buildSingleDatafile($featurevisorProjectPath, $cliOptions['environment']); $f = Featurevisor::createInstance([ - 'datafile' => $datafilesByEnvironment[$cliOptions['environment']], + 'datafile' => $datafile, 'logger' => Logger::create([ 'level' => $level, ]), @@ -553,11 +553,11 @@ function assessDistribution(array $cliOptions) { $context = $cliOptions['context'] ? json_decode($cliOptions['context'], true) : []; $populateUuid = $cliOptions['populateUuid']; - $datafilesByEnvironment = buildDatafiles($featurevisorProjectPath, [$cliOptions['environment']]); + $datafile = buildSingleDatafile($featurevisorProjectPath, $cliOptions['environment']); $level = getLoggerLevel($cliOptions); $f = Featurevisor::createInstance([ - 'datafile' => $datafilesByEnvironment[$cliOptions['environment']], + 'datafile' => $datafile, 'logger' => Logger::create([ 'level' => $level, ]), diff --git a/src/DatafileReader.php b/src/DatafileReader.php index c40042c..42bfcb2 100644 --- a/src/DatafileReader.php +++ b/src/DatafileReader.php @@ -13,12 +13,14 @@ class DatafileReader private const EMPTY_CONTENT = [ 'schemaVersion' => '2', 'revision' => 'unknown', + 'featurevisorVersion' => null, 'segments' => [], 'features' => [] ]; private string $schemaVersion; private string $revision; + private ?string $featurevisorVersion; private array $segments; private array $features; private LoggerInterface $logger; @@ -75,10 +77,11 @@ public function __construct(array $datafileContent, ?LoggerInterface $logger = n { $this->logger = $logger ?? new NullLogger(); - $this->schemaVersion = $datafileContent['schemaVersion']; - $this->revision = $datafileContent['revision']; - $this->segments = $datafileContent['segments']; - $this->features = $datafileContent['features']; + $this->schemaVersion = $datafileContent['schemaVersion'] ?? '2'; + $this->revision = $datafileContent['revision'] ?? 'unknown'; + $this->featurevisorVersion = $datafileContent['featurevisorVersion'] ?? null; + $this->segments = $datafileContent['segments'] ?? []; + $this->features = $datafileContent['features'] ?? []; $this->regexCache = []; } @@ -92,6 +95,28 @@ public function getSchemaVersion(): string return $this->schemaVersion; } + public function getFeaturevisorVersion(): ?string + { + return $this->featurevisorVersion; + } + + public function getDatafile(): array + { + $datafile = [ + 'schemaVersion' => $this->schemaVersion, + 'revision' => $this->revision, + 'featurevisorVersion' => $this->featurevisorVersion, + 'segments' => $this->segments, + 'features' => $this->features, + ]; + + if ($datafile['featurevisorVersion'] === null) { + unset($datafile['featurevisorVersion']); + } + + return $datafile; + } + public function getSegment(string $segmentKey): ?array { $segment = $this->segments[$segmentKey] ?? null; diff --git a/src/Evaluate.php b/src/Evaluate.php index 2737e9a..4d91f01 100644 --- a/src/Evaluate.php +++ b/src/Evaluate.php @@ -4,19 +4,13 @@ class Evaluate { - public static function evaluateWithHooks(array $opts): array + public static function evaluateWithModules(array $opts): array { try { - $hooksManager = $opts['hooksManager']; - $hooks = $hooksManager->getAll(); - - // run before hooks - $options = $opts; - foreach ($hooksManager->getAll() as $hook) { - if (isset($hook['before'])) { - $options = $hook['before']($options); - } - } + $modulesManager = $opts['modulesManager']; + + // run before modules + $options = $modulesManager->runBeforeModules($opts); // evaluate $evaluation = self::evaluate($options); @@ -39,12 +33,8 @@ public static function evaluateWithHooks(array $opts): array $evaluation['variableValue'] = $options['defaultVariableValue']; } - // run after hooks - foreach ($hooks as $hook) { - if (isset($hook['after'])) { - $evaluation = $hook['after']($evaluation, $options); - } - } + // run after modules + $evaluation = $modulesManager->runAfterModules($evaluation, $options); return $evaluation; } catch (\Exception $e) { diff --git a/src/EvaluateByBucketing.php b/src/EvaluateByBucketing.php index 228a209..4c20e67 100644 --- a/src/EvaluateByBucketing.php +++ b/src/EvaluateByBucketing.php @@ -12,9 +12,7 @@ public static function evaluate(array $options, array $feature, ?array $variable $variableKey = $options['variableKey'] ?? null; $logger = $options['logger']; $datafileReader = $options['datafileReader']; - $hooksManager = $options['hooksManager']; - - $hooks = $hooksManager->getAll(); + $modulesManager = $options['modulesManager']; // bucketKey $bucketKey = Bucketer::getBucketKey([ @@ -24,30 +22,28 @@ public static function evaluate(array $options, array $feature, ?array $variable 'logger' => $logger ]); - foreach ($hooks as $hook) { - if (isset($hook['bucketKey'])) { - $bucketKey = $hook['bucketKey']([ - 'featureKey' => $featureKey, - 'context' => $context, - 'bucketBy' => $feature['bucketBy'], - 'bucketKey' => $bucketKey - ]); - } - } + $bucketKey = $modulesManager->runBucketKeyModules([ + 'type' => $type, + 'featureKey' => $featureKey, + 'variableKey' => $variableKey, + 'context' => $context, + 'bucketBy' => $feature['bucketBy'], + 'bucketKey' => $bucketKey, + 'feature' => $feature, + ]); // bucketValue $bucketValue = Bucketer::getBucketedNumber($bucketKey); - foreach ($hooks as $hook) { - if (isset($hook['bucketValue'])) { - $bucketValue = $hook['bucketValue']([ - 'featureKey' => $featureKey, - 'bucketKey' => $bucketKey, - 'context' => $context, - 'bucketValue' => $bucketValue - ]); - } - } + $bucketValue = $modulesManager->runBucketValueModules([ + 'type' => $type, + 'featureKey' => $featureKey, + 'variableKey' => $variableKey, + 'bucketKey' => $bucketKey, + 'context' => $context, + 'bucketValue' => $bucketValue, + 'feature' => $feature, + ]); $matchedTraffic = null; $matchedAllocation = null; diff --git a/src/Events.php b/src/Events.php index 2d6a315..2547ed3 100644 --- a/src/Events.php +++ b/src/Events.php @@ -18,7 +18,7 @@ public static function getParamsForStickySetEvent(array $previousStickyFeatures ]; } - public static function getParamsForDatafileSetEvent(DatafileReader $previousDatafileReader, DatafileReader $newDatafileReader): array + public static function getParamsForDatafileSetEvent(DatafileReader $previousDatafileReader, DatafileReader $newDatafileReader, bool $replace = false): array { $previousRevision = $previousDatafileReader->getRevision(); $previousFeatureKeys = $previousDatafileReader->getFeatureKeys(); @@ -64,7 +64,8 @@ public static function getParamsForDatafileSetEvent(DatafileReader $previousData 'revision' => $newRevision, 'previousRevision' => $previousRevision, 'revisionChanged' => $previousRevision !== $newRevision, - 'features' => array_values($allAffectedFeatures) + 'features' => array_values($allAffectedFeatures), + 'replaced' => $replace ]; } } diff --git a/src/Featurevisor.php b/src/Featurevisor.php index baeb12c..b308b20 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -12,8 +12,13 @@ class Featurevisor private LoggerInterface $logger; private ?array $sticky; private DatafileReader $datafileReader; - private HooksManager $hooksManager; + private ModulesManager $modulesManager; private Emitter $emitter; + /** @var callable|null */ + private $onDiagnostic; + /** @var array> */ + private array $moduleDiagnosticSubscriptions; + private bool $closed; /** * @param array{ @@ -22,7 +27,7 @@ class Featurevisor * logger?: LoggerInterface, * context?: array, * sticky?: array, - * hooks?: array $options + */ + public function __construct(array $options = []) + { + $this->logger = $options['logger'] ?? Logger::create([ 'level' => $options['logLevel'] ?? Logger::DEFAULT_LEVEL, ]); + $this->emitter = new Emitter(); + $this->context = $options['context'] ?? []; + $this->sticky = $options['sticky'] ?? null; + $this->onDiagnostic = $options['onDiagnostic'] ?? null; + $this->moduleDiagnosticSubscriptions = []; + $this->closed = false; + $this->datafileReader = DatafileReader::createEmpty($this->logger); + $this->modulesManager = ModulesManager::createFromOptions([ + 'modules' => $options['modules'] ?? [], + 'reportDiagnostic' => [$this, 'reportDiagnostic'], + 'moduleApiFactory' => [$this, 'createModuleApi'], + 'clearModuleDiagnosticSubscriptions' => [$this, 'clearModuleDiagnosticSubscriptions'], + ]); + + if (isset($options['datafile'])) { + $this->setDatafile($options['datafile'], true); + } - return new self( - isset($options['datafile']) - ? DatafileReader::createFromMixed($options['datafile'], $logger) - : DatafileReader::createEmpty($logger), - $logger, - HooksManager::createFromOptions($options), - new Emitter(), - $options['context'] ?? [], - $options['sticky'] ?? null - ); - } - - public function __construct( - DatafileReader $datafile, - LoggerInterface $logger, - HooksManager $hooksManager, - Emitter $emitter, - array $context = [], - ?array $sticky = null - ) - { - $this->datafileReader = $datafile; - $this->logger = $logger; - $this->hooksManager = $hooksManager; - $this->sticky = $sticky; - $this->emitter = $emitter; - $this->context = $context; - - $this->logger->info('Featurevisor SDK initialized'); + $this->reportDiagnostic([ + 'level' => 'info', + 'code' => 'sdk_initialized', + 'message' => 'Featurevisor SDK initialized', + ]); } /** * @param string|array $datafile */ - public function setDatafile($datafile): void + public function setDatafile($datafile, bool $replace = false): void { + if ($this->closed) { + return; + } + try { - $newDatafileReader = DatafileReader::createFromMixed($datafile, $this->logger); + $incomingDatafile = is_string($datafile) + ? json_decode($datafile, true, 512, JSON_THROW_ON_ERROR) + : $datafile; + $nextDatafile = $replace + ? $incomingDatafile + : $this->mergeDatafiles($this->datafileReader->getDatafile(), $incomingDatafile); + $newDatafileReader = DatafileReader::createFromMixed($nextDatafile, $this->logger); - $details = Events::getParamsForDatafileSetEvent($this->datafileReader, $newDatafileReader); + $details = Events::getParamsForDatafileSetEvent($this->datafileReader, $newDatafileReader, $replace); $this->datafileReader = $newDatafileReader; - $this->logger->info('datafile set', $details); + $this->reportDiagnostic([ + 'level' => 'info', + 'code' => 'datafile_set', + 'message' => 'datafile set', + 'details' => $details, + ]); $this->emitter->trigger('datafile_set', $details); } catch (\Exception $e) { - $this->logger->error('could not parse datafile', ['error' => $e->getMessage(), 'exception' => $e]); + $this->reportDiagnostic([ + 'level' => 'error', + 'code' => 'invalid_datafile', + 'message' => 'could not parse datafile', + 'error' => $e->getMessage(), + ]); } } @@ -93,6 +119,10 @@ public function setDatafile($datafile): void */ public function setSticky(array $sticky, bool $replace = false): void { + if ($this->closed) { + return; + } + $previousStickyFeatures = $this->sticky ?? []; if ($replace) { @@ -124,9 +154,25 @@ public function setLogLevel(string $level): void } } - public function addHook(array $hook): ?callable + public function addModule(array $module): ?callable + { + if ($this->closed) { + return null; + } + + return $this->modulesManager->add($module); + } + + /** + * @param string|array $nameOrModule + */ + public function removeModule($nameOrModule): void { - return $this->hooksManager->add($hook); + if ($this->closed) { + return; + } + + $this->modulesManager->remove($nameOrModule); } public function on(string $eventName, callable $callback): callable @@ -136,6 +182,13 @@ public function on(string $eventName, callable $callback): callable public function close(): void { + if ($this->closed) { + return; + } + + $this->closed = true; + $this->modulesManager->closeAll(); + $this->moduleDiagnosticSubscriptions = []; $this->emitter->clearAll(); } @@ -144,6 +197,10 @@ public function close(): void */ public function setContext(array $context, bool $replace = false): void { + if ($this->closed) { + return; + } + if ($replace) { $this->context = $context; } else { @@ -161,6 +218,142 @@ public function setContext(array $context, bool $replace = false): void ]); } + /** + * @param array $module + * @return array + */ + public function createModuleApi(array $module): array + { + return [ + 'getRevision' => function(): string { + return $this->getRevision(); + }, + 'onDiagnostic' => function(callable $handler, array $options = []) use ($module): callable { + $subscription = [ + 'id' => uniqid('diagnostic_', true), + 'moduleId' => $module['id'] ?? null, + 'handler' => $handler, + 'level' => $options['level'] ?? Logger::DEFAULT_LEVEL, + ]; + $this->moduleDiagnosticSubscriptions[] = $subscription; + + return function() use ($subscription): void { + $this->moduleDiagnosticSubscriptions = array_values(array_filter( + $this->moduleDiagnosticSubscriptions, + static fn(array $existing): bool => $existing['id'] !== $subscription['id'] + )); + }; + }, + 'reportDiagnostic' => function(array $diagnostic) use ($module): void { + $this->reportDiagnostic($diagnostic, $module); + }, + ]; + } + + /** + * @param array $module + */ + public function clearModuleDiagnosticSubscriptions(array $module): void + { + $moduleId = $module['id'] ?? null; + $this->moduleDiagnosticSubscriptions = array_values(array_filter( + $this->moduleDiagnosticSubscriptions, + static fn(array $subscription): bool => $subscription['moduleId'] !== $moduleId + )); + } + + /** + * @param array $diagnostic + * @param array|null $sourceModule + */ + public function reportDiagnostic(array $diagnostic, ?array $sourceModule = null): void + { + $diagnostic['level'] = $diagnostic['level'] ?? 'info'; + if ($sourceModule && isset($sourceModule['name']) && !isset($diagnostic['moduleName'])) { + $diagnostic['moduleName'] = $sourceModule['name']; + } + + foreach ($this->moduleDiagnosticSubscriptions as $subscription) { + if ($sourceModule && ($subscription['moduleId'] ?? null) === ($sourceModule['id'] ?? null)) { + continue; + } + + if (!$this->levelAllows($diagnostic['level'], $subscription['level'])) { + continue; + } + + ($subscription['handler'])($diagnostic); + } + + $instanceLevel = method_exists($this->logger, 'getLevel') ? $this->logger->getLevel() : Logger::DEFAULT_LEVEL; + if ($this->levelAllows($diagnostic['level'], $instanceLevel)) { + if ($this->onDiagnostic) { + ($this->onDiagnostic)($diagnostic); + } else { + $context = $diagnostic['details'] ?? $diagnostic; + unset($context['level'], $context['message']); + $this->logger->log( + $this->normalizeLogLevel($diagnostic['level']), + $diagnostic['message'] ?? ($diagnostic['code'] ?? 'diagnostic'), + $context + ); + } + } + + if (in_array($this->normalizeLogLevel($diagnostic['level']), [LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR], true)) { + $this->emitter->trigger('error', $diagnostic); + } + } + + /** + * @param array $previous + * @param array $incoming + * @return array + */ + private function mergeDatafiles(array $previous, array $incoming): array + { + return array_merge($previous, $incoming, [ + 'segments' => array_merge($previous['segments'] ?? [], $incoming['segments'] ?? []), + 'features' => array_merge($previous['features'] ?? [], $incoming['features'] ?? []), + ]); + } + + private function normalizeLogLevel(string $level): string + { + if ($level === 'fatal') { + return LogLevel::EMERGENCY; + } + + if ($level === 'warn') { + return LogLevel::WARNING; + } + + return $level; + } + + private function levelAllows(string $diagnosticLevel, string $configuredLevel): bool + { + $levels = [ + LogLevel::EMERGENCY, + LogLevel::ALERT, + LogLevel::CRITICAL, + LogLevel::ERROR, + LogLevel::WARNING, + LogLevel::NOTICE, + LogLevel::INFO, + LogLevel::DEBUG, + ]; + + $diagnosticLevel = $this->normalizeLogLevel($diagnosticLevel); + $configuredLevel = $this->normalizeLogLevel($configuredLevel); + + if (!in_array($diagnosticLevel, $levels, true) || !in_array($configuredLevel, $levels, true)) { + return false; + } + + return array_search($configuredLevel, $levels, true) >= array_search($diagnosticLevel, $levels, true); + } + /** * @param array $context * @return array @@ -209,7 +402,7 @@ private function getEvaluationDependencies(array $context, array $options = []): return array_merge($options, [ 'context' => $this->getContext($context), 'logger' => $this->logger, - 'hooksManager' => $this->hooksManager, + 'modulesManager' => $this->modulesManager, 'datafileReader' => $this->datafileReader, 'sticky' => $sticky, 'defaultVariationValue' => $options['defaultVariationValue'] ?? null, @@ -240,7 +433,7 @@ public function evaluateFlag(string $featureKey, array $context = [], array $opt { $deps = $this->getEvaluationDependencies($context, $options); - return Evaluate::evaluateWithHooks(array_merge($deps, [ + return Evaluate::evaluateWithModules(array_merge($deps, [ 'type' => 'flag', 'featureKey' => $featureKey ])); @@ -285,7 +478,7 @@ public function evaluateVariation(string $featureKey, array $context = [], array { $deps = $this->getEvaluationDependencies($context, $options); - return Evaluate::evaluateWithHooks(array_merge($deps, [ + return Evaluate::evaluateWithModules(array_merge($deps, [ 'type' => 'variation', 'featureKey' => $featureKey ])); @@ -348,7 +541,7 @@ public function evaluateVariable(string $featureKey, string $variableKey, array { $deps = $this->getEvaluationDependencies($context, $options); - return Evaluate::evaluateWithHooks(array_merge($deps, [ + return Evaluate::evaluateWithModules(array_merge($deps, [ 'type' => 'variable', 'featureKey' => $featureKey, 'variableKey' => $variableKey diff --git a/src/HooksManager.php b/src/HooksManager.php deleted file mode 100644 index 1339f96..0000000 --- a/src/HooksManager.php +++ /dev/null @@ -1,92 +0,0 @@ -, - * logger?: LoggerInterface, - * } $options - * @return self - */ - public static function createFromOptions(array $options): self - { - return new self( - $options['hooks'] ?? [], - $options['logger'] ?? new NullLogger() - ); - } - - /** - * @param array $hooks - */ - public function __construct(array $hooks, LoggerInterface $logger) - { - $this->logger = $logger; - foreach ($hooks as $hook) { - $this->add($hook); - } - } - - /** - * @param array{ - * name: string, - * before: Closure, - * after: Closure, - * bucketKey: Closure, - * bucketValue: Closure - * } $hook - * @return callable|null - */ - public function add(array $hook): ?callable - { - foreach ($this->hooks as $existingHook) { - if ($existingHook['name'] === $hook['name']) { - $this->logger->error("Hook with name \"{$hook['name']}\" already exists.", [ - 'name' => $hook['name'], - 'hook' => $hook - ]); - return null; - } - } - - $this->hooks[] = $hook; - - return function() use ($hook) { - $this->remove($hook['name']); - }; - } - - public function remove(string $name): void - { - $this->hooks = array_filter($this->hooks, function($hook) use ($name) { - return $hook['name'] !== $name; - }); - } - - public function getAll(): array - { - return $this->hooks; - } -} diff --git a/src/Logger.php b/src/Logger.php index ddd6bf3..0c1a6bf 100644 --- a/src/Logger.php +++ b/src/Logger.php @@ -59,6 +59,11 @@ public function setLevel(string $level): void $this->level = $level; } + public function getLevel(): string + { + return $this->level; + } + public function log($level, $message, array $context = []): void { $level = (string) $level; diff --git a/src/ModulesManager.php b/src/ModulesManager.php new file mode 100644 index 0000000..3e7e18a --- /dev/null +++ b/src/ModulesManager.php @@ -0,0 +1,196 @@ + $options + * @return self + */ + public static function createFromOptions(array $options): self + { + return new self( + $options['modules'] ?? [], + $options['reportDiagnostic'] ?? null, + $options['moduleApiFactory'] ?? null, + $options['clearModuleDiagnosticSubscriptions'] ?? null + ); + } + + /** + * @param array> $modules + * @param callable|null $reportDiagnostic + * @param callable|null $moduleApiFactory + * @param callable|null $clearModuleDiagnosticSubscriptions + */ + public function __construct( + array $modules = [], + ?callable $reportDiagnostic = null, + ?callable $moduleApiFactory = null, + ?callable $clearModuleDiagnosticSubscriptions = null + ) + { + $this->reportDiagnostic = $reportDiagnostic; + $this->moduleApiFactory = $moduleApiFactory; + $this->clearModuleDiagnosticSubscriptions = $clearModuleDiagnosticSubscriptions; + + foreach ($modules as $module) { + $this->add($module); + } + } + + /** + * @param array $module + * @return callable|null + */ + public function add(array $module): ?callable + { + if (!isset($module['id'])) { + $module['id'] = uniqid('module_', true); + } + + if (isset($module['name']) && $module['name'] !== '') { + foreach ($this->modules as $existingModule) { + if (($existingModule['name'] ?? null) === $module['name']) { + $this->report([ + 'level' => 'error', + 'code' => 'duplicate_module', + 'message' => 'Duplicate module name', + 'moduleName' => $module['name'], + ], $module); + return null; + } + } + } + + if (isset($module['setup']) && is_callable($module['setup']) && $this->moduleApiFactory) { + $api = ($this->moduleApiFactory)($module); + $module['setup']($api); + } + + $this->modules[] = $module; + + return function() use ($module) { + $this->remove($module); + }; + } + + /** + * @param string|array $nameOrModule + */ + public function remove($nameOrModule): void + { + $remainingModules = []; + $removedModules = []; + + foreach ($this->modules as $module) { + $matches = is_array($nameOrModule) + ? (($module['id'] ?? null) === ($nameOrModule['id'] ?? null)) + : (($module['name'] ?? null) === $nameOrModule); + + if ($matches) { + $removedModules[] = $module; + } else { + $remainingModules[] = $module; + } + } + + $this->modules = $remainingModules; + + foreach ($removedModules as $module) { + if (isset($module['close']) && is_callable($module['close'])) { + $module['close'](); + } + if ($this->clearModuleDiagnosticSubscriptions) { + ($this->clearModuleDiagnosticSubscriptions)($module); + } + } + } + + public function getAll(): array + { + return $this->modules; + } + + public function runBeforeModules(array $options): array + { + foreach ($this->modules as $module) { + if (isset($module['before']) && is_callable($module['before'])) { + $options = $module['before']($options); + } + } + + return $options; + } + + public function runBucketKeyModules(array $options): string + { + $bucketKey = $options['bucketKey']; + + foreach ($this->modules as $module) { + if (isset($module['bucketKey']) && is_callable($module['bucketKey'])) { + $bucketKey = $module['bucketKey'](array_merge($options, [ + 'bucketKey' => $bucketKey, + ])); + } + } + + return $bucketKey; + } + + public function runBucketValueModules(array $options): int + { + $bucketValue = $options['bucketValue']; + + foreach ($this->modules as $module) { + if (isset($module['bucketValue']) && is_callable($module['bucketValue'])) { + $bucketValue = $module['bucketValue'](array_merge($options, [ + 'bucketValue' => $bucketValue, + ])); + } + } + + return $bucketValue; + } + + public function runAfterModules(array $evaluation, array $options): array + { + foreach ($this->modules as $module) { + if (isset($module['after']) && is_callable($module['after'])) { + $evaluation = $module['after']($evaluation, $options); + } + } + + return $evaluation; + } + + public function closeAll(): void + { + foreach ($this->modules as $module) { + if (isset($module['close']) && is_callable($module['close'])) { + $module['close'](); + } + if ($this->clearModuleDiagnosticSubscriptions) { + ($this->clearModuleDiagnosticSubscriptions)($module); + } + } + + $this->modules = []; + } + + private function report(array $diagnostic, ?array $module = null): void + { + if ($this->reportDiagnostic) { + ($this->reportDiagnostic)($diagnostic, $module); + } + } +} diff --git a/tests/EventsTest.php b/tests/EventsTest.php index 3ec7a2b..32d1979 100644 --- a/tests/EventsTest.php +++ b/tests/EventsTest.php @@ -83,6 +83,7 @@ public function testGetParamsForDatafileSetEventEmptyToNew() 'previousRevision' => '1', 'revisionChanged' => true, 'features' => ['feature1', 'feature2'], + 'replaced' => false, ], $result); } @@ -126,6 +127,7 @@ public function testGetParamsForDatafileSetEventChangeHashAddition() 'previousRevision' => '1', 'revisionChanged' => true, 'features' => ['feature2', 'feature3'], + 'replaced' => false, ], $result); } @@ -167,6 +169,7 @@ public function testGetParamsForDatafileSetEventChangeHashRemoval() 'previousRevision' => '1', 'revisionChanged' => true, 'features' => ['feature1', 'feature2'], + 'replaced' => false, ], $result); } } diff --git a/tests/FeaturevisorTest.php b/tests/FeaturevisorTest.php index f0a8f63..a339692 100644 --- a/tests/FeaturevisorTest.php +++ b/tests/FeaturevisorTest.php @@ -103,7 +103,7 @@ public function testShouldConfigurePlainBucketBy() ], 'segments' => [], ], - 'hooks' => [ + 'modules' => [ [ 'name' => 'unit-test', 'bucketKey' => function($options) use (&$capturedBucketKey) { @@ -152,7 +152,7 @@ public function testShouldConfigureAndBucketBy() ], 'segments' => [], ], - 'hooks' => [ + 'modules' => [ [ 'name' => 'unit-test', 'bucketKey' => function($options) use (&$capturedBucketKey) { @@ -201,7 +201,7 @@ public function testShouldConfigureOrBucketBy() ], 'segments' => [], ], - 'hooks' => [ + 'modules' => [ [ 'name' => 'unit-test', 'bucketKey' => function($options) use (&$capturedBucketKey) { @@ -228,7 +228,7 @@ public function testShouldConfigureOrBucketBy() self::assertEquals('456.test', $capturedBucketKey); } - public function testShouldInterceptContextBeforeHook() + public function testShouldInterceptContextBeforeModule() { $intercepted = false; $interceptedFeatureKey = ''; @@ -258,7 +258,7 @@ public function testShouldInterceptContextBeforeHook() ], 'segments' => [], ], - 'hooks' => [ + 'modules' => [ [ 'name' => 'unit-test', 'before' => function($options) use (&$intercepted, &$interceptedFeatureKey, &$interceptedVariableKey) { @@ -281,7 +281,7 @@ public function testShouldInterceptContextBeforeHook() self::assertEquals('', $interceptedVariableKey); } - public function testShouldInterceptValueAfterHook() + public function testShouldInterceptValueAfterModule() { $intercepted = false; $interceptedFeatureKey = ''; @@ -311,7 +311,7 @@ public function testShouldInterceptValueAfterHook() ], 'segments' => [], ], - 'hooks' => [ + 'modules' => [ [ 'name' => 'unit-test', 'after' => function($options) use (&$intercepted, &$interceptedFeatureKey, &$interceptedVariableKey) { @@ -686,7 +686,7 @@ public function testShouldCheckIfEnabledForMutuallyExclusiveFeatures() $bucketValue = 10000; $sdk = Featurevisor::createInstance([ - 'hooks' => [ + 'modules' => [ [ 'name' => 'unit-test', 'bucketValue' => function() use (&$bucketValue) { @@ -1481,4 +1481,166 @@ public function testShouldGetArrayAndObjectVariables() $all['withObject']['variables']['themeConfig'] ); } + + public function testShouldSetDatafileByMergingByDefaultAndReplacingWhenRequested() + { + $events = []; + $sdk = Featurevisor::createInstance([ + 'logger' => Logger::create(['level' => LogLevel::ERROR]), + 'datafile' => [ + 'schemaVersion' => '2', + 'revision' => 'base', + 'segments' => [], + 'features' => [ + 'first' => [ + 'key' => 'first', + 'bucketBy' => 'userId', + 'traffic' => [ + [ + 'key' => '1', + 'segments' => '*', + 'percentage' => 100000, + ], + ], + ], + ], + ], + ]); + $sdk->on('datafile_set', function(array $details) use (&$events) { + $events[] = $details; + }); + + $sdk->setDatafile([ + 'schemaVersion' => '2', + 'revision' => 'merged', + 'segments' => [], + 'features' => [ + 'second' => [ + 'key' => 'second', + 'bucketBy' => 'userId', + 'traffic' => [ + [ + 'key' => '1', + 'segments' => '*', + 'percentage' => 100000, + ], + ], + ], + ], + ]); + + self::assertTrue($sdk->isEnabled('first', ['userId' => '123'])); + self::assertTrue($sdk->isEnabled('second', ['userId' => '123'])); + self::assertFalse($events[0]['replaced']); + + $sdk->setDatafile([ + 'schemaVersion' => '2', + 'revision' => 'replaced', + 'segments' => [], + 'features' => [ + 'third' => [ + 'key' => 'third', + 'bucketBy' => 'userId', + 'traffic' => [ + [ + 'key' => '1', + 'segments' => '*', + 'percentage' => 100000, + ], + ], + ], + ], + ], true); + + self::assertFalse($sdk->isEnabled('first', ['userId' => '123'])); + self::assertTrue($sdk->isEnabled('third', ['userId' => '123'])); + self::assertTrue($events[1]['replaced']); + } + + public function testShouldManageModulesAndDuplicateDiagnostics() + { + $diagnostics = []; + $setupCalls = 0; + $closeCalls = 0; + + $sdk = Featurevisor::createInstance([ + 'logger' => Logger::create(['level' => LogLevel::ERROR]), + 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { + $diagnostics[] = $diagnostic; + }, + 'modules' => [ + [ + 'name' => 'module-a', + 'setup' => function(array $api) use (&$setupCalls) { + $setupCalls++; + self::assertSame('unknown', $api['getRevision']()); + }, + 'close' => function() use (&$closeCalls) { + $closeCalls++; + }, + ], + ], + ]); + + $removeModule = $sdk->addModule([ + 'name' => 'module-b', + 'close' => function() use (&$closeCalls) { + $closeCalls++; + }, + ]); + $duplicate = $sdk->addModule(['name' => 'module-b']); + + self::assertSame(1, $setupCalls); + self::assertNull($duplicate); + self::assertSame('duplicate_module', $diagnostics[0]['code']); + + $removeModule(); + $sdk->close(); + + self::assertSame(2, $closeCalls); + } + + public function testShouldSupportModuleDiagnosticsSubscriptions() + { + $received = []; + $reporter = null; + + $sdk = Featurevisor::createInstance([ + 'logger' => Logger::create(['level' => LogLevel::ERROR]), + 'modules' => [ + [ + 'name' => 'listener', + 'setup' => function(array $api) use (&$received) { + $api['onDiagnostic'](function(array $diagnostic) use (&$received) { + $received[] = $diagnostic; + }, ['level' => LogLevel::WARNING]); + }, + ], + [ + 'name' => 'reporter', + 'setup' => function(array $api) use (&$reporter) { + $reporter = $api['reportDiagnostic']; + }, + ], + ], + ]); + + $reporter([ + 'level' => 'warning', + 'code' => 'from_reporter', + 'message' => 'diagnostic from reporter', + ]); + + self::assertCount(1, $received); + self::assertSame('from_reporter', $received[0]['code']); + + $sdk->removeModule('listener'); + $reporter([ + 'level' => 'warning', + 'code' => 'after_remove', + 'message' => 'after remove', + ]); + + self::assertCount(1, $received); + } } From 6a6d56a6f01bf4a88e7b0fa165b8b974e480c3b8 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 25 Jun 2026 21:30:14 +0200 Subject: [PATCH 02/15] updates --- README.md | 47 ++++++++++++++++++- featurevisor | 88 ++++++++++++++++++++++++----------- phpstan.neon | 2 + tests/FeaturevisorCliTest.php | 77 ++++++++++++++++++++++++++++++ tests/phpstan-bootstrap.php | 7 +++ 5 files changed, 193 insertions(+), 28 deletions(-) create mode 100644 tests/FeaturevisorCliTest.php create mode 100644 tests/phpstan-bootstrap.php diff --git a/README.md b/README.md index 8442ca7..fbbe222 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) + - [Merging by default](#merging-by-default) + - [Replacing](#replacing) + - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) - [Logging](#logging) - [Levels](#levels) @@ -32,6 +35,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) + - [`error`](#error) - [Evaluation details](#evaluation-details) - [Diagnostics](#diagnostics) - [Modules](#modules) @@ -337,7 +341,17 @@ You may also initialize the SDK without passing `datafile`, and set it later on: $f->setDatafile($datafileContent); ``` -In v3, `setDatafile($datafileContent)` merges the incoming datafile into the existing one by default. This is useful when you receive partial Target datafiles. +### Merging by default + +By default, `setDatafile($datafileContent)` merges the incoming datafile with the SDK instance's existing datafile: + +- incoming `features` and `segments` override matching keys +- existing `features` and `segments` that are missing from the incoming datafile are kept +- `revision`, `schemaVersion`, and `featurevisorVersion` are taken from the incoming datafile + +This means you can call `setDatafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. + +### Replacing To replace the stored datafile completely, pass `true` as the second argument: @@ -345,6 +359,29 @@ To replace the stored datafile completely, pass `true` as the second argument: $f->setDatafile($datafileContent, true); ``` +### Loading datafiles on demand + +Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront. + +This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: + +```php +$f = Featurevisor::createInstance([]); + +function loadDatafile($f, string $target): void { + $url = "https://cdn.yoursite.com/production/featurevisor-$target.json"; + $datafile = json_decode(file_get_contents($url), true); + + // merges into whatever was loaded before + $f->setDatafile($datafile); +} + +loadDatafile($f, 'products'); + +// later, when the user reaches checkout +loadDatafile($f, 'checkout'); +``` + ### Updating datafile You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly. @@ -478,6 +515,14 @@ $unsubscribe = $f->on('sticky_set', function ($event) { }); ``` +### `error` + +```php +$unsubscribe = $f->on('error', function ($diagnostic) { + echo $diagnostic['message']; +}); +``` + ## Evaluation details Besides logging with debug level enabled, you can also get more details about how the feature variations and variables are evaluated in the runtime against given context: diff --git a/featurevisor b/featurevisor index 9372ed4..26c69d6 100755 --- a/featurevisor +++ b/featurevisor @@ -29,6 +29,7 @@ function parseCliOption(array $argv, string $key) { } $cwd = getcwd(); +$argv = $_SERVER['argv'] ?? []; $cliOptions = [ '0' => $argv[1] ?? null, @@ -119,7 +120,10 @@ function buildSingleDatafile( ?string $target = null ): array { $command = "(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor build --json"; - $command .= " --environment=" . escapeshellarg($environment); + + if ($environment !== false && $environment !== null && $environment !== '') { + $command .= " --environment=" . escapeshellarg($environment); + } if ($target) { $command .= " --target=" . escapeshellarg($target); @@ -133,26 +137,62 @@ function buildSingleDatafile( return is_array($decoded) ? $decoded : []; } +function getEnvironmentList(array $config): array { + $environments = $config['environments'] ?? false; + + if ($environments === false || $environments === null) { + return [false]; + } + + if (is_array($environments) && count($environments) > 0) { + return $environments; + } + + return [false]; +} + +function getBaseDatafileKey($environment) { + return ($environment === false || $environment === null || $environment === '') + ? false + : (string) $environment; +} + function getTargetDatafileKey($environment, string $target): string { - $environmentKey = ($environment === false || $environment === null || $environment === '') + $baseDatafileKey = getBaseDatafileKey($environment); + $environmentKey = $baseDatafileKey === false ? 'false' - : (string) $environment; + : (string) $baseDatafileKey; return $environmentKey . '-target-' . $target; } +function getDatafileForAssertion(array $assertion, array $datafilesByKey): ?array { + $baseDatafileKey = getBaseDatafileKey($assertion['environment'] ?? false); + $target = $assertion['target'] ?? null; + + if (is_string($target)) { + $targetDatafileKey = getTargetDatafileKey($baseDatafileKey, $target); + if (isset($datafilesByKey[$targetDatafileKey])) { + return $datafilesByKey[$targetDatafileKey]; + } + } + + return $datafilesByKey[$baseDatafileKey] ?? null; +} + function buildDatafiles(string $featurevisorProjectPath, array $config, array $targets = []): array { $datafilesByKey = []; - $environments = $config['environments'] ?? []; + $environments = getEnvironmentList($config); foreach ($environments as $environment) { - echo "Building datafile for environment: $environment..." . PHP_EOL; - $datafilesByKey[$environment] = buildSingleDatafile($featurevisorProjectPath, $environment); + $baseDatafileKey = getBaseDatafileKey($environment); + echo "Building datafile for environment: " . ($baseDatafileKey === false ? 'default' : $baseDatafileKey) . "..." . PHP_EOL; + $datafilesByKey[$baseDatafileKey] = buildSingleDatafile($featurevisorProjectPath, $environment); foreach ($targets as $target) { - echo "Building target datafile for environment: $environment, target: $target..." . PHP_EOL; - $datafilesByKey[getTargetDatafileKey($environment, $target)] = buildSingleDatafile( + echo "Building target datafile for environment: " . ($baseDatafileKey === false ? 'default' : $baseDatafileKey) . ", target: $target..." . PHP_EOL; + $datafilesByKey[getTargetDatafileKey($baseDatafileKey, $target)] = buildSingleDatafile( $featurevisorProjectPath, $environment, $target @@ -366,7 +406,6 @@ function test(array $cliOptions) { $featurevisorProjectPath = $cliOptions['rootDirectoryPath']; $config = getConfig($featurevisorProjectPath); - $environments = $config['environments']; $segmentsByKey = getSegments($featurevisorProjectPath); $targets = getTargets($featurevisorProjectPath); $datafilesByKey = buildDatafiles($featurevisorProjectPath, $config, $targets); @@ -398,22 +437,15 @@ function test(array $cliOptions) { if (isset($test["feature"])) { $environment = $assertion["environment"] ?? null; - if (!$environment || !isset($datafilesByKey[$environment])) { + $datafile = getDatafileForAssertion($assertion, $datafilesByKey); + + if (!$datafile) { $testResult = [ 'hasError' => true, 'errors' => " ✘ missing datafile for environment: " . json_encode($environment) . PHP_EOL, 'duration' => 0 ]; } else { - $datafile = $datafilesByKey[$environment]; - - if (isset($assertion["target"])) { - $targetDatafileKey = getTargetDatafileKey($environment, $assertion["target"]); - if (isset($datafilesByKey[$targetDatafileKey])) { - $datafile = $datafilesByKey[$targetDatafileKey]; - } - } - $f = Featurevisor::createInstance([ 'datafile' => $datafile, 'logger' => Logger::create([ @@ -611,12 +643,14 @@ function assessDistribution(array $cliOptions) { /** * Main */ -if ($cliOptions['0'] === 'test') { - test($cliOptions); -} else if ($cliOptions['0'] === 'benchmark') { - benchmark($cliOptions); -} else if ($cliOptions['0'] === 'assess-distribution') { - assessDistribution($cliOptions); -} else { - echo "Learn more at https://featurevisor.com/docs/sdks/php/" . PHP_EOL; +if (!defined('FEATUREVISOR_CLI_TEST')) { + if ($cliOptions['0'] === 'test') { + test($cliOptions); + } else if ($cliOptions['0'] === 'benchmark') { + benchmark($cliOptions); + } else if ($cliOptions['0'] === 'assess-distribution') { + assessDistribution($cliOptions); + } else { + echo "Learn more at https://featurevisor.com/docs/sdks/php/" . PHP_EOL; + } } diff --git a/phpstan.neon b/phpstan.neon index 69bdbb4..d6e1a40 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,8 @@ parameters: level: 5 treatPhpDocTypesAsCertain: false + bootstrapFiles: + - tests/phpstan-bootstrap.php paths: - src - tests diff --git a/tests/FeaturevisorCliTest.php b/tests/FeaturevisorCliTest.php new file mode 100644 index 0000000..2febf09 --- /dev/null +++ b/tests/FeaturevisorCliTest.php @@ -0,0 +1,77 @@ + false])); + self::assertSame([false], \getEnvironmentList([])); + self::assertSame(['staging', 'production'], \getEnvironmentList(['environments' => ['staging', 'production']])); + } + + public function testTargetAssertionSelectsTargetDatafile() + { + $datafile = \getDatafileForAssertion( + [ + 'environment' => 'production', + 'target' => 'checkout', + ], + [ + 'production' => ['kind' => 'base'], + 'production-target-checkout' => ['kind' => 'target'], + ] + ); + + self::assertSame('target', $datafile['kind']); + } + + public function testTargetAssertionFallsBackToBaseDatafile() + { + $datafile = \getDatafileForAssertion( + [ + 'environment' => 'production', + 'target' => 'checkout', + ], + [ + 'production' => ['kind' => 'base'], + ] + ); + + self::assertSame('base', $datafile['kind']); + } + + public function testNoEnvironmentTargetAssertionSelectsTargetDatafile() + { + $datafile = \getDatafileForAssertion( + [ + 'target' => 'checkout', + ], + [ + false => ['kind' => 'base'], + 'false-target-checkout' => ['kind' => 'target'], + ] + ); + + self::assertSame('target', $datafile['kind']); + } +} diff --git a/tests/phpstan-bootstrap.php b/tests/phpstan-bootstrap.php new file mode 100644 index 0000000..16564de --- /dev/null +++ b/tests/phpstan-bootstrap.php @@ -0,0 +1,7 @@ + Date: Fri, 26 Jun 2026 19:06:53 +0200 Subject: [PATCH 03/15] updates --- src/DatafileReader.php | 9 ++-- src/ModulesManager.php | 30 ++++++++++--- tests/ConditionsTest.php | 8 ++++ tests/DatafileReaderTest.php | 9 ++++ tests/EmitterTest.php | 19 +++++++++ tests/FeaturevisorTest.php | 82 +++++++++++++++++++++++++++++++++++- 6 files changed, 144 insertions(+), 13 deletions(-) diff --git a/src/DatafileReader.php b/src/DatafileReader.php index 42bfcb2..17ff427 100644 --- a/src/DatafileReader.php +++ b/src/DatafileReader.php @@ -280,12 +280,9 @@ public function allSegmentsAreMatched($groupSegments, array $context): bool return false; } if (isset($groupSegments['not']) && is_array($groupSegments['not'])) { - foreach ($groupSegments['not'] as $subSegment) { - if ($this->allSegmentsAreMatched($subSegment, $context)) { - return false; - } - } - return true; + return $this->allSegmentsAreMatched([ + 'and' => $groupSegments['not'], + ], $context) === false; } // If it's a plain array, treat as AND (all must match) if (array_keys($groupSegments) === range(0, count($groupSegments) - 1)) { diff --git a/src/ModulesManager.php b/src/ModulesManager.php index 3e7e18a..920d80a 100644 --- a/src/ModulesManager.php +++ b/src/ModulesManager.php @@ -107,12 +107,10 @@ public function remove($nameOrModule): void $this->modules = $remainingModules; foreach ($removedModules as $module) { - if (isset($module['close']) && is_callable($module['close'])) { - $module['close'](); - } if ($this->clearModuleDiagnosticSubscriptions) { ($this->clearModuleDiagnosticSubscriptions)($module); } + $this->closeModule($module); } } @@ -176,17 +174,37 @@ public function runAfterModules(array $evaluation, array $options): array public function closeAll(): void { foreach ($this->modules as $module) { - if (isset($module['close']) && is_callable($module['close'])) { - $module['close'](); - } if ($this->clearModuleDiagnosticSubscriptions) { ($this->clearModuleDiagnosticSubscriptions)($module); } + $this->closeModule($module); } $this->modules = []; } + /** + * @param array $module + */ + private function closeModule(array $module): void + { + if (!isset($module['close']) || !is_callable($module['close'])) { + return; + } + + try { + $module['close'](); + } catch (\Throwable $error) { + $this->report([ + 'level' => 'error', + 'code' => 'module_close_error', + 'message' => 'Module close failed', + 'moduleName' => $module['name'] ?? null, + 'originalError' => $error, + ], null); + } + } + private function report(array $diagnostic, ?array $module = null): void { if ($this->reportDiagnostic) { diff --git a/tests/ConditionsTest.php b/tests/ConditionsTest.php index 52d36c3..74e138d 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -284,6 +284,14 @@ public function testNotCondition() { self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); + + $conditions = [[ 'not' => [[ 'or' => [ + [ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'chrome' ], + [ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'firefox' ], + ]]]]]; + self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'edge'])); + self::assertFalse($this->datafileReader->allConditionsAreMatched([[ 'not' => [] ]], [])); } public function testNestedConditions() { diff --git a/tests/DatafileReaderTest.php b/tests/DatafileReaderTest.php index 08b2ad9..9b786fc 100644 --- a/tests/DatafileReaderTest.php +++ b/tests/DatafileReaderTest.php @@ -173,5 +173,14 @@ public function testSegmentsMatching() { self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['version' => 5.7])); self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['version' => '5.5'])); self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['version' => 5.5])); + + $segments = ['not' => ['mobileUsers', 'netherlands']]; + self::assertFalse($datafileReader->allSegmentsAreMatched($segments, ['country' => 'nl', 'deviceType' => 'mobile'])); + self::assertTrue($datafileReader->allSegmentsAreMatched($segments, ['country' => 'nl', 'deviceType' => 'desktop'])); + + $segments = ['not' => [[ 'or' => ['mobileUsers', 'desktopUsers'] ]]]; + self::assertFalse($datafileReader->allSegmentsAreMatched($segments, ['deviceType' => 'mobile'])); + self::assertTrue($datafileReader->allSegmentsAreMatched($segments, ['deviceType' => 'tv'])); + self::assertFalse($datafileReader->allSegmentsAreMatched(['not' => []], [])); } } diff --git a/tests/EmitterTest.php b/tests/EmitterTest.php index 7ae80a0..efd284f 100644 --- a/tests/EmitterTest.php +++ b/tests/EmitterTest.php @@ -38,4 +38,23 @@ public function testAddListenerForEvent() { $emitter->clearAll(); self::assertEquals([], $emitter->listeners); } + + public function testTriggerUsesListenerSnapshot() { + $emitter = new Emitter(); + $calls = []; + $unsubscribeSecond = function(): void {}; + + $emitter->on('sticky_set', function() use (&$calls, &$unsubscribeSecond) { + $calls[] = 'first'; + call_user_func($unsubscribeSecond); + }); + $unsubscribeSecond = $emitter->on('sticky_set', function() use (&$calls) { + $calls[] = 'second'; + }); + + $emitter->trigger('sticky_set'); + $emitter->trigger('sticky_set'); + + self::assertSame(['first', 'second', 'first'], $calls); + } } diff --git a/tests/FeaturevisorTest.php b/tests/FeaturevisorTest.php index a339692..8d30759 100644 --- a/tests/FeaturevisorTest.php +++ b/tests/FeaturevisorTest.php @@ -1595,9 +1595,89 @@ public function testShouldManageModulesAndDuplicateDiagnostics() self::assertSame('duplicate_module', $diagnostics[0]['code']); $removeModule(); + $sdk->addModule([ + 'name' => 'module-c', + 'close' => function() use (&$closeCalls) { + $closeCalls++; + }, + ]); + $sdk->removeModule('module-c'); + $sdk->close(); + + self::assertSame(3, $closeCalls); + } + + public function testShouldReportModuleCloseErrorsAndContinueCleanup() + { + $diagnostics = []; + $errors = []; + $closed = []; + + $sdk = Featurevisor::createInstance([ + 'logger' => Logger::create(['level' => LogLevel::ERROR]), + 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { + $diagnostics[] = $diagnostic; + }, + 'modules' => [ + [ + 'name' => 'first', + 'close' => function() use (&$closed) { + $closed[] = 'first'; + throw new \RuntimeException('first close failed'); + }, + ], + [ + 'name' => 'second', + 'close' => function() use (&$closed) { + $closed[] = 'second'; + }, + ], + ], + ]); + $sdk->on('error', function(array $event) use (&$errors) { + $errors[] = $event; + }); + $sdk->close(); - self::assertSame(2, $closeCalls); + self::assertSame(['first', 'second'], $closed); + self::assertTrue(count(array_filter($diagnostics, fn($diagnostic) => + ($diagnostic['code'] ?? null) === 'module_close_error' + && ($diagnostic['moduleName'] ?? null) === 'first' + && ($diagnostic['level'] ?? null) === 'error' + && ($diagnostic['originalError'] ?? null) instanceof \RuntimeException + )) > 0); + self::assertTrue(count(array_filter($errors, fn($event) => + ($event['code'] ?? null) === 'module_close_error' + && ($event['moduleName'] ?? null) === 'first' + )) > 0); + } + + public function testShouldReportModuleCloseErrorsFromUnsubscribeOnce() + { + $diagnostics = []; + + $sdk = Featurevisor::createInstance([ + 'logger' => Logger::create(['level' => LogLevel::ERROR]), + 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { + $diagnostics[] = $diagnostic; + }, + ]); + + $removeModule = $sdk->addModule([ + 'name' => 'dynamic', + 'close' => function() { + throw new \RuntimeException('dynamic close failed'); + }, + ]); + + $removeModule(); + $removeModule(); + + self::assertSame(1, count(array_filter($diagnostics, fn($diagnostic) => + ($diagnostic['code'] ?? null) === 'module_close_error' + && ($diagnostic['moduleName'] ?? null) === 'dynamic' + ))); } public function testShouldSupportModuleDiagnosticsSubscriptions() From b3feb28ca7d1ffcf03d6085903fc1161e5b9efa6 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 27 Jun 2026 23:07:35 +0200 Subject: [PATCH 04/15] updates --- src/Conditions.php | 6 +++--- src/Featurevisor.php | 2 +- tests/ConditionsTest.php | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Conditions.php b/src/Conditions.php index 4bb303e..97a02a2 100644 --- a/src/Conditions.php +++ b/src/Conditions.php @@ -83,10 +83,10 @@ public static function conditionIsMatched($condition, array $context, callable $ return false; } if (isset($condition['not'])) { - $notConditions = self::isSequentialArray($condition['not']) ? $condition['not'] : [$condition['not']]; - if (count($notConditions) === 0) { - return true; + if (is_array($condition['not']) && count($condition['not']) === 0) { + return false; } + $notConditions = self::isSequentialArray($condition['not']) ? $condition['not'] : [$condition['not']]; // JS SDK semantics: "not" negates the entire AND group. return !self::conditionIsMatched(['and' => $notConditions], $context, $getRegex); } diff --git a/src/Featurevisor.php b/src/Featurevisor.php index b308b20..280c74d 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -108,7 +108,7 @@ public function setDatafile($datafile, bool $replace = false): void $this->reportDiagnostic([ 'level' => 'error', 'code' => 'invalid_datafile', - 'message' => 'could not parse datafile', + 'message' => 'Could not parse datafile', 'error' => $e->getMessage(), ]); } diff --git a/tests/ConditionsTest.php b/tests/ConditionsTest.php index 74e138d..3e7eab8 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -3,6 +3,7 @@ namespace Featurevisor\Tests; use DateTime; +use Featurevisor\Conditions; use Featurevisor\Logger; use PHPUnit\Framework\TestCase; use Featurevisor\DatafileReader; @@ -292,6 +293,7 @@ public function testNotCondition() { self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'edge'])); self::assertFalse($this->datafileReader->allConditionsAreMatched([[ 'not' => [] ]], [])); + self::assertFalse(Conditions::conditionIsMatched(['not' => []], [], fn($regex, $flags) => '/' . $regex . '/' . $flags)); } public function testNestedConditions() { From ebd6700371f0e81f9d3a639b1c4d4b54aabbb4d7 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 27 Jun 2026 23:28:37 +0200 Subject: [PATCH 05/15] updates --- README.md | 2 -- featurevisor | 2 +- src/Events.php | 2 ++ src/Featurevisor.php | 1 + src/{ => Internal}/DatafileReader.php | 3 ++- tests/ConditionsTest.php | 2 +- tests/DatafileReaderTest.php | 2 +- tests/EventsTest.php | 2 +- 8 files changed, 9 insertions(+), 7 deletions(-) rename src/{ => Internal}/DatafileReader.php (99%) diff --git a/README.md b/README.md index fbbe222..5f33cc6 100644 --- a/README.md +++ b/README.md @@ -757,8 +757,6 @@ $ vendor/bin/featurevisor test \ If assertions include `target`, the runner builds and selects the corresponding Target datafile automatically via `npx featurevisor build --target= --environment= --json`. -Legacy `--with-scopes`, `--with-tags`, and schema-version flags are accepted for older scripts but ignored in v3. - ### Benchmark Learn more about benchmarking [here](https://featurevisor.com/docs/cli/#benchmarking). diff --git a/featurevisor b/featurevisor index 26c69d6..e40851e 100755 --- a/featurevisor +++ b/featurevisor @@ -3,8 +3,8 @@ require __DIR__ . '/vendor/autoload.php'; -use Featurevisor\DatafileReader; use Featurevisor\Featurevisor; +use Featurevisor\Internal\DatafileReader; use Featurevisor\Logger; use Psr\Log\LogLevel; diff --git a/src/Events.php b/src/Events.php index 2547ed3..f5db649 100644 --- a/src/Events.php +++ b/src/Events.php @@ -2,6 +2,8 @@ namespace Featurevisor; +use Featurevisor\Internal\DatafileReader; + class Events { public static function getParamsForStickySetEvent(array $previousStickyFeatures = [], array $newStickyFeatures = [], bool $replace = false): array diff --git a/src/Featurevisor.php b/src/Featurevisor.php index 280c74d..98965ba 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -3,6 +3,7 @@ namespace Featurevisor; use Closure; +use Featurevisor\Internal\DatafileReader; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; diff --git a/src/DatafileReader.php b/src/Internal/DatafileReader.php similarity index 99% rename from src/DatafileReader.php rename to src/Internal/DatafileReader.php index 17ff427..d2c10ce 100644 --- a/src/DatafileReader.php +++ b/src/Internal/DatafileReader.php @@ -1,7 +1,8 @@ Date: Sat, 27 Jun 2026 23:39:15 +0200 Subject: [PATCH 06/15] updates --- README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 5f33cc6..3e12297 100644 --- a/README.md +++ b/README.md @@ -459,6 +459,24 @@ $f = Featurevisor::createInstance([ Further log levels like `info` and `debug` will help you understand how the feature variations and variables are evaluated in the runtime against given context. +## Diagnostics + +Diagnostics are structured SDK messages for initialization, datafile updates, module reports, and errors. You can subscribe to them at initialization time: + +```php +$f = Featurevisor::createInstance([ + 'onDiagnostic' => function (array $diagnostic) { + $level = $diagnostic['level']; + $code = $diagnostic['code'] ?? null; + $message = $diagnostic['message'] ?? null; + + // send to your own observability system + }, +]); +``` + +If `onDiagnostic` is not provided, diagnostics are written through the configured logger. Error-level diagnostics also emit the SDK `error` event. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -555,24 +573,6 @@ And optionally these properties depending on whether you are evaluating a featur - `variableValue`: the variable value - `variableSchema`: the variable schema -## Diagnostics - -Diagnostics are structured SDK messages for initialization, datafile updates, module reports, and errors. You can subscribe to them at initialization time: - -```php -$f = Featurevisor::createInstance([ - 'onDiagnostic' => function (array $diagnostic) { - $level = $diagnostic['level']; - $code = $diagnostic['code'] ?? null; - $message = $diagnostic['message'] ?? null; - - // send to your own observability system - }, -]); -``` - -If `onDiagnostic` is not provided, diagnostics are written through the configured logger. Error-level diagnostics also emit the SDK `error` event. - ## Modules Modules allow you to intercept the evaluation process, report diagnostics, and customize behavior further as per your needs. From 8b8ca8c042e0b254bf2a4b4ab76e689d055677fe Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Tue, 7 Jul 2026 23:17:59 +0200 Subject: [PATCH 07/15] benchmark --- featurevisor | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/featurevisor b/featurevisor index e40851e..91523c0 100755 --- a/featurevisor +++ b/featurevisor @@ -117,7 +117,8 @@ function getTargets(string $featurevisorProjectPath): array { function buildSingleDatafile( string $featurevisorProjectPath, $environment, - ?string $target = null + ?string $target = null, + $inflate = null ): array { $command = "(cd " . escapeshellarg($featurevisorProjectPath) . " && npx featurevisor build --json"; @@ -129,6 +130,10 @@ function buildSingleDatafile( $command .= " --target=" . escapeshellarg($target); } + if ($inflate !== null && (int) $inflate > 0) { + $command .= " --inflate=" . escapeshellarg((string) $inflate); + } + $command .= ")"; $output = executeCommand($command); @@ -525,7 +530,12 @@ function benchmark(array $cliOptions) { $context = $cliOptions['context'] ? json_decode($cliOptions['context'], true) : []; $level = getLoggerLevel($cliOptions); - $datafile = buildSingleDatafile($featurevisorProjectPath, $cliOptions['environment']); + $datafile = buildSingleDatafile( + $featurevisorProjectPath, + $cliOptions['environment'], + null, + $cliOptions['inflate'] + ); $f = Featurevisor::createInstance([ 'datafile' => $datafile, @@ -548,8 +558,11 @@ function benchmark(array $cliOptions) { echo "Running $cliOptions[n] times..." . PHP_EOL; - $startTime = microtime(true); + $totalDuration = 0.0; + $minDuration = null; + $maxDuration = 0.0; for ($i = 0; $i < $cliOptions['n']; $i++) { + $evaluationStartTime = hrtime(true); if ($cliOptions['variation']) { $value = $f->getVariation($cliOptions['feature'], $context); } else if ($cliOptions['variable']) { @@ -557,13 +570,19 @@ function benchmark(array $cliOptions) { } else { $value = $f->isEnabled($cliOptions['feature'], $context); } + $evaluationDuration = (hrtime(true) - $evaluationStartTime) / 1000000000; + $totalDuration += $evaluationDuration; + $minDuration = $minDuration === null ? $evaluationDuration : min($minDuration, $evaluationDuration); + $maxDuration = max($maxDuration, $evaluationDuration); } - $duration = microtime(true) - $startTime; + $duration = $totalDuration; echo "Evaluated value: " . json_encode($value) . PHP_EOL; echo "Total duration: " . number_format($duration * 1000, 3) . "ms" . PHP_EOL; - echo "Average duration: " . number_format(($duration / $cliOptions['n']) * 1000, 3) . "ms" . PHP_EOL; + echo "Minimum duration: " . number_format(($minDuration ?? 0) * 1000, 6) . "ms" . PHP_EOL; + echo "Average duration: " . number_format(($duration / $cliOptions['n']) * 1000, 6) . "ms" . PHP_EOL; + echo "Maximum duration: " . number_format($maxDuration * 1000, 6) . "ms" . PHP_EOL; } /** From c797119c49a906d09f9ea3a03a8255623db62903 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 9 Jul 2026 21:52:51 +0200 Subject: [PATCH 08/15] updates --- phpunit.xml | 3 +-- src/Featurevisor.php | 20 +++++++++++++++----- tests/FeaturevisorTest.php | 25 +++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index 7d30377..60f4e36 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,8 +3,7 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" colors="true" processIsolation="false" - stopOnFailure="false" - cacheDirectory=".phpunit.cache"> + stopOnFailure="false"> tests diff --git a/src/Featurevisor.php b/src/Featurevisor.php index 98965ba..f50e041 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -101,7 +101,7 @@ public function setDatafile($datafile, bool $replace = false): void $this->reportDiagnostic([ 'level' => 'info', 'code' => 'datafile_set', - 'message' => 'datafile set', + 'message' => 'Datafile set', 'details' => $details, ]); $this->emitter->trigger('datafile_set', $details); @@ -134,7 +134,12 @@ public function setSticky(array $sticky, bool $replace = false): void $params = Events::getParamsForStickySetEvent($previousStickyFeatures, $this->sticky, $replace); - $this->logger->info('sticky features set', $params); + $this->reportDiagnostic([ + 'level' => 'info', + 'code' => 'sticky_set', + 'message' => 'Sticky features set', + 'details' => $params, + ]); $this->emitter->trigger('sticky_set', $params); } @@ -213,9 +218,14 @@ public function setContext(array $context, bool $replace = false): void 'replaced' => $replace ]); - $this->logger->debug($replace ? 'context replaced' : 'context updated', [ - 'context' => $this->context, - 'replaced' => $replace + $this->reportDiagnostic([ + 'level' => 'debug', + 'code' => 'context_set', + 'message' => $replace ? 'Context replaced' : 'Context updated', + 'details' => [ + 'context' => $this->context, + 'replaced' => $replace, + ], ]); } diff --git a/tests/FeaturevisorTest.php b/tests/FeaturevisorTest.php index 8d30759..6afffa7 100644 --- a/tests/FeaturevisorTest.php +++ b/tests/FeaturevisorTest.php @@ -23,6 +23,31 @@ public function testShouldCreateInstanceWithDatafileContent() self::assertTrue(method_exists($sdk, 'getVariation')); } + public function testShouldReportLifecycleMutationDiagnostics() + { + $diagnostics = []; + $sdk = Featurevisor::createInstance([ + 'logger' => Logger::create(['level' => LogLevel::DEBUG]), + 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { + $diagnostics[] = $diagnostic; + }, + ]); + + $sdk->setDatafile([ + 'schemaVersion' => '2', + 'revision' => '1', + 'segments' => [], + 'features' => [], + ]); + $sdk->setSticky(['test' => ['enabled' => true]]); + $sdk->setContext(['country' => 'nl']); + + $codes = array_column($diagnostics, 'code'); + self::assertContains('datafile_set', $codes); + self::assertContains('sticky_set', $codes); + self::assertContains('context_set', $codes); + } + public function testShouldCreateInstanceWithLogLevel() { $logs = []; From 409ca81d459cc90ad038ea064f7be4b9e3bb8e6c Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 9 Jul 2026 22:59:28 +0200 Subject: [PATCH 09/15] diagnostics --- README.md | 4 +++ src/Child.php | 22 +++++++-------- src/Featurevisor.php | 65 ++++++++++++++++++-------------------------- 3 files changed, 41 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 3e12297..f7b172d 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,8 @@ This is handy especially when you want to pass all evaluations from a backend ap For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): +Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `spawn($context, ['sticky' => ...])` when a child needs its own sticky state. + ### Initialize with sticky ```php @@ -477,6 +479,8 @@ $f = Featurevisor::createInstance([ If `onDiagnostic` is not provided, diagnostics are written through the configured logger. Error-level diagnostics also emit the SDK `error` event. +Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` array. Optional `module`, `moduleName`, and `originalError` fields describe provenance; evaluation metadata belongs in `details`. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. diff --git a/src/Child.php b/src/Child.php index f436b1c..5517657 100644 --- a/src/Child.php +++ b/src/Child.php @@ -70,7 +70,7 @@ public function isEnabled(string $featureKey, array $context = [], array $option return $this->parent->isEnabled( $featureKey, array_merge($this->context, $context), - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } @@ -79,7 +79,7 @@ public function getVariation(string $featureKey, array $context = [], array $opt return $this->parent->getVariation( $featureKey, array_merge($this->context, $context), - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } @@ -89,7 +89,7 @@ public function getVariable(string $featureKey, string $variableKey, array $cont $featureKey, $variableKey, array_merge($this->context, $context), - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } @@ -99,7 +99,7 @@ public function getVariableBoolean(string $featureKey, string $variableKey, arra $featureKey, $variableKey, array_merge($this->context, $context), - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } @@ -109,7 +109,7 @@ public function getVariableString(string $featureKey, string $variableKey, array $featureKey, $variableKey, array_merge($this->context, $context), - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } @@ -119,7 +119,7 @@ public function getVariableInteger(string $featureKey, string $variableKey, arra $featureKey, $variableKey, array_merge($this->context, $context), - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } @@ -129,7 +129,7 @@ public function getVariableDouble(string $featureKey, string $variableKey, array $featureKey, $variableKey, array_merge($this->context, $context), - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } @@ -139,7 +139,7 @@ public function getVariableArray(string $featureKey, string $variableKey, array $featureKey, $variableKey, array_merge($this->context, $context), - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } @@ -149,7 +149,7 @@ public function getVariableObject(string $featureKey, string $variableKey, array $featureKey, $variableKey, array_merge($this->context, $context), - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } @@ -159,7 +159,7 @@ public function getVariableJSON(string $featureKey, string $variableKey, array $ $featureKey, $variableKey, array_merge($this->context, $context), - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } @@ -168,7 +168,7 @@ public function getAllEvaluations(array $context = [], array $featureKeys = [], return $this->parent->getAllEvaluations( array_merge($this->context, $context), $featureKeys, - array_merge(['sticky' => $this->sticky], $options) + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) ); } } diff --git a/src/Featurevisor.php b/src/Featurevisor.php index f50e041..ee3b117 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -283,6 +283,15 @@ public function reportDiagnostic(array $diagnostic, ?array $sourceModule = null) if ($sourceModule && isset($sourceModule['name']) && !isset($diagnostic['moduleName'])) { $diagnostic['moduleName'] = $sourceModule['name']; } + $details = is_array($diagnostic['details'] ?? null) ? $diagnostic['details'] : []; + $reservedKeys = ['level', 'code', 'message', 'module', 'moduleName', 'originalError', 'details']; + foreach ($diagnostic as $key => $value) { + if (!in_array($key, $reservedKeys, true)) { + $details[$key] = $value; + unset($diagnostic[$key]); + } + } + $diagnostic['details'] = $details; foreach ($this->moduleDiagnosticSubscriptions as $subscription) { if ($sourceModule && ($subscription['moduleId'] ?? null) === ($sourceModule['id'] ?? null)) { @@ -301,12 +310,10 @@ public function reportDiagnostic(array $diagnostic, ?array $sourceModule = null) if ($this->onDiagnostic) { ($this->onDiagnostic)($diagnostic); } else { - $context = $diagnostic['details'] ?? $diagnostic; - unset($context['level'], $context['message']); $this->logger->log( $this->normalizeLogLevel($diagnostic['level']), $diagnostic['message'] ?? ($diagnostic['code'] ?? 'diagnostic'), - $context + $diagnostic ); } } @@ -377,7 +384,7 @@ public function getContext(array $context = []): array /** * @param array $context * @param array{ - * sticky?: array + * __featurevisorChildSticky?: array * } $options * @return Child */ @@ -396,20 +403,13 @@ public function spawn(array $context = [], array $options = []): Child * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, * flagEvaluation?: array, - * sticky?: array + * __featurevisorChildSticky?: array * } $options * @return array */ private function getEvaluationDependencies(array $context, array $options = []): array { - $sticky = $this->sticky; - if (isset($options['sticky'])) { - if ($this->sticky && is_array($this->sticky) && is_array($options['sticky'])) { - $sticky = array_merge($this->sticky, $options['sticky']); - } else { - $sticky = $options['sticky']; - } - } + $sticky = $options['__featurevisorChildSticky'] ?? $this->sticky; return array_merge($options, [ 'context' => $this->getContext($context), 'logger' => $this->logger, @@ -428,7 +428,7 @@ private function getEvaluationDependencies(array $context, array $options = []): * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, * flagEvaluation?: array, - * sticky?: array + * __featurevisorChildSticky?: array * } $options * @return array{ * type: string, @@ -455,8 +455,7 @@ public function evaluateFlag(string $featureKey, array $context = [], array $opt * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options */ public function isEnabled(string $featureKey, array $context = [], array $options = []): bool @@ -471,8 +470,7 @@ public function isEnabled(string $featureKey, array $context = [], array $option * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options * @return array{ * type: string, @@ -500,8 +498,7 @@ public function evaluateVariation(string $featureKey, array $context = [], array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options * @return mixed|null */ @@ -535,8 +532,7 @@ public function getVariation(string $featureKey, array $context = [], array $opt * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options * @return array{ * type: string, @@ -564,8 +560,7 @@ public function evaluateVariable(string $featureKey, string $variableKey, array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options * @return mixed|null */ @@ -605,8 +600,7 @@ public function getVariable(string $featureKey, string $variableKey, array $cont * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options */ public function getVariableBoolean(string $featureKey, string $variableKey, array $context = [], array $options = []): ?bool @@ -620,8 +614,7 @@ public function getVariableBoolean(string $featureKey, string $variableKey, arra * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options */ public function getVariableString(string $featureKey, string $variableKey, array $context = [], array $options = []): ?string @@ -635,8 +628,7 @@ public function getVariableString(string $featureKey, string $variableKey, array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options */ public function getVariableInteger(string $featureKey, string $variableKey, array $context = [], array $options = []): ?int @@ -650,8 +642,7 @@ public function getVariableInteger(string $featureKey, string $variableKey, arra * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options */ public function getVariableDouble(string $featureKey, string $variableKey, array $context = [], array $options = []): ?float @@ -665,8 +656,7 @@ public function getVariableDouble(string $featureKey, string $variableKey, array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options */ public function getVariableArray(string $featureKey, string $variableKey, array $context = [], array $options = []): ?array @@ -680,8 +670,7 @@ public function getVariableArray(string $featureKey, string $variableKey, array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options */ public function getVariableObject(string $featureKey, string $variableKey, array $context = [], array $options = []) @@ -695,8 +684,7 @@ public function getVariableObject(string $featureKey, string $variableKey, array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, - * sticky?: array + * flagEvaluation?: array * } $options * @return array|mixed|null */ @@ -723,7 +711,6 @@ public function getVariableJSON(string $featureKey, string $variableKey, array $ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, * flagEvaluation?: array, - * sticky?: array * } $options * @return array */ From ae26f891705791c74881c6ac1f3adf49aa588da2 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 11 Jul 2026 18:46:10 +0200 Subject: [PATCH 10/15] improvements --- README.md | 8 +++++- conformance/sdk-v3.json | 49 ++++++++++++++++++++++++++++++++ src/Featurevisor.php | 14 +++++++-- src/Helpers.php | 14 +++++++-- src/ModulesManager.php | 19 +++++++++++-- tests/DatafileReaderTest.php | 15 +++++++++- tests/DiagnosticContractTest.php | 35 +++++++++++++++++++++++ tests/HelpersTest.php | 6 ++-- tests/ModulesManagerTest.php | 42 +++++++++++++++++++++++++++ 9 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 conformance/sdk-v3.json create mode 100644 tests/DiagnosticContractTest.php create mode 100644 tests/ModulesManagerTest.php diff --git a/README.md b/README.md index f7b172d..dad3ba3 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,8 @@ $f->getVariableObject($featureKey, $variableKey, $context = []); $f->getVariableJSON($featureKey, $variableKey, $context = []); ``` +Type specific methods do not coerce values. `getVariableInteger()` returns `null` for the string `"1"`, and boolean getters return `null` for non-boolean values. + ## Getting all evaluations You can get evaluations of all features available in the SDK instance: @@ -479,7 +481,9 @@ $f = Featurevisor::createInstance([ If `onDiagnostic` is not provided, diagnostics are written through the configured logger. Error-level diagnostics also emit the SDK `error` event. -Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` array. Optional `module`, `moduleName`, and `originalError` fields describe provenance; evaluation metadata belongs in `details`. +Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` value. Optional `module`, `moduleName`, and `originalError` fields describe provenance; evaluation metadata belongs in `details`. + +Diagnostic handlers are isolated from SDK behavior. An exception in a handler does not stop other handlers or evaluations. ## Events @@ -585,6 +589,8 @@ Modules allow you to intercept the evaluation process, report diagnostics, and c A module is a simple object with a unique required `name` and optional functions: +If `setup` throws, the module is not registered. Featurevisor removes subscriptions created during setup, reports `module_setup_error`, and calls `close` when present. + ```php $myCustomModule = [ // only required property diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json new file mode 100644 index 0000000..ceae692 --- /dev/null +++ b/conformance/sdk-v3.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "description": "Featurevisor v3 cross SDK compatibility contracts", + "bucketing": { + "minimum": 0, + "maximum": 100000, + "percentage": { + "percentage": 50000, + "enabledAt": [0, 50000], + "disabledAt": [50001, 100000] + }, + "allocations": [ + { "variation": "control", "range": [0, 50000] }, + { "variation": "treatment", "range": [50000, 100000] } + ], + "allocationExpectations": { + "0": "control", + "49999": "control", + "50000": "control", + "50001": "treatment", + "99999": "treatment", + "100000": "treatment" + } + }, + "regularExpressions": { + "pattern": "chrome", + "flags": "g", + "values": ["chrome", "chrome", "firefox", "chrome"], + "matches": [true, true, false, true] + }, + "typedVariables": [ + { "type": "integer", "value": 1, "valid": true }, + { "type": "integer", "value": 1.5, "valid": false }, + { "type": "integer", "value": "1", "valid": false }, + { "type": "double", "value": 1.5, "valid": true }, + { "type": "double", "value": "1.5", "valid": false }, + { "type": "boolean", "value": true, "valid": true }, + { "type": "boolean", "value": "true", "valid": false } + ], + "datafile": { + "schemaVersionIsInformational": true, + "schemaVersionType": "string" + }, + "diagnostics": { + "requiredFields": ["level", "code", "message", "details"], + "detailsType": "object", + "emptyDetailsJson": "{}" + } +} diff --git a/src/Featurevisor.php b/src/Featurevisor.php index ee3b117..e67f56b 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -291,7 +291,7 @@ public function reportDiagnostic(array $diagnostic, ?array $sourceModule = null) unset($diagnostic[$key]); } } - $diagnostic['details'] = $details; + $diagnostic['details'] = $details === [] ? (object) [] : $details; foreach ($this->moduleDiagnosticSubscriptions as $subscription) { if ($sourceModule && ($subscription['moduleId'] ?? null) === ($sourceModule['id'] ?? null)) { @@ -302,13 +302,21 @@ public function reportDiagnostic(array $diagnostic, ?array $sourceModule = null) continue; } - ($subscription['handler'])($diagnostic); + try { + ($subscription['handler'])($diagnostic); + } catch (\Throwable $error) { + error_log('[Featurevisor] Diagnostic handler failed: '.$error->getMessage()); + } } $instanceLevel = method_exists($this->logger, 'getLevel') ? $this->logger->getLevel() : Logger::DEFAULT_LEVEL; if ($this->levelAllows($diagnostic['level'], $instanceLevel)) { if ($this->onDiagnostic) { - ($this->onDiagnostic)($diagnostic); + try { + ($this->onDiagnostic)($diagnostic); + } catch (\Throwable $error) { + error_log('[Featurevisor] Diagnostic handler failed: '.$error->getMessage()); + } } else { $this->logger->log( $this->normalizeLogLevel($diagnostic['level']), diff --git a/src/Helpers.php b/src/Helpers.php index 274ca74..8d59dbd 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -15,11 +15,19 @@ public static function getValueByType($value, string $fieldType) case 'string': return is_string($value) ? $value : null; case 'integer': - return intval($value); + if (is_int($value)) { + return $value; + } + if (is_float($value) && is_finite($value) && floor($value) === $value) { + return (int) $value; + } + return null; case 'double': - return floatval($value); + return (is_int($value) || is_float($value)) && is_finite((float) $value) + ? (float) $value + : null; case 'boolean': - return $value === true; + return is_bool($value) ? $value : null; case 'array': return is_array($value) ? $value : null; case 'object': diff --git a/src/ModulesManager.php b/src/ModulesManager.php index 920d80a..1200705 100644 --- a/src/ModulesManager.php +++ b/src/ModulesManager.php @@ -73,8 +73,23 @@ public function add(array $module): ?callable } if (isset($module['setup']) && is_callable($module['setup']) && $this->moduleApiFactory) { - $api = ($this->moduleApiFactory)($module); - $module['setup']($api); + try { + $api = ($this->moduleApiFactory)($module); + $module['setup']($api); + } catch (\Throwable $error) { + if ($this->clearModuleDiagnosticSubscriptions) { + ($this->clearModuleDiagnosticSubscriptions)($module); + } + $this->report([ + 'level' => 'error', + 'code' => 'module_setup_error', + 'message' => 'Module setup failed', + 'moduleName' => $module['name'] ?? null, + 'originalError' => $error, + ], null); + $this->closeModule($module); + return null; + } } $this->modules[] = $module; diff --git a/tests/DatafileReaderTest.php b/tests/DatafileReaderTest.php index b3644d2..2bc92c5 100644 --- a/tests/DatafileReaderTest.php +++ b/tests/DatafileReaderTest.php @@ -8,6 +8,19 @@ class DatafileReaderTest extends TestCase { + public function testSharedV3ConformanceFixture(): void + { + $fixture = json_decode(file_get_contents(__DIR__.'/../conformance/sdk-v3.json'), true, 512, JSON_THROW_ON_ERROR); + self::assertSame(1, $fixture['version']); + + $reader = DatafileReader::createEmpty(Logger::create(['level' => 'emergency'])); + $traffic = ['allocation' => $fixture['bucketing']['allocations']]; + foreach ($fixture['bucketing']['allocationExpectations'] as $bucket => $expected) { + $allocation = $reader->getMatchedAllocation($traffic, (int) $bucket); + self::assertSame($expected, $allocation['variation']); + } + } + public function testV2DatafileSchemaEntities() { $datafileJson = [ 'schemaVersion' => '2', @@ -128,7 +141,7 @@ public function testSegmentsMatching() { self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], [])); self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'mobile'])); // dutchMobileUsers2 (same as above) - $group = $groups[1]; + $group = $groups[2]; self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile'])); self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile', 'browser' => 'chrome'])); self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], [])); diff --git a/tests/DiagnosticContractTest.php b/tests/DiagnosticContractTest.php new file mode 100644 index 0000000..dfafb82 --- /dev/null +++ b/tests/DiagnosticContractTest.php @@ -0,0 +1,35 @@ + 'info', + 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { + $diagnostics[] = $diagnostic; + }, + ]); + + $encoded = json_encode($diagnostics[0], JSON_THROW_ON_ERROR); + self::assertStringContainsString('"details":{}', $encoded); + } + + public function testDiagnosticHandlerFailureIsIsolated(): void + { + $sdk = Featurevisor::createInstance([ + 'onDiagnostic' => static function (): void { + throw new \RuntimeException('handler failed'); + }, + ]); + + self::assertFalse($sdk->isEnabled('missing', [])); + $sdk->close(); + } +} diff --git a/tests/HelpersTest.php b/tests/HelpersTest.php index 91437e4..41d836e 100644 --- a/tests/HelpersTest.php +++ b/tests/HelpersTest.php @@ -18,8 +18,10 @@ public function testShouldResolveSupportedTypes() self::assertSame(true, Helpers::getValueByType(true, 'boolean')); self::assertSame(['a' => 1], Helpers::getValueByType(['a' => 1], 'object')); self::assertSame(['1', '2'], Helpers::getValueByType(['1', '2'], 'array')); - self::assertSame(1, Helpers::getValueByType('1', 'integer')); - self::assertSame(1.1, Helpers::getValueByType('1.1', 'double')); + self::assertNull(Helpers::getValueByType('1', 'integer')); + self::assertNull(Helpers::getValueByType(1.1, 'integer')); + self::assertNull(Helpers::getValueByType('1.1', 'double')); + self::assertNull(Helpers::getValueByType('true', 'boolean')); self::assertSame(['x' => 1], Helpers::getValueByType(['x' => 1], 'json')); } diff --git a/tests/ModulesManagerTest.php b/tests/ModulesManagerTest.php new file mode 100644 index 0000000..7145761 --- /dev/null +++ b/tests/ModulesManagerTest.php @@ -0,0 +1,42 @@ + [], + static function (array $module) use (&$cleared): void { + $cleared++; + } + ); + + $unsubscribe = $manager->add([ + 'name' => 'broken-setup', + 'setup' => static function (): void { + throw new \RuntimeException('setup failed'); + }, + 'close' => static function () use (&$closed): void { + $closed++; + }, + ]); + + self::assertNull($unsubscribe); + self::assertSame([], $manager->getAll()); + self::assertSame(1, $closed); + self::assertSame(1, $cleared); + self::assertSame('module_setup_error', $diagnostics[0]['code']); + } +} From f4717741354342629bd11c5650b117bbc425d01c Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 12:34:17 +0200 Subject: [PATCH 11/15] cli alignment --- README.md | 2 ++ featurevisor | 66 ++++++++++++++++++++++++++++++++--- tests/FeaturevisorCliTest.php | 8 +++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dad3ba3..901327e 100644 --- a/README.md +++ b/README.md @@ -746,6 +746,8 @@ $f->close(); This package also provides a CLI tool for running your Featurevisor project's test specs and benchmarking against this PHP SDK: +All three commands accept repeatable `--target=` options. `test` builds only the selected Target datafiles and runs untargeted assertions plus assertions for those targets. `benchmark` and `assess-distribution` run independently against every selected Target datafile. Without `--target`, existing project-wide behavior is preserved. Project definitions, test specs, Target discovery, and datafile generation continue to come from the Node.js CLI. + ### Test Learn more about testing [here](https://featurevisor.com/docs/testing/). diff --git a/featurevisor b/featurevisor index 91523c0..fd161c9 100755 --- a/featurevisor +++ b/featurevisor @@ -28,6 +28,20 @@ function parseCliOption(array $argv, string $key) { return null; } +function parseCliOptions(array $argv, string $key): array { + $prefix = '--' . $key . '='; + $values = []; + foreach ($argv as $arg) { + if (strpos($arg, $prefix) === 0) { + $value = substr($arg, strlen($prefix)); + if ($value !== '' && !in_array($value, $values, true)) { + $values[] = $value; + } + } + } + return $values; +} + $cwd = getcwd(); $argv = $_SERVER['argv'] ?? []; @@ -56,6 +70,7 @@ $cliOptions = [ } return $acc; }, []), + 'targets' => parseCliOptions($argv, 'target'), ]; if (parseCliOption($argv, 'rootDirectoryPath')) { @@ -69,7 +84,11 @@ if (parseCliOption($argv, 'rootDirectoryPath')) { */ function executeCommand(string $command): string { $output = []; - exec($command, $output); + $exitCode = 0; + exec($command . ' 2>&1', $output, $exitCode); + if ($exitCode !== 0) { + throw new RuntimeException("Command failed with exit code $exitCode: " . implode("\n", $output)); + } return implode("\n", $output); } @@ -412,7 +431,8 @@ function test(array $cliOptions) { $config = getConfig($featurevisorProjectPath); $segmentsByKey = getSegments($featurevisorProjectPath); - $targets = getTargets($featurevisorProjectPath); + $availableTargets = getTargets($featurevisorProjectPath); + $targets = count($cliOptions['targets']) > 0 ? $cliOptions['targets'] : $availableTargets; $datafilesByKey = buildDatafiles($featurevisorProjectPath, $config, $targets); echo PHP_EOL; @@ -433,6 +453,14 @@ function test(array $cliOptions) { foreach ($tests as $test) { $testKey = $test['key']; $assertions = $test["assertions"]; + if (isset($test['feature']) && count($cliOptions['targets']) > 0) { + $assertions = array_values(array_filter($assertions, function ($assertion) use ($cliOptions) { + return !isset($assertion['target']) || in_array($assertion['target'], $cliOptions['targets'], true); + })); + if (count($assertions) === 0) { + continue; + } + } $results = ""; $testHasError = false; $testDuration = 0; @@ -527,13 +555,22 @@ function benchmark(array $cliOptions) { return; } + if (count($cliOptions['targets']) > 1) { + foreach ($cliOptions['targets'] as $target) { + $selected = $cliOptions; + $selected['targets'] = [$target]; + benchmark($selected); + } + return; + } + $context = $cliOptions['context'] ? json_decode($cliOptions['context'], true) : []; $level = getLoggerLevel($cliOptions); $datafile = buildSingleDatafile( $featurevisorProjectPath, $cliOptions['environment'], - null, + $cliOptions['targets'][0] ?? null, $cliOptions['inflate'] ); @@ -546,6 +583,12 @@ function benchmark(array $cliOptions) { $value = null; + echo PHP_EOL . "Benchmark Featurevisor feature" . PHP_EOL; + echo " Feature: $cliOptions[feature]" . PHP_EOL; + echo " Environment: $cliOptions[environment]" . PHP_EOL; + if (isset($cliOptions['targets'][0])) echo " Target: " . $cliOptions['targets'][0] . PHP_EOL; + echo " Iterations: $cliOptions[n]" . PHP_EOL; + if ($cliOptions['variation']) { echo "Benchmarking variation for feature '$cliOptions[feature]'..." . PHP_EOL; } else if ($cliOptions['variable']) { @@ -601,10 +644,19 @@ function assessDistribution(array $cliOptions) { return; } + if (count($cliOptions['targets']) > 1) { + foreach ($cliOptions['targets'] as $target) { + $selected = $cliOptions; + $selected['targets'] = [$target]; + assessDistribution($selected); + } + return; + } + $context = $cliOptions['context'] ? json_decode($cliOptions['context'], true) : []; $populateUuid = $cliOptions['populateUuid']; - $datafile = buildSingleDatafile($featurevisorProjectPath, $cliOptions['environment']); + $datafile = buildSingleDatafile($featurevisorProjectPath, $cliOptions['environment'], $cliOptions['targets'][0] ?? null, $cliOptions['inflate']); $level = getLoggerLevel($cliOptions); $f = Featurevisor::createInstance([ @@ -616,6 +668,12 @@ function assessDistribution(array $cliOptions) { $value = null; + echo PHP_EOL . "Assess Featurevisor distribution" . PHP_EOL; + echo " Feature: $cliOptions[feature]" . PHP_EOL; + echo " Environment: $cliOptions[environment]" . PHP_EOL; + if (isset($cliOptions['targets'][0])) echo " Target: " . $cliOptions['targets'][0] . PHP_EOL; + echo " Iterations: $cliOptions[n]" . PHP_EOL; + if ($cliOptions['variation']) { echo "Assessing distribution for feature '$cliOptions[feature]'..." . PHP_EOL; } else if ($cliOptions['variable']) { diff --git a/tests/FeaturevisorCliTest.php b/tests/FeaturevisorCliTest.php index 2febf09..03336b6 100644 --- a/tests/FeaturevisorCliTest.php +++ b/tests/FeaturevisorCliTest.php @@ -6,6 +6,14 @@ class FeaturevisorCliTest extends TestCase { + public function testRepeatedTargetOptions() + { + self::assertSame( + ['web', 'mobile'], + \parseCliOptions(['featurevisor', 'test', '--target=web', '--target=mobile', '--target=web'], 'target') + ); + } + public static function setUpBeforeClass(): void { if (!defined('FEATUREVISOR_CLI_TEST')) { From 4efa347e051cd9b4ee276bf2bbb3db9bafade983 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 18:30:40 +0200 Subject: [PATCH 12/15] alignment --- README.md | 109 ++++++++++------------------- featurevisor | 6 +- src/Featurevisor.php | 65 ++++++++++++++++-- src/Logger.php | 17 ++++- tests/ChildTest.php | 2 +- tests/DiagnosticContractTest.php | 4 +- tests/FeaturevisorTest.php | 113 +++++++++++++++---------------- 7 files changed, 171 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index 901327e..fb05677 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje ## Table of contents - [Installation](#installation) +- [Public API](#public-api) - [Initialization](#initialization) - [Evaluation types](#evaluation-types) - [Context](#context) @@ -27,17 +28,15 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [Replacing](#replacing) - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) -- [Logging](#logging) +- [Evaluation details](#evaluation-details) +- [Diagnostics](#diagnostics) - [Levels](#levels) - - [Customizing levels](#customizing-levels) - [Handler](#handler) - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) - [`error`](#error) -- [Evaluation details](#evaluation-details) -- [Diagnostics](#diagnostics) - [Modules](#modules) - [Defining a module](#defining-a-module) - [Registering modules](#registering-modules) @@ -63,6 +62,20 @@ In your PHP application, install the SDK using [Composer](https://getcomposer.or $ composer require featurevisor/featurevisor-php ``` +## Public API + +The main runtime API is `Featurevisor::createFeaturevisor()`: + +```php +use Featurevisor\Featurevisor; + +$f = Featurevisor::createFeaturevisor([ + "datafile" => $datafileContent, +]); +``` + +Most applications only need this factory and the returned `Featurevisor` instance. Public extension and observability APIs include modules, diagnostics, events, and the datafile arrays accepted by the factory. + ## Initialization The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: @@ -77,7 +90,7 @@ $datafileUrl = "https://cdn.yoursite.com/datafile.json"; $datafileContent = file_get_contents($datafileUrl); $datafileContent = json_decode($datafileContent, true); -$f = Featurevisor::createInstance([ +$f = Featurevisor::createFeaturevisor([ "datafile" => $datafileContent ]); ``` @@ -117,7 +130,7 @@ You can set context at the time of initialization: ```php use Featurevisor\Featurevisor; -$f = Featurevisor::createInstance([ +$f = Featurevisor::createFeaturevisor([ "context" => [ "deviceId" => "123", "country" => "nl", @@ -292,7 +305,7 @@ Sticky values belong to an SDK or child instance. Evaluation options do not acce ```php use Featurevisor\Featurevisor; -$f = Featurevisor::createInstance([ +$f = Featurevisor::createFeaturevisor([ "sticky" => [ "myFeatureKey" => [ "enabled" => true, @@ -370,7 +383,7 @@ Because merging is the default, a single SDK instance can start with a small dat This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: ```php -$f = Featurevisor::createInstance([]); +$f = Featurevisor::createFeaturevisor([]); function loadDatafile($f, string $target): void { $url = "https://cdn.yoursite.com/production/featurevisor-$target.json"; @@ -397,94 +410,42 @@ The triggers for setting the datafile again can be: - a specific event in your application (like a user action), or - an event served via websocket or server-sent events (SSE) -## Logging +## Diagnostics -By default, Featurevisor SDKs will print out logs to the console for `info` level and above. -Featurevisor PHP-SDK by default uses [PSR-3 standard](https://www.php-fig.org/psr/psr-3/) simple implementation. -You can also choose from many mature implementations like e.g. [Monolog](https://github.com/Seldaek/monolog) +By default, Featurevisor reports diagnostics to the console for `info` level and above with a `[Featurevisor]` prefix. ### Levels -These are all the available log levels: - -- `error` -- `warning` -- `info` -- `debug` +Available diagnostic levels are `fatal`, `error`, `warn`, `info`, and `debug`. -### Customizing levels - -If you choose `debug` level to make the logs more verbose, you can set it at the time of SDK initialization. - -Setting `debug` level will print out all logs, including `info`, `warning`, and `error` levels. +Set the level during initialization or update it afterwards: ```php -use Featurevisor\Featurevisor; -use Featurevisor\Logger; - -$f = Featurevisor::createInstance([ - "logger" => Logger::create([ - "level" => "debug", - ]), -]); -``` - -Alternatively, you can also set `logLevel` directly: - -```php -$f = Featurevisor::createInstance([ +$f = Featurevisor::createFeaturevisor([ "logLevel" => "debug", ]); -``` -You can also set log level from SDK instance afterwards: - -```php -$f->setLogLevel("debug"); +$f->setLogLevel("info"); ``` ### Handler -You can also pass your own log handler, if you do not wish to print the logs to the console: +Use `onDiagnostic` to send structured diagnostics to your observability system: ```php -use Featurevisor\Featurevisor; -use Featurevisor\Logger; - -$f = Featurevisor::createInstance([ - "logger" => Logger::create([ - "level" => "info", - "handler" => function ($level, $message, $details) { - // do something with the log - }, - ]), -]); -``` - -Further log levels like `info` and `debug` will help you understand how the feature variations and variables are evaluated in the runtime against given context. - -## Diagnostics - -Diagnostics are structured SDK messages for initialization, datafile updates, module reports, and errors. You can subscribe to them at initialization time: - -```php -$f = Featurevisor::createInstance([ - 'onDiagnostic' => function (array $diagnostic) { - $level = $diagnostic['level']; - $code = $diagnostic['code'] ?? null; - $message = $diagnostic['message'] ?? null; - - // send to your own observability system +$f = Featurevisor::createFeaturevisor([ + "logLevel" => "info", + "onDiagnostic" => function (array $diagnostic) { + // send $diagnostic to your observability system }, ]); ``` -If `onDiagnostic` is not provided, diagnostics are written through the configured logger. Error-level diagnostics also emit the SDK `error` event. - -Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` value. Optional `module`, `moduleName`, and `originalError` fields describe provenance; evaluation metadata belongs in `details`. +Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` value. Optional `module`, `moduleName`, and `originalError` fields describe provenance. Evaluation metadata belongs in `details`. Diagnostic handlers are isolated from SDK behavior. An exception in a handler does not stop other handlers or evaluations. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -675,7 +636,7 @@ You can register modules at the time of SDK initialization: ```php use Featurevisor\Featurevisor; -$f = Featurevisor::createInstance([ +$f = Featurevisor::createFeaturevisor([ 'modules' => [ $myCustomModule ], diff --git a/featurevisor b/featurevisor index fd161c9..b4b1b4a 100755 --- a/featurevisor +++ b/featurevisor @@ -479,7 +479,7 @@ function test(array $cliOptions) { 'duration' => 0 ]; } else { - $f = Featurevisor::createInstance([ + $f = Featurevisor::createFeaturevisor([ 'datafile' => $datafile, 'logger' => Logger::create([ 'level' => $level, @@ -574,7 +574,7 @@ function benchmark(array $cliOptions) { $cliOptions['inflate'] ); - $f = Featurevisor::createInstance([ + $f = Featurevisor::createFeaturevisor([ 'datafile' => $datafile, 'logger' => Logger::create([ 'level' => $level, @@ -659,7 +659,7 @@ function assessDistribution(array $cliOptions) { $datafile = buildSingleDatafile($featurevisorProjectPath, $cliOptions['environment'], $cliOptions['targets'][0] ?? null, $cliOptions['inflate']); $level = getLoggerLevel($cliOptions); - $f = Featurevisor::createInstance([ + $f = Featurevisor::createFeaturevisor([ 'datafile' => $datafile, 'logger' => Logger::create([ 'level' => $level, diff --git a/src/Featurevisor.php b/src/Featurevisor.php index e67f56b..059e45a 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -25,7 +25,6 @@ class Featurevisor * @param array{ * datafile?: string|array, * logLevel?: LogLevel::*|string, - * logger?: LoggerInterface, * context?: array, * sticky?: array, * modules?: array $options */ - public function __construct(array $options = []) + private function __construct(array $options = []) { - $this->logger = $options['logger'] ?? Logger::create([ + $this->logger = Logger::create([ 'level' => $options['logLevel'] ?? Logger::DEFAULT_LEVEL, + 'handler' => function (string $level, string $message, ?array $details = null): void { + $details = $details ?? []; + $message = preg_replace('/^\[Featurevisor\]\s*/', '', $message); + if ($level === LogLevel::WARNING) { + $level = 'warn'; + } elseif (in_array($level, [LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL], true)) { + $level = 'fatal'; + } + $code = isset($details['reason']) ? (string) $details['reason'] : $message; + if ($message === 'feature is deprecated') { + $code = 'deprecated_feature'; + } elseif ($message === 'variable is deprecated') { + $code = 'deprecated_variable'; + } elseif ($message === 'feature not found') { + $code = 'feature_not_found'; + } elseif ($message === 'variable schema not found') { + $code = 'variable_not_found'; + } elseif ($message === 'no variations') { + $code = 'no_variations'; + } elseif ($message === 'invalid bucketBy') { + $code = 'invalid_bucket_by'; + } + $this->reportDiagnostic([ + 'level' => $level, + 'code' => $code, + 'message' => $message, + 'details' => $details, + ]); + }, ]); $this->emitter = new Emitter(); $this->context = $options['context'] ?? []; @@ -148,6 +176,31 @@ public function getRevision(): string return $this->datafileReader->getRevision(); } + public function getSchemaVersion(): string + { + return $this->datafileReader->getSchemaVersion(); + } + + public function getSegment(string $segmentKey): ?array + { + return $this->datafileReader->getSegment($segmentKey); + } + + public function getFeatureKeys(): array + { + return $this->datafileReader->getFeatureKeys(); + } + + public function getVariableKeys(string $featureKey): array + { + return $this->datafileReader->getVariableKeys($featureKey); + } + + public function hasVariations(string $featureKey): bool + { + return $this->datafileReader->hasVariations($featureKey); + } + public function getFeature(string $featureKey): ?array { return $this->datafileReader->getFeature($featureKey); @@ -318,7 +371,7 @@ public function reportDiagnostic(array $diagnostic, ?array $sourceModule = null) error_log('[Featurevisor] Diagnostic handler failed: '.$error->getMessage()); } } else { - $this->logger->log( + Logger::create(['level' => $this->logger->getLevel()])->log( $this->normalizeLogLevel($diagnostic['level']), $diagnostic['message'] ?? ($diagnostic['code'] ?? 'diagnostic'), $diagnostic @@ -392,7 +445,7 @@ public function getContext(array $context = []): array /** * @param array $context * @param array{ - * __featurevisorChildSticky?: array + * sticky?: array * } $options * @return Child */ diff --git a/src/Logger.php b/src/Logger.php index 0c1a6bf..950bef4 100644 --- a/src/Logger.php +++ b/src/Logger.php @@ -9,7 +9,8 @@ use Psr\Log\LogLevel; use Stringable; -class Logger implements LoggerInterface +/** @internal SDK infrastructure. Use diagnostics through Featurevisor instead. */ +final class Logger implements LoggerInterface { use LoggerTrait; private const MSG_PREFIX = '[Featurevisor]'; @@ -52,6 +53,7 @@ public function __construct(string $level = self::DEFAULT_LEVEL, ?Closure $handl public function setLevel(string $level): void { + $level = self::normalizeLevel($level); if (!in_array($level, self::ALL_LEVELS, true)) { throw new InvalidArgumentException('Invalid log level'); } @@ -66,7 +68,7 @@ public function getLevel(): string public function log($level, $message, array $context = []): void { - $level = (string) $level; + $level = self::normalizeLevel((string) $level); if (!in_array($level, self::ALL_LEVELS, true)) { throw new InvalidArgumentException('Invalid log level'); @@ -81,6 +83,17 @@ public function log($level, $message, array $context = []): void ($this->handler)($level, self::MSG_PREFIX.' '.$message, $context); } + private static function normalizeLevel(string $level): string + { + if ($level === 'fatal') { + return LogLevel::EMERGENCY; + } + if ($level === 'warn') { + return LogLevel::WARNING; + } + return $level; + } + private static function defaultLogHandler($level, $message, ?array $details = null): void { if (STDOUT == false) { diff --git a/tests/ChildTest.php b/tests/ChildTest.php index 9b7153f..97ade04 100644 --- a/tests/ChildTest.php +++ b/tests/ChildTest.php @@ -7,7 +7,7 @@ class ChildTest extends TestCase { public function testCreateChildInstanceAndAllBehaviors() { - $f = Featurevisor::createInstance([ + $f = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', diff --git a/tests/DiagnosticContractTest.php b/tests/DiagnosticContractTest.php index dfafb82..40c0dbf 100644 --- a/tests/DiagnosticContractTest.php +++ b/tests/DiagnosticContractTest.php @@ -10,7 +10,7 @@ class DiagnosticContractTest extends TestCase public function testEmptyDetailsSerializeAsAnObject(): void { $diagnostics = []; - Featurevisor::createInstance([ + Featurevisor::createFeaturevisor([ 'logLevel' => 'info', 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { $diagnostics[] = $diagnostic; @@ -23,7 +23,7 @@ public function testEmptyDetailsSerializeAsAnObject(): void public function testDiagnosticHandlerFailureIsIsolated(): void { - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'onDiagnostic' => static function (): void { throw new \RuntimeException('handler failed'); }, diff --git a/tests/FeaturevisorTest.php b/tests/FeaturevisorTest.php index 6afffa7..e2cfdbd 100644 --- a/tests/FeaturevisorTest.php +++ b/tests/FeaturevisorTest.php @@ -9,9 +9,15 @@ class FeaturevisorTest extends TestCase { + public function testV3FactoryIsTheOnlyFactory() + { + self::assertTrue(method_exists(Featurevisor::class, 'createFeaturevisor')); + self::assertFalse(method_exists(Featurevisor::class, 'createInstance')); + } + public function testShouldCreateInstanceWithDatafileContent() { - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -21,13 +27,15 @@ public function testShouldCreateInstanceWithDatafileContent() ]); self::assertTrue(method_exists($sdk, 'getVariation')); + self::assertSame('2', $sdk->getSchemaVersion()); + self::assertSame([], $sdk->getFeatureKeys()); } public function testShouldReportLifecycleMutationDiagnostics() { $diagnostics = []; - $sdk = Featurevisor::createInstance([ - 'logger' => Logger::create(['level' => LogLevel::DEBUG]), + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => LogLevel::DEBUG, 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, @@ -50,15 +58,12 @@ public function testShouldReportLifecycleMutationDiagnostics() public function testShouldCreateInstanceWithLogLevel() { - $logs = []; - $sdk = Featurevisor::createInstance([ + $diagnostics = []; + $sdk = Featurevisor::createFeaturevisor([ 'logLevel' => LogLevel::DEBUG, - 'logger' => Logger::create([ - 'level' => LogLevel::ERROR, - 'handler' => function ($level, $message, $context) use (&$logs) { - $logs[] = compact('level', 'message', 'context'); - }, - ]), + 'onDiagnostic' => function (array $diagnostic) use (&$diagnostics) { + $diagnostics[] = $diagnostic; + }, 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -69,20 +74,17 @@ public function testShouldCreateInstanceWithLogLevel() $sdk->setContext(['userId' => '123']); - // logger option should take precedence over logLevel option - self::assertCount(0, $logs); + self::assertContains('context_set', array_column($diagnostics, 'code')); } public function testShouldSetLogLevelAfterInitialization() { - $logs = []; - $sdk = Featurevisor::createInstance([ - 'logger' => Logger::create([ - 'level' => LogLevel::ERROR, - 'handler' => function ($level, $message, $context) use (&$logs) { - $logs[] = compact('level', 'message', 'context'); - }, - ]), + $diagnostics = []; + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => LogLevel::ERROR, + 'onDiagnostic' => function (array $diagnostic) use (&$diagnostics) { + $diagnostics[] = $diagnostic; + }, 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -92,19 +94,18 @@ public function testShouldSetLogLevelAfterInitialization() ]); $sdk->setContext(['userId' => '123']); - self::assertCount(0, $logs); + self::assertNotContains('context_set', array_column($diagnostics, 'code')); $sdk->setLogLevel(LogLevel::DEBUG); $sdk->setContext(['country' => 'nl']); - self::assertCount(1, $logs); - self::assertSame('debug', $logs[0]['level']); + self::assertContains('context_set', array_column($diagnostics, 'code')); } public function testShouldConfigurePlainBucketBy() { $capturedBucketKey = ''; - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -153,7 +154,7 @@ public function testShouldConfigureAndBucketBy() { $capturedBucketKey = ''; - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -202,7 +203,7 @@ public function testShouldConfigureOrBucketBy() { $capturedBucketKey = ''; - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -259,7 +260,7 @@ public function testShouldInterceptContextBeforeModule() $interceptedFeatureKey = ''; $interceptedVariableKey = ''; - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -312,7 +313,7 @@ public function testShouldInterceptValueAfterModule() $interceptedFeatureKey = ''; $interceptedVariableKey = ''; - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -386,7 +387,7 @@ public function testShouldInitializeWithStickyFeatures() 'segments' => [], ]; - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'sticky' => [ 'test' => [ 'enabled' => true, @@ -422,7 +423,7 @@ public function testShouldInitializeWithStickyFeatures() public function testShouldHonourSimpleRequiredFeatures() { - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -461,7 +462,7 @@ public function testShouldHonourSimpleRequiredFeatures() self::assertFalse($sdk->isEnabled('myKey')); // enabling required should enable the feature too - $sdk2 = Featurevisor::createInstance([ + $sdk2 = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -501,7 +502,7 @@ public function testShouldHonourSimpleRequiredFeatures() public function testShouldHonourRequiredFeaturesWithVariation() { // should be disabled because required has different variation - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -548,7 +549,7 @@ public function testShouldHonourRequiredFeaturesWithVariation() self::assertFalse($sdk->isEnabled('myKey')); // child should be enabled because required has desired variation - $sdk2 = Featurevisor::createInstance([ + $sdk2 = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -598,7 +599,7 @@ public function testShouldEmitWarningsForDeprecatedFeature() { $deprecatedCount = 0; - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -639,13 +640,11 @@ public function testShouldEmitWarningsForDeprecatedFeature() ], 'segments' => [], ], - 'logger' => Logger::create([ - 'handler' => function($level, $message) use (&$deprecatedCount) { - if ($level === LogLevel::WARNING && strpos($message, 'is deprecated') !== false) { + 'onDiagnostic' => function(array $diagnostic) use (&$deprecatedCount) { + if ($diagnostic['code'] === 'deprecated_feature') { $deprecatedCount += 1; } }, - ]), ]); $testVariation = $sdk->getVariation('test', [ @@ -662,7 +661,7 @@ public function testShouldEmitWarningsForDeprecatedFeature() public function testShouldCheckIfEnabledForOverriddenFlagsFromRules() { - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -710,7 +709,7 @@ public function testShouldCheckIfEnabledForMutuallyExclusiveFeatures() { $bucketValue = 10000; - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'modules' => [ [ 'name' => 'unit-test', @@ -746,7 +745,7 @@ public function testShouldCheckIfEnabledForMutuallyExclusiveFeatures() public function testShouldGetVariation() { - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -826,7 +825,7 @@ public function testShouldGetVariation() public function testShouldGetVariable() { - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -1113,7 +1112,7 @@ public function testShouldGetVariable() public function testShouldGetVariablesWithoutAnyVariations() { - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -1175,7 +1174,7 @@ public function testShouldGetVariablesWithoutAnyVariations() public function testShouldApplyRuleVariableOverridesOnTopOfRuleVariables() { - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -1310,7 +1309,7 @@ public function testShouldApplyRuleVariableOverridesOnTopOfRuleVariables() public function testShouldCheckIfEnabledForIndividuallyNamedSegments() { - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -1375,7 +1374,7 @@ public function testShouldCheckIfEnabledForIndividuallyNamedSegments() public function testShouldGetArrayAndObjectVariables() { - $sdk = Featurevisor::createInstance([ + $sdk = Featurevisor::createFeaturevisor([ 'datafile' => [ 'schemaVersion' => '2', 'revision' => '1.0', @@ -1510,8 +1509,8 @@ public function testShouldGetArrayAndObjectVariables() public function testShouldSetDatafileByMergingByDefaultAndReplacingWhenRequested() { $events = []; - $sdk = Featurevisor::createInstance([ - 'logger' => Logger::create(['level' => LogLevel::ERROR]), + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => LogLevel::ERROR, 'datafile' => [ 'schemaVersion' => '2', 'revision' => 'base', @@ -1588,8 +1587,8 @@ public function testShouldManageModulesAndDuplicateDiagnostics() $setupCalls = 0; $closeCalls = 0; - $sdk = Featurevisor::createInstance([ - 'logger' => Logger::create(['level' => LogLevel::ERROR]), + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => LogLevel::ERROR, 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, @@ -1638,8 +1637,8 @@ public function testShouldReportModuleCloseErrorsAndContinueCleanup() $errors = []; $closed = []; - $sdk = Featurevisor::createInstance([ - 'logger' => Logger::create(['level' => LogLevel::ERROR]), + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => LogLevel::ERROR, 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, @@ -1682,8 +1681,8 @@ public function testShouldReportModuleCloseErrorsFromUnsubscribeOnce() { $diagnostics = []; - $sdk = Featurevisor::createInstance([ - 'logger' => Logger::create(['level' => LogLevel::ERROR]), + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => LogLevel::ERROR, 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, @@ -1710,8 +1709,8 @@ public function testShouldSupportModuleDiagnosticsSubscriptions() $received = []; $reporter = null; - $sdk = Featurevisor::createInstance([ - 'logger' => Logger::create(['level' => LogLevel::ERROR]), + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => LogLevel::ERROR, 'modules' => [ [ 'name' => 'listener', From 5e543a181e22c428d780f7ba54228c4cd94a06a1 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 20:40:13 +0200 Subject: [PATCH 13/15] alignment --- README.md | 6 ++++-- src/Featurevisor.php | 42 +++++++++++++++++++++----------------- src/ModulesManager.php | 2 +- tests/FeaturevisorTest.php | 12 +++++------ 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index fb05677..1728923 100644 --- a/README.md +++ b/README.md @@ -505,11 +505,13 @@ $unsubscribe = $f->on('sticky_set', function ($event) { ### `error` ```php -$unsubscribe = $f->on('error', function ($diagnostic) { - echo $diagnostic['message']; +$unsubscribe = $f->on('error', function ($event) { + echo $event['diagnostic']['message']; }); ``` +The `error` event is emitted for diagnostics whose level is `error`. + ## Evaluation details Besides logging with debug level enabled, you can also get more details about how the feature variations and variables are evaluated in the runtime against given context: diff --git a/src/Featurevisor.php b/src/Featurevisor.php index 059e45a..93805d4 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -4,13 +4,12 @@ use Closure; use Featurevisor\Internal\DatafileReader; -use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; class Featurevisor { private array $context; - private LoggerInterface $logger; + private Logger $logger; private ?array $sticky; private DatafileReader $datafileReader; private ModulesManager $modulesManager; @@ -88,9 +87,15 @@ private function __construct(array $options = []) $this->datafileReader = DatafileReader::createEmpty($this->logger); $this->modulesManager = ModulesManager::createFromOptions([ 'modules' => $options['modules'] ?? [], - 'reportDiagnostic' => [$this, 'reportDiagnostic'], - 'moduleApiFactory' => [$this, 'createModuleApi'], - 'clearModuleDiagnosticSubscriptions' => [$this, 'clearModuleDiagnosticSubscriptions'], + 'reportDiagnostic' => function(array $diagnostic, ?array $module = null): void { + $this->reportDiagnostic($diagnostic, $module); + }, + 'moduleApiFactory' => function(array $module): array { + return $this->createModuleApi($module); + }, + 'clearModuleDiagnosticSubscriptions' => function(array $module): void { + $this->clearModuleDiagnosticSubscriptions($module); + }, ]); if (isset($options['datafile'])) { @@ -100,7 +105,7 @@ private function __construct(array $options = []) $this->reportDiagnostic([ 'level' => 'info', 'code' => 'sdk_initialized', - 'message' => 'Featurevisor SDK initialized', + 'message' => 'SDK initialized', ]); } @@ -133,12 +138,13 @@ public function setDatafile($datafile, bool $replace = false): void 'details' => $details, ]); $this->emitter->trigger('datafile_set', $details); - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->reportDiagnostic([ 'level' => 'error', 'code' => 'invalid_datafile', 'message' => 'Could not parse datafile', - 'error' => $e->getMessage(), + 'originalError' => $e, + 'details' => [], ]); } } @@ -208,9 +214,7 @@ public function getFeature(string $featureKey): ?array public function setLogLevel(string $level): void { - if (method_exists($this->logger, 'setLevel')) { - $this->logger->setLevel($level); - } + $this->logger->setLevel($level); } public function addModule(array $module): ?callable @@ -286,7 +290,7 @@ public function setContext(array $context, bool $replace = false): void * @param array $module * @return array */ - public function createModuleApi(array $module): array + private function createModuleApi(array $module): array { return [ 'getRevision' => function(): string { @@ -317,7 +321,7 @@ public function createModuleApi(array $module): array /** * @param array $module */ - public function clearModuleDiagnosticSubscriptions(array $module): void + private function clearModuleDiagnosticSubscriptions(array $module): void { $moduleId = $module['id'] ?? null; $this->moduleDiagnosticSubscriptions = array_values(array_filter( @@ -330,11 +334,11 @@ public function clearModuleDiagnosticSubscriptions(array $module): void * @param array $diagnostic * @param array|null $sourceModule */ - public function reportDiagnostic(array $diagnostic, ?array $sourceModule = null): void + private function reportDiagnostic(array $diagnostic, ?array $sourceModule = null): void { $diagnostic['level'] = $diagnostic['level'] ?? 'info'; - if ($sourceModule && isset($sourceModule['name']) && !isset($diagnostic['moduleName'])) { - $diagnostic['moduleName'] = $sourceModule['name']; + if ($sourceModule && isset($sourceModule['name']) && !isset($diagnostic['module'])) { + $diagnostic['module'] = $sourceModule['name']; } $details = is_array($diagnostic['details'] ?? null) ? $diagnostic['details'] : []; $reservedKeys = ['level', 'code', 'message', 'module', 'moduleName', 'originalError', 'details']; @@ -362,7 +366,7 @@ public function reportDiagnostic(array $diagnostic, ?array $sourceModule = null) } } - $instanceLevel = method_exists($this->logger, 'getLevel') ? $this->logger->getLevel() : Logger::DEFAULT_LEVEL; + $instanceLevel = $this->logger->getLevel(); if ($this->levelAllows($diagnostic['level'], $instanceLevel)) { if ($this->onDiagnostic) { try { @@ -379,8 +383,8 @@ public function reportDiagnostic(array $diagnostic, ?array $sourceModule = null) } } - if (in_array($this->normalizeLogLevel($diagnostic['level']), [LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR], true)) { - $this->emitter->trigger('error', $diagnostic); + if ($diagnostic['level'] === 'error') { + $this->emitter->trigger('error', ['diagnostic' => $diagnostic]); } } diff --git a/src/ModulesManager.php b/src/ModulesManager.php index 1200705..98744fa 100644 --- a/src/ModulesManager.php +++ b/src/ModulesManager.php @@ -66,7 +66,7 @@ public function add(array $module): ?callable 'code' => 'duplicate_module', 'message' => 'Duplicate module name', 'moduleName' => $module['name'], - ], $module); + ], null); return null; } } diff --git a/tests/FeaturevisorTest.php b/tests/FeaturevisorTest.php index e2cfdbd..8528935 100644 --- a/tests/FeaturevisorTest.php +++ b/tests/FeaturevisorTest.php @@ -9,9 +9,8 @@ class FeaturevisorTest extends TestCase { - public function testV3FactoryIsTheOnlyFactory() + public function testLegacyFactoryIsAbsent() { - self::assertTrue(method_exists(Featurevisor::class, 'createFeaturevisor')); self::assertFalse(method_exists(Featurevisor::class, 'createInstance')); } @@ -1672,8 +1671,8 @@ public function testShouldReportModuleCloseErrorsAndContinueCleanup() && ($diagnostic['originalError'] ?? null) instanceof \RuntimeException )) > 0); self::assertTrue(count(array_filter($errors, fn($event) => - ($event['code'] ?? null) === 'module_close_error' - && ($event['moduleName'] ?? null) === 'first' + ($event['diagnostic']['code'] ?? null) === 'module_close_error' + && ($event['diagnostic']['moduleName'] ?? null) === 'first' )) > 0); } @@ -1730,17 +1729,18 @@ public function testShouldSupportModuleDiagnosticsSubscriptions() ]); $reporter([ - 'level' => 'warning', + 'level' => 'warn', 'code' => 'from_reporter', 'message' => 'diagnostic from reporter', ]); self::assertCount(1, $received); self::assertSame('from_reporter', $received[0]['code']); + self::assertSame('reporter', $received[0]['module']); $sdk->removeModule('listener'); $reporter([ - 'level' => 'warning', + 'level' => 'warn', 'code' => 'after_remove', 'message' => 'after remove', ]); From 1f31661134f36c5d281a2abaeddf2f9ca6900cb8 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 20:49:44 +0200 Subject: [PATCH 14/15] alignment --- src/Featurevisor.php | 11 +++++++++-- src/Logger.php | 1 - tests/FeaturevisorTest.php | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Featurevisor.php b/src/Featurevisor.php index 93805d4..4a4dbd9 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -122,6 +122,13 @@ public function setDatafile($datafile, bool $replace = false): void $incomingDatafile = is_string($datafile) ? json_decode($datafile, true, 512, JSON_THROW_ON_ERROR) : $datafile; + if (!is_array($incomingDatafile) + || !is_string($incomingDatafile['schemaVersion'] ?? null) + || !is_string($incomingDatafile['revision'] ?? null) + || !is_array($incomingDatafile['segments'] ?? null) + || !is_array($incomingDatafile['features'] ?? null)) { + throw new \InvalidArgumentException('Invalid datafile'); + } $nextDatafile = $replace ? $incomingDatafile : $this->mergeDatafiles($this->datafileReader->getDatafile(), $incomingDatafile); @@ -301,7 +308,7 @@ private function createModuleApi(array $module): array 'id' => uniqid('diagnostic_', true), 'moduleId' => $module['id'] ?? null, 'handler' => $handler, - 'level' => $options['level'] ?? Logger::DEFAULT_LEVEL, + 'level' => $options['logLevel'] ?? Logger::DEFAULT_LEVEL, ]; $this->moduleDiagnosticSubscriptions[] = $subscription; @@ -337,7 +344,7 @@ private function clearModuleDiagnosticSubscriptions(array $module): void private function reportDiagnostic(array $diagnostic, ?array $sourceModule = null): void { $diagnostic['level'] = $diagnostic['level'] ?? 'info'; - if ($sourceModule && isset($sourceModule['name']) && !isset($diagnostic['module'])) { + if ($sourceModule && isset($sourceModule['name'])) { $diagnostic['module'] = $sourceModule['name']; } $details = is_array($diagnostic['details'] ?? null) ? $diagnostic['details'] : []; diff --git a/src/Logger.php b/src/Logger.php index 950bef4..a5dfe01 100644 --- a/src/Logger.php +++ b/src/Logger.php @@ -7,7 +7,6 @@ use Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; use Psr\Log\LogLevel; -use Stringable; /** @internal SDK infrastructure. Use diagnostics through Featurevisor instead. */ final class Logger implements LoggerInterface diff --git a/tests/FeaturevisorTest.php b/tests/FeaturevisorTest.php index 8528935..91e4012 100644 --- a/tests/FeaturevisorTest.php +++ b/tests/FeaturevisorTest.php @@ -1716,7 +1716,7 @@ public function testShouldSupportModuleDiagnosticsSubscriptions() 'setup' => function(array $api) use (&$received) { $api['onDiagnostic'](function(array $diagnostic) use (&$received) { $received[] = $diagnostic; - }, ['level' => LogLevel::WARNING]); + }, ['logLevel' => 'warn']); }, ], [ From 10249110e6d6fead17f5e3b34be51eec464a0774 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 23:55:50 +0200 Subject: [PATCH 15/15] updates --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 03c2b47..1029734 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,6 @@ update-monorepo: test-example-1: composer test - ./featurevisor test --projectDirectoryPath="/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1" --onlyFailures + ./featurevisor test --projectDirectoryPath="../featurevisor/examples/example-1" --onlyFailures test-example-project: test-example-1