Skip to content

Commit 3bcfa34

Browse files
sl0thentr0pyclaude
andauthored
feat: New set_attribute api (#2998)
set_attribute(key, value, unit: nil) set_attributes(attributes_hash) remove_attribute(key) Claude-Session: https://claude.ai/code/session_01AtcJFmXABtrEgpwngfZxq5 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a51d194 commit 3bcfa34

9 files changed

Lines changed: 375 additions & 16 deletions

File tree

sentry-ruby/lib/sentry-ruby.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,35 @@ def set_context(*args)
220220
get_current_scope.set_context(*args)
221221
end
222222

223+
# @!method set_attributes
224+
# Updates the current scope's attributes by merging with the old value.
225+
# @param attributes_hash [Hash]
226+
# @return [Hash]
227+
def set_attributes(attributes_hash)
228+
return unless initialized?
229+
get_current_scope.set_attributes(attributes_hash)
230+
end
231+
232+
# @!method set_attribute
233+
# Sets a single attribute on the current scope.
234+
# @param key [String, Symbol]
235+
# @param value [Object]
236+
# @param unit [String, Symbol, nil] an optional measurement unit for the value
237+
# @return [Hash]
238+
def set_attribute(key, value, unit: nil)
239+
return unless initialized?
240+
get_current_scope.set_attribute(key, value, unit: unit)
241+
end
242+
243+
# @!method remove_attribute
244+
# Removes a single attribute from the current scope.
245+
# @param key [String, Symbol]
246+
# @return [void]
247+
def remove_attribute(key)
248+
return unless initialized?
249+
get_current_scope.remove_attribute(key)
250+
end
251+
223252
# @!method add_attachment
224253
# @!macro add_attachment
225254
def add_attachment(**opts)

sentry-ruby/lib/sentry/scope.rb

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require "set"
34
require "sentry/breadcrumb_buffer"
45
require "sentry/propagation_context"
56
require "sentry/attachment"
@@ -24,7 +25,8 @@ class Scope
2425
:span,
2526
:session,
2627
:attachments,
27-
:propagation_context
28+
:propagation_context,
29+
:attributes
2830
]
2931

3032
attr_reader(*ATTRIBUTES)
@@ -84,7 +86,15 @@ def apply_to_event(event, hint = nil)
8486
# @param telemetry [MetricEvent, LogEvent] the telemetry event to apply scope context to
8587
# @return [MetricEvent, LogEvent] the telemetry event with scope context applied
8688
def apply_to_telemetry(telemetry)
87-
# TODO-neel when new scope set_attribute api is added: add them here
89+
# Compare as strings since String and Symbol keys serialize to the same wire key.
90+
existing_keys = telemetry.attributes.keys.map(&:to_s).to_set
91+
92+
attributes.each do |key, value|
93+
next if existing_keys.include?(key)
94+
95+
telemetry.attributes[key] = value
96+
end
97+
8898
trace_context = get_trace_context
8999
telemetry.trace_id = trace_context[:trace_id]
90100
telemetry.span_id = trace_context[:span_id]
@@ -136,6 +146,7 @@ def dup
136146
copy.propagation_context = propagation_context.deep_dup
137147
copy.attachments = attachments.dup
138148
copy.event_processors = event_processors.dup
149+
copy.attributes = attributes.deep_dup
139150
copy
140151
end
141152

@@ -154,6 +165,7 @@ def update_from_scope(scope)
154165
self.span = scope.span
155166
self.propagation_context = scope.propagation_context
156167
self.attachments = scope.attachments
168+
self.attributes = scope.attributes
157169
end
158170

159171
# Updates the scope's data from the given options.
@@ -256,6 +268,32 @@ def set_context(key, value)
256268
set_contexts(key => value)
257269
end
258270

271+
# Updates the scope's attributes by merging with the old value.
272+
# @param attributes_hash [Hash]
273+
# @return [Hash]
274+
def set_attributes(attributes_hash)
275+
check_argument_type!(attributes_hash, Hash)
276+
attributes_hash.each { |key, value| @attributes[key.to_s] = value }
277+
@attributes
278+
end
279+
280+
# Sets a single attribute on the scope.
281+
# @param key [String, Symbol]
282+
# @param value [Object]
283+
# @param unit [String, Symbol, nil] an optional measurement unit for the value
284+
# @return [Hash]
285+
def set_attribute(key, value, unit: nil)
286+
value = { value: value, unit: unit } unless unit.nil?
287+
set_attributes(key => value)
288+
end
289+
290+
# Removes a single attribute from the scope. No-op if the attribute is not set.
291+
# @param key [String, Symbol]
292+
# @return [void]
293+
def remove_attribute(key)
294+
@attributes.delete(key.to_s)
295+
end
296+
259297
# Sets the scope's level attribute.
260298
# @param level [String, Symbol]
261299
# @return [void]
@@ -361,6 +399,7 @@ def set_default_value
361399
@span = nil
362400
@session = nil
363401
@attachments = []
402+
@attributes = {}
364403
generate_propagation_context
365404
set_new_breadcrumb_buffer
366405
end

