From 46131de7aea406ae6d09008a8169e4b390f4feca Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 27 Apr 2026 14:27:12 -0400 Subject: [PATCH] feat: expose environmentId on EvaluationSeriesContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the X-Ld-Envid header from LaunchDarkly polling responses and surfaces the resulting environment ID on EvaluationSeriesContext so downstream hooks (notably the future OpenTelemetry tracing hook) can implement OTEL Integration spec §1.2.2.9.2. Implementation: - New internal EnvironmentIdProvider value holder, owned by LDClient and injected into the feature requester via $options['_environment_id_provider']. - GuzzleFeatureRequester reads X-Ld-Envid on each response (success and BadResponseException paths — LD's polling endpoints emit the header on 4xx/5xx too) and writes it into the holder. - LDClient builds EvaluationSeriesContext twice per variation call: once before evaluation (env ID may be null on the very first variation in a process; populated on subsequent calls) and once after (always sees the env ID captured by the call's own fetch). Caveats baked in: - Persistent-store feature requesters (Redis, Consul, DynamoDB) never write to the holder, so environmentId stays null when the SDK isn't fetching directly from LaunchDarkly. - The first variation in a PHP process sees null in beforeEvaluation; the fetch happens during evaluation, so the value is only known by the time afterEvaluation runs. - environmentId on TrackSeriesContext is intentionally deferred; can be added later using the same holder if needed. The FeatureRequester subsystem interface is unchanged — third-party persistent-store packages do not need to be updated. --- psalm-baseline.xml | 1 + .../Hooks/EvaluationSeriesContext.php | 20 ++- .../Impl/EnvironmentIdProvider.php | 34 +++++ .../Integrations/GuzzleFeatureRequester.php | 26 +++- src/LaunchDarkly/LDClient.php | 16 ++- tests/Hooks/EnvIdSettingFeatureRequester.php | 51 +++++++ tests/Hooks/LDClientHooksTest.php | 53 ++++++++ tests/Impl/EnvironmentIdProviderTest.php | 48 +++++++ ...uzzleFeatureRequesterEnvironmentIdTest.php | 128 ++++++++++++++++++ 9 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 src/LaunchDarkly/Impl/EnvironmentIdProvider.php create mode 100644 tests/Hooks/EnvIdSettingFeatureRequester.php create mode 100644 tests/Impl/EnvironmentIdProviderTest.php create mode 100644 tests/Impl/Integrations/GuzzleFeatureRequesterEnvironmentIdTest.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 4cdf763f..6b148f95 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -49,6 +49,7 @@ + diff --git a/src/LaunchDarkly/Hooks/EvaluationSeriesContext.php b/src/LaunchDarkly/Hooks/EvaluationSeriesContext.php index 317fa499..70155413 100644 --- a/src/LaunchDarkly/Hooks/EvaluationSeriesContext.php +++ b/src/LaunchDarkly/Hooks/EvaluationSeriesContext.php @@ -9,16 +9,32 @@ /** * Contextual information provided to stages of the evaluation series. * - * An instance is created once per variation call and passed, unchanged, to every - * stage of every registered hook for that call. + * One instance is provided to each stage of the evaluation series. The instance + * passed to `beforeEvaluation` and the one passed to `afterEvaluation` may differ + * in their `environmentId` value: the env ID is unknown until the SDK has fetched + * flag data from LaunchDarkly at least once, so the first variation call's + * `beforeEvaluation` typically sees `null` while its `afterEvaluation` sees the + * captured value. See {@see \LaunchDarkly\Hooks\EvaluationSeriesContext::$environmentId}. */ final class EvaluationSeriesContext { + /** + * @param string $flagKey The key of the flag being evaluated. + * @param LDContext $context The evaluation context. + * @param mixed $defaultValue The default value the caller passed to the variation method. + * @param string $method The variation method being executed (e.g. `variation`, `variationDetail`). + * @param ?string $environmentId The LaunchDarkly environment ID associated with the SDK key, + * if known. Populated from the `X-Ld-Envid` response header when the SDK fetches flags + * directly from LaunchDarkly. Always `null` when using a persistent-store feature requester + * (e.g. Redis, Consul, DynamoDB), and `null` for the very first variation call's + * `beforeEvaluation` stage in a PHP process (the value is captured during evaluation). + */ public function __construct( public readonly string $flagKey, public readonly LDContext $context, public readonly mixed $defaultValue, public readonly string $method, + public readonly ?string $environmentId = null, ) { } } diff --git a/src/LaunchDarkly/Impl/EnvironmentIdProvider.php b/src/LaunchDarkly/Impl/EnvironmentIdProvider.php new file mode 100644 index 00000000..df3ea509 --- /dev/null +++ b/src/LaunchDarkly/Impl/EnvironmentIdProvider.php @@ -0,0 +1,34 @@ +_environmentId; + } + + public function set(?string $environmentId): void + { + if ($environmentId === null || $environmentId === '') { + return; + } + $this->_environmentId = $environmentId; + } +} diff --git a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php index 0da8bb73..e012d582 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php @@ -10,12 +10,14 @@ use GuzzleHttp\HandlerStack; use Kevinrob\GuzzleCache\CacheMiddleware; use Kevinrob\GuzzleCache\Strategy\PublicCacheStrategy; +use LaunchDarkly\Impl\EnvironmentIdProvider; use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\Impl\Model\Segment; use LaunchDarkly\Impl\UnrecoverableHTTPStatusException; use LaunchDarkly\Impl\Util; use LaunchDarkly\Subsystems\FeatureRequester; use Psr\Cache\CacheItemPoolInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; /** @@ -26,14 +28,18 @@ class GuzzleFeatureRequester implements FeatureRequester { const SDK_FLAGS = "sdk/flags"; const SDK_SEGMENTS = "sdk/segments"; + const ENVIRONMENT_ID_HEADER = "X-Ld-Envid"; private Client $_client; private LoggerInterface $_logger; + private ?EnvironmentIdProvider $_environmentIdProvider; public function __construct(string $baseUri, string $sdkKey, array $options) { $baseUri = \LaunchDarkly\Impl\Util::adjustBaseUri($baseUri); $this->_logger = $options['logger']; + $envIdProvider = $options['_environment_id_provider'] ?? null; + $this->_environmentIdProvider = $envIdProvider instanceof EnvironmentIdProvider ? $envIdProvider : null; $stack = HandlerStack::create(); if (class_exists('\Kevinrob\GuzzleCache\CacheMiddleware')) { $cache = $options['cache'] ?? null; @@ -70,11 +76,14 @@ public function getFeature(string $key): ?FeatureFlag { try { $response = $this->_client->get(self::SDK_FLAGS . "/" . $key); + $this->captureEnvironmentId($response); $body = $response->getBody(); return FeatureFlag::decode(json_decode($body->getContents(), true)); } catch (BadResponseException $e) { /** @psalm-suppress PossiblyNullReference (resolved in guzzle 7) */ - $code = $e->getResponse()->getStatusCode(); + $response = $e->getResponse(); + $this->captureEnvironmentId($response); + $code = $response->getStatusCode(); if ($code == 404) { $this->_logger->warning("GuzzleFeatureRequester::get returned 404. Feature flag does not exist for key: " . $key); } else { @@ -97,11 +106,14 @@ public function getSegment(string $key): ?Segment { try { $response = $this->_client->get(self::SDK_SEGMENTS . "/" . $key); + $this->captureEnvironmentId($response); $body = $response->getBody(); return Segment::decode(json_decode($body->getContents(), true)); } catch (BadResponseException $e) { /** @psalm-suppress PossiblyNullReference (resolved in guzzle 7) */ - $code = $e->getResponse()->getStatusCode(); + $response = $e->getResponse(); + $this->captureEnvironmentId($response); + $code = $response->getStatusCode(); if ($code == 404) { $this->_logger->warning("GuzzleFeatureRequester::get returned 404. Segment does not exist for key: " . $key); } else { @@ -123,11 +135,14 @@ public function getAllFeatures(): ?array { try { $response = $this->_client->get(self::SDK_FLAGS); + $this->captureEnvironmentId($response); $body = $response->getBody(); return array_map(FeatureFlag::getDecoder(), json_decode($body->getContents(), true)); } catch (BadResponseException $e) { /** @psalm-suppress PossiblyNullReference (resolved in guzzle 7) */ - $this->handleUnexpectedStatus($e->getResponse()->getStatusCode(), "GuzzleFeatureRequester::getAll"); + $response = $e->getResponse(); + $this->captureEnvironmentId($response); + $this->handleUnexpectedStatus($response->getStatusCode(), "GuzzleFeatureRequester::getAll"); return null; } catch (Exception $e) { $this->_logger->error("GuzzleFeatureRequester::getAll encountered an exception retrieving all flags: " . $e->getMessage()); @@ -142,4 +157,9 @@ private function handleUnexpectedStatus(int $code, string $method): void throw new UnrecoverableHTTPStatusException($code); } } + + private function captureEnvironmentId(ResponseInterface $response): void + { + $this->_environmentIdProvider?->set($response->getHeaderLine(self::ENVIRONMENT_ID_HEADER)); + } } diff --git a/src/LaunchDarkly/LDClient.php b/src/LaunchDarkly/LDClient.php index 2e7da601..685093eb 100644 --- a/src/LaunchDarkly/LDClient.php +++ b/src/LaunchDarkly/LDClient.php @@ -8,6 +8,7 @@ use LaunchDarkly\Hooks\Hook; use LaunchDarkly\Hooks\TrackSeriesContext; use LaunchDarkly\Impl\BigSegments; +use LaunchDarkly\Impl\EnvironmentIdProvider; use LaunchDarkly\Impl\Evaluation\EvalResult; use LaunchDarkly\Impl\Evaluation\Evaluator; use LaunchDarkly\Impl\Evaluation\PrerequisiteEvaluationRecord; @@ -61,6 +62,7 @@ class LDClient protected BigSegments\StoreManager $_bigSegmentsStoreManager; protected BigSegmentStatusProvider $_bigSegmentStatusProvider; protected HookRunner $_hookRunner; + protected EnvironmentIdProvider $_environmentIdProvider; /** * Creates a new client instance that connects to LaunchDarkly. @@ -183,6 +185,9 @@ public function __construct(string $sdkKey, array $options = []) $this->_eventProcessor = new EventProcessor($sdkKey, $options); } + $this->_environmentIdProvider = new EnvironmentIdProvider(); + $options['_environment_id_provider'] = $this->_environmentIdProvider; + $this->_featureRequester = $this->getFeatureRequester($sdkKey, $options); $this->_evaluator = new Evaluator($this->_featureRequester, $this->_bigSegmentsStoreManager, $this->_logger); @@ -363,10 +368,15 @@ private function variationDetailInternal(string $key, LDContext $context, mixed return $this->evaluateInternal($key, $context, $default, $eventFactory); } - $seriesContext = new EvaluationSeriesContext($key, $context, $default, $method); - $beforeData = $this->_hookRunner->beforeEvaluation($seriesContext); + // Build the series context for beforeEvaluation with whatever env ID the SDK already + // knows. On the first variation call in a PHP process this is null; the fetch inside + // evaluateInternal may populate it before afterEvaluation runs. + $beforeContext = new EvaluationSeriesContext($key, $context, $default, $method, $this->_environmentIdProvider->get()); + $beforeData = $this->_hookRunner->beforeEvaluation($beforeContext); $result = $this->evaluateInternal($key, $context, $default, $eventFactory); - $this->_hookRunner->afterEvaluation($seriesContext, $beforeData, $result['detail']); + // Re-read the env ID so afterEvaluation sees a value captured during this call's fetch. + $afterContext = new EvaluationSeriesContext($key, $context, $default, $method, $this->_environmentIdProvider->get()); + $this->_hookRunner->afterEvaluation($afterContext, $beforeData, $result['detail']); return $result; } diff --git a/tests/Hooks/EnvIdSettingFeatureRequester.php b/tests/Hooks/EnvIdSettingFeatureRequester.php new file mode 100644 index 00000000..bb48f267 --- /dev/null +++ b/tests/Hooks/EnvIdSettingFeatureRequester.php @@ -0,0 +1,51 @@ +provider = $envIdProvider instanceof EnvironmentIdProvider ? $envIdProvider : null; + } + + public function getFeature(string $key): ?FeatureFlag + { + $this->provider?->set($this->envId); + return ModelBuilders::flagBuilder($key) + ->version(1) + ->on(false) + ->variations('v') + ->offVariation(0) + ->fallthroughVariation(0) + ->build(); + } + + public function getSegment(string $key): ?Segment + { + $this->provider?->set($this->envId); + return null; + } + + public function getAllFeatures(): ?array + { + $this->provider?->set($this->envId); + return []; + } +} diff --git a/tests/Hooks/LDClientHooksTest.php b/tests/Hooks/LDClientHooksTest.php index d62030a1..90cc49ca 100644 --- a/tests/Hooks/LDClientHooksTest.php +++ b/tests/Hooks/LDClientHooksTest.php @@ -306,4 +306,57 @@ public function getMetadata(): Metadata $value = $client->variation('flag', LDContext::create('u'), 'default'); $this->assertSame('v', $value); } + + public function testEnvironmentIdAvailableInAfterEvaluationOnFirstCall(): void + { + // Simulate what GuzzleFeatureRequester does: write to the env ID holder during the + // fetch that happens inside variation(). beforeEvaluation runs prior to the fetch + // and sees null; afterEvaluation runs after and sees the captured ID. + $factory = function (string $baseUri, string $sdkKey, array $options) { + return new EnvIdSettingFeatureRequester($options, 'env-from-fetch'); + }; + $hook = new RecordingHook('A'); + $client = $this->makeClient(['feature_requester' => $factory, 'hooks' => [$hook]]); + + $client->variation('any-flag', LDContext::create('u'), 'default'); + + $this->assertCount(2, $hook->calls); + $this->assertNull($hook->calls[0]['ctx']->environmentId); + $this->assertSame('env-from-fetch', $hook->calls[1]['ctx']->environmentId); + } + + public function testEnvironmentIdAvailableInBothStagesOnSubsequentCalls(): void + { + // After the first variation populates the holder, subsequent calls within the same + // LDClient lifetime see the env ID in beforeEvaluation as well. + $factory = function (string $baseUri, string $sdkKey, array $options) { + return new EnvIdSettingFeatureRequester($options, 'env-from-fetch'); + }; + $hook = new RecordingHook('A'); + $client = $this->makeClient(['feature_requester' => $factory, 'hooks' => [$hook]]); + + $client->variation('flag-1', LDContext::create('u'), 'default'); + $hook->calls = []; + $client->variation('flag-2', LDContext::create('u'), 'default'); + + $this->assertCount(2, $hook->calls); + $this->assertSame('env-from-fetch', $hook->calls[0]['ctx']->environmentId); + $this->assertSame('env-from-fetch', $hook->calls[1]['ctx']->environmentId); + } + + public function testEnvironmentIdNullWhenRequesterDoesNotPopulate(): void + { + // Persistent-store feature requesters do not write to the holder. Hooks see null + // for environmentId in both stages, regardless of how many calls occur. + $hook = new RecordingHook('A'); + $this->addFlag('flag', 'v'); + $client = $this->makeClient(['hooks' => [$hook]]); + + $client->variation('flag', LDContext::create('u'), 'default'); + $client->variation('flag', LDContext::create('u'), 'default'); + + foreach ($hook->calls as $call) { + $this->assertNull($call['ctx']->environmentId); + } + } } diff --git a/tests/Impl/EnvironmentIdProviderTest.php b/tests/Impl/EnvironmentIdProviderTest.php new file mode 100644 index 00000000..6d9c7394 --- /dev/null +++ b/tests/Impl/EnvironmentIdProviderTest.php @@ -0,0 +1,48 @@ +assertNull($provider->get()); + } + + public function testSetStoresValue(): void + { + $provider = new EnvironmentIdProvider(); + $provider->set('env-abc'); + $this->assertSame('env-abc', $provider->get()); + } + + public function testSetIgnoresNull(): void + { + $provider = new EnvironmentIdProvider(); + $provider->set('env-abc'); + $provider->set(null); + $this->assertSame('env-abc', $provider->get()); + } + + public function testSetIgnoresEmptyString(): void + { + $provider = new EnvironmentIdProvider(); + $provider->set('env-abc'); + $provider->set(''); + $this->assertSame('env-abc', $provider->get()); + } + + public function testSetOverwritesPriorValue(): void + { + $provider = new EnvironmentIdProvider(); + $provider->set('first'); + $provider->set('second'); + $this->assertSame('second', $provider->get()); + } +} diff --git a/tests/Impl/Integrations/GuzzleFeatureRequesterEnvironmentIdTest.php b/tests/Impl/Integrations/GuzzleFeatureRequesterEnvironmentIdTest.php new file mode 100644 index 00000000..5aac3d3f --- /dev/null +++ b/tests/Impl/Integrations/GuzzleFeatureRequesterEnvironmentIdTest.php @@ -0,0 +1,128 @@ + new NullLogger(), + 'timeout' => 3, + 'connect_timeout' => 3, + '_environment_id_provider' => $provider, + // Inject our handler into Guzzle by way of GuzzleFeatureRequester's existing + // base_uri/timeout/etc construction: we replace the client's HandlerStack + // by passing our own via a private property override below. + ]; + + $requester = new GuzzleFeatureRequester('http://example.invalid', 'sdk-key', $options); + // Swap the internal Guzzle client for one wired to MockHandler. The captureEnvironmentId + // logic does not depend on the rest of the client setup, so this is sufficient. + $reflection = new \ReflectionProperty($requester, '_client'); + $reflection->setValue($requester, new \GuzzleHttp\Client(['handler' => HandlerStack::create($mock)])); + return $requester; + } + + public function testHeaderPopulatesProvider(): void + { + $provider = new EnvironmentIdProvider(); + $mock = new MockHandler([ + new Response(200, ['X-Ld-Envid' => 'env-abc'], '{"key": "flag", "version": 1, "on": false, "variations": ["v"], "offVariation": 0, "fallthrough": {"variation": 0}}'), + ]); + + $this->makeRequester($mock, $provider)->getFeature('flag'); + $this->assertSame('env-abc', $provider->get()); + } + + public function testMissingHeaderLeavesProviderNull(): void + { + $provider = new EnvironmentIdProvider(); + $mock = new MockHandler([ + new Response(200, [], '{"key": "flag", "version": 1, "on": false, "variations": ["v"], "offVariation": 0, "fallthrough": {"variation": 0}}'), + ]); + + $this->makeRequester($mock, $provider)->getFeature('flag'); + $this->assertNull($provider->get()); + } + + public function testHeaderCaseInsensitive(): void + { + $provider = new EnvironmentIdProvider(); + $mock = new MockHandler([ + new Response(200, ['x-ld-envid' => 'env-lower'], '{"key": "flag", "version": 1, "on": false, "variations": ["v"], "offVariation": 0, "fallthrough": {"variation": 0}}'), + ]); + + $this->makeRequester($mock, $provider)->getFeature('flag'); + $this->assertSame('env-lower', $provider->get()); + } + + public function testGetAllFeaturesAlsoCapturesHeader(): void + { + $provider = new EnvironmentIdProvider(); + $mock = new MockHandler([ + new Response(200, ['X-Ld-Envid' => 'env-all'], '{}'), + ]); + + $this->makeRequester($mock, $provider)->getAllFeatures(); + $this->assertSame('env-all', $provider->get()); + } + + public function test404ResponsePopulatesProvider(): void + { + // LaunchDarkly's polling endpoints emit X-Ld-Envid on error responses too, + // so a 404 for an unknown flag should still surface the env ID. + $provider = new EnvironmentIdProvider(); + $mock = new MockHandler([ + new Response(404, ['X-Ld-Envid' => 'env-404'], '{}'), + ]); + + $this->makeRequester($mock, $provider)->getFeature('flag'); + $this->assertSame('env-404', $provider->get()); + } + + public function test5xxResponsePopulatesProvider(): void + { + // Non-recoverable HTTP errors throw UnrecoverableHTTPStatusException after + // capturing the env ID so it remains observable to subsequent hook contexts. + $provider = new EnvironmentIdProvider(); + $mock = new MockHandler([ + new Response(500, ['X-Ld-Envid' => 'env-500'], '{}'), + ]); + + try { + $this->makeRequester($mock, $provider)->getFeature('flag'); + } catch (\LaunchDarkly\Impl\UnrecoverableHTTPStatusException) { + // expected for non-recoverable status + } + $this->assertSame('env-500', $provider->get()); + } + + public function testNoProviderInOptionsWorksFine(): void + { + // When LDClient does not inject a provider (e.g. external tooling instantiates + // the requester directly), the requester must not error. + $mock = new MockHandler([ + new Response(200, ['X-Ld-Envid' => 'env-anything'], '{"key": "flag", "version": 1, "on": false, "variations": ["v"], "offVariation": 0, "fallthrough": {"variation": 0}}'), + ]); + + $this->makeRequester($mock, null)->getFeature('flag'); + $this->assertTrue(true); // no exception raised + } +}