Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,26 @@ $growthbook = new Growthbook([
$trackingCallback = $growthbook->getTrackingCallback();
```

Or track all events at the end of the request by looping through an array:
### Tracking Feature Usage

You can also track every time a feature is evaluated using `featureUsageCallback`. Unlike `trackingCallback`, this fires for all feature evaluations — not just experiments.

```php
$growthbook = Growthbook\Growthbook::create()
->withFeatureUsageCallback(function (
string $featureKey,
Growthbook\FeatureResult $result
) {
echo $featureKey . ': ' . json_encode($result->value) . ' (' . $result->source . ')';
});

// Getter method
$featureUsageCallback = $growthbook->getFeatureUsageCallback();
```

The callback is deduplicated — it will only fire again for the same feature if the value changes.

Or track all experiment events at the end of the request by looping through an array:

```php
$impressions = $growthbook->getViewedExperiments();
Expand Down
111 changes: 99 additions & 12 deletions src/Growthbook.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
public $qaMode = false;
/** @var callable|null */
private $trackingCallback = null;
/** @var callable|null */
private $featureUsageCallback = null;
/** @var array<string,string> */
private $trackedFeatures = [];

/**
* @var null|\Psr\SimpleCache\CacheInterface
Expand Down Expand Up @@ -531,6 +535,37 @@
return $this->trackingCallback;
}

/**
* @param callable|null $featureUsageCallback
* @return static
*/
public function setFeatureUsageCallback($featureUsageCallback): static
{
$this->featureUsageCallback = $featureUsageCallback;

return $this;
}

/**
* @param callable|null $featureUsageCallback
* @return static
*/
public function withFeatureUsageCallback($featureUsageCallback): static
{
$self = clone $this;
$self->setFeatureUsageCallback($featureUsageCallback);

return $self;
}

/**
* @return callable|null
*/
public function getFeatureUsageCallback(): ?callable
{
return $this->featureUsageCallback;
}

/**
* @return array<string,array<string,mixed>>
*/
Expand Down Expand Up @@ -620,7 +655,9 @@
{
if (!array_key_exists($key, $this->features)) {
$this->log(LogLevel::DEBUG, "Unknown feature - $key");
return new FeatureResult(null, "unknownFeature");
$result = new FeatureResult(null, "unknownFeature");
$this->fireFeatureUsageCallback($key, $result);
return $result;
}
$this->log(LogLevel::DEBUG, "Evaluating feature - $key");
$feature = $this->features[$key];
Expand All @@ -629,7 +666,9 @@
$this->log(LogLevel::WARNING, "Cyclic prerequisite detected, stack", [
"stack" => $stack,
]);
return new FeatureResult(null, "cyclicPrerequisite");
$result = new FeatureResult(null, "cyclicPrerequisite");
$this->fireFeatureUsageCallback($key, $result);
return $result;
}
$stack[] = $key;

Expand All @@ -651,10 +690,14 @@
$this->log(LogLevel::DEBUG, "Top-level prerequisite failed, return None, feature", [
"feature" => $key,
]);
return new FeatureResult(null, "prerequisite");
$result = new FeatureResult(null, "prerequisite");
$this->fireFeatureUsageCallback($key, $result);
return $result;
}
if ($prereqRes === 'cyclic') {
return new FeatureResult(null, "cyclicPrerequisite");
$result = new FeatureResult(null, "cyclicPrerequisite");
$this->fireFeatureUsageCallback($key, $result);
return $result;
}
if ($prereqRes === 'fail') {
$this->log(LogLevel::DEBUG, "Skip rule because of failing prerequisite, feature", [
Expand Down Expand Up @@ -703,7 +746,9 @@
"feature" => $key,
"value" => $rule->force
]);
return new FeatureResult($rule->force, "force", null, null, $rule->id);
$result = new FeatureResult($rule->force, "force", null, null, $rule->id);
$this->fireFeatureUsageCallback($key, $result);
return $result;
}
$exp = $rule->toExperiment($key);
if (!$exp) {
Expand All @@ -730,10 +775,14 @@
"feature" => $key,
"value" => $result->value
]);
return new FeatureResult($result->value, "experiment", $exp, $result, $rule->id);
$featureResult = new FeatureResult($result->value, "experiment", $exp, $result, $rule->id);
$this->fireFeatureUsageCallback($key, $featureResult);
return $featureResult;
}
}
return new FeatureResult($feature->defaultValue ?? null, "defaultValue");
$result = new FeatureResult($feature->defaultValue ?? null, "defaultValue");
$this->fireFeatureUsageCallback($key, $result);
return $result;
}