sentry-ruby/lib/sentry/utils/telemetry_attributes.rb

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,35 @@ module Utils
77
module TelemetryAttributes
88
private
99

10-
def attribute_hash(value)
11-
case value
12-
when String
13-
{ value: value, type: "string" }
14-
when TrueClass, FalseClass
15-
{ value: value, type: "boolean" }
16-
when Integer
17-
{ value: value, type: "integer" }
18-
when Float
19-
{ value: value, type: "double" }
10+
def attribute_hash(raw_value)
11+
if raw_value.is_a?(Hash) && (raw_value.key?(:value) || raw_value.key?("value"))
12+
value = raw_value.key?(:value) ? raw_value[:value] : raw_value["value"]
13+
unit = raw_value.key?(:unit) ? raw_value[:unit] : raw_value["unit"]
2014
else
21-
begin
22-
{ value: JSON.generate(value), type: "string" }
23-
rescue
15+
value = raw_value
16+
unit = nil
17+
end
18+
19+
result =
20+
case value
21+
when String
2422
{ value: value, type: "string" }
23+
when TrueClass, FalseClass
24+
{ value: value, type: "boolean" }
25+
when Integer
26+
{ value: value, type: "integer" }
27+
when Float
28+
{ value: value, type: "double" }
29+
else
30+
begin
31+
{ value: JSON.generate(value), type: "string" }
32+
rescue
33+
{ value: value, type: "string" }
34+
end
2535
end
26-
end
36+
37+
result[:unit] = unit.to_s if unit.is_a?(String) || unit.is_a?(Symbol)
38+
result
2739
end
2840
end
2941
end

sentry-ruby/spec/sentry/metric_event_spec.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,59 @@
117117
expect(attributes["unknown"][:value]).to include("Object")
118118
end
119119

120+
it "carries units through the object form" do
121+
event = described_class.new(
122+
name: "test.metric",
123+
type: "counter",
124+
value: 1.0,
125+
attributes: {
126+
"duration_int" => { value: 3600, unit: "second" },
127+
"duration_float" => { value: 1.5, unit: "millisecond" },
128+
"version" => { value: "v1", unit: "version" },
129+
"symbol_unit" => { value: 3600, unit: :second },
130+
"invalid_unit" => { value: 1, unit: 5 }
131+
}
132+
)
133+
134+
attributes = event.to_h[:attributes]
135+
136+
expect(attributes["duration_int"]).to eq({ type: "integer", value: 3600, unit: "second" })
137+
expect(attributes["duration_float"]).to eq({ type: "double", value: 1.5, unit: "millisecond" })
138+
expect(attributes["version"]).to eq({ type: "string", value: "v1", unit: "version" })
139+
expect(attributes["symbol_unit"]).to eq({ type: "integer", value: 3600, unit: "second" })
140+
expect(attributes["invalid_unit"]).to eq({ type: "integer", value: 1 })
141+
end
142+
143+
it "carries units through the object form with string keys" do
144+
event = described_class.new(
145+
name: "test.metric",
146+
type: "counter",
147+
value: 1.0,
148+
attributes: {
149+
"duration" => { "value" => 3600, "unit" => "second" },
150+
"version" => { "value" => "v1" }
151+
}
152+
)
153+
154+
attributes = event.to_h[:attributes]
155+
156+
expect(attributes["duration"]).to eq({ type: "integer", value: 3600, unit: "second" })
157+
expect(attributes["version"]).to eq({ type: "string", value: "v1" })
158+
end
159+
160+
it "treats a hash without a value key as a plain JSON string value" do
161+
event = described_class.new(
162+
name: "test.metric",
163+
type: "counter",
164+
value: 1.0,
165+
attributes: { "obj" => { "foo" => "bar" } }
166+
)
167+
168+
attributes = event.to_h[:attributes]
169+
170+
expect(attributes["obj"]).to eq({ type: "string", value: "{\"foo\":\"bar\"}" })
171+
end
172+
120173
it "does not mutate the original attributes hash" do
121174
attributes = { "foo" => "bar" }
122175
event1 = described_class.new(name: "test.metric", type: :counter, value: 1, attributes: attributes)

