diff --git a/src/State/Scope.php b/src/State/Scope.php index e4e054c3c..7d3736c42 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -21,6 +21,13 @@ */ class Scope { + /** + * Maximum number of flags allowed. We only track the first flags set. + * + * @internal + */ + public const MAX_FLAGS = 100; + /** * @var PropagationContext */ @@ -46,6 +53,11 @@ class Scope */ private $tags = []; + /** + * @var array> The list of flags associated to this scope + */ + private $flags = []; + /** * @var array A set of extra data associated to this scope */ @@ -130,6 +142,35 @@ public function removeTag(string $key): self return $this; } + /** + * Adds a feature flag to the scope. + * + * @return $this + */ + public function addFeatureFlag(string $key, bool $result): self + { + // If the flag was already set, remove it first + // This basically mimics an LRU cache so that the most recently added flags are kept + foreach ($this->flags as $flagIndex => $flag) { + if (isset($flag[$key])) { + unset($this->flags[$flagIndex]); + } + } + + // Keep only the most recent MAX_FLAGS flags + if (\count($this->flags) >= self::MAX_FLAGS) { + array_shift($this->flags); + } + + $this->flags[] = [$key => $result]; + + if ($this->span !== null) { + $this->span->setFlag($key, $result); + } + + return $this; + } + /** * Sets data to the context by a given name. * @@ -331,6 +372,7 @@ public function clear(): self $this->fingerprint = []; $this->breadcrumbs = []; $this->tags = []; + $this->flags = []; $this->extra = []; $this->contexts = []; @@ -359,6 +401,17 @@ public function applyToEvent(Event $event, ?EventHint $hint = null, ?Options $op $event->setTags(array_merge($this->tags, $event->getTags())); } + if (!empty($this->flags)) { + $event->setContext('flags', [ + 'values' => array_map(static function (array $flag) { + return [ + 'flag' => key($flag), + 'result' => current($flag), + ]; + }, $this->flags), + ]); + } + if (!empty($this->extra)) { $event->setExtra(array_merge($this->extra, $event->getExtra())); } diff --git a/src/Tracing/Span.php b/src/Tracing/Span.php index 51308b06e..e55c4c948 100644 --- a/src/Tracing/Span.php +++ b/src/Tracing/Span.php @@ -22,6 +22,13 @@ */ class Span { + /** + * Maximum number of flags allowed. We only track the first flags set. + * + * @internal + */ + public const MAX_FLAGS = 10; + /** * @var SpanId Span ID */ @@ -62,6 +69,11 @@ class Span */ protected $tags = []; + /** + * @var array A List of flags associated to this span + */ + protected $flags = []; + /** * @var array An arbitrary mapping of additional metadata */ @@ -328,6 +340,20 @@ public function setTags(array $tags) return $this; } + /** + * Sets a feature flag associated to this span. + * + * @return $this + */ + public function setFlag(string $key, bool $result) + { + if (\count($this->flags) < self::MAX_FLAGS) { + $this->flags[$key] = $result; + } + + return $this; + } + /** * Gets the ID of the span. */ @@ -369,7 +395,13 @@ public function setSampled(?bool $sampled) public function getData(?string $key = null, $default = null) { if ($key === null) { - return $this->data; + $data = $this->data; + + foreach ($this->flags as $flagKey => $flagValue) { + $data["flag.evaluation.{$flagKey}"] = $flagValue; + } + + return $data; } return $this->data[$key] ?? $default; diff --git a/tests/State/ScopeTest.php b/tests/State/ScopeTest.php index caf6b3fd7..281cbadb9 100644 --- a/tests/State/ScopeTest.php +++ b/tests/State/ScopeTest.php @@ -12,6 +12,7 @@ use Sentry\State\Scope; use Sentry\Tracing\DynamicSamplingContext; use Sentry\Tracing\PropagationContext; +use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanId; use Sentry\Tracing\TraceId; @@ -77,6 +78,88 @@ public function testRemoveTag(): void $this->assertSame(['bar' => 'baz'], $event->getTags()); } + public function testSetFlag(): void + { + $scope = new Scope(); + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertArrayNotHasKey('flags', $event->getContexts()); + + $scope->addFeatureFlag('foo', true); + $scope->addFeatureFlag('bar', false); + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertArrayHasKey('flags', $event->getContexts()); + $this->assertEquals([ + 'values' => [ + [ + 'flag' => 'foo', + 'result' => true, + ], + [ + 'flag' => 'bar', + 'result' => false, + ], + ], + ], $event->getContexts()['flags']); + } + + public function testSetFlagLimit(): void + { + $scope = new Scope(); + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertArrayNotHasKey('flags', $event->getContexts()); + + $expectedFlags = []; + + foreach (range(1, Scope::MAX_FLAGS) as $i) { + $scope->addFeatureFlag("feature{$i}", true); + + $expectedFlags[] = [ + 'flag' => "feature{$i}", + 'result' => true, + ]; + } + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertArrayHasKey('flags', $event->getContexts()); + $this->assertEquals(['values' => $expectedFlags], $event->getContexts()['flags']); + + array_shift($expectedFlags); + + $scope->addFeatureFlag('should-not-be-discarded', true); + + $expectedFlags[] = [ + 'flag' => 'should-not-be-discarded', + 'result' => true, + ]; + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertArrayHasKey('flags', $event->getContexts()); + $this->assertEquals(['values' => $expectedFlags], $event->getContexts()['flags']); + } + + public function testSetFlagPropagatesToSpan(): void + { + $span = new Span(); + + $scope = new Scope(); + $scope->setSpan($span); + + $scope->addFeatureFlag('feature', true); + + $this->assertSame(['flag.evaluation.feature' => true], $span->getData()); + } + public function testSetAndRemoveContext(): void { $propgationContext = PropagationContext::fromDefaults(); @@ -364,6 +447,7 @@ public function testClear(): void $scope->setFingerprint(['foo']); $scope->setExtras(['foo' => 'bar']); $scope->setTags(['bar' => 'foo']); + $scope->addFeatureFlag('feature', true); $scope->setUser(UserDataBag::createFromUserIdentifier('unique_id')); $scope->clear(); @@ -376,6 +460,7 @@ public function testClear(): void $this->assertEmpty($event->getExtra()); $this->assertEmpty($event->getTags()); $this->assertEmpty($event->getUser()); + $this->assertArrayNotHasKey('flags', $event->getContexts()); } public function testApplyToEvent(): void @@ -403,6 +488,7 @@ public function testApplyToEvent(): void $scope->setUser($user); $scope->setContext('foocontext', ['foo' => 'bar']); $scope->setContext('barcontext', ['bar' => 'foo']); + $scope->addFeatureFlag('feature', true); $scope->setSpan($span); $this->assertSame($event, $scope->applyToEvent($event)); @@ -417,6 +503,14 @@ public function testApplyToEvent(): void 'foo' => 'foo', 'bar' => 'bar', ], + 'flags' => [ + 'values' => [ + [ + 'flag' => 'feature', + 'result' => true, + ], + ], + ], 'trace' => [ 'span_id' => '566e3688a61d4bc8', 'trace_id' => '566e3688a61d4bc888951642d6f14a19', diff --git a/tests/Tracing/SpanTest.php b/tests/Tracing/SpanTest.php index 987feffb5..e0db7c6d5 100644 --- a/tests/Tracing/SpanTest.php +++ b/tests/Tracing/SpanTest.php @@ -187,4 +187,34 @@ public function testOriginIsCopiedFromContext(): void $this->assertSame($context->getOrigin(), $span->getOrigin()); $this->assertSame($context->getOrigin(), $span->getTraceContext()['origin']); } + + public function testFlagIsRecorded(): void + { + $span = new Span(); + + $span->setFlag('feature', true); + + $this->assertSame(['flag.evaluation.feature' => true], $span->getData()); + } + + public function testFlagLimitRecorded(): void + { + $span = new Span(); + + $expectedFlags = [ + 'flag.evaluation.should-not-be-discarded' => true, + ]; + + $span->setFlag('should-not-be-discarded', true); + + foreach (range(1, Span::MAX_FLAGS - 1) as $i) { + $span->setFlag("feature{$i}", true); + + $expectedFlags["flag.evaluation.feature{$i}"] = true; + } + + $span->setFlag('should-be-discarded', true); + + $this->assertSame($expectedFlags, $span->getData()); + } }