diff --git a/.changeset/brave-foxes-noop.md b/.changeset/brave-foxes-noop.md new file mode 100644 index 0000000..460e4ac --- /dev/null +++ b/.changeset/brave-foxes-noop.md @@ -0,0 +1,5 @@ +--- +'posthog-php': patch +--- + +No-op facade and feature flag APIs when the SDK is uninitialized or non-operational. diff --git a/lib/Client.php b/lib/Client.php index cbb5691..dceac83 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -147,7 +147,9 @@ public function __construct( $this->debug = $options["debug"] ?? false; $this->options['host'] = StringNormalizer::normalizeHost($options['host'] ?? null); if (!$this->enabled) { - error_log('[PostHog][Client] apiKey is empty after trimming whitespace; check your project API key'); + if (($this->options['consumer'] ?? null) !== 'noop') { + error_log('[PostHog][Client] apiKey is empty after trimming whitespace; check your project API key'); + } $this->options['consumer'] = 'noop'; } $Consumer = self::CONSUMERS[$this->options["consumer"] ?? "lib_curl"]; @@ -542,7 +544,7 @@ private function doGetFeatureFlagResult( if (!$flagWasEvaluatedLocally && !$onlyEvaluateLocally) { try { - $response = $this->fetchFlagsResponse($distinctId, $groups, $personProperties, $groupProperties); + $response = $this->requestFlags($distinctId, $groups, $personProperties, $groupProperties); $errors = []; if (isset($response['errorsWhileComputingFlags']) && $response['errorsWhileComputingFlags']) { @@ -872,7 +874,7 @@ public function evaluateFlags( if ($shouldHitRemote) { try { - $response = $this->flags( + $response = $this->requestFlags( $distinctId, $groups, $personProperties, @@ -1076,7 +1078,6 @@ private function computeFlagLocally( * @param array $personProperties Person properties to use for flag evaluation. * @param array> $groupProperties Group properties to use for flag evaluation. * @return array Feature flag values by key. - * @throws Exception */ public function fetchFeatureVariants( string $distinctId, @@ -1090,16 +1091,17 @@ public function fetchFeatureVariants( /** * @param string $distinctId - * @param array $groups - * @return array of feature flags - * @throws Exception + * @param array $groups + * @param array $personProperties + * @param array> $groupProperties + * @return array Feature flags response. */ private function fetchFlagsResponse( string $distinctId, array $groups = [], array $personProperties = [], array $groupProperties = [] - ): ?array { + ): array { return $this->flags($distinctId, $groups, $personProperties, $groupProperties); } @@ -1279,7 +1281,6 @@ private function normalizeFeatureFlags(string $response): array * @param bool $disableGeoip Whether to disable GeoIP enrichment during remote evaluation. * @param list|null $flagKeys Optional list of flag keys to evaluate. * @return array The normalized feature flags response. - * @throws HttpException On network errors, API errors, or quota limits. */ public function flags( string $distinctId, @@ -1288,13 +1289,36 @@ public function flags( array $groupProperties = [], bool $disableGeoip = false, ?array $flagKeys = null + ): array { + try { + return $this->requestFlags( + $distinctId, + $groups, + $personProperties, + $groupProperties, + $disableGeoip, + $flagKeys + ); + } catch (HttpException $e) { + error_log('[PostHog][Client] Unable to fetch feature flags: ' . $e->getMessage()); + return $this->emptyFlagsResponse(); + } + } + + /** + * @return array + * @throws HttpException On network errors, API errors, or quota limits. + */ + private function requestFlags( + string $distinctId, + array $groups = array(), + array $personProperties = [], + array $groupProperties = [], + bool $disableGeoip = false, + ?array $flagKeys = null ): array { if (!$this->enabled) { - return [ - 'featureFlags' => [], - 'featureFlagPayloads' => [], - 'flags' => [], - ]; + return $this->emptyFlagsResponse(); } $payload = array( @@ -1371,6 +1395,16 @@ public function flags( return $this->normalizeFeatureFlags($httpResponse->getResponse()); } + /** @return array{featureFlags: array, featureFlagPayloads: array, flags: array} */ + private function emptyFlagsResponse(): array + { + return [ + 'featureFlags' => [], + 'featureFlagPayloads' => [], + 'flags' => [], + ]; + } + /** * Aliases from one user id to another. * diff --git a/lib/PostHog.php b/lib/PostHog.php index 60a0661..0e289bf 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -13,7 +13,7 @@ class PostHog public const ENV_API_KEY = "POSTHOG_API_KEY"; public const ENV_HOST = "POSTHOG_HOST"; - private static Client $client; + private static ?Client $client = null; /** * Initializes the default client to use. Uses the libcurl consumer by default. @@ -330,6 +330,8 @@ public static function getFeatureFlagPayload( array $personProperties = array(), array $groupProperties = array(), ): mixed { + self::checkClient(); + return self::$client->getFeatureFlagPayload( $key, $distinctId, @@ -415,7 +417,6 @@ public static function getAllFlags( * @param string $distinctId The user's distinct ID. * @param array $groups Group identifiers for group-based flags. * @return array - * @throws Exception */ public static function fetchFeatureVariants(string $distinctId, array $groups = array()): array { @@ -498,6 +499,8 @@ public static function contextFromHeaders(array $headers): array */ public static function raw(array $message) { + self::checkClient(); + return self::$client->raw($message); } @@ -564,9 +567,8 @@ private static function cleanHost(?string $host): string } /** - * Check the client. - * - * @throws Exception + * Ensure the default client exists. If init() was never called, install a disabled no-op client + * so public SDK methods do not throw into the host application. */ private static function checkClient() { @@ -574,7 +576,8 @@ private static function checkClient() return; } - throw new Exception("PostHog::init() must be called before any other capturing method."); + error_log('[PostHog] PostHog::init() was not called; SDK will no-op.'); + self::$client = new Client(null, array('consumer' => 'noop'), null, null, false); } /** diff --git a/test/PostHogTest.php b/test/PostHogTest.php index 8604257..0b14dc8 100644 --- a/test/PostHogTest.php +++ b/test/PostHogTest.php @@ -76,6 +76,15 @@ private function withEnvApiKey(?string $apiKey, callable $callback): void } } + private function unsetFacadeClient(): void + { + $resetClient = \Closure::bind(function (): void { + self::$client = null; + }, null, PostHog::class); + + $resetClient(); + } + public static function initNoOpApiKeyCases(): array { return [ @@ -92,6 +101,53 @@ public static function disabledClientNoRequestCases(): array ]; } + public static function facadeNoOpBeforeInitCases(): array + { + return [ + 'capture' => [ + static fn() => PostHog::capture([ + "distinctId" => "john", + "event" => "Module PHP Event", + ]), + false, + ], + 'identify' => [ + static fn() => PostHog::identify(["distinctId" => "john"]), + false, + ], + 'alias' => [ + static fn() => PostHog::alias([ + "distinctId" => "john", + "alias" => "anonymous", + ]), + false, + ], + 'groupIdentify' => [ + static fn() => PostHog::groupIdentify([ + "groupType" => "organization", + "groupKey" => "id:5", + ]), + false, + ], + 'raw' => [ + static fn() => PostHog::raw(["type" => "capture"]), + false, + ], + 'flush' => [ + static fn() => PostHog::flush(), + false, + ], + 'getFeatureFlagPayload' => [ + static fn() => PostHog::getFeatureFlagPayload("flag", "john"), + null, + ], + 'fetchFeatureVariants' => [ + static fn() => PostHog::fetchFeatureVariants("john"), + [], + ], + ]; + } + public function testInitWithParamApiKey(): void { $this->expectNotToPerformAssertions(); @@ -312,6 +368,44 @@ public function testClientWithBlankApiKeyDoesNotSendRequests(?string $apiKey, st $this->assertSame([], $httpClient->calls ?? []); } + /** + * @dataProvider facadeNoOpBeforeInitCases + */ + public function testFacadeMethodsNoOpBeforeInit(callable $call, mixed $expectedValue): void + { + $this->unsetFacadeClient(); + + try { + $this->assertSame($expectedValue, $call()); + $this->assertInstanceOf(NoOp::class, $this->getConsumer(PostHog::getClient())); + + global $errorMessages; + $this->assertContains( + '[PostHog] PostHog::init() was not called; SDK will no-op.', + $errorMessages + ); + $this->assertNotContains( + '[PostHog][Client] apiKey is empty after trimming whitespace; check your project API key', + $errorMessages + ); + } finally { + PostHog::init(null, null, $this->client); + } + } + + public function testDirectFlagsApisReturnDefaultsOnApiError(): void + { + $httpClient = new MockedHttpClient("app.posthog.com", flagsEndpointResponseCode: 401); + $client = new Client(self::FAKE_API_KEY, ["debug" => true], $httpClient, null, false); + + $this->assertSame([ + 'featureFlags' => [], + 'featureFlagPayloads' => [], + 'flags' => [], + ], $client->flags("john")); + $this->assertSame([], $client->fetchFeatureVariants("john")); + } + public function testDisabledClientLoadFlagsDoesNotMutateCachedFlags(): void { $httpClient = new MockedHttpClient("app.posthog.com");