/**
Expand Down Expand Up @@ -947,15 +996,23 @@
}

// 14. Fire tracking callback
$this->tracks[$exp->key] = new ViewedExperiment($exp, $result);
$dedupeKey = $result->hashAttribute . $result->hashValue . $exp->key . $result->variationId;
if (isset($this->tracks[$dedupeKey])) {
return $result;
}
$this->tracks[$dedupeKey] = new ViewedExperiment($exp, $result);
if ($this->trackingCallback) {
try {
call_user_func($this->trackingCallback, $exp, $result);
} catch (\Throwable $e) {
$this->log(LogLevel::ERROR, "Error calling the trackingCallback function", [
"experiment" => $exp->key,
"error" => $e
]);
if ($this->logger) {
$this->log(LogLevel::ERROR, "Error calling the trackingCallback function", [
"experiment" => $exp->key,
"error" => $e
]);
} else {
throw $e;
}
}
}

Expand All @@ -979,6 +1036,36 @@
}
}

/**
* @param string $key
* @param FeatureResult<mixed> $result
*/
private function fireFeatureUsageCallback(string $key, FeatureResult $result): void
{
if (!$this->featureUsageCallback) {
return;
}

$value = json_encode($result->value);
if (isset($this->trackedFeatures[$key]) && $this->trackedFeatures[$key] === $value) {
return;
}
$this->trackedFeatures[$key] = $value;

Check failure on line 1053 in src/Growthbook.php

View workflow job for this annotation

GitHub Actions / lint-test (8.3)

Array (array<string, string>) does not accept string|false.

Check failure on line 1053 in src/Growthbook.php

View workflow job for this annotation

GitHub Actions / lint-test (8.1)

Array (array<string, string>) does not accept string|false.

Check failure on line 1053 in src/Growthbook.php

View workflow job for this annotation

GitHub Actions / lint-test (8.0)

Array (array<string, string>) does not accept string|false.

Check failure on line 1053 in src/Growthbook.php

View workflow job for this annotation

GitHub Actions / lint-test (8.2)

Array (array<string, string>) does not accept string|false.

Check failure on line 1053 in src/Growthbook.php

View workflow job for this annotation

GitHub Actions / lint-test (8.4)

Array (array<string, string>) does not accept string|false.

Check failure on line 1053 in src/Growthbook.php

View workflow job for this annotation

GitHub Actions / lint-test (8.5)

Array (array<string, string>) does not accept string|false.

Check failure on line 1053 in src/Growthbook.php

View workflow job for this annotation

GitHub Actions / lint-test (8.0, --prefer-lowest)

Array (array<string, string>) does not accept string|false.

try {
call_user_func($this->featureUsageCallback, $key, $result);
} catch (\Throwable $e) {
if ($this->logger) {
$this->log(LogLevel::ERROR, "Error calling the featureUsageCallback function", [
"feature" => $key,
"error" => $e
]);
} else {
throw $e;
}
}
}

public static function hash(string $seed, string $value, int $version): ?float
{
// New hashing algorithm
Expand Down
43 changes: 43 additions & 0 deletions tests/GrowthbookTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1141,4 +1141,47 @@ public function testInitializeHandlesTimeoutGracefully(): void

$this->assertEmpty($gb->getFeatures());
}

public function testTrackingCallbackExceptionWithLogger(): void
{
$logCalls = [];
$logger = $this->createMock('Psr\Log\AbstractLogger');
$logger->method('log')->willReturnCallback(function ($level, $message, $context = []) use (&$logCalls) {
$logCalls[] = [$level, $message, $context];
});

$gb = Growthbook::create()
->withLogger($logger)
->withAttributes(['id' => 'user123'])
->withTrackingCallback(function () {
throw new \Exception("Test exception");
});

$exp = new InlineExperiment("test-exp", [0, 1]);
$gb->runInlineExperiment($exp);

$foundErrorLog = false;
foreach ($logCalls as $call) {
if ($call[0] === 'error' && strpos($call[1], 'Error calling the trackingCallback function') !== false) {
$foundErrorLog = true;
break;
}
}
$this->assertTrue($foundErrorLog, 'Expected error log was not found');
}

public function testTrackingCallbackExceptionWithoutLogger(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage("Test exception");

$gb = Growthbook::create()
->withAttributes(['id' => 'user123'])
->withTrackingCallback(function () {
throw new \Exception("Test exception");
});

$exp = new InlineExperiment("test-exp", [0, 1]);
$gb->runInlineExperiment($exp);
}
}
Loading