diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d179f16..fc9f43c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### New Features ✨ + +- Add feature flag support by @jespersandnielsen [#3108](https://github.com/getsentry/sentry-ruby/pull/3108) + ## 7.1.0 ### Breaking Changes 🛠 diff --git a/sentry-ruby/lib/sentry-ruby.rb b/sentry-ruby/lib/sentry-ruby.rb index c961d732a..696ce3b0f 100644 --- a/sentry-ruby/lib/sentry-ruby.rb +++ b/sentry-ruby/lib/sentry-ruby.rb @@ -221,6 +221,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