Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
92 changes: 87 additions & 5 deletions example.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,10 @@ function loadEnvFile()
echo "2. Feature flag local evaluation examples\n";
echo "3. Feature flag dependencies examples\n";
echo "4. Context management and tagging examples\n";
echo "5. Run all examples\n";
echo "6. Exit\n";
$choice = trim(readline("\nEnter your choice (1-6): "));
echo "5. ETag polling examples (for local evaluation)\n";
echo "6. Run all examples\n";
echo "7. Exit\n";
$choice = trim(readline("\nEnter your choice (1-7): "));

function identifyAndCaptureExamples()
{
Expand Down Expand Up @@ -420,6 +421,83 @@ function contextManagementExamples()
echo "✅ Context management examples completed!\n";
}

function etagPollingExamples()
{
echo "\n" . str_repeat("=", 60) . "\n";
echo "ETAG POLLING EXAMPLES\n";
echo str_repeat("=", 60) . "\n";
echo "This example demonstrates ETag-based caching for feature flags.\n";
echo "ETag support reduces bandwidth by skipping full payload transfers\n";
echo "when flags haven't changed (304 Not Modified response).\n\n";

// Re-initialize with debug enabled
PostHog::init(
$_ENV['POSTHOG_PROJECT_API_KEY'],
[
'host' => $_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com',
'debug' => true,
'ssl' => !str_starts_with($_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', 'http://')
],
null,
$_ENV['POSTHOG_PERSONAL_API_KEY']
);

$client = PostHog::getClient();

// Initial load - should get full response with ETag
echo "📥 Initial flag load (expecting full response with ETag)...\n";
$client->loadFlags();
$initialEtag = $client->getFlagsEtag();
$flagCount = count($client->featureFlags);

if ($initialEtag) {
echo " ✅ Received ETag: " . substr($initialEtag, 0, 30) . "...\n";
} else {
echo " ⚠️ No ETag received (server may not support ETag caching)\n";
}
echo " 📊 Loaded $flagCount feature flag(s)\n\n";

// Second load - should get 304 Not Modified if flags haven't changed
echo "📥 Second flag load (expecting 304 Not Modified if unchanged)...\n";
$client->loadFlags();
$secondEtag = $client->getFlagsEtag();
$secondFlagCount = count($client->featureFlags);

echo " 📊 Flag count: $secondFlagCount (should match initial: $flagCount)\n";
if ($secondEtag === $initialEtag && $initialEtag !== null) {
echo " ✅ ETag unchanged - server likely returned 304 Not Modified\n";
} elseif ($secondEtag !== null) {
echo " 📝 ETag changed: " . substr($secondEtag, 0, 30) . "...\n";
echo " (flags may have been updated on the server)\n";
}
echo "\n";

// Continuous polling - runs until Ctrl+C
echo "🔄 Starting continuous polling (every 5 seconds)...\n";
echo " Press Ctrl+C to stop.\n";
echo " Try changing feature flags in PostHog to see ETag changes!\n\n";

$iteration = 1;
while (true) {
$timestamp = date('H:i:s');
echo " [$timestamp] Poll #$iteration: ";

$beforeEtag = $client->getFlagsEtag();
$client->loadFlags();
$afterEtag = $client->getFlagsEtag();
$currentFlagCount = count($client->featureFlags);

if ($beforeEtag === $afterEtag && $beforeEtag !== null) {
echo "No change (304 Not Modified) - $currentFlagCount flag(s)\n";
} else {
echo "🔄 Flags updated! New ETag: " . ($afterEtag ? substr($afterEtag, 0, 20) . "..." : "none") . " - $currentFlagCount flag(s)\n";
}

$iteration++;
sleep(5);
}
}

function runAllExamples()
{
identifyAndCaptureExamples();
Expand All @@ -434,6 +512,7 @@ function runAllExamples()
contextManagementExamples();

echo "\n🎉 All examples completed!\n";
echo " (ETag polling skipped - run separately with option 5)\n";
}

// Handle user choice
Expand All @@ -451,13 +530,16 @@ function runAllExamples()
contextManagementExamples();
break;
case '5':
runAllExamples();
etagPollingExamples();
break;
case '6':
runAllExamples();
break;
case '7':
echo "👋 Goodbye!\n";
exit(0);
default:
echo "❌ Invalid choice. Please run the script again and choose 1-6.\n";
echo "❌ Invalid choice. Please run the script again and choose 1-7.\n";
exit(1);
}

Expand Down
64 changes: 58 additions & 6 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ class Client
*/
public $distinctIdsFeatureFlagsReported;

/**
* @var string|null Cached ETag for feature flag definitions
*/
private $flagsEtag;

/**
* @var bool
*/
private $debug;

