Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 🛠
Expand Down
10 changes: 10 additions & 0 deletions sentry-ruby/lib/sentry-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
47 changes: 46 additions & 1 deletion sentry-ruby/lib/sentry/scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 = {}
Expand All @@ -404,6 +448,7 @@ def set_default_value
@session = nil
@attachments = []
@attributes = {}
@flags = []
generate_propagation_context
set_new_breadcrumb_buffer
end
Expand Down
80 changes: 80 additions & 0 deletions sentry-ruby/spec/sentry/scope/feature_flags_spec.rb
Original file line number Diff line number Diff line change
@@ -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