Skip to content

Commit 87922d8

Browse files
jespersandnielsensl0thentr0py
authored andcommitted
feat: Add feature flag support
Add Sentry.add_feature_flag and Scope#add_feature_flag to record boolean flag evaluations. Flags are kept in a 100-entry LRU buffer on the scope, attached to error events as contexts.flags.values (not transactions), and recorded on the active span as flag.evaluation.* data (max 10 per span).
1 parent ac534f3 commit 87922d8

3 files changed

Lines changed: 136 additions & 1 deletion

File tree

‎sentry-ruby/lib/sentry-ruby.rb‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,16 @@ def set_context(*args)
223223
get_current_scope.set_context(*args)
224224
end
225225

226+
# Records a feature flag evaluation on the current scope and active span.
227+
# Only boolean results are recorded.
228+
# @param name [String, Symbol] the flag name
229+
# @param result [Boolean] the evaluation result
230+
# @return [void]
231+
def add_feature_flag(name, result)
232+
return unless initialized?
233+
get_current_scope.add_feature_flag(name, result)
234+
end
235+
226236
# @!method set_attributes
227237
# Updates the current scope's attributes by merging with the old value.
228238
# @param attributes_hash [Hash]

‎sentry-ruby/lib/sentry/scope.rb‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,16 @@ class Scope
2727
:session,
2828
:attachments,
2929
:propagation_context,
30-
:attributes
30+
:attributes,
31+
:flags
3132
]
3233

34+
# Maximum number of feature flag evaluations kept on a scope.
35+
MAX_FLAGS = 100
36+
# Maximum number of feature flag evaluations recorded on a single span.
37+
MAX_FLAGS_PER_SPAN = 10
38+
SPAN_FLAG_ATTRIBUTE_PREFIX = "flag.evaluation."
39+
3340
attr_reader(*ATTRIBUTES)
3441

3542
# @param max_breadcrumbs [Integer] the maximum number of breadcrumbs to be stored in the scope.
@@ -61,6 +68,7 @@ def apply_to_event(event, hint = nil)
6168
event.breadcrumbs = breadcrumbs
6269
event.rack_env = rack_env if rack_env
6370
event.attachments = attachments
71+
apply_flags_to_event(event)
6472
end
6573

6674
trace_context = get_trace_context
@@ -151,6 +159,7 @@ def dup
151159
copy.attachments = attachments.dup
152160
copy.event_processors = event_processors.dup
153161
copy.attributes = attributes.deep_dup
162+
copy.flags = flags.dup
154163
copy
155164
end
156165

@@ -170,6 +179,7 @@ def update_from_scope(scope)
170179
self.propagation_context = scope.propagation_context
171180
self.attachments = scope.attachments
172181
self.attributes = scope.attributes
182+
self.flags = scope.flags
173183
end
174184

175185
# Updates the scope's data from the given options.
@@ -298,6 +308,24 @@ def remove_attribute(key)
298308
@attributes.delete(key.to_s)
299309
end
300310

311+
# Records a feature flag evaluation on the scope (and the active span, if any).
312+
# Only boolean results are supported; other values are ignored.
313+
# The scope keeps the most recent {MAX_FLAGS} distinct flags (oldest evicted first).
314+
# @param name [String, Symbol] the flag name
315+
# @param result [Boolean] the evaluation result
316+
# @return [void]
317+
def add_feature_flag(name, result)
318+
return unless result == true || result == false
319+
320+
name = name.to_s
321+
flags.reject! { |flag| flag[:flag] == name }
322+
flags.shift if flags.size >= MAX_FLAGS
323+
flags << { flag: name, result: result }
324+
325+
add_feature_flag_to_span(name, result)
326+
nil
327+
end
328+
301329
# Sets the scope's level attribute.
302330
# @param level [String, Symbol]
303331
# @return [void]
@@ -389,6 +417,22 @@ def add_attachment(**opts)
389417

390418
private
391419

