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..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 @@ -27,9 +28,13 @@ class Scope :session, :attachments, :propagation_context, - :attributes + :attributes, + :flags ] + # Maximum number of feature flag evaluations kept on a scope. + MAX_FLAGS = 100 + attr_reader(*ATTRIBUTES) # @param max_breadcrumbs [Integer] the maximum number of breadcrumbs to be stored in the scope. @@ -61,6 +66,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 +157,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 +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.dup end # Updates the scope's data from the given options. @@ -298,6 +306,22 @@ 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.set(name, result) + + add_feature_flag_to_span(name, result) + nil + end + # Sets the scope's level attribute. # @param level [String, Symbol] # @return [void] @@ -389,6 +413,19 @@ def add_attachment(**opts) private + def add_feature_flag_to_span(name, result) + return unless span + + span.set_flag(name, result) + end + + def apply_flags_to_event(event) + return unless event.is_a?(ErrorEvent) && !flags.empty? + + values = flags.values.map { |flag, result| { flag: flag, result: result } } + event.contexts[:flags] = { values: values } + end + def set_default_value @contexts = { os: self.class.os_context, runtime: self.class.runtime_context } @extra = {} @@ -404,6 +441,7 @@ def set_default_value @session = nil @attachments = [] @attributes = {} + @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 new file mode 100644 index 000000000..0851f799e --- /dev/null +++ b/sentry-ruby/spec/sentry/scope/feature_flags_spec.rb @@ -0,0 +1,94 @@ +# 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.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.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.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 + 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 "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.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.values).to eq("a" => true) + + scope.clear + expect(scope.flags.values).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.to_h[:data]["flag.evaluation.a"]).to eq(true) + + 12.times { |i| scope.add_feature_flag("g#{i}", true) } + 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.to_h[:data]["flag.evaluation.a"]).to eq(true) + 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.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