sentry-ruby/spec/sentry/metrics_spec.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,34 @@
188188
end
189189
end
190190

191+
context "with attributes on scope" do
192+
it "includes scope attributes with inferred types in the metric" do
193+
Sentry.set_attribute("app.flag", true)
194+
Sentry.set_attribute("app.duration", 3600, unit: "second")
195+
196+
Sentry.metrics.count("test.counter")
197+
198+
Sentry.get_current_client.flush
199+
200+
attributes = sentry_metrics.first[:attributes]
201+
202+
expect(attributes["app.flag"]).to eq({ type: "boolean", value: true })
203+
expect(attributes["app.duration"]).to eq({ type: "integer", value: 3600, unit: "second" })
204+
end
205+
206+
it "lets metric attributes take precedence over scope attributes" do
207+
Sentry.set_attribute("shared", "from_scope")
208+
209+
Sentry.metrics.count("test.counter", attributes: { "shared" => "from_metric" })
210+
211+
Sentry.get_current_client.flush
212+
213+
attributes = sentry_metrics.first[:attributes]
214+
215+
expect(attributes["shared"]).to eq({ type: "string", value: "from_metric" })
216+
end
217+
end
218+
191219
it "includes default attributes from configuration" do
192220
Sentry.metrics.count("test.counter")
193221

sentry-ruby/spec/sentry/scope/setters_spec.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,85 @@
228228
expect(subject.fingerprint).to eq(["bar"])
229229
end
230230
end
231+
232+
describe "#set_attributes" do
233+
it "merges the given attributes into the scope" do
234+
subject.set_attributes("foo" => "bar")
235+
subject.set_attributes("baz" => 42)
236+
237+
expect(subject.attributes).to eq({ "foo" => "bar", "baz" => 42 })
238+
end
239+
240+
it "overwrites an existing attribute with the same key" do
241+
subject.set_attributes("foo" => "bar")
242+
subject.set_attributes("foo" => "qux")
243+
244+
expect(subject.attributes).to eq({ "foo" => "qux" })
245+
end
246+
247+
it "normalizes symbol keys to strings" do
248+
subject.set_attributes(foo: "bar")
249+
subject.set_attributes("foo" => "qux")
250+
251+
expect(subject.attributes).to eq({ "foo" => "qux" })
252+
end
253+
254+
it "raises when the argument is not a Hash" do
255+
expect { subject.set_attributes("foo") }.to raise_error(ArgumentError)
256+
end
257+
end
258+
259+
describe "#set_attribute" do
260+
it "sets a single attribute" do
261+
subject.set_attribute("foo", "bar")
262+
263+
expect(subject.attributes).to eq({ "foo" => "bar" })
264+
end
265+
266+
it "overwrites an existing attribute" do
267+
subject.set_attribute("foo", "bar")
268+
subject.set_attribute("foo", "qux")
269+
270+
expect(subject.attributes).to eq({ "foo" => "qux" })
271+
end
272+
273+
it "supports the object form with a unit" do
274+
subject.set_attribute("duration", { value: 3600, unit: "second" })
275+
276+
expect(subject.attributes).to eq({ "duration" => { value: 3600, unit: "second" } })
277+
end
278+
279+
it "wraps the value when given the optional unit: param" do
280+
subject.set_attribute("duration", 3600, unit: "second")
281+
282+
expect(subject.attributes).to eq({ "duration" => { value: 3600, unit: "second" } })
283+
end
284+
285+
it "normalizes a symbol key to a string" do
286+
subject.set_attribute(:foo, "bar")
287+
288+
expect(subject.attributes).to eq({ "foo" => "bar" })
289+
end
290+
end
291+
292+
describe "#remove_attribute" do
293+
it "removes the attribute" do
294+
subject.set_attribute("foo", "bar")
295+
subject.remove_attribute("foo")
296+
297+
expect(subject.attributes).to eq({})
298+
end
299+
300+
it "is a no-op when the attribute is not set" do
301+
expect { subject.remove_attribute("missing") }.not_to raise_error
302+
expect(subject.attributes).to eq({})
303+
end
304+
305+
it "removes an attribute set with a symbol key" do
306+
subject.set_attribute(:foo, "bar")
307+
subject.remove_attribute(:foo)
308+
309+
expect(subject.attributes).to eq({})
310+
end
311+
end
231312
end

0 commit comments

Comments
 (0)