Skip to content

Commit 8e24c11

Browse files
authored
feat: feature flags (#1951)
1 parent 6df603d commit 8e24c11

File tree

4 files changed

+210
-1
lines changed

4 files changed

+210
-1
lines changed

src/State/Scope.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@
2121
*/
2222
class Scope
2323
{
24+
/**
25+
* Maximum number of flags allowed. We only track the first flags set.
26+
*
27+
* @internal
28+
*/
29+
public const MAX_FLAGS = 100;
30+
2431
/**
2532
* @var PropagationContext
2633
*/
@@ -46,6 +53,11 @@ class Scope
4653
*/
4754
private $tags = [];
4855

56+
/**
57+
* @var array<int, array<string, bool>> The list of flags associated to this scope
58+
*/
59+
private $flags = [];
60+
4961
/**
5062
* @var array<string, mixed> A set of extra data associated to this scope
5163
*/
@@ -130,6 +142,35 @@ public function removeTag(string $key): self
130142
return $this;
131143
}
132144

145+
/**
146+
* Adds a feature flag to the scope.
147+
*
148+
* @return $this
149+
*/
150+
public function addFeatureFlag(string $key, bool $result): self
151+
{
152+
// If the flag was already set, remove it first
153+
// This basically mimics an LRU cache so that the most recently added flags are kept
154+
foreach ($this->flags as $flagIndex => $flag) {
155+
if (isset($flag[$key])) {
156+
unset($this->flags[$flagIndex]);
157+
}
158+
}
159+
160+
// Keep only the most recent MAX_FLAGS flags
161+
if (\count($this->flags) >= self::MAX_FLAGS) {
162+
array_shift($this->flags);
163+
}
164+
165+
$this->flags[] = [$key => $result];
166+
167+
if ($this->span !== null) {
168+
$this->span->setFlag($key, $result);
169+
}
170+
171+
return $this;
172+
}
173+
133174
/**
134175
* Sets data to the context by a given name.
135176
*
@@ -331,6 +372,7 @@ public function clear(): self
331372
$this->fingerprint = [];
332373
$this->breadcrumbs = [];
333374
$this->tags = [];
375+
$this->flags = [];
334376
$this->extra = [];
335377
$this->contexts = [];
336378

@@ -359,6 +401,17 @@ public function applyToEvent(Event $event, ?EventHint $hint = null, ?Options $op
359401
$event->setTags(array_merge($this->tags, $event->getTags()));
360402
}
361403

404+
if (!empty($this->flags)) {
405+
$event->setContext('flags', [
406+
'values' => array_map(static function (array $flag) {
407+
return [
408+
'flag' => key($flag),
409+
'result' => current($flag),
410+
];
411+
}, $this->flags),
412+
]);
413+
}
414+
362415
if (!empty($this->extra)) {
363416
$event->setExtra(array_merge($this->extra, $event->getExtra()));
364417
}

src/Tracing/Span.php

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@
2222
*/
2323
class Span
2424
{
25+
/**
26+
* Maximum number of flags allowed. We only track the first flags set.
27+
*
28+
* @internal
29+
*/
30+
public const MAX_FLAGS = 10;
31+
2532
/**
2633
* @var SpanId Span ID
2734
*/
@@ -62,6 +69,11 @@ class Span
6269
*/
6370
protected $tags = [];
6471

72+
/**
73+
* @var array<string, bool> A List of flags associated to this span
74+
*/
75+
protected $flags = [];
76+
6577
/**
6678
* @var array<string, mixed> An arbitrary mapping of additional metadata
6779
*/
@@ -328,6 +340,20 @@ public function setTags(array $tags)
328340
return $this;
329341
}
330342

343+
/**
344+
* Sets a feature flag associated to this span.
345+
*
346+
* @return $this
347+
*/
348+
public function setFlag(string $key, bool $result)
349+
{
350+
if (\count($this->flags) < self::MAX_FLAGS) {
351+
$this->flags[$key] = $result;
352+
}
353+
354+
return $this;
355+
}
356+
331357
/**
332358
* Gets the ID of the span.
333359
*/
@@ -369,7 +395,13 @@ public function setSampled(?bool $sampled)
369395
public function getData(?string $key = null, $default = null)
370396
{
371397
if ($key === null) {
372-
return $this->data;
398+
$data = $this->data;
399+
400+
foreach ($this->flags as $flagKey => $flagValue) {
401+
$data["flag.evaluation.{$flagKey}"] = $flagValue;
402+
}
403+
404+
return $data;
373405
}
374406

375407
return $this->data[$key] ?? $default;

tests/State/ScopeTest.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Sentry\State\Scope;
1313
use Sentry\Tracing\DynamicSamplingContext;
1414
use Sentry\Tracing\PropagationContext;
15+
use Sentry\Tracing\Span;
1516
use Sentry\Tracing\SpanContext;
1617
use Sentry\Tracing\SpanId;
1718
use Sentry\Tracing\TraceId;
@@ -77,6 +78,88 @@ public function testRemoveTag(): void
7778
$this->assertSame(['bar' => 'baz'], $event->getTags());
7879
}
7980

81+
public function testSetFlag(): void
82+
{
83+
$scope = new Scope();
84+
$event = $scope->applyToEvent(Event::createEvent());
85+
86+
$this->assertNotNull($event);
87+
$this->assertArrayNotHasKey('flags', $event->getContexts());
88+
89+
$scope->addFeatureFlag('foo', true);
90+
$scope->addFeatureFlag('bar', false);
91+
92+
$event = $scope->applyToEvent(Event::createEvent());
93+
94+
$this->assertNotNull($event);
95+
$this->assertArrayHasKey('flags', $event->getContexts());
96+
$this->assertEquals([
97+
'values' => [
98+
[
99+
'flag' => 'foo',
100+
'result' => true,
101+
],
102+
[
103+
'flag' => 'bar',
104+
'result' => false,
105+
],
106+
],
107+
], $event->getContexts()['flags']);
108+
}
109+
110+
public function testSetFlagLimit(): void
111+
{
112+
$scope = new Scope();
113+
$event = $scope->applyToEvent(Event::createEvent());
114+
115+
$this->assertNotNull($event);
116+
$this->assertArrayNotHasKey('flags', $event->getContexts());
117+
118+
$expectedFlags = [];
119+
120+
foreach (range(1, Scope::MAX_FLAGS) as $i) {
121+
$scope->addFeatureFlag("feature{$i}", true);
122+
123+
$expectedFlags[] = [
124+
'flag' => "feature{$i}",
125+
'result' => true,
126+
];
127+
}
128+
129+
$event = $scope->applyToEvent(Event::createEvent());
130+
131+
$this->assertNotNull($event);
132+
$this->assertArrayHasKey('flags', $event->getContexts());
133+
$this->assertEquals(['values' => $expectedFlags], $event->getContexts()['flags']);
134+
135+
array_shift($expectedFlags);
136+
137+
$scope->addFeatureFlag('should-not-be-discarded', true);
138+
139+
$expectedFlags[] = [
140+
'flag' => 'should-not-be-discarded',
141+
'result' => true,
142+
];
143+
144+
$event = $scope->applyToEvent(Event::createEvent());
145+
146+
$this->assertNotNull($event);
147+
$this->assertArrayHasKey('flags', $event->getContexts());
148+
$this->assertEquals(['values' => $expectedFlags], $event->getContexts()['flags']);
149+
}
150+
151+
public function testSetFlagPropagatesToSpan(): void
152+
{
153+
$span = new Span();
154+
155+
$scope = new Scope();
156+
$scope->setSpan($span);
157+
158+
$scope->addFeatureFlag('feature', true);
159+
160+
$this->assertSame(['flag.evaluation.feature' => true], $span->getData());
161+
}
162+
80163
public function testSetAndRemoveContext(): void
81164
{
82165
$propgationContext = PropagationContext::fromDefaults();
@@ -364,6 +447,7 @@ public function testClear(): void
364447
$scope->setFingerprint(['foo']);
365448
$scope->setExtras(['foo' => 'bar']);
366449
$scope->setTags(['bar' => 'foo']);
450+
$scope->addFeatureFlag('feature', true);
367451
$scope->setUser(UserDataBag::createFromUserIdentifier('unique_id'));
368452
$scope->clear();
369453

@@ -376,6 +460,7 @@ public function testClear(): void
376460
$this->assertEmpty($event->getExtra());
377461
$this->assertEmpty($event->getTags());
378462
$this->assertEmpty($event->getUser());
463+
$this->assertArrayNotHasKey('flags', $event->getContexts());
379464
}
380465

381466
public function testApplyToEvent(): void
@@ -403,6 +488,7 @@ public function testApplyToEvent(): void
403488
$scope->setUser($user);
404489
$scope->setContext('foocontext', ['foo' => 'bar']);
405490
$scope->setContext('barcontext', ['bar' => 'foo']);
491+
$scope->addFeatureFlag('feature', true);
406492
$scope->setSpan($span);
407493

408494
$this->assertSame($event, $scope->applyToEvent($event));
@@ -417,6 +503,14 @@ public function testApplyToEvent(): void
417503
'foo' => 'foo',
418504
'bar' => 'bar',
419505
],
506+
'flags' => [
507+
'values' => [
508+
[
509+
'flag' => 'feature',
510+
'result' => true,
511+
],
512+
],
513+
],
420514
'trace' => [
421515
'span_id' => '566e3688a61d4bc8',
422516
'trace_id' => '566e3688a61d4bc888951642d6f14a19',

tests/Tracing/SpanTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,34 @@ public function testOriginIsCopiedFromContext(): void
187187
$this->assertSame($context->getOrigin(), $span->getOrigin());
188188
$this->assertSame($context->getOrigin(), $span->getTraceContext()['origin']);
189189
}
190+
191+
public function testFlagIsRecorded(): void
192+
{
193+
$span = new Span();
194+
195+
$span->setFlag('feature', true);
196+
197+
$this->assertSame(['flag.evaluation.feature' => true], $span->getData());
198+
}
199+
200+
public function testFlagLimitRecorded(): void
201+
{
202+
$span = new Span();
203+
204+
$expectedFlags = [
205+
'flag.evaluation.should-not-be-discarded' => true,
206+
];
207+
208+
$span->setFlag('should-not-be-discarded', true);
209+
210+
foreach (range(1, Span::MAX_FLAGS - 1) as $i) {
211+
$span->setFlag("feature{$i}", true);
212+
213+
$expectedFlags["flag.evaluation.feature{$i}"] = true;
214+
}
215+
216+
$span->setFlag('should-be-discarded', true);
217+
218+
$this->assertSame($expectedFlags, $span->getData());
219+
}
190220
}

0 commit comments

Comments
 (0)