420+
def add_feature_flag_to_span(name, result)
421+
return unless span
422+
423+
key = "#{SPAN_FLAG_ATTRIBUTE_PREFIX}#{name}"
424+
flag_count = span.data.count { |k, _| k.to_s.start_with?(SPAN_FLAG_ATTRIBUTE_PREFIX) }
425+
return if flag_count >= MAX_FLAGS_PER_SPAN && !span.data.key?(key)
426+
427+
span.set_data(key, result)
428+
end
429+
430+
def apply_flags_to_event(event)
431+
return if flags.empty? || event.is_a?(TransactionEvent)
432+
433+
event.contexts[:flags] = { values: flags.map(&:dup) }
434+
end
435+
392436
def set_default_value
393437
@contexts = { os: self.class.os_context, runtime: self.class.runtime_context }
394438
@extra = {}
@@ -404,6 +448,7 @@ def set_default_value
404448
@session = nil
405449
@attachments = []
406450
@attributes = {}
451+
@flags = []
407452
generate_propagation_context
408453
set_new_breadcrumb_buffer
409454
end
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Sentry::Scope do
6+
subject(:scope) { described_class.new }
7+
8+
before do
9+
perform_basic_setup { |config| config.traces_sample_rate = 1.0 }
10+
end
11+
12+
describe "#add_feature_flag" do
13+
it "adds flags to event contexts" do
14+
scope.add_feature_flag("a", true)
15+
scope.add_feature_flag(:b, false)
16+
17+
event = scope.apply_to_event(Sentry::ErrorEvent.new(configuration: Sentry.configuration))
18+
expect(event.contexts[:flags]).to eq(values: [{ flag: "a", result: true }, { flag: "b", result: false }])
19+
end
20+
21+
it "ignores non-boolean results" do
22+
scope.add_feature_flag("a", "yes")
23+
scope.add_feature_flag("b", nil)
24+
expect(scope.flags).to be_empty
25+
end
26+
27+
it "moves a re-evaluated flag to the end with its new result" do
28+
scope.add_feature_flag("a", true)
29+
scope.add_feature_flag("b", true)
30+
scope.add_feature_flag("a", false)
31+
expect(scope.flags).to eq([{ flag: "b", result: true }, { flag: "a", result: false }])
32+
end
33+
34+
it "evicts the oldest flag beyond the limit" do
35+
(Sentry::Scope::MAX_FLAGS + 1).times { |i| scope.add_feature_flag("f#{i}", true) }
36+
expect(scope.flags.size).to eq(Sentry::Scope::MAX_FLAGS)
37+
expect(scope.flags.first[:flag]).to eq("f1")
38+
end
39+
40+
it "does not add flags to transaction events" do
41+
scope.add_feature_flag("a", true)
42+
transaction = Sentry.start_transaction(name: "foo", op: "bar")
43+
event = scope.apply_to_event(Sentry::TransactionEvent.new(configuration: Sentry.configuration, transaction: transaction))
44+
expect(event.contexts).not_to have_key(:flags)
45+
end
46+
47+
it "is isolated in duplicated scopes and cleared by #clear" do
48+
scope.add_feature_flag("a", true)
49+
copy = scope.dup
50+
copy.add_feature_flag("b", true)
51+
expect(scope.flags.size).to eq(1)
52+
scope.clear
53+
expect(scope.flags).to be_empty
54+
end
55+
56+
context "with an active span" do
57+
let(:span) { Sentry::Span.new(transaction: nil) }
58+
before { scope.set_span(span) }
59+
60+
it "records flag.evaluation.* span data, capped per span" do
61+
scope.add_feature_flag("a", true)
62+
expect(span.data["flag.evaluation.a"]).to eq(true)
63+
64+
12.times { |i| scope.add_feature_flag("g#{i}", true) }
65+
keys = span.data.keys.select { |k| k.start_with?("flag.evaluation.") }
66+
expect(keys.size).to eq(Sentry::Scope::MAX_FLAGS_PER_SPAN)
67+
68+
scope.add_feature_flag("a", false)
69+
expect(span.data["flag.evaluation.a"]).to eq(false)
70+
end
71+
end
72+
end
73+
74+
describe "Sentry.add_feature_flag" do
75+
it "adds to the current scope" do
76+
Sentry.add_feature_flag("x", true)
77+
expect(Sentry.get_current_scope.flags).to eq([{ flag: "x", result: true }])
78+
end
79+
end
80+
end

0 commit comments

Comments
 (0)