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
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 @@ class Growthbook implements LoggerAwareInterface
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 @@ public function getTrackingCallback(): ?callable
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 @@ public function getFeature(string $key, array $stack = []): FeatureResult
{
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 @@ public function getFeature(string $key, array $stack = []): FeatureResult
$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 @@ public function getFeature(string $key, array $stack = []): FeatureResult
$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 @@ public function getFeature(string $key, array $stack = []): FeatureResult
"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 @@ public function getFeature(string $key, array $stack = []): FeatureResult
"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 @@ private function runExperiment(InlineExperiment $exp, ?string $featureId = null)
}

// 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 @@ public function log(string $level, string $message, $context = []): void
}
}

/**
* @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;

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);
}
}