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
85 changes: 85 additions & 0 deletions src/api/FeatureFlags/BufferedExposureWriter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

namespace DDTrace\FeatureFlags;

final class BufferedExposureWriter implements ExposureWriter
{
private $sink;
private $maxBatchSize;
private $buffer = array();
private $dedup = array();

public function __construct($sink, $maxBatchSize = 100)
{
if (!is_callable($sink)) {
throw new \InvalidArgumentException('Expected an exposure sink callable');
}

if (!is_int($maxBatchSize) || $maxBatchSize < 1) {
throw new \InvalidArgumentException('Exposure batch size must be a positive integer');
}

$this->sink = $sink;
$this->maxBatchSize = $maxBatchSize;
}

public function write(array $event)
{
if (array_key_exists('doLog', $event) && $event['doLog'] === false) {
return;
}

$dedupKey = $this->dedupKey($event);
$stateKey = $this->stateKey($event);
if (isset($this->dedup[$dedupKey]) && $this->dedup[$dedupKey] === $stateKey) {
return;
}

$this->dedup[$dedupKey] = $stateKey;
$this->buffer[] = $event;

if (count($this->buffer) >= $this->maxBatchSize) {
$this->flush();
}
}

public function flush()
{
if (!$this->buffer) {
return;
}

$batch = $this->buffer;
$this->buffer = array();

call_user_func($this->sink, $batch);
}

public function getBufferedCount()
{
return count($this->buffer);
}

private function dedupKey(array $event)
{
return $this->eventValue($event, 'flagKey') . "\0" . $this->eventValue($event, 'targetingKey');
}

private function stateKey(array $event)
{
return $this->eventValue($event, 'allocationKey') . "\0" . $this->eventValue($event, 'variant');
}

private function eventValue(array $event, $key)
{
if (!array_key_exists($key, $event) || $event[$key] === null) {
return '';
}

if (is_scalar($event[$key])) {
return (string) $event[$key];
}

return json_encode($event[$key]);
}
}
48 changes: 48 additions & 0 deletions src/api/FeatureFlags/CallableMetricsRecorder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace DDTrace\FeatureFlags;

