Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/support feature flags #12

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 3 additions & 2 deletions composer.json
Expand Up @@ -18,7 +18,7 @@
"php": ">=7.1"
},
"require-dev": {
"overtrue/phplint": "^2.3",
"overtrue/phplint": "^3.0",
"phpunit/phpunit": "^9.0",
"squizlabs/php_codesniffer": "^3.6"
},
Expand All @@ -29,7 +29,8 @@
},
"autoload-dev": {
"psr-4": {
"PostHog\\Test\\": "test/"
"PostHog\\Test\\": "test/",
"PostHog\\Test\\Assets\\": "test/assests"
}
},
"bin": [
Expand Down
166 changes: 153 additions & 13 deletions lib/Client.php
Expand Up @@ -2,6 +2,7 @@

namespace PostHog;

use Exception;
use PostHog\Consumer\File;
use PostHog\Consumer\ForkCurl;
use PostHog\Consumer\LibCurl;
Expand All @@ -10,35 +11,61 @@
class Client
{

private const CONSUMERS = [
"socket" => Socket::class,
"file" => File::class,
"fork_curl" => ForkCurl::class,
"lib_curl" => LibCurl::class,
];

private const LONG_SCALE = 0xFFFFFFFFFFFFFFF;

/**
* @var string
*/
private $apiKey;

/**
* Consumer object handles queueing and bundling requests to PostHog.
*
* @var Consumer
*/
protected $consumer;

protected $featureFlags = null;

/**
* @var string|null
*/
private $personalApiKey;

/**
* @var HttpClient
*/
private $httpClient;


/**
* Create a new posthog object with your app's API key
* key
*
* @param string $apiKey
* @param array $options array of consumer options [optional]
* @param string Consumer constructor to use, libcurl by default.
*
* @param HttpClient|null $httpClient
*/
public function __construct($apiKey, $options = array())
public function __construct(string $apiKey, array $options = [], ?HttpClient $httpClient = null)
{
$consumers = array(
"socket" => Socket::class,
"file" => File::class,
"fork_curl" => ForkCurl::class,
"lib_curl" => LibCurl::class,
);

// Use our socket libcurl by default
$consumer_type = $options["consumer"] ?? "lib_curl";
$Consumer = $consumers[$consumer_type];
$this->apiKey = $apiKey;
$Consumer = self::CONSUMERS[$options["consumer"] ?? "lib_curl"];
$this->consumer = new $Consumer($apiKey, $options);
$this->personalApiKey = $options["personal_api_key"] ?? null;
$this->httpClient = $httpClient !== null ? $httpClient : new HttpClient(
$options['host'] ?? "app.posthog.com",
$options['ssl'] ?? true,
10000,
false,
$options["debug"] ?? false
);
}

public function __destruct()
Expand Down Expand Up @@ -79,6 +106,81 @@ public function identify(array $message)
return $this->consumer->identify($message);
}

/**
* decide if the feature flag is enabled for this distinct id.
*
* @param string $key
* @param string $distinctId
* @param mixed $defaultValue
* @return bool
* @throws Exception
*/
public function isFeatureEnabled(string $key, string $distinctId, $defaultValue = false): bool
{
if (null === $this->personalApiKey) {
throw new Exception(
"To use feature flags, please set a personal_api_key.
For More information: https://posthog.com/docs/api/overview"
);
}

if (null === $this->featureFlags) {
$this->loadFeatureFlags();
}

if (null === $this->featureFlags) { // if loading failed.
return $defaultValue;
}

$selectedFlag = null;
foreach ($this->featureFlags as $flag) {
if ($flag['key'] === $key) {
$selectedFlag = $flag;
}
}
if (null === $selectedFlag) {
return $defaultValue;
}

if ((bool) $selectedFlag['is_simple_flag']) {
$result = $this->isSimpleFlagEnabled($key, $distinctId, $flag['rollout_percentage']);
} else {
$result = in_array($key, $this->fetchEnabledFeatureFlags($distinctId));
}

$this->capture([
"properties" => [
'$feature_flag' => $key,
'$feature_flag_response' => $result,
],
"distinct_id" => $distinctId,
"event" => '$feature_flag_called',
]);

return $result ?? $defaultValue;
}


/**
* @param string $distinctId
* @return array
* @throws Exception
*/
public function fetchEnabledFeatureFlags(string $distinctId): array
{
return json_decode($this->decide($distinctId), true)['featureFlags'] ?? [];
}

public function decide(string $distinctId)
{
$payload = json_encode([
'api_key' => $this->apiKey,
'distinct_id' => $distinctId,
]);

return $this->httpClient->sendRequest('/decide/', $payload)->getResponse();
}

/**
* Aliases from one user id to another
*
Expand Down Expand Up @@ -203,4 +305,42 @@ private function message($msg)

return $msg;
}

private function loadFeatureFlags(): void
{
$response = $this->httpClient->sendRequest(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok so I tried this locally and it didn't work. I suspect this is a potential problem. We're sending a POST request here, when it should actually be a GET request.

'/api/feature_flag',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've made a change since you last edited this, so this should now be /api/feature_flag?token=project_api_key_here.

No way you could have known though!

null,
[
"Authorization: Bearer $this->personalApiKey",
]
);
if (401 === $response->getResponseCode()) {
throw new Exception(
"Your personalApiKey is invalid. Are you sure you're not using your Project API key?
More information: https://posthog.com/docs/api/overview"
);
}

$responseBody = json_decode($response->getResponse(), true);
if (null === $responseBody) {
return;
}

if (empty($responseBody)) {
$this->featureFlags = [];
}

$this->featureFlags = $responseBody['results'];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another change we made is that this should filter by active flags.

The active property is in $responseBody['results'][i]['active']

}

private function isSimpleFlagEnabled(string $key, string $distinctId, ?int $rolloutPercentage): bool
{
if (! (bool) $rolloutPercentage) {
return true;
}
$hexValueOfHash = sha1("$key.$distinctId", false);
$integerRepresentationOfHashSubset = intval(substr($hexValueOfHash, 0, 15), 16);
return ($integerRepresentationOfHashSubset / self::LONG_SCALE) <= ($rolloutPercentage / 100);
}
}
8 changes: 2 additions & 6 deletions lib/Consumer/ForkCurl.php
Expand Up @@ -47,13 +47,9 @@ public function flushBatch($messages)
$payload = escapeshellarg($payload);

$protocol = $this->ssl() ? "https://" : "http://";
if ($this->host) {
$host = $this->host;
} else {
$host = "t.posthog.com";
}

$path = "/batch/";
$url = $protocol . $host . $path;
$url = $protocol . $this->host . $path;

$cmd = "curl -X POST -H 'Content-Type: application/json'";

Expand Down
98 changes: 22 additions & 76 deletions lib/Consumer/LibCurl.php
Expand Up @@ -2,11 +2,16 @@

namespace PostHog\Consumer;

use PostHog\HttpClient;
use PostHog\QueueConsumer;

class LibCurl extends QueueConsumer
{
protected $type = "LibCurl";
/**
* @var HttpClient
*/
private $httpClient;

/**
* Creates a new queued libcurl consumer
Expand All @@ -16,9 +21,17 @@ class LibCurl extends QueueConsumer
* number "max_queue_size" - the max size of messages to enqueue
* number "batch_size" - how many messages to send in a single request
*/
public function __construct($apiKey, $options = array())
public function __construct($apiKey, $options = [])
{
parent::__construct($apiKey, $options);
$this->httpClient = new HttpClient(
$this->host,
$this->ssl(),
$this->maximum_backoff_duration,
$this->compress_request,
$this->debug(),
$this->options['error_handler'] ?? null
);
}

/**
Expand All @@ -42,7 +55,6 @@ public function flushBatch($messages)
{
$body = $this->payload($messages);
$payload = json_encode($body);
$apiKey = $this->apiKey;

// Verify message size is below than 32KB
if (strlen($payload) >= 32 * 1024) {
Expand All @@ -58,79 +70,13 @@ public function flushBatch($messages)
$payload = gzencode($payload);
}

$protocol = $this->ssl() ? "https://" : "http://";
if ($this->host) {
$host = $this->host;
} else {
$host = "t.posthog.com";
}
$path = "/batch/";
$url = $protocol . $host . $path;

$backoff = 100; // Set initial waiting time to 100ms

while ($backoff < $this->maximum_backoff_duration) {
$start_time = microtime(true);

// open connection
$ch = curl_init();

// set the url, number of POST vars, POST data
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);

// set variables for headers
$header = array();
$header[] = 'Content-Type: application/json';

if ($this->compress_request) {
$header[] = 'Content-Encoding: gzip';
}

// Send user agent in the form of {library_name}/{library_version} as per RFC 7231.
$libName = $messages[0]['library'];
$libVersion = $messages[0]['library_version'];
$header[] = "User-Agent: ${libName}/${libVersion}";

curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

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

//close connection
curl_close($ch);

$elapsed_time = microtime(true) - $start_time;

if (200 != $httpResponse) {
// log error
$this->handleError($ch, $httpResponse);

if (($httpResponse >= 500 && $httpResponse <= 600) || 429 == $httpResponse) {
// If status code is greater than 500 and less than 600, it indicates server error
// Error code 429 indicates rate limited.
// Retry uploading in these cases.
usleep($backoff * 1000);
$backoff *= 2;
} elseif ($httpResponse >= 400) {
break;
} elseif ($httpResponse == 0) {
break;
}
} else {
break; // no error
}
}

return $httpResponse;
}

public function executePost($ch)
{
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

return $httpCode;
return $this->httpClient->sendRequest(
'/batch/',
$payload,
[
// Send user agent in the form of {library_name}/{library_version} as per RFC 7231.
"User-Agent: {$messages[0]['library']}/{$messages[0]['library_version']}",
]
)->getResponse();
}
}