From 87922d812f21f3a7a2eff86c3cabdb057bd0c6bf Mon Sep 17 00:00:00 2001 From: Jesper Sand Nielsen Date: Thu, 1 Oct 2026 12:54:25 +0200 Subject: [PATCH 1/3] 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). --- sentry-ruby/lib/sentry-ruby.rb | 10 +++ sentry-ruby/lib/sentry/scope.rb | 47 ++++++++++- .../spec/sentry/scope/feature_flags_spec.rb | 80 +++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 sentry-ruby/spec/sentry/scope/feature_flags_spec.rb diff --git a/sentry-ruby/lib/sentry-ruby.rb b/sentry-ruby/lib/sentry-ruby.rb index 52afec20e..12ed09d93 100644 --- a/sentry-ruby/lib/sentry-ruby.rb +++ b/sentry-ruby/lib/sentry-ruby.rb @@ -223,6 +223,16 @@ def set_context(*args) get_current_scope.set_context(*args) end + # Records a feature flag evaluation on the current scope and active span. + # Only boolean results are recorded. + # @param name [String, Symbol] the flag name + # @param result [Boolean] the evaluation result + # @return [void] + def add_feature_flag(name, result) + return unless initialized? + get_current_scope.add_feature_flag(name, result) + end + # @!method set_attributes # Updates the current scope's attributes by merging with the old value. # @param attributes_hash [Hash] diff --git a/sentry-ruby/lib/sentry/scope.rb b/sentry-ruby/lib/sentry/scope.rb index bcf4328ad..879d5f644 100644 --- a/sentry-ruby/lib/sentry/scope.rb +++ b/sentry-ruby/lib/sentry/scope.rb @@ -27,9 +27,16 @@ class Scope :session, :attachments, :propagation_context, - :attributes + :attributes, + :flags ] + # Maximum number of feature flag evaluations kept on a scope. + MAX_FLAGS = 100 + # Maximum number of feature flag evaluations recorded on a single span. + MAX_FLAGS_PER_SPAN = 10 + SPAN_FLAG_ATTRIBUTE_PREFIX = "flag.evaluation." + attr_reader(*ATTRIBUTES) # @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) event.breadcrumbs = breadcrumbs event.rack_env = rack_env if rack_env event.attachments = attachments + apply_flags_to_event(event) end trace_context = get_trace_context @@ -151,6 +159,7 @@ def dup copy.attachments = attachments.dup copy.event_processors = event_processors.dup copy.attributes = attributes.deep_dup + copy.flags = flags.dup copy end @@ -170,6 +179,7 @@ def update_from_scope(scope) self.propagation_context = scope.propagation_context self.attachments = scope.attachments self.attributes = scope.attributes + self.flags = scope.flags end # Updates the scope's data from the given options. @@ -298,6 +308,24 @@ def remove_attribute(key) @attributes.delete(key.to_s) end + # Records a feature flag evaluation on the scope (and the active span, if any). + # Only boolean results are supported; other values are ignored. + # The scope keeps the most recent {MAX_FLAGS} distinct flags (oldest evicted first). + # @param name [String, Symbol] the flag name + # @param result [Boolean] the evaluation result + # @return [void] + def add_feature_flag(name, result) + return unless result == true || result == false + + name = name.to_s + flags.reject! { |flag| flag[:flag] == name } + flags.shift if flags.size >= MAX_FLAGS + flags << { flag: name, result: result } + + add_feature_flag_to_span(name, result) + nil + end + # Sets the scope's level attribute. # @param level [String, Symbol] # @return [void] @@ -389,6 +417,22 @@ def add_attachment(**opts) private + def add_feature_flag_to_span(name, result) + return unless span + + key = "#{SPAN_FLAG_ATTRIBUTE_PREFIX}#{name}" + flag_count = span.data.count { |k, _| k.to_s.start_with?(SPAN_FLAG_ATTRIBUTE_PREFIX) } + return if flag_count >= MAX_FLAGS_PER_SPAN && !span.data.key?(key) + + span.set_data(key, result) + end + + def apply_flags_to_event(event) + return if flags.empty? || event.is_a?(TransactionEvent) + + event.contexts[:flags] = { values: flags.map(&:dup) } + end + def set_default_value @contexts = { os: self.class.os_context, runtime: self.class.runtime_context } @extra = {} @@ -404,6 +448,7 @@ def set_default_value @session = nil @attachments = [] @attributes = {} + @flags = [] generate_propagation_context set_new_breadcrumb_buffer end diff --git a/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb b/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb new file mode 100644 index 000000000..8087e6603 --- /dev/null +++ b/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::Scope do + subject(:scope) { described_class.new } + + before do + perform_basic_setup { |config| config.traces_sample_rate = 1.0 } + end + + describe "#add_feature_flag" do + it "adds flags to event contexts" do + scope.add_feature_flag("a", true) + scope.add_feature_flag(:b, false) + + event = scope.apply_to_event(Sentry::ErrorEvent.new(configuration: Sentry.configuration)) + expect(event.contexts[:flags]).to eq(values: [{ flag: "a", result: true }, { flag: "b", result: false }]) + end + + it "ignores non-boolean results" do + scope.add_feature_flag("a", "yes") + scope.add_feature_flag("b", nil) + expect(scope.flags).to be_empty + end + + it "moves a re-evaluated flag to the end with its new result" do + scope.add_feature_flag("a", true) + scope.add_feature_flag("b", true) + scope.add_feature_flag("a", false) + expect(scope.flags).to eq([{ flag: "b", result: true }, { flag: "a", result: false }]) + end + + it "evicts the oldest flag beyond the limit" do + (Sentry::Scope::MAX_FLAGS + 1).times { |i| scope.add_feature_flag("f#{i}", true) } + expect(scope.flags.size).to eq(Sentry::Scope::MAX_FLAGS) + expect(scope.flags.first[:flag]).to eq("f1") + end + + it "does not add flags to transaction events" do + scope.add_feature_flag("a", true) + transaction = Sentry.start_transaction(name: "foo", op: "bar") + event = scope.apply_to_event(Sentry::TransactionEvent.new(configuration: Sentry.configuration, transaction: transaction)) + expect(event.contexts).not_to have_key(:flags) + end + + it "is isolated in duplicated scopes and cleared by #clear" do + scope.add_feature_flag("a", true) + copy = scope.dup + copy.add_feature_flag("b", true) + expect(scope.flags.size).to eq(1) + scope.clear + expect(scope.flags).to be_empty + end + + context "with an active span" do + let(:span) { Sentry::Span.new(transaction: nil) } + before { scope.set_span(span) } + + it "records flag.evaluation.* span data, capped per span" do + scope.add_feature_flag("a", true) + expect(span.data["flag.evaluation.a"]).to eq(true) + + 12.times { |i| scope.add_feature_flag("g#{i}", true) } + keys = span.data.keys.select { |k| k.start_with?("flag.evaluation.") } + expect(keys.size).to eq(Sentry::Scope::MAX_FLAGS_PER_SPAN) + + scope.add_feature_flag("a", false) + expect(span.data["flag.evaluation.a"]).to eq(false) + end + end + end + + describe "Sentry.add_feature_flag" do + it "adds to the current scope" do + Sentry.add_feature_flag("x", true) + expect(Sentry.get_current_scope.flags).to eq([{ flag: "x", result: true }]) + end + end +end From b27d32e8bfac336a278b1eddd5a21c4c10398f27 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Thu, 29 Oct 2026 15:44:56 +0100 Subject: [PATCH 2/3] deep_dup flags --- sentry-ruby/lib/sentry/scope.rb | 4 ++-- sentry-ruby/spec/sentry/scope/feature_flags_spec.rb | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/sentry-ruby/lib/sentry/scope.rb b/sentry-ruby/lib/sentry/scope.rb index 879d5f644..fc645c709 100644 --- a/sentry-ruby/lib/sentry/scope.rb +++ b/sentry-ruby/lib/sentry/scope.rb @@ -159,7 +159,7 @@ def dup copy.attachments = attachments.dup copy.event_processors = event_processors.dup copy.attributes = attributes.deep_dup - copy.flags = flags.dup + copy.flags = flags.deep_dup copy end @@ -179,7 +179,7 @@ def update_from_scope(scope) self.propagation_context = scope.propagation_context self.attachments = scope.attachments self.attributes = scope.attributes - self.flags = scope.flags + self.flags = scope.flags.deep_dup end # Updates the scope's data from the given options. diff --git a/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb b/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb index 8087e6603..1836055dd 100644 --- a/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb +++ b/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb @@ -44,11 +44,18 @@ expect(event.contexts).not_to have_key(:flags) end - it "is isolated in duplicated scopes and cleared by #clear" do + it "is isolated in duplicated and updated scopes and cleared by #clear" do scope.add_feature_flag("a", true) copy = scope.dup copy.add_feature_flag("b", true) - expect(scope.flags.size).to eq(1) + copy.flags.first[:result] = false + expect(scope.flags).to eq([{ flag: "a", result: true }]) + + updated = described_class.new + updated.update_from_scope(scope) + updated.add_feature_flag("b", true) + expect(scope.flags).to eq([{ flag: "a", result: true }]) + scope.clear expect(scope.flags).to be_empty end From 28220bcae97beb5b81f2d4f5ea86583be9789233 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Wed, 4 Nov 2026 15:26:03 +0100 Subject: [PATCH 3/3] add a ring buffer --- sentry-ruby/lib/sentry/scope.rb | 25 +++++------- sentry-ruby/lib/sentry/span.rb | 26 ++++++++++++- sentry-ruby/lib/sentry/utils/ring_buffer.rb | 38 +++++++++++++++++++ .../spec/sentry/scope/feature_flags_spec.rb | 33 +++++++++------- .../spec/sentry/utils/ring_buffer_spec.rb | 27 +++++++++++++ 5 files changed, 118 insertions(+), 31 deletions(-) create mode 100644 sentry-ruby/lib/sentry/utils/ring_buffer.rb create mode 100644 sentry-ruby/spec/sentry/utils/ring_buffer_spec.rb diff --git a/sentry-ruby/lib/sentry/scope.rb b/sentry-ruby/lib/sentry/scope.rb index fc645c709..c1fda4400 100644 --- a/sentry-ruby/lib/sentry/scope.rb +++ b/sentry-ruby/lib/sentry/scope.rb @@ -4,6 +4,7 @@ require "sentry/breadcrumb_buffer" require "sentry/propagation_context" require "sentry/attachment" +require "sentry/utils/ring_buffer" require "etc" module Sentry @@ -33,9 +34,6 @@ class Scope # Maximum number of feature flag evaluations kept on a scope. MAX_FLAGS = 100 - # Maximum number of feature flag evaluations recorded on a single span. - MAX_FLAGS_PER_SPAN = 10 - SPAN_FLAG_ATTRIBUTE_PREFIX = "flag.evaluation." attr_reader(*ATTRIBUTES) @@ -159,7 +157,7 @@ def dup copy.attachments = attachments.dup copy.event_processors = event_processors.dup copy.attributes = attributes.deep_dup - copy.flags = flags.deep_dup + copy.flags = flags.dup copy end @@ -179,7 +177,7 @@ def update_from_scope(scope) self.propagation_context = scope.propagation_context self.attachments = scope.attachments self.attributes = scope.attributes - self.flags = scope.flags.deep_dup + self.flags = scope.flags.dup end # Updates the scope's data from the given options. @@ -318,9 +316,7 @@ def add_feature_flag(name, result) return unless result == true || result == false name = name.to_s - flags.reject! { |flag| flag[:flag] == name } - flags.shift if flags.size >= MAX_FLAGS - flags << { flag: name, result: result } + flags.set(name, result) add_feature_flag_to_span(name, result) nil @@ -420,17 +416,14 @@ def add_attachment(**opts) def add_feature_flag_to_span(name, result) return unless span - key = "#{SPAN_FLAG_ATTRIBUTE_PREFIX}#{name}" - flag_count = span.data.count { |k, _| k.to_s.start_with?(SPAN_FLAG_ATTRIBUTE_PREFIX) } - return if flag_count >= MAX_FLAGS_PER_SPAN && !span.data.key?(key) - - span.set_data(key, result) + span.set_flag(name, result) end def apply_flags_to_event(event) - return if flags.empty? || event.is_a?(TransactionEvent) + return unless event.is_a?(ErrorEvent) && !flags.empty? - event.contexts[:flags] = { values: flags.map(&:dup) } + values = flags.values.map { |flag, result| { flag: flag, result: result } } + event.contexts[:flags] = { values: values } end def set_default_value @@ -448,7 +441,7 @@ def set_default_value @session = nil @attachments = [] @attributes = {} - @flags = [] + @flags = Utils::RingBuffer.new(MAX_FLAGS) generate_propagation_context set_new_breadcrumb_buffer end diff --git a/sentry-ruby/lib/sentry/span.rb b/sentry-ruby/lib/sentry/span.rb index b53554dc4..4942de161 100644 --- a/sentry-ruby/lib/sentry/span.rb +++ b/sentry-ruby/lib/sentry/span.rb @@ -70,6 +70,8 @@ module DataConventions } DEFAULT_SPAN_ORIGIN = "manual" + MAX_FLAGS = 10 + SPAN_FLAG_ATTRIBUTE_PREFIX = "flag.evaluation." # An uuid that can be used to identify a trace. # @return [String] @@ -104,6 +106,9 @@ module DataConventions # Span data # @return [Hash] attr_reader :data + # Feature flag data + # @return [Hash] + attr_reader :flags # Span origin that tracks what kind of instrumentation created a span # @return [String] attr_reader :origin @@ -142,6 +147,7 @@ def initialize( @op = op @status = status @data = {} + @flags = {} @tags = {} @origin = origin || DEFAULT_SPAN_ORIGIN end @@ -187,7 +193,7 @@ def to_h op: @op, status: @status, tags: @tags, - data: @data, + data: serialized_data, origin: @origin } end @@ -203,7 +209,7 @@ def get_trace_context op: @op, status: @status, origin: @origin, - data: @data + data: serialized_data } end @@ -293,6 +299,14 @@ def set_data(key, value) @data[key] = value end + # Sets a feature flag on the span. + # @param name [String] the flag name + # @param result [Boolean] the evaluation result + def set_flag(name, result) + flag = "#{SPAN_FLAG_ATTRIBUTE_PREFIX}#{name}" + @flags[flag] = result if @flags.size < MAX_FLAGS + end + # Sets a tag to the span. # @param key [String, Symbol] # @param value [String] @@ -305,5 +319,13 @@ def set_tag(key, value) def set_origin(origin) @origin = origin end + + private + + def serialized_data + return @data if @flags.empty? + + @flags.merge(@data) + end end end diff --git a/sentry-ruby/lib/sentry/utils/ring_buffer.rb b/sentry-ruby/lib/sentry/utils/ring_buffer.rb new file mode 100644 index 000000000..1fe0ed391 --- /dev/null +++ b/sentry-ruby/lib/sentry/utils/ring_buffer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "sentry/core_ext/object/deep_dup" + +module Sentry + module Utils + class RingBuffer + attr_reader :values + + def initialize(capacity) + raise ArgumentError, "capacity must be positive" unless capacity.positive? + + @capacity = capacity + @values = {} + end + + def set(key, value) + @values.delete(key) + @values[key] = value + @values.shift if @values.size > @capacity + end + + def clear + @values.clear + end + + def empty? + @values.empty? + end + + def dup + copy = self.class.new(@capacity) + copy.values.merge!(@values) + copy + end + end + end +end diff --git a/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb b/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb index 1836055dd..0851f799e 100644 --- a/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb +++ b/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb @@ -21,20 +21,20 @@ it "ignores non-boolean results" do scope.add_feature_flag("a", "yes") scope.add_feature_flag("b", nil) - expect(scope.flags).to be_empty + expect(scope.flags.values).to be_empty end it "moves a re-evaluated flag to the end with its new result" do scope.add_feature_flag("a", true) scope.add_feature_flag("b", true) scope.add_feature_flag("a", false) - expect(scope.flags).to eq([{ flag: "b", result: true }, { flag: "a", result: false }]) + expect(scope.flags.values).to eq("b" => true, "a" => false) end it "evicts the oldest flag beyond the limit" do (Sentry::Scope::MAX_FLAGS + 1).times { |i| scope.add_feature_flag("f#{i}", true) } - expect(scope.flags.size).to eq(Sentry::Scope::MAX_FLAGS) - expect(scope.flags.first[:flag]).to eq("f1") + expect(scope.flags.values.size).to eq(Sentry::Scope::MAX_FLAGS) + expect(scope.flags.values.first.first).to eq("f1") end it "does not add flags to transaction events" do @@ -44,20 +44,27 @@ expect(event.contexts).not_to have_key(:flags) end + it "does not add flags to check-in events" do + scope.add_feature_flag("a", true) + event = Sentry::CheckInEvent.new(configuration: Sentry.configuration, slug: "foo", status: :ok) + scope.apply_to_event(event) + expect(event.contexts).not_to have_key(:flags) + end + it "is isolated in duplicated and updated scopes and cleared by #clear" do scope.add_feature_flag("a", true) copy = scope.dup copy.add_feature_flag("b", true) - copy.flags.first[:result] = false - expect(scope.flags).to eq([{ flag: "a", result: true }]) + copy.add_feature_flag("a", false) + expect(scope.flags.values).to eq("a" => true) updated = described_class.new updated.update_from_scope(scope) updated.add_feature_flag("b", true) - expect(scope.flags).to eq([{ flag: "a", result: true }]) + expect(scope.flags.values).to eq("a" => true) scope.clear - expect(scope.flags).to be_empty + expect(scope.flags.values).to be_empty end context "with an active span" do @@ -66,14 +73,14 @@ it "records flag.evaluation.* span data, capped per span" do scope.add_feature_flag("a", true) - expect(span.data["flag.evaluation.a"]).to eq(true) + expect(span.to_h[:data]["flag.evaluation.a"]).to eq(true) 12.times { |i| scope.add_feature_flag("g#{i}", true) } - keys = span.data.keys.select { |k| k.start_with?("flag.evaluation.") } - expect(keys.size).to eq(Sentry::Scope::MAX_FLAGS_PER_SPAN) + keys = span.to_h[:data].keys.select { |k| k.start_with?("flag.evaluation.") } + expect(keys.size).to eq(Sentry::Span::MAX_FLAGS) scope.add_feature_flag("a", false) - expect(span.data["flag.evaluation.a"]).to eq(false) + expect(span.to_h[:data]["flag.evaluation.a"]).to eq(true) end end end @@ -81,7 +88,7 @@ describe "Sentry.add_feature_flag" do it "adds to the current scope" do Sentry.add_feature_flag("x", true) - expect(Sentry.get_current_scope.flags).to eq([{ flag: "x", result: true }]) + expect(Sentry.get_current_scope.flags.values).to eq("x" => true) end end end diff --git a/sentry-ruby/spec/sentry/utils/ring_buffer_spec.rb b/sentry-ruby/spec/sentry/utils/ring_buffer_spec.rb new file mode 100644 index 000000000..0bb9f947d --- /dev/null +++ b/sentry-ruby/spec/sentry/utils/ring_buffer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::Utils::RingBuffer do + subject(:buffer) { described_class.new(2) } + + it "keeps the most recent unique values" do + buffer.set("a", 1) + buffer.set("b", 2) + buffer.set("a", 3) + buffer.set("c", 4) + + expect(buffer.values).to eq("a" => 3, "c" => 4) + end + + it "copies and clears independently" do + buffer.set("a", 1) + copy = buffer.dup + + copy.set("b", 2) + copy.clear + + expect(buffer.values).to eq("a" => 1) + expect(copy).to be_empty + end +end