Skip to content
Merged
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
10 changes: 10 additions & 0 deletions sentry-ruby/lib/sentry-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
40 changes: 39 additions & 1 deletion sentry-ruby/lib/sentry/scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "sentry/breadcrumb_buffer"
require "sentry/propagation_context"
require "sentry/attachment"
require "sentry/utils/ring_buffer"
require "etc"

module Sentry
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 = {}
Expand All @@ -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
Expand Down
26 changes: 24 additions & 2 deletions sentry-ruby/lib/sentry/span.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -142,6 +147,7 @@ def initialize(
@op = op
@status = status
@data = {}
@flags = {}
@tags = {}
@origin = origin || DEFAULT_SPAN_ORIGIN
end
Expand Down Expand Up @@ -187,7 +193,7 @@ def to_h
op: @op,
status: @status,
tags: @tags,
data: @data,
data: serialized_data,
origin: @origin
}
end
Expand All @@ -203,7 +209,7 @@ def get_trace_context
op: @op,
status: @status,
origin: @origin,
data: @data
data: serialized_data
}
end

Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Span flags skip updates at cap

Medium Severity

Once a span already holds 10 feature flags, set_flag rejects every write, including a new result for a name that is already stored. The scope buffer still keeps the latest evaluation, so error events and the active span can disagree on the same flag.evaluation.* value after a re-evaluation.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 28220bc. Configure here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matches python behavior 🤷 agree that the logic is a bit whatever


# Sets a tag to the span.
# @param key [String, Symbol]
# @param value [String]
Expand All @@ -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
38 changes: 38 additions & 0 deletions sentry-ruby/lib/sentry/utils/ring_buffer.rb
Original file line number Diff line number Diff line change
@@ -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
94 changes: 94 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,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
27 changes: 27 additions & 0 deletions sentry-ruby/spec/sentry/utils/ring_buffer_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading