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
5 changes: 5 additions & 0 deletions .changeset/brave-foxes-noop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-php': patch
---

No-op facade and feature flag APIs when the SDK is uninitialized or non-operational.
62 changes: 48 additions & 14 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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']) {
Expand Down Expand Up @@ -872,7 +874,7 @@ public function evaluateFlags(

if ($shouldHitRemote) {
try {
$response = $this->flags(
$response = $this->requestFlags(
$distinctId,
$groups,
$personProperties,
Expand Down Expand Up @@ -1076,7 +1078,6 @@ private function computeFlagLocally(
* @param array<string, mixed> $personProperties Person properties to use for flag evaluation.
* @param array<string, array<string, mixed>> $groupProperties Group properties to use for flag evaluation.
* @return array<string, bool|string> Feature flag values by key.
* @throws Exception
*/
public function fetchFeatureVariants(
string $distinctId,
Expand All @@ -1090,16 +1091,17 @@ public function fetchFeatureVariants(

/**
* @param string $distinctId
* @param array $groups
* @return array of feature flags
* @throws Exception
* @param array<string, mixed> $groups
* @param array<string, mixed> $personProperties
* @param array<string, array<string, mixed>> $groupProperties
* @return array<string, mixed> Feature flags response.
*/
private function fetchFlagsResponse(
string $distinctId,
array $groups = [],
array $personProperties = [],
array $groupProperties = []
): ?array {
): array {
return $this->flags($distinctId, $groups, $personProperties, $groupProperties);
}

Expand Down Expand Up @@ -1279,7 +1281,6 @@ private function normalizeFeatureFlags(string $response): array
* @param bool $disableGeoip Whether to disable GeoIP enrichment during remote evaluation.
* @param list<string>|null $flagKeys Optional list of flag keys to evaluate.
* @return array<string, mixed> The normalized feature flags response.
* @throws HttpException On network errors, API errors, or quota limits.
*/
public function flags(
string $distinctId,
Expand All @@ -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<string, mixed>
* @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(
Expand Down Expand Up @@ -1371,6 +1395,16 @@ public function flags(
return $this->normalizeFeatureFlags($httpResponse->getResponse());
}

/** @return array{featureFlags: array<string, mixed>, featureFlagPayloads: array<string, mixed>, flags: array<string, mixed>} */
private function emptyFlagsResponse(): array
{
return [
'featureFlags' => [],
'featureFlagPayloads' => [],
'flags' => [],
];
}

/**
* Aliases from one user id to another.
*
Expand Down
15 changes: 9 additions & 6 deletions lib/PostHog.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -330,6 +330,8 @@ public static function getFeatureFlagPayload(
array $personProperties = array(),
array $groupProperties = array(),
): mixed {
self::checkClient();

return self::$client->getFeatureFlagPayload(
$key,
$distinctId,
Expand Down Expand Up @@ -415,7 +417,6 @@ public static function getAllFlags(
* @param string $distinctId The user's distinct ID.
* @param array<string, mixed> $groups Group identifiers for group-based flags.
* @return array<string, bool|string>
* @throws Exception
*/
public static function fetchFeatureVariants(string $distinctId, array $groups = array()): array
{
Expand Down Expand Up @@ -498,6 +499,8 @@ public static function contextFromHeaders(array $headers): array
*/
public static function raw(array $message)
{
self::checkClient();

return self::$client->raw($message);
}

Expand Down Expand Up @@ -564,17 +567,17 @@ 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()
{
if (isset(self::$client)) {
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);
Comment thread
marandaneto marked this conversation as resolved.
}

/**
Expand Down
94 changes: 94 additions & 0 deletions test/PostHogTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
Loading