final class CallableMetricsRecorder implements MetricsRecorder
{
const METRIC_NAME = 'feature_flag.evaluations';

private $sink;
private $enabled;

public function __construct($sink, $enabled)
{
if (!is_callable($sink)) {
throw new \InvalidArgumentException('Expected a metrics sink callable');
}

$this->sink = $sink;
$this->enabled = (bool) $enabled;
}

public static function createFromEnvironment($sink)
{
return new self($sink, self::isTruthy(getenv('DD_METRICS_OTEL_ENABLED')));
}

public function recordEvaluation($flagKey, $valueType, $reason, $errorCode = null)
{
if (!$this->enabled) {
return;
}

call_user_func($this->sink, array(
'name' => self::METRIC_NAME,
'attributes' => array(
'feature_flag.key' => $flagKey,
'feature_flag.result.reason' => $reason,
'feature_flag.result.value_type' => $valueType,
'feature_flag.error.code' => $errorCode === null ? 'none' : $errorCode,
),
));
}

private static function isTruthy($value)
{
return in_array(strtolower((string) $value), array('1', 'true', 'yes', 'on'), true);
}
}
73 changes: 68 additions & 5 deletions src/api/FeatureFlags/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,36 @@ final class Client
{
private $evaluator;
private $warningEmitter;
private $exposureWriter;
private $metricsRecorder;
private $warnedAboutNonProductionRuntime = false;

public function __construct(Evaluator $evaluator, WarningEmitter $warningEmitter)
{
public function __construct(
Evaluator $evaluator,
WarningEmitter $warningEmitter,
$exposureWriter = null,
$metricsRecorder = null
) {
if ($exposureWriter !== null && !$exposureWriter instanceof ExposureWriter) {
throw new \InvalidArgumentException('Expected an ExposureWriter instance');
}

if ($metricsRecorder !== null && !$metricsRecorder instanceof MetricsRecorder) {
throw new \InvalidArgumentException('Expected a MetricsRecorder instance');
}

$this->evaluator = $evaluator;
$this->warningEmitter = $warningEmitter;
$this->exposureWriter = $exposureWriter ?: new NoopExposureWriter();
$this->metricsRecorder = $metricsRecorder ?: new NoopMetricsRecorder();
}

public static function create($evaluator = null, $warningEmitter = null)
{
public static function create(
$evaluator = null,
$warningEmitter = null,
$exposureWriter = null,
$metricsRecorder = null
) {
if ($evaluator !== null && !$evaluator instanceof Evaluator) {
throw new \InvalidArgumentException('Expected an Evaluator instance');
}
Expand All @@ -26,7 +46,9 @@ public static function create($evaluator = null, $warningEmitter = null)

return new self(
$evaluator ?: new UnavailableEvaluator(),
$warningEmitter ?: new TriggerErrorWarningEmitter()
$warningEmitter ?: new TriggerErrorWarningEmitter(),
$exposureWriter,
$metricsRecorder
);
}

Expand Down Expand Up @@ -94,10 +116,51 @@ private function evaluate($flagKey, $expectedType, $defaultValue, array $context
);

$this->warnIfNonProductionRuntime($details);
$this->metricsRecorder->recordEvaluation(
$flagKey,
$details->getValueType(),
$details->getReason(),
$details->getErrorCode()
);
$this->writeExposure($flagKey, $targetingKey, $attributes, $details);

return $details;
}

private function writeExposure($flagKey, $targetingKey, array $attributes, EvaluationDetails $details)
{
if ($details->isError()) {
return;
}

$exposureData = $details->getExposureData();
if (!$exposureData || (array_key_exists('doLog', $exposureData) && $exposureData['doLog'] === false)) {
return;
}

$event = array(
'flagKey' => $flagKey,
'targetingKey' => $targetingKey,
'attributes' => $attributes,
'value' => $details->getValue(),
'valueType' => $details->getValueType(),
'reason' => $details->getReason(),
'variant' => $details->getVariant(),
'flagMetadata' => $details->getFlagMetadata(),
'exposureData' => $exposureData,
);

if (array_key_exists('allocationKey', $exposureData)) {
$event['allocationKey'] = $exposureData['allocationKey'];
}

if (array_key_exists('doLog', $exposureData)) {
$event['doLog'] = $exposureData['doLog'];
}

$this->exposureWriter->write($event);
}

private function normalizeContext(array $context)
{
$targetingKey = null;
Expand Down
17 changes: 17 additions & 0 deletions src/api/FeatureFlags/ExposureWriter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace DDTrace\FeatureFlags;

interface ExposureWriter
{
/**
* @param array<string, mixed> $event
* @return void
*/
public function write(array $event);

/**
* @return void
*/
public function flush();
}
12 changes: 12 additions & 0 deletions src/api/FeatureFlags/MetricsRecorder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace DDTrace\FeatureFlags;

interface MetricsRecorder
{
/**
* @param string|null $errorCode
* @return void
*/
public function recordEvaluation($flagKey, $valueType, $reason, $errorCode = null);
}
14 changes: 14 additions & 0 deletions src/api/FeatureFlags/NoopExposureWriter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace DDTrace\FeatureFlags;

final class NoopExposureWriter implements ExposureWriter
{
public function write(array $event)
{
}

public function flush()
{
}
}
10 changes: 10 additions & 0 deletions src/api/FeatureFlags/NoopMetricsRecorder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace DDTrace\FeatureFlags;

final class NoopMetricsRecorder implements MetricsRecorder
{
public function recordEvaluation($flagKey, $valueType, $reason, $errorCode = null)
{
}
}
Loading
Loading