diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 48f6241e..4cdf763f 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + @@ -45,6 +45,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -451,6 +478,7 @@ + diff --git a/src/LaunchDarkly/Hooks/EvaluationSeriesContext.php b/src/LaunchDarkly/Hooks/EvaluationSeriesContext.php new file mode 100644 index 00000000..317fa499 --- /dev/null +++ b/src/LaunchDarkly/Hooks/EvaluationSeriesContext.php @@ -0,0 +1,24 @@ + $data Data returned by the previous stage of this hook. + * For beforeEvaluation this will be an empty array. + * @return array Data to be passed to the next stage of this hook. + * Implementations should return the input data (optionally augmented) unchanged + * if they do not need to pass additional information forward. + */ + public function beforeEvaluation(EvaluationSeriesContext $seriesContext, array $data): array + { + return $data; + } + + /** + * Stage executed after the flag value has been determined. + * + * Called synchronously on the thread performing the variation call. + * + * @param array $data Data returned by the beforeEvaluation stage of this hook. + * @param EvaluationDetail $detail The result of the evaluation. This value should not be modified. + * @return array Data to be passed to the next stage of this hook. + * The return is currently unused but future stages may consume it. + */ + public function afterEvaluation( + EvaluationSeriesContext $seriesContext, + array $data, + EvaluationDetail $detail, + ): array { + return $data; + } + + /** + * Handler executed after a custom event has been enqueued by a call to `track`. + * + * Not invoked if the track call could not enqueue an event (e.g. invalid context). + */ + public function afterTrack(TrackSeriesContext $seriesContext): void + { + } +} diff --git a/src/LaunchDarkly/Hooks/Metadata.php b/src/LaunchDarkly/Hooks/Metadata.php new file mode 100644 index 00000000..8359ac2f --- /dev/null +++ b/src/LaunchDarkly/Hooks/Metadata.php @@ -0,0 +1,16 @@ + */ + private array $_hooks; + + /** + * @param list $hooks + */ + public function __construct( + private readonly LoggerInterface $_logger, + array $hooks = [], + ) { + $this->_hooks = $hooks; + } + + public function addHook(Hook $hook): void + { + $this->_hooks[] = $hook; + } + + public function hasHooks(): bool + { + return count($this->_hooks) > 0; + } + + /** + * Executes the beforeEvaluation stage of every registered hook in registration order. + * + * @return list> The data returned by each hook, in the same order as + * the registered hooks. On error, the slot for that hook contains an empty array. + */ + public function beforeEvaluation(EvaluationSeriesContext $seriesContext): array + { + $result = []; + foreach ($this->_hooks as $hook) { + $result[] = $this->safeInvoke( + 'beforeEvaluation', + $seriesContext->flagKey, + $hook, + fn () => $hook->beforeEvaluation($seriesContext, []), + [], + ); + } + return $result; + } + + /** + * Executes the afterEvaluation stage of every registered hook in reverse registration order. + * + * @param list> $beforeData The per-hook data returned from beforeEvaluation. + */ + public function afterEvaluation( + EvaluationSeriesContext $seriesContext, + array $beforeData, + EvaluationDetail $detail, + ): void { + for ($i = count($this->_hooks) - 1; $i >= 0; $i--) { + $hook = $this->_hooks[$i]; + $data = $beforeData[$i] ?? []; + $this->safeInvoke( + 'afterEvaluation', + $seriesContext->flagKey, + $hook, + fn () => $hook->afterEvaluation($seriesContext, $data, $detail), + $data, + ); + } + } + + /** + * Executes the afterTrack handler of every registered hook in registration order. + */ + public function afterTrack(TrackSeriesContext $seriesContext): void + { + foreach ($this->_hooks as $hook) { + try { + $hook->afterTrack($seriesContext); + } catch (Throwable $e) { + $name = $this->hookName($hook); + $this->_logger->error( + "During tracking of event \"{$seriesContext->key}\", stage \"afterTrack\" of hook \"{$name}\" reported error: {$e->getMessage()}" + ); + } + } + } + + /** + * @template T + * @param callable(): T $fn + * @param T $fallback + * @return T + */ + private function safeInvoke(string $stage, string $flagKey, Hook $hook, callable $fn, mixed $fallback): mixed + { + try { + return $fn(); + } catch (Throwable $e) { + $name = $this->hookName($hook); + $this->_logger->error( + "During evaluation of flag \"{$flagKey}\", stage \"{$stage}\" of hook \"{$name}\" reported error: {$e->getMessage()}" + ); + return $fallback; + } + } + + private function hookName(Hook $hook): string + { + try { + return $hook->getMetadata()->name; + } catch (Throwable) { + return 'unknown hook'; + } + } +} diff --git a/src/LaunchDarkly/LDClient.php b/src/LaunchDarkly/LDClient.php index a1508d92..2e7da601 100644 --- a/src/LaunchDarkly/LDClient.php +++ b/src/LaunchDarkly/LDClient.php @@ -4,6 +4,9 @@ namespace LaunchDarkly; +use LaunchDarkly\Hooks\EvaluationSeriesContext; +use LaunchDarkly\Hooks\Hook; +use LaunchDarkly\Hooks\TrackSeriesContext; use LaunchDarkly\Impl\BigSegments; use LaunchDarkly\Impl\Evaluation\EvalResult; use LaunchDarkly\Impl\Evaluation\Evaluator; @@ -11,6 +14,7 @@ use LaunchDarkly\Impl\Events\EventFactory; use LaunchDarkly\Impl\Events\EventProcessor; use LaunchDarkly\Impl\Events\NullEventProcessor; +use LaunchDarkly\Impl\Hooks\HookRunner; use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\Impl\PreloadedFeatureRequester; use LaunchDarkly\Impl\UnrecoverableHTTPStatusException; @@ -56,6 +60,7 @@ class LDClient protected EventFactory $_eventFactoryWithReasons; protected BigSegments\StoreManager $_bigSegmentsStoreManager; protected BigSegmentStatusProvider $_bigSegmentStatusProvider; + protected HookRunner $_hookRunner; /** * Creates a new client instance that connects to LaunchDarkly. @@ -87,6 +92,9 @@ class LDClient * - `wrapper_name`: For use by wrapper libraries to set an identifying name for the wrapper being used. This will be sent in User-Agent headers during requests to the LaunchDarkly servers to allow recording metrics on the usage of these wrapper libraries. * - `wrapper_version`: For use by wrapper libraries to report the version of the library in use. If `wrapper_name` is not set, this field will be ignored. Otherwise the version string will be included in the User-Agent headers along with the `wrapper_name` during requests to the LaunchDarkly servers. * - `big_segments`: An option {@see \LaunchDarkly\Types\BigSegmentsConfig} instance. + * - `hooks`: An optional list of {@see \LaunchDarkly\Hooks\Hook} implementations. Entries that are not Hook + * instances are logged and ignored. Additional hooks can be registered after construction via + * {@see \LaunchDarkly\LDClient::addHook()}. * - Other options may be available depending on any features you are using from the `LaunchDarkly\Integrations` namespace. * * @return LDClient @@ -178,6 +186,28 @@ public function __construct(string $sdkKey, array $options = []) $this->_featureRequester = $this->getFeatureRequester($sdkKey, $options); $this->_evaluator = new Evaluator($this->_featureRequester, $this->_bigSegmentsStoreManager, $this->_logger); + + $hooks = []; + foreach ($options['hooks'] ?? [] as $hook) { + if ($hook instanceof Hook) { + $hooks[] = $hook; + } else { + $this->_logger->warning("Ignoring non-Hook entry in 'hooks' option"); + } + } + $this->_hookRunner = new HookRunner($this->_logger, $hooks); + } + + /** + * Registers a hook with the client. + * + * Hooks registered here are invoked in addition to any hooks provided via the `hooks` + * constructor option. Prefer the constructor option for hooks that need to observe every + * evaluation from the start of the client's lifetime. + */ + public function addHook(Hook $hook): void + { + $this->_hookRunner->addHook($hook); } public function getLogger(): LoggerInterface @@ -240,7 +270,7 @@ private function getFeatureRequester(string $sdkKey, array $options): FeatureReq */ public function variation(string $key, LDContext $context, mixed $defaultValue = false): mixed { - $detail = $this->variationDetailInternal($key, $context, $defaultValue, $this->_eventFactoryDefault)['detail']; + $detail = $this->variationDetailInternal($key, $context, $defaultValue, $this->_eventFactoryDefault, 'variation')['detail']; return $detail->getValue(); } @@ -260,7 +290,7 @@ public function variation(string $key, LDContext $context, mixed $defaultValue = */ public function variationDetail(string $key, LDContext $context, mixed $defaultValue = false): EvaluationDetail { - return $this->variationDetailInternal($key, $context, $defaultValue, $this->_eventFactoryWithReasons)['detail']; + return $this->variationDetailInternal($key, $context, $defaultValue, $this->_eventFactoryWithReasons, 'variationDetail')['detail']; } /** @@ -276,7 +306,7 @@ public function variationDetail(string $key, LDContext $context, mixed $defaultV */ public function migrationVariation(string $key, LDContext $context, Stage $defaultStage): array { - $result = $this->variationDetailInternal($key, $context, $defaultStage->value, $this->_eventFactoryDefault); + $result = $this->variationDetailInternal($key, $context, $defaultStage->value, $this->_eventFactoryDefault, 'migrationVariation'); $detail = $result['detail']; $flag = $result['flag']; @@ -321,13 +351,37 @@ public function migrationVariation(string $key, LDContext $context, Stage $defau * @param LDContext $context * @param mixed $default * @param EventFactory $eventFactory + * @param string $method Name of the calling public variation method, passed to hooks. * * @psalm-return array{'detail': EvaluationDetail, 'flag': ?FeatureFlag} */ - private function variationDetailInternal(string $key, LDContext $context, mixed $default, EventFactory $eventFactory): array + private function variationDetailInternal(string $key, LDContext $context, mixed $default, EventFactory $eventFactory, string $method): array { $default = $this->_get_default($key, $default); + if (!$this->_hookRunner->hasHooks()) { + return $this->evaluateInternal($key, $context, $default, $eventFactory); + } + + $seriesContext = new EvaluationSeriesContext($key, $context, $default, $method); + $beforeData = $this->_hookRunner->beforeEvaluation($seriesContext); + $result = $this->evaluateInternal($key, $context, $default, $eventFactory); + $this->_hookRunner->afterEvaluation($seriesContext, $beforeData, $result['detail']); + return $result; + } + + /** + * Core evaluation logic, wrapped by {@see variationDetailInternal} which adds hook execution. + * + * @param string $key + * @param LDContext $context + * @param mixed $default + * @param EventFactory $eventFactory + * + * @psalm-return array{'detail': EvaluationDetail, 'flag': ?FeatureFlag} + */ + private function evaluateInternal(string $key, LDContext $context, mixed $default, EventFactory $eventFactory): array + { $errorDetail = fn (string $errorKind): EvaluationDetail => new EvaluationDetail($default, null, EvaluationReason::error($errorKind)); $sendEvent = function (EvalResult $result, ?FeatureFlag $flag) use ($key, $context, $default, $eventFactory): void { @@ -393,7 +447,7 @@ function (PrerequisiteEvaluationRecord $pe) use ($context, $eventFactory) { } $sendEvent($evalResult, $flag); return ['detail' => $detail, 'flag' => $flag]; - } catch (\Exception $e) { + } catch (\Throwable $e) { Util::logExceptionAtErrorLevel($this->_logger, $e, "Unexpected error evaluating flag $key"); $result = $errorDetail(EvaluationReason::EXCEPTION_ERROR); $sendEvent(new EvalResult($result, false), null); @@ -432,6 +486,10 @@ public function track(string $eventName, LDContext $context, mixed $data = null, return; } $this->_eventProcessor->enqueue($this->_eventFactoryDefault->newCustomEvent($eventName, $context, $data, $metricValue)); + + if ($this->_hookRunner->hasHooks()) { + $this->_hookRunner->afterTrack(new TrackSeriesContext($context, $eventName, $metricValue, $data)); + } } /** diff --git a/test-service/PostingHook.php b/test-service/PostingHook.php new file mode 100644 index 00000000..fdce341e --- /dev/null +++ b/test-service/PostingHook.php @@ -0,0 +1,103 @@ +name); + } + + public function beforeEvaluation(EvaluationSeriesContext $seriesContext, array $data): array + { + return $this->post('beforeEvaluation', $seriesContext, $data, null); + } + + public function afterEvaluation( + EvaluationSeriesContext $seriesContext, + array $data, + EvaluationDetail $detail, + ): array { + return $this->post('afterEvaluation', $seriesContext, $data, $detail); + } + + public function afterTrack(TrackSeriesContext $seriesContext): void + { + $stage = 'afterTrack'; + if (isset($this->errors[$stage])) { + throw new Exception($this->errors[$stage]); + } + $payload = [ + 'stage' => $stage, + 'trackSeriesContext' => [ + 'key' => $seriesContext->key, + 'context' => json_decode(json_encode($seriesContext->context), true), + 'data' => $seriesContext->data, + 'metricValue' => $seriesContext->metricValue, + ], + ]; + (new Client())->post($this->callbackUri, ['json' => $payload]); + } + + /** + * @param array $data + * @return array + */ + private function post( + string $stage, + EvaluationSeriesContext $seriesContext, + array $data, + ?EvaluationDetail $detail, + ): array { + if (isset($this->errors[$stage])) { + throw new Exception($this->errors[$stage]); + } + + $payload = [ + 'evaluationSeriesContext' => [ + 'flagKey' => $seriesContext->flagKey, + 'context' => json_decode(json_encode($seriesContext->context), true), + 'defaultValue' => $seriesContext->defaultValue, + 'method' => $seriesContext->method, + ], + 'evaluationSeriesData' => (object) $data, + 'stage' => $stage, + ]; + + if ($detail !== null) { + $payload['evaluationDetail'] = [ + 'value' => $detail->getValue(), + 'variationIndex' => $detail->getVariationIndex(), + 'reason' => $detail->getReason(), + ]; + } + + (new Client())->post($this->callbackUri, ['json' => $payload]); + + return array_merge($data, $this->data[$stage] ?? []); + } +} diff --git a/test-service/SdkClientEntity.php b/test-service/SdkClientEntity.php index 7059f0aa..0b4f04ad 100644 --- a/test-service/SdkClientEntity.php +++ b/test-service/SdkClientEntity.php @@ -59,6 +59,19 @@ public static function createSdkClient($params, bool $resetBigSegmentsStore, $lo $options['all_attributes_private'] = $eventsConfig['allAttributesPrivate'] ?? false; $options['private_attribute_names'] = $eventsConfig['globalPrivateAttributes'] ?? null; + $hooks = $config['hooks']['hooks'] ?? null; + if ($hooks) { + $options['hooks'] = array_map( + fn (array $h) => new PostingHook( + $h['name'], + $h['callbackUri'], + $h['data'] ?? [], + $h['errors'] ?? [], + ), + $hooks + ); + } + $bigSegments = $config['bigSegments'] ?? null; if ($bigSegments) { $store = new BigSegmentsStoreGuzzle(new Client(), $bigSegments['callbackUri']); diff --git a/test-service/TestService.php b/test-service/TestService.php index 8b99e172..d280e8c1 100644 --- a/test-service/TestService.php +++ b/test-service/TestService.php @@ -80,7 +80,9 @@ public function getStatus(): array 'instance-id', 'anonymous-redaction', 'client-prereq-events', - 'big-segments' + 'big-segments', + 'evaluation-hooks', + 'track-hooks' ], 'clientVersion' => \LaunchDarkly\LDClient::VERSION ]; diff --git a/tests/Hooks/CallLog.php b/tests/Hooks/CallLog.php new file mode 100644 index 00000000..99d2ee31 --- /dev/null +++ b/tests/Hooks/CallLog.php @@ -0,0 +1,23 @@ +> */ + public array $calls = []; + + /** + * @param array $entry + */ + public function append(array $entry): void + { + $this->calls[] = $entry; + } +} diff --git a/tests/Hooks/HookRunnerTest.php b/tests/Hooks/HookRunnerTest.php new file mode 100644 index 00000000..4a8fe52e --- /dev/null +++ b/tests/Hooks/HookRunnerTest.php @@ -0,0 +1,170 @@ +nullLogger(), [$a, $b]); + + $runner->beforeEvaluation($this->ctx()); + + $this->assertSame(['A', 'B'], array_column($shared->calls, 'hook')); + } + + public function testAfterEvaluationRunsInReverseRegistrationOrder(): void + { + $shared = new CallLog(); + $a = new RecordingHook('A', $shared); + $b = new RecordingHook('B', $shared); + $runner = new HookRunner($this->nullLogger(), [$a, $b]); + + $before = $runner->beforeEvaluation($this->ctx()); + $runner->afterEvaluation($this->ctx(), $before, $this->detail()); + + // beforeEvaluation: A, B; afterEvaluation: B, A. + $this->assertSame( + ['A:beforeEvaluation', 'B:beforeEvaluation', 'B:afterEvaluation', 'A:afterEvaluation'], + array_map(fn ($e) => $e['hook'] . ':' . $e['stage'], $shared->calls), + ); + } + + public function testSeriesDataIsPropagatedPerHook(): void + { + $a = new RecordingHook('A', returnData: ['beforeEvaluation' => ['from-a' => 1]]); + $b = new RecordingHook('B', returnData: ['beforeEvaluation' => ['from-b' => 2]]); + $runner = new HookRunner($this->nullLogger(), [$a, $b]); + + $before = $runner->beforeEvaluation($this->ctx()); + $runner->afterEvaluation($this->ctx(), $before, $this->detail()); + + // Each hook sees only its own beforeEvaluation data. + $this->assertSame(['from-a' => 1], $a->calls[1]['data']); + $this->assertSame(['from-b' => 2], $b->calls[1]['data']); + } + + public function testBeforeEvaluationErrorYieldsEmptyDataForThatHook(): void + { + $a = new RecordingHook('A', throwFrom: ['beforeEvaluation' => new RuntimeException('boom')]); + $b = new RecordingHook('B', returnData: ['beforeEvaluation' => ['from-b' => 2]]); + $runner = new HookRunner($this->nullLogger(), [$a, $b]); + + $before = $runner->beforeEvaluation($this->ctx()); + $runner->afterEvaluation($this->ctx(), $before, $this->detail()); + + // Hook A's afterEvaluation receives [] because its beforeEvaluation failed. + $this->assertSame('afterEvaluation', $a->calls[1]['stage']); + $this->assertSame([], $a->calls[1]['data']); + // Hook B is unaffected; its afterEvaluation sees the data returned from its beforeEvaluation. + $this->assertSame('afterEvaluation', $b->calls[1]['stage']); + $this->assertSame(['from-b' => 2], $b->calls[1]['data']); + } + + public function testAfterEvaluationErrorIsLoggedAndIsolated(): void + { + $a = new RecordingHook('A', throwFrom: ['afterEvaluation' => new RuntimeException('boom')]); + $b = new RecordingHook('B'); + $runner = new HookRunner($this->nullLogger(), [$a, $b]); + + $before = $runner->beforeEvaluation($this->ctx()); + $runner->afterEvaluation($this->ctx(), $before, $this->detail()); + + // Both hooks' afterEvaluation still ran, despite A throwing. + $this->assertCount(2, $a->calls); + $this->assertCount(2, $b->calls); + } + + public function testExceptionLoggedWithRequiredFormat(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('error') + ->with($this->equalTo('During evaluation of flag "flag-key", stage "beforeEvaluation" of hook "A" reported error: boom')); + + $a = new RecordingHook('A', throwFrom: ['beforeEvaluation' => new RuntimeException('boom')]); + $runner = new HookRunner($logger, [$a]); + $runner->beforeEvaluation($this->ctx()); + } + + public function testAfterTrackRunsInRegistrationOrder(): void + { + $shared = new CallLog(); + $a = new RecordingHook('A', $shared); + $b = new RecordingHook('B', $shared); + $runner = new HookRunner($this->nullLogger(), [$a, $b]); + + $runner->afterTrack(new TrackSeriesContext(LDContext::create('u'), 'event', null, null)); + + $this->assertSame(['A', 'B'], array_column($shared->calls, 'hook')); + } + + public function testAfterTrackExceptionIsLoggedAndIsolated(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('stage "afterTrack" of hook "A" reported error: boom')); + + $a = new RecordingHook('A', throwFrom: ['afterTrack' => new RuntimeException('boom')]); + $b = new RecordingHook('B'); + $runner = new HookRunner($logger, [$a, $b]); + + $runner->afterTrack(new TrackSeriesContext(LDContext::create('u'), 'event', null, null)); + + // B still runs after A throws. + $this->assertCount(1, $b->calls); + } + + public function testAddHookAppendsToEndOfOrder(): void + { + $shared = new CallLog(); + $a = new RecordingHook('A', $shared); + $runner = new HookRunner($this->nullLogger(), [$a]); + + $b = new RecordingHook('B', $shared); + $runner->addHook($b); + + $runner->beforeEvaluation($this->ctx()); + $this->assertSame(['A', 'B'], array_column($shared->calls, 'hook')); + } + + public function testHasHooksReflectsRegistrationState(): void + { + $runner = new HookRunner($this->nullLogger(), []); + $this->assertFalse($runner->hasHooks()); + + $runner->addHook(new RecordingHook('A')); + $this->assertTrue($runner->hasHooks()); + } +} diff --git a/tests/Hooks/HookTest.php b/tests/Hooks/HookTest.php new file mode 100644 index 00000000..cdd8f2fa --- /dev/null +++ b/tests/Hooks/HookTest.php @@ -0,0 +1,61 @@ + 'bar']; + $this->assertSame($data, $hook->beforeEvaluation($ctx, $data)); + } + + public function testDefaultAfterEvaluationReturnsInputData(): void + { + $hook = new class extends Hook { + public function getMetadata(): Metadata + { + return new Metadata('test'); + } + }; + + $ctx = new EvaluationSeriesContext('flag', LDContext::create('u'), false, 'variation'); + $detail = new EvaluationDetail(true, 0, EvaluationReason::off()); + $data = ['foo' => 'bar']; + $this->assertSame($data, $hook->afterEvaluation($ctx, $data, $detail)); + } + + public function testDefaultAfterTrackIsNoOp(): void + { + $hook = new class extends Hook { + public function getMetadata(): Metadata + { + return new Metadata('test'); + } + }; + + $ctx = new TrackSeriesContext(LDContext::create('u'), 'event', null, null); + // Simply invoking should not throw or have observable effects. + $hook->afterTrack($ctx); + $this->assertTrue(true); + } +} diff --git a/tests/Hooks/LDClientHooksTest.php b/tests/Hooks/LDClientHooksTest.php new file mode 100644 index 00000000..d62030a1 --- /dev/null +++ b/tests/Hooks/LDClientHooksTest.php @@ -0,0 +1,309 @@ +mockRequester = new MockFeatureRequester(); + } + + /** + * @param array $overrideOptions + */ + private function makeClient(array $overrideOptions = []): LDClient + { + $options = [ + 'feature_requester' => $this->mockRequester, + 'event_processor' => new MockEventProcessor(), + 'logger' => new \Psr\Log\NullLogger(), + ]; + return new LDClient('someKey', array_merge($options, $overrideOptions)); + } + + private function addFlag(string $key, mixed $value): void + { + $flag = ModelBuilders::flagBuilder($key) + ->version(1) + ->on(false) + ->variations($value) + ->offVariation(0) + ->fallthroughVariation(0) + ->build(); + $this->mockRequester->addFlag($flag); + } + + public function testVariationInvokesBeforeAndAfterInOrder(): void + { + $this->addFlag('flag', 'v'); + $shared = new CallLog(); + $a = new RecordingHook('A', $shared); + $b = new RecordingHook('B', $shared); + + $client = $this->makeClient(['hooks' => [$a, $b]]); + $client->variation('flag', LDContext::create('u'), 'default'); + + $this->assertSame( + ['A:beforeEvaluation', 'B:beforeEvaluation', 'B:afterEvaluation', 'A:afterEvaluation'], + array_map(fn ($e) => $e['hook'] . ':' . $e['stage'], $shared->calls), + ); + } + + public function testVariationPassesCorrectMethodAndContext(): void + { + $this->addFlag('flag', 'v'); + $hook = new RecordingHook('A'); + $client = $this->makeClient(['hooks' => [$hook]]); + + $context = LDContext::create('u'); + $client->variation('flag', $context, 'the-default'); + + /** @var EvaluationSeriesContext $seriesCtx */ + $seriesCtx = $hook->calls[0]['ctx']; + $this->assertSame('flag', $seriesCtx->flagKey); + $this->assertSame('the-default', $seriesCtx->defaultValue); + $this->assertSame('variation', $seriesCtx->method); + $this->assertSame($context, $seriesCtx->context); + } + + public function testVariationDetailUsesVariationDetailMethodName(): void + { + $this->addFlag('flag', 'v'); + $hook = new RecordingHook('A'); + $client = $this->makeClient(['hooks' => [$hook]]); + + $client->variationDetail('flag', LDContext::create('u'), 'default'); + + /** @var EvaluationSeriesContext $seriesCtx */ + $seriesCtx = $hook->calls[0]['ctx']; + $this->assertSame('variationDetail', $seriesCtx->method); + } + + public function testMigrationVariationUsesMigrationVariationMethodName(): void + { + $flag = ModelBuilders::flagBuilder('migration') + ->version(1) + ->on(false) + ->variations('off') + ->offVariation(0) + ->fallthroughVariation(0) + ->build(); + $this->mockRequester->addFlag($flag); + + $hook = new RecordingHook('A'); + $client = $this->makeClient(['hooks' => [$hook]]); + + $client->migrationVariation('migration', LDContext::create('u'), Stage::OFF); + + /** @var EvaluationSeriesContext $seriesCtx */ + $seriesCtx = $hook->calls[0]['ctx']; + $this->assertSame('migrationVariation', $seriesCtx->method); + } + + public function testAfterEvaluationReceivesEvaluationDetail(): void + { + $this->addFlag('flag', 'v'); + $hook = new RecordingHook('A'); + $client = $this->makeClient(['hooks' => [$hook]]); + + $client->variation('flag', LDContext::create('u'), 'default'); + + $afterCall = $hook->calls[1]; + $this->assertSame('afterEvaluation', $afterCall['stage']); + $this->assertSame('v', $afterCall['detail']->getValue()); + } + + public function testHookExceptionDoesNotBreakEvaluation(): void + { + $this->addFlag('flag', 'v'); + $hook = new RecordingHook('A', throwFrom: [ + 'beforeEvaluation' => new RuntimeException('x'), + 'afterEvaluation' => new RuntimeException('y'), + ]); + $client = $this->makeClient(['hooks' => [$hook]]); + + $value = $client->variation('flag', LDContext::create('u'), 'default'); + $this->assertSame('v', $value); + } + + public function testAfterEvaluationFiresWhenEvaluationThrowsNonExceptionThrowable(): void + { + // evaluateInternal catches \Throwable so a non-Exception error (e.g. TypeError) + // from a downstream component is converted into an EXCEPTION_ERROR detail rather + // than propagating. Hooks must still fire, and afterEvaluation must see that detail. + $throwingRequester = new class extends MockFeatureRequester { + public function getFeature(string $key): ?\LaunchDarkly\Impl\Model\FeatureFlag + { + throw new \TypeError('synthetic'); + } + }; + $hook = new RecordingHook('A'); + $client = $this->makeClient(['feature_requester' => $throwingRequester, 'hooks' => [$hook]]); + + $value = $client->variation('flag', LDContext::create('u'), 'default'); + $this->assertSame('default', $value); + + $this->assertCount(2, $hook->calls); + $this->assertSame('beforeEvaluation', $hook->calls[0]['stage']); + $this->assertSame('afterEvaluation', $hook->calls[1]['stage']); + $this->assertSame( + EvaluationReason::EXCEPTION_ERROR, + $hook->calls[1]['detail']->getReason()->getErrorKind(), + ); + } + + public function testHookFiresWhenContextIsInvalid(): void + { + // Invalid context — variation returns default, but hooks should still fire so + // telemetry can be emitted for failed evaluations. + $hook = new RecordingHook('A'); + $client = $this->makeClient(['hooks' => [$hook]]); + + $client->variation('flag', LDContext::create(''), 'default'); + + $this->assertCount(2, $hook->calls); + $this->assertSame('beforeEvaluation', $hook->calls[0]['stage']); + $this->assertSame('afterEvaluation', $hook->calls[1]['stage']); + $this->assertSame( + EvaluationReason::USER_NOT_SPECIFIED_ERROR, + $hook->calls[1]['detail']->getReason()->getErrorKind(), + ); + } + + public function testTrackInvokesAfterTrack(): void + { + $hook = new RecordingHook('A'); + $client = $this->makeClient(['hooks' => [$hook]]); + + $client->track('event', LDContext::create('u'), ['d' => 1], 2.5); + + $this->assertCount(1, $hook->calls); + /** @var TrackSeriesContext $tctx */ + $tctx = $hook->calls[0]['ctx']; + $this->assertSame('event', $tctx->key); + $this->assertSame(['d' => 1], $tctx->data); + $this->assertSame(2.5, $tctx->metricValue); + } + + public function testTrackDoesNotInvokeAfterTrackOnInvalidContext(): void + { + $hook = new RecordingHook('A'); + $client = $this->makeClient(['hooks' => [$hook]]); + + $client->track('event', LDContext::create(''), null); + + $this->assertCount(0, $hook->calls); + } + + public function testTrackMigrationOperationDoesNotInvokeAfterTrack(): void + { + $flag = ModelBuilders::flagBuilder('migration') + ->version(1) + ->on(false) + ->variations('off') + ->offVariation(0) + ->fallthroughVariation(0) + ->build(); + $this->mockRequester->addFlag($flag); + + $hook = new RecordingHook('A'); + $client = $this->makeClient(['hooks' => [$hook]]); + + $result = $client->migrationVariation('migration', LDContext::create('u'), Stage::OFF); + $tracker = $result['tracker']; + // Populate the tracker enough that build() returns an event (not a string error). + $tracker->operation(\LaunchDarkly\Migrations\Operation::READ); + $tracker->invoked(\LaunchDarkly\Migrations\Origin::OLD); + + // Clear hook calls from migrationVariation above. + $hook->calls = []; + + $client->trackMigrationOperation($tracker); + + // afterTrack must not fire for migration events (spec 1.6 — only custom events). + $this->assertCount(0, $hook->calls); + } + + public function testAddHookAfterConstruction(): void + { + $this->addFlag('flag', 'v'); + $client = $this->makeClient(); + + $hook = new RecordingHook('A'); + $client->addHook($hook); + + $client->variation('flag', LDContext::create('u'), 'default'); + $this->assertCount(2, $hook->calls); + } + + public function testNonHookEntriesInOptionAreIgnored(): void + { + $this->addFlag('flag', 'v'); + $hook = new RecordingHook('A'); + $client = $this->makeClient(['hooks' => [$hook, 'not-a-hook', new \stdClass()]]); + + // Should not throw; invalid entries silently dropped (with a warning log). + $client->variation('flag', LDContext::create('u'), 'default'); + $this->assertCount(2, $hook->calls); + } + + public function testHookRegisteredViaAllMechanismsAllFire(): void + { + $this->addFlag('flag', 'v'); + $shared = new CallLog(); + $a = new RecordingHook('A', $shared); + $b = new RecordingHook('B', $shared); + $client = $this->makeClient(['hooks' => [$a]]); + $client->addHook($b); + + $client->variation('flag', LDContext::create('u'), 'default'); + $this->assertSame( + ['A:beforeEvaluation', 'B:beforeEvaluation', 'B:afterEvaluation', 'A:afterEvaluation'], + array_map(fn ($e) => $e['hook'] . ':' . $e['stage'], $shared->calls), + ); + } + + public function testEmptyHooksDoesNotBreakEvaluation(): void + { + $this->addFlag('flag', 'v'); + $client = $this->makeClient(); + + $value = $client->variation('flag', LDContext::create('u'), 'default'); + $this->assertSame('v', $value); + } + + public function testNoOpHookImplementationDoesNotInterfere(): void + { + $noOp = new class extends Hook { + public function getMetadata(): Metadata + { + return new Metadata('NoOp'); + } + }; + + $this->addFlag('flag', 'v'); + $client = $this->makeClient(['hooks' => [$noOp]]); + + $value = $client->variation('flag', LDContext::create('u'), 'default'); + $this->assertSame('v', $value); + } +} diff --git a/tests/Hooks/RecordingHook.php b/tests/Hooks/RecordingHook.php new file mode 100644 index 00000000..284d5c8a --- /dev/null +++ b/tests/Hooks/RecordingHook.php @@ -0,0 +1,81 @@ +> */ + public array $calls = []; + + /** + * @param CallLog|null $sharedLog When provided, all hooks sharing the same CallLog append to it + * in invocation order. Allows cross-hook ordering assertions. + * @param array $throwFrom Maps stage name ('beforeEvaluation'|'afterEvaluation'|'afterTrack') + * to the exception that should be thrown when that stage runs. + * @param array> $returnData Additional data to merge into the return value of + * the given stage. + */ + public function __construct( + private readonly string $name, + private readonly ?CallLog $sharedLog = null, + private readonly array $throwFrom = [], + private readonly array $returnData = [], + ) { + } + + public function getMetadata(): Metadata + { + return new Metadata($this->name); + } + + public function beforeEvaluation(EvaluationSeriesContext $seriesContext, array $data): array + { + $this->record('beforeEvaluation', ['ctx' => $seriesContext, 'data' => $data]); + if (isset($this->throwFrom['beforeEvaluation'])) { + throw $this->throwFrom['beforeEvaluation']; + } + return array_merge($data, $this->returnData['beforeEvaluation'] ?? []); + } + + public function afterEvaluation( + EvaluationSeriesContext $seriesContext, + array $data, + EvaluationDetail $detail, + ): array { + $this->record('afterEvaluation', ['ctx' => $seriesContext, 'data' => $data, 'detail' => $detail]); + if (isset($this->throwFrom['afterEvaluation'])) { + throw $this->throwFrom['afterEvaluation']; + } + return array_merge($data, $this->returnData['afterEvaluation'] ?? []); + } + + public function afterTrack(TrackSeriesContext $seriesContext): void + { + $this->record('afterTrack', ['ctx' => $seriesContext]); + if (isset($this->throwFrom['afterTrack'])) { + throw $this->throwFrom['afterTrack']; + } + } + + /** + * @param array $payload + */ + private function record(string $stage, array $payload): void + { + $entry = ['hook' => $this->name, 'stage' => $stage] + $payload; + $this->calls[] = $entry; + $this->sharedLog?->append($entry); + } +}