Skip to content

Commit c5c0ad9

Browse files
sl0thentr0pyclaude
andcommitted
feat: New set_attribute api
set_attribute(key, value, unit: nil) set_attributes(attributes_hash) remove_attribute(key) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtcJFmXABtrEgpwngfZxq5
1 parent f5f5b91 commit c5c0ad9

9 files changed

Lines changed: 351 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: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ class Scope
2424
:span,
2525
:session,
2626
:attachments,
27-
:propagation_context
27+
:propagation_context,
28+
:attributes
2829
]
2930

3031
attr_reader(*ATTRIBUTES)
@@ -84,7 +85,13 @@ def apply_to_event(event, hint = nil)
8485
# @param telemetry [MetricEvent, LogEvent] the telemetry event to apply scope context to
8586
# @return [MetricEvent, LogEvent] the telemetry event with scope context applied
8687
def apply_to_telemetry(telemetry)
87-
# TODO-neel when new scope set_attribute api is added: add them here
88+
attributes.each do |key, value|
89+
# Compare as strings since String and Symbol keys serialize to the same wire key.
90+
next if telemetry.attributes.keys.any? { |existing| existing.to_s == key.to_s }
91+
92+
telemetry.attributes[key] = value
93+
end
94+
8895
trace_context = get_trace_context
8996
telemetry.trace_id = trace_context[:trace_id]
9097
telemetry.span_id = trace_context[:span_id]
@@ -136,6 +143,7 @@ def dup
136143
copy.propagation_context = propagation_context.deep_dup
137144
copy.attachments = attachments.dup
138145
copy.event_processors = event_processors.dup
146+
copy.attributes = attributes.deep_dup
139147
copy
140148
end
141149

@@ -154,6 +162,7 @@ def update_from_scope(scope)
154162
self.span = scope.span
155163
self.propagation_context = scope.propagation_context
156164
self.attachments = scope.attachments
165+
self.attributes = scope.attributes
157166
end
158167

159168
# Updates the scope's data from the given options.
@@ -256,6 +265,31 @@ def set_context(key, value)
256265
set_contexts(key => value)
257266
end
258267

268+
# Updates the scope's attributes by merging with the old value.
269+
# @param attributes_hash [Hash]
270+
# @return [Hash]
271+
def set_attributes(attributes_hash)
272+
check_argument_type!(attributes_hash, Hash)
273+
@attributes.merge!(attributes_hash)
274+
end
275+
276+
# Sets a single attribute on the scope.
277+
# @param key [String, Symbol]
278+
# @param value [Object]
279+
# @param unit [String, Symbol, nil] an optional measurement unit for the value
280+
# @return [Hash]
281+
def set_attribute(key, value, unit: nil)
282+
value = { value: value, unit: unit } unless unit.nil?
283+
set_attributes(key => value)
284+
end
285+
286+
# Removes a single attribute from the scope. No-op if the attribute is not set.
287+
# @param key [String, Symbol]
288+
# @return [void]
289+
def remove_attribute(key)
290+
@attributes.delete(key)
291+
end
292+
259293
# Sets the scope's level attribute.
260294
# @param level [String, Symbol]
261295
# @return [void]
@@ -361,6 +395,7 @@ def set_default_value
361395
@span = nil
362396
@session = nil
363397
@attachments = []
398+
@attributes = {}
364399
generate_propagation_context
365400
set_new_breadcrumb_buffer
366401
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: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,65 @@
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 "raises when the argument is not a Hash" do
248+
expect { subject.set_attributes("foo") }.to raise_error(ArgumentError)
249+
end
250+
end
251+
252+
describe "#set_attribute" do
253+
it "sets a single attribute" do
254+
subject.set_attribute("foo", "bar")
255+
256+
expect(subject.attributes).to eq({ "foo" => "bar" })
257+
end
258+
259+
it "overwrites an existing attribute" do
260+
subject.set_attribute("foo", "bar")
261+
subject.set_attribute("foo", "qux")
262+
263+
expect(subject.attributes).to eq({ "foo" => "qux" })
264+
end
265+
266+
it "supports the object form with a unit" do
267+
subject.set_attribute("duration", { value: 3600, unit: "second" })
268+
269+
expect(subject.attributes).to eq({ "duration" => { value: 3600, unit: "second" } })
270+
end
271+
272+
it "wraps the value when given the optional unit: param" do
273+
subject.set_attribute("duration", 3600, unit: "second")
274+
275+
expect(subject.attributes).to eq({ "duration" => { value: 3600, unit: "second" } })
276+
end
277+
end
278+
279+
describe "#remove_attribute" do
280+
it "removes the attribute" do
281+
subject.set_attribute("foo", "bar")
282+
subject.remove_attribute("foo")
283+
284+
expect(subject.attributes).to eq({})
285+
end
286+
287+
it "is a no-op when the attribute is not set" do
288+
expect { subject.remove_attribute("missing") }.not_to raise_error
289+
expect(subject.attributes).to eq({})
290+
end
291+
end
231292
end

0 commit comments

Comments
 (0)