/**
* Create a new posthog object with your app's API key
* key
Expand All @@ -89,6 +99,7 @@ public function __construct(
) {
$this->apiKey = $apiKey;
$this->personalAPIKey = $personalAPIKey;
$this->debug = $options["debug"] ?? false;
$Consumer = self::CONSUMERS[$options["consumer"] ?? "lib_curl"];
$this->consumer = new $Consumer($apiKey, $options, $httpClient);
$this->httpClient = $httpClient !== null ? $httpClient : new HttpClient(
Expand All @@ -106,6 +117,7 @@ public function __construct(
$this->cohorts = [];
$this->featureFlagsByKey = [];
$this->distinctIdsFeatureFlagsReported = new SizeLimitedHash(SIZE_LIMIT);
$this->flagsEtag = null;

// Populate featureflags and grouptypemapping if possible
if (
Expand Down Expand Up @@ -514,12 +526,32 @@ private function fetchFlagsResponse(

public function loadFlags()
{
$payload = json_decode($this->localFlags(), true);
$response = $this->localFlags();

// Handle 304 Not Modified - flags haven't changed, skip processing.
// On 304, we preserve the existing ETag unless the server sends a new one.
// This handles edge cases like server restarts where the server may send
// a refreshed ETag even though the content hasn't changed.
if ($response->isNotModified()) {
if ($response->getEtag()) {
$this->flagsEtag = $response->getEtag();
}
if ($this->debug) {
error_log("[PostHog][Client] Flags not modified (304), using cached data");
}
return;
}

$payload = json_decode($response->getResponse(), true);

if ($payload && array_key_exists("detail", $payload)) {
throw new Exception($payload["detail"]);
}

// On 200 responses, always update ETag (even if null) since we're replacing
// the cached flag data. A null ETag means the server doesn't support caching.
$this->flagsEtag = $response->getEtag();

$this->featureFlags = $payload['flags'] ?? [];
$this->groupTypeMapping = $payload['group_type_mapping'] ?? [];
$this->cohorts = $payload['cohorts'] ?? [];
Expand All @@ -532,17 +564,37 @@ public function loadFlags()
}


public function localFlags()
public function localFlags(): HttpResponse
{
$headers = [
// Send user agent in the form of {library_name}/{library_version} as per RFC 7231.
"User-Agent: posthog-php/" . PostHog::VERSION,
"Authorization: Bearer " . $this->personalAPIKey
];

// Add If-None-Match header if we have a cached ETag
if ($this->flagsEtag !== null) {
$headers[] = "If-None-Match: " . $this->flagsEtag;
}

return $this->httpClient->sendRequest(
'/api/feature_flag/local_evaluation?send_cohorts&token=' . $this->apiKey,
null,
$headers,
[
// Send user agent in the form of {library_name}/{library_version} as per RFC 7231.
"User-Agent: posthog-php/" . PostHog::VERSION,
"Authorization: Bearer " . $this->personalAPIKey
'includeEtag' => true
]
)->getResponse();
);
}

/**
* Get the current cached ETag for feature flag definitions
*
* @return string|null
*/
public function getFlagsEtag(): ?string
{
return $this->flagsEtag;
}

private function normalizeFeatureFlags(string $response): string
Expand Down
53 changes: 47 additions & 6 deletions lib/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders

$shouldRetry = $requestOptions['shouldRetry'] ?? true;
$shouldVerify = $requestOptions['shouldVerify'] ?? true;
$includeEtag = $requestOptions['includeEtag'] ?? false;

do {
// open connection
Expand Down Expand Up @@ -104,13 +105,27 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders
curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
}

// Capture response headers if we need to extract ETag
if ($includeEtag) {
curl_setopt($ch, CURLOPT_HEADER, true);
}

// retry failed requests just once to diminish impact on performance
$httpResponse = $this->executePost($ch);
$httpResponse = $this->executePost($ch, $includeEtag);
$responseCode = $httpResponse->getResponseCode();

//close connection
curl_close($ch);

// Handle 304 Not Modified - this is a success, not an error
if ($responseCode === 304) {
if ($this->debug) {
$maskedUrl = $this->maskTokensInUrl($protocol . $this->host . $path);
error_log("[PostHog][HttpClient] GET " . $maskedUrl . " returned 304 Not Modified");
}
break;
}

if ($shouldVerify && 200 != $responseCode) {
// log error
$this->handleError($ch, $responseCode);
Expand All @@ -136,12 +151,27 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders
return $httpResponse;
}

private function executePost($ch): HttpResponse
private function executePost($ch, bool $includeEtag = false): HttpResponse
{
return new HttpResponse(
curl_exec($ch),
curl_getinfo($ch, CURLINFO_RESPONSE_CODE)
);
$response = curl_exec($ch);
$responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$etag = null;

if ($includeEtag && $response !== false) {
// Parse headers to extract ETag
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headers = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);

// Extract ETag from headers (case-insensitive)
if (preg_match('/^etag:\s*(.+)$/mi', $headers, $matches)) {
$etag = trim($matches[1]);
}

return new HttpResponse($body, $responseCode, $etag);
}

return new HttpResponse($response, $responseCode);
}

private function handleError($code, $message)
Expand All @@ -155,4 +185,15 @@ private function handleError($code, $message)
error_log("[PostHog][HttpClient] " . $message);
}
}

/**
* Mask tokens in URLs to avoid exposing them in logs
*
* @param string $url
* @return string
*/
public function maskTokensInUrl(string $url): string
{
return preg_replace('/token=[^&]+/', 'token=[REDACTED]', $url);
}
}
22 changes: 21 additions & 1 deletion lib/HttpResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ class HttpResponse
{
private $response;
private $responseCode;
private $etag;

public function __construct($response, $responseCode)
public function __construct($response, $responseCode, ?string $etag = null)
{
$this->response = $response;
$this->responseCode = $responseCode;
$this->etag = $etag;
}

/**
Expand All @@ -28,4 +30,22 @@ public function getResponseCode()
{
return $this->responseCode;
}

/**
* @return string|null
*/
public function getEtag(): ?string
{
return $this->etag;
}

/**
* Check if the response is a 304 Not Modified
*
* @return bool
*/
public function isNotModified(): bool
{
return $this->responseCode === 304;
}
}
14 changes: 14 additions & 0 deletions lib/PostHog.php
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,20 @@ public static function flush()
return self::$client->flush();
}

/**
* Get the underlying client instance.
* Useful for accessing client-level functionality like loadFlags() or getFlagsEtag().
*
* @return Client
* @throws Exception
*/
public static function getClient(): Client
{
self::checkClient();

return self::$client;
}

private static function cleanHost(?string $host): string
{
if (!isset($host)) {
Expand Down
Loading