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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
<PossiblyUnusedProperty>
<code><![CDATA[$context]]></code>
<code><![CDATA[$defaultValue]]></code>
<code><![CDATA[$environmentId]]></code>
<code><![CDATA[$method]]></code>
</PossiblyUnusedProperty>
</file>
Expand Down
20 changes: 18 additions & 2 deletions src/LaunchDarkly/Hooks/EvaluationSeriesContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
}
34 changes: 34 additions & 0 deletions src/LaunchDarkly/Impl/EnvironmentIdProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace LaunchDarkly\Impl;

/**
* @ignore
* @internal
*
* Mutable holder for the LaunchDarkly environment ID associated with the SDK key.
*
* The HTTP feature requester populates this holder from the `X-Ld-Envid` response
* header on successful fetches; LDClient reads from it when building hook contexts.
* Persistent-store feature requesters never write to the holder, so the value
* remains null for those configurations.
*/
final class EnvironmentIdProvider
{
private ?string $_environmentId = null;

public function get(): ?string
{
return $this->_environmentId;
}

public function set(?string $environmentId): void
{
if ($environmentId === null || $environmentId === '') {
return;
}
$this->_environmentId = $environmentId;
}
}
26 changes: 23 additions & 3 deletions src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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());
Expand All @@ -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));
}
}
16 changes: 13 additions & 3 deletions src/LaunchDarkly/LDClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
51 changes: 51 additions & 0 deletions tests/Hooks/EnvIdSettingFeatureRequester.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace LaunchDarkly\Tests\Hooks;

use LaunchDarkly\Impl\EnvironmentIdProvider;
use LaunchDarkly\Impl\Model\FeatureFlag;
use LaunchDarkly\Impl\Model\Segment;
use LaunchDarkly\Subsystems\FeatureRequester;
use LaunchDarkly\Tests\ModelBuilders;

/**
* Test feature requester that simulates GuzzleFeatureRequester's behavior: each fetch
* writes a configured value into the env ID holder. Used by hook tests that exercise
* the env-ID-on-EvaluationSeriesContext plumbing.
*/
class EnvIdSettingFeatureRequester implements FeatureRequester
{
private ?EnvironmentIdProvider $provider;

public function __construct(array $options, private readonly string $envId)
{
$envIdProvider = $options['_environment_id_provider'] ?? null;
$this->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 [];
}
}
53 changes: 53 additions & 0 deletions tests/Hooks/LDClientHooksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
48 changes: 48 additions & 0 deletions tests/Impl/EnvironmentIdProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace LaunchDarkly\Tests\Impl;

use LaunchDarkly\Impl\EnvironmentIdProvider;
use PHPUnit\Framework\TestCase;

class EnvironmentIdProviderTest extends TestCase
{
public function testStartsNull(): void
{
$provider = new EnvironmentIdProvider();
$this->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());
}
}
Loading
Loading