From 1c37fbbfba9585501c4394fd1d811899e10c0c52 Mon Sep 17 00:00:00 2001 From: aubes <3941035+aubes@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:05:42 +0200 Subject: [PATCH] feat: autoconfigure Hook and EvaluationContextProvider services --- CHANGELOG.md | 19 ++++++ README.md | 2 + docs/features/evaluation-context.md | 4 +- docs/features/hooks.md | 55 +++++++++--------- .../EvaluationContextProviderInterface.php | 2 - .../EvaluationContextContributedEvent.php | 18 ++++++ .../EvaluationContextListener.php | 10 +++- src/OpenFeatureBundle.php | 15 +++++ src/Profiler/ContextProviderRecorder.php | 36 ++++++++++++ src/Profiler/OpenFeatureDataCollector.php | 53 +++++++++++++++++ src/Resources/config/services.php | 1 + .../views/Collector/openfeature.html.twig | 58 +++++++++++++++++++ 12 files changed, 240 insertions(+), 33 deletions(-) create mode 100644 src/Event/EvaluationContextContributedEvent.php create mode 100644 src/Profiler/ContextProviderRecorder.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d06fb..e7d87a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.2.0] - 2026-04-24 + +### Changed + +- Services implementing `OpenFeature\interfaces\hooks\Hook` are now autoconfigured with the `openfeature.hook` tag. No more manual tagging required in `services.yaml` (opt out with `autoconfigure: false` for per-call hooks such as `RegexpValidatorHook`). +- Services implementing `EvaluationContextProviderInterface` are now autoconfigured via `registerForAutoconfiguration()` in the bundle extension (the previous `#[AutoconfigureTag]` on the interface was not picked up by Symfony and had no effect). + +### Upgrade notes + +- **Remove redundant hook tags.** If your `services.yaml` has both `_defaults: autoconfigure: true` and `tags: [openfeature.hook]` on a `Hook` service, the hook will now be registered **twice** and fire twice per evaluation. Remove the explicit `tags: [openfeature.hook]` from such services. +- **Remove redundant evaluation context provider tags.** Same applies to `tags: [openfeature.evaluation_context_provider]` on services implementing `EvaluationContextProviderInterface` with autoconfigure on. + ## [0.1.1] - 2026-04-18 ### Fixed @@ -14,3 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.0] - 2026-04-11 Initial release. + +[Unreleased]: https://github.com/aubes/openfeature-bundle/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/aubes/openfeature-bundle/compare/v0.1.1...v0.2.0 +[0.1.1]: https://github.com/aubes/openfeature-bundle/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/aubes/openfeature-bundle/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 566fc7e..3b64ee3 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ class CheckoutController - **`#[FeatureGate]`** blocks access when a flag is off - **`#[FeatureFlag]`** injects resolved values, fully typed - **Twig** helpers: `feature('flag')`, `feature_value('flag', default)` +- **Hooks** autoconfigured: implement `Hook` for logging, tracing, validation +- **Evaluation context** autoconfigured: implement `EvaluationContextProviderInterface` to feed targeting attributes - **Symfony Profiler** panel with evaluated flags, provider info, and context - **Any provider**: basic built-ins (InMemory, EnvVar, Redis) for quick starts, or plug any real OpenFeature provider (Flagd, ConfigCat, Unleash, LaunchDarkly...) - **FrankenPHP** worker mode safe out of the box diff --git a/docs/features/evaluation-context.md b/docs/features/evaluation-context.md index 11ad86f..1e3d58b 100644 --- a/docs/features/evaluation-context.md +++ b/docs/features/evaluation-context.md @@ -41,7 +41,7 @@ class TenantContextProvider implements EvaluationContextProviderInterface } ``` -> **Note:** The interface is autoconfigured: if your service implements `EvaluationContextProviderInterface`, the `openfeature.evaluation_context_provider` tag is added automatically. +The service is picked up automatically, no tag or config needed. ## Multiple providers @@ -49,4 +49,4 @@ Multiple context providers are supported. Their contexts are merged via the SDK' ## FrankenPHP worker mode -The global `EvaluationContext` is automatically reset between requests via Symfony's `kernel.reset` mechanism. No configuration required. +The global `EvaluationContext` is automatically cleared between requests. No configuration required. diff --git a/docs/features/hooks.md b/docs/features/hooks.md index dbaef99..bed1f6a 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -4,57 +4,58 @@ Hooks run around every flag evaluation. They are useful for logging, tracing, me ## Registering a hook -Implement the OpenFeature `Hook` interface and tag the service with `openfeature.hook`: +A service implementing the OpenFeature `Hook` interface is autoconfigured with the `openfeature.hook` tag. ```php use OpenFeature\interfaces\hooks\Hook; use OpenFeature\interfaces\hooks\HookContext; use OpenFeature\interfaces\hooks\HookHints; use OpenFeature\interfaces\provider\ResolutionDetails; +use Psr\Log\LoggerInterface; class LoggerHook implements Hook { - public function before(HookContext $context, HookHints $hints): ?EvaluationContext - { - return null; - } + public function __construct(private readonly LoggerInterface $logger) {} public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void { - // log, trace, metrics... + $this->logger->info('Flag evaluated', [ + 'flag' => $context->getFlagKey(), + 'value' => $details->getValue(), + ]); } - public function error(HookContext $context, \Throwable $error, HookHints $hints): void - { - // handle evaluation errors - } + // before(), error(), finally(), supportsFlagValueType() are also available + // see: https://openfeature.dev/specification/sections/hooks +} +``` - public function finally(HookContext $context, HookHints $hints): void - { - // cleanup - } +### Hooks with constructor options - public function supportsFlagValueType(): bool - { - return true; // support all flag types - } -} +When a hook takes constructor arguments (for example the `RegexpValidatorHook` from [php-sdk-contrib](https://github.com/open-feature/php-sdk-contrib/blob/main/hooks/Validators/README.md)), declare the service with its arguments. Autoconfiguration still adds the tag: + +```yaml +services: + App\Hook\HexadecimalValidatorHook: + class: OpenFeature\Hooks\Validators\RegexpValidatorHook + arguments: ['/^[0-9a-f]+$/'] ``` +### Per-call hooks (opt-out) + +If you want a hook to be applied only at call-site via `EvaluationOptions` and not globally, disable autoconfiguration on that service: + ```yaml services: - App\OpenFeature\LoggerHook: - tags: [openfeature.hook] + App\Hook\HexadecimalValidatorHook: + class: OpenFeature\Hooks\Validators\RegexpValidatorHook + arguments: ['/^[0-9a-f]+$/'] + autoconfigure: false ``` ## Hook lifecycle -Hooks are called in the following order for each flag evaluation: - -1. `before()` : before the provider resolves the flag -2. `after()` : after successful resolution -3. `error()` : if the provider throws an exception (instead of `after`) -4. `finally()` : always called, regardless of success or failure +See the [OpenFeature hooks specification](https://openfeature.dev/specification/sections/hooks) for the full lifecycle. ## Pre-built hooks diff --git a/src/EvaluationContext/EvaluationContextProviderInterface.php b/src/EvaluationContext/EvaluationContextProviderInterface.php index 357fc1d..410d29f 100644 --- a/src/EvaluationContext/EvaluationContextProviderInterface.php +++ b/src/EvaluationContext/EvaluationContextProviderInterface.php @@ -5,7 +5,6 @@ namespace Aubes\OpenFeatureBundle\EvaluationContext; use OpenFeature\interfaces\flags\EvaluationContext; -use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; use Symfony\Component\HttpFoundation\Request; /** @@ -15,7 +14,6 @@ * Multiple providers are supported and their contexts are merged in priority order * (highest priority first). Use the "priority" attribute on the tag to control ordering. */ -#[AutoconfigureTag('openfeature.evaluation_context_provider')] interface EvaluationContextProviderInterface { public function getContext(Request $request): ?EvaluationContext; diff --git a/src/Event/EvaluationContextContributedEvent.php b/src/Event/EvaluationContextContributedEvent.php new file mode 100644 index 0000000..0c27e2b --- /dev/null +++ b/src/Event/EvaluationContextContributedEvent.php @@ -0,0 +1,18 @@ +providers as $provider) { $context = $provider->getContext($event->getRequest()); - if ($context !== null) { - $contexts[] = $context; + if ($context === null) { + continue; } + + $contexts[] = $context; + $this->dispatcher?->dispatch(new EvaluationContextContributedEvent($provider, $context)); } if ($contexts === []) { diff --git a/src/OpenFeatureBundle.php b/src/OpenFeatureBundle.php index a7a4b43..3e6aac1 100644 --- a/src/OpenFeatureBundle.php +++ b/src/OpenFeatureBundle.php @@ -7,11 +7,14 @@ use Aubes\OpenFeatureBundle\Command\DebugFeatureFlagsCommand; use Aubes\OpenFeatureBundle\DependencyInjection\Compiler\RegisterHooksPass; use Aubes\OpenFeatureBundle\DependencyInjection\Compiler\SetProviderPass; +use Aubes\OpenFeatureBundle\EvaluationContext\EvaluationContextProviderInterface; use Aubes\OpenFeatureBundle\EvaluationContext\UserEvaluationContextProvider; +use Aubes\OpenFeatureBundle\Profiler\ContextProviderRecorder; use Aubes\OpenFeatureBundle\Profiler\OpenFeatureDataCollector; use Aubes\OpenFeatureBundle\Profiler\ProfilerHook; use OpenFeature\interfaces\flags\API; use OpenFeature\interfaces\flags\Client; +use OpenFeature\interfaces\hooks\Hook; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -99,6 +102,12 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $loader = new PhpFileLoader($builder, new FileLocator(__DIR__ . '/Resources/config')); $loader->load('services.php'); + $builder->registerForAutoconfiguration(Hook::class) + ->addTag('openfeature.hook'); + + $builder->registerForAutoconfiguration(EvaluationContextProviderInterface::class) + ->addTag('openfeature.evaluation_context_provider'); + $builder->setParameter('open_feature.provider', $config['provider']); $builder->setParameter('open_feature.flags', $config['flags']); @@ -156,9 +165,15 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ->setAutoconfigured(true) ->addTag('openfeature.hook'); + $builder->register(ContextProviderRecorder::class) + ->setPublic(false) + ->setAutoconfigured(true) + ->addTag('kernel.reset', ['method' => 'reset']); + $builder->register(OpenFeatureDataCollector::class) ->addArgument(new Reference(ProfilerHook::class)) ->addArgument(new Reference(API::class)) + ->addArgument(new Reference(ContextProviderRecorder::class)) ->addTag('data_collector', [ 'template' => '@OpenFeature/Collector/openfeature.html.twig', 'id' => 'open_feature', diff --git a/src/Profiler/ContextProviderRecorder.php b/src/Profiler/ContextProviderRecorder.php new file mode 100644 index 0000000..da1622b --- /dev/null +++ b/src/Profiler/ContextProviderRecorder.php @@ -0,0 +1,36 @@ +}> */ + private array $contributions = []; + + public function __invoke(EvaluationContextContributedEvent $event): void + { + $this->contributions[] = [ + 'provider' => $event->provider::class, + 'targeting_key' => $event->context->getTargetingKey(), + 'attributes' => $event->context->getAttributes()->toArray(), + ]; + } + + /** @return list}> */ + public function getContributions(): array + { + return $this->contributions; + } + + public function reset(): void + { + $this->contributions = []; + } +} diff --git a/src/Profiler/OpenFeatureDataCollector.php b/src/Profiler/OpenFeatureDataCollector.php index 85aba55..4d51e42 100644 --- a/src/Profiler/OpenFeatureDataCollector.php +++ b/src/Profiler/OpenFeatureDataCollector.php @@ -14,6 +14,7 @@ class OpenFeatureDataCollector extends DataCollector public function __construct( private readonly ProfilerHook $hook, private readonly API $api, + private readonly ?ContextProviderRecorder $recorder = null, ) { } @@ -23,6 +24,8 @@ public function collect(Request $request, Response $response, ?\Throwable $excep 'evaluations' => $this->hook->getEvaluations(), 'provider' => $this->api->getProviderMetadata()->getName(), 'evaluation_context' => $this->serializeContext(), + 'hooks' => $this->collectHooks(), + 'context_providers' => $this->collectContextProviders(), ]; } @@ -57,6 +60,24 @@ public function getEvaluationContext(): array return $context; } + /** @return list */ + public function getHooks(): array + { + /** @var list $hooks */ + $hooks = $this->data['hooks'] ?? []; + + return $hooks; + } + + /** @return list}> */ + public function getContextProviders(): array + { + /** @var list}> $providers */ + $providers = $this->data['context_providers'] ?? []; + + return $providers; + } + public function getName(): string { return 'open_feature'; @@ -90,4 +111,36 @@ private function serializeContext(): array 'attributes' => $context->getAttributes()->toArray(), ]; } + + /** @return list */ + private function collectHooks(): array + { + $hooks = []; + foreach ($this->api->getHooks() as $hook) { + if ($hook instanceof ProfilerHook) { + continue; + } + + $hooks[] = $hook::class; + } + + return $hooks; + } + + /** @return list}> */ + private function collectContextProviders(): array + { + if ($this->recorder === null) { + return []; + } + + return \array_map( + fn (array $c): array => [ + 'provider' => $c['provider'], + 'targeting_key' => $this->anonymizeTargetingKey($c['targeting_key']), + 'attributes' => $c['attributes'], + ], + $this->recorder->getContributions(), + ); + } } diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index f4d743c..7303072 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -36,6 +36,7 @@ ->args([ service(API::class), tagged_iterator('openfeature.evaluation_context_provider'), + service('event_dispatcher')->nullOnInvalid(), ]) ->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest', 'priority' => 4]) ->tag('kernel.reset', ['method' => 'reset']); diff --git a/src/Resources/views/Collector/openfeature.html.twig b/src/Resources/views/Collector/openfeature.html.twig index 22458c9..11025e0 100644 --- a/src/Resources/views/Collector/openfeature.html.twig +++ b/src/Resources/views/Collector/openfeature.html.twig @@ -78,6 +78,64 @@ {% endif %} +

Context Providers

+ + {% if collector.contextProviders is empty %} +
+

No context provider contributed during this request.

+
+ {% else %} + + + + + + + + + + {% for contribution in collector.contextProviders %} + + + + + + {% endfor %} + +
ProviderTargeting keyAttributes
{{ contribution.provider }}{{ contribution.targeting_key ?? '-' }} + {% if contribution.attributes is empty %} + - + {% else %} + {% for key, value in contribution.attributes %} + {{ key }}: {{ value }}{{ not loop.last ? ', ' : '' }} + {% endfor %} + {% endif %} +
+ {% endif %} + +

Registered Hooks

+ + {% if collector.hooks is empty %} +
+

No hooks are registered.

+
+ {% else %} + + + + + + + + {% for hook in collector.hooks %} + + + + {% endfor %} + +
Hook
{{ hook }}
+ {% endif %} +

Flag Evaluations

{% if collector.evaluations is empty %}