Skip to content

Commit bf28b5c

Browse files
committed
Metric summaries on span
* add a `LocalAggregator` instance on spans to duplicate metrics on the span as a gauge metric * proxy the main aggregator add calls to the local aggregator if a span is running * start a `metrics.timing` span in the `Sentry::Metrics.timing` API
1 parent 094e4aa commit bf28b5c

11 files changed

+285
-10
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Add [Metrics](https://docs.sentry.io/product/metrics/) support
99
- Add main APIs and `Aggregator` thread [#2247](https://github.com/getsentry/sentry-ruby/pull/2247)
1010
- Add `Sentry::Metrics.timing` API for measuring block duration [#2254](https://github.com/getsentry/sentry-ruby/pull/2254)
11+
- Add metric summaries on spans [#2255](https://github.com/getsentry/sentry-ruby/pull/2255)
1112

1213
The SDK now supports recording and aggregating metrics. A new thread will be started
1314
for aggregation and will flush the pending data to Sentry every 5 seconds.
@@ -39,6 +40,7 @@
3940
Sentry::Metrics.set('user_view', 'jane')
4041
4142
# timing - measure duration of code block, defaults to seconds
43+
# will also automatically create a `metric.timing` span
4244
Sentry::Metrics.timing('how_long') { sleep(1) }
4345
# timing - measure duration of code block in other duraton units
4446
Sentry::Metrics.timing('how_long_ms', unit: 'millisecond') { sleep(0.5) }

sentry-ruby/lib/sentry/metrics.rb

+10-5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ module Metrics
1414
INFORMATION_UNITS = %w[bit byte kilobyte kibibyte megabyte mebibyte gigabyte gibibyte terabyte tebibyte petabyte pebibyte exabyte exbibyte]
1515
FRACTIONAL_UNITS = %w[ratio percent]
1616

17+
OP_NAME = 'metric.timing'
18+
1719
class << self
1820
def increment(key, value = 1.0, unit: 'none', tags: {}, timestamp: nil)
1921
Sentry.metrics_aggregator&.add(:c, key, value, unit: unit, tags: tags, timestamp: timestamp)
@@ -32,15 +34,18 @@ def gauge(key, value, unit: 'none', tags: {}, timestamp: nil)
3234
end
3335

3436
def timing(key, unit: 'second', tags: {}, timestamp: nil, &block)
35-
return unless Sentry.metrics_aggregator
3637
return unless block_given?
3738
return unless DURATION_UNITS.include?(unit)
3839

39-
start = Timing.send(unit.to_sym)
40-
yield
41-
value = Timing.send(unit.to_sym) - start
40+
Sentry.with_child_span(op: OP_NAME, description: key) do |span|
41+
tags.each { |k, v| span.set_tag(k, v.is_a?(Array) ? v.join(', ') : v.to_s) } if span
42+
43+
start = Timing.send(unit.to_sym)
44+
yield
45+
value = Timing.send(unit.to_sym) - start
4246

43-
Sentry.metrics_aggregator.add(:d, key, value, unit: unit, tags: tags, timestamp: timestamp)
47+
Sentry.metrics_aggregator&.add(:d, key, value, unit: unit, tags: tags, timestamp: timestamp)
48+
end
4449
end
4550
end
4651
end

sentry-ruby/lib/sentry/metrics/aggregator.rb

+20-4
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,23 @@ def add(type,
5959
serialized_tags = serialize_tags(get_updated_tags(tags))
6060
bucket_key = [type, key, unit, serialized_tags]
6161

62-
@mutex.synchronize do
62+
added = @mutex.synchronize do
6363
@buckets[bucket_timestamp] ||= {}
6464

65-
if @buckets[bucket_timestamp][bucket_key]
66-
@buckets[bucket_timestamp][bucket_key].add(value)
65+
if (metric = @buckets[bucket_timestamp][bucket_key])
66+
old_weight = metric.weight
67+
metric.add(value)
68+
metric.weight - old_weight
6769
else
68-
@buckets[bucket_timestamp][bucket_key] = METRIC_TYPES[type].new(value)
70+
metric = METRIC_TYPES[type].new(value)
71+
@buckets[bucket_timestamp][bucket_key] = metric
72+
metric.weight
6973
end
7074
end
75+
76+
# for sets, we pass on if there was a new entry to the local gauge
77+
local_value = type == :s ? added : value
78+
process_span_aggregator(bucket_key, local_value)
7179
end
7280

7381
def flush(force: false)
@@ -180,6 +188,14 @@ def get_updated_tags(tags)
180188

181189
updated_tags
182190
end
191+
192+
def process_span_aggregator(key, value)
193+
scope = Sentry.get_current_scope
194+
return nil unless scope && scope.span
195+
return nil if scope.transaction_source_low_quality?
196+
197+
scope.span.metrics_local_aggregator.add(key, value)
198+
end
183199
end
184200
end
185201
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
module Metrics
5+
class LocalAggregator
6+
# exposed only for testing
7+
attr_reader :buckets
8+
9+
def initialize
10+
@buckets = {}
11+
end
12+
13+
def add(key, value)
14+
if @buckets[key]
15+
@buckets[key].add(value)
16+
else
17+
@buckets[key] = GaugeMetric.new(value)
18+
end
19+
end
20+
21+
def to_hash
22+
return nil if @buckets.empty?
23+
24+
@buckets.map do |bucket_key, metric|
25+
type, key, unit, tags = bucket_key
26+
27+
payload_key = "#{type}:#{key}@#{unit}"
28+
payload_value = {
29+
tags: deserialize_tags(tags),
30+
min: metric.min,
31+
max: metric.max,
32+
count: metric.count,
33+
sum: metric.sum
34+
}
35+
36+
[payload_key, payload_value]
37+
end.to_h
38+
end
39+
40+
private
41+
42+
def deserialize_tags(tags)
43+
tags.inject({}) do |h, tag|
44+
k, v = tag
45+
old = h[k]
46+
# make it an array if key repeats
47+
h[k] = old ? (old.is_a?(Array) ? old << v : [old, v]) : v
48+
h
49+
end
50+
end
51+
end
52+
end
53+
end

sentry-ruby/lib/sentry/span.rb

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "securerandom"
4+
require "sentry/metrics/local_aggregator"
45

56
module Sentry
67
class Span
@@ -149,7 +150,7 @@ def to_baggage
149150

150151
# @return [Hash]
151152
def to_hash
152-
{
153+
hash = {
153154
trace_id: @trace_id,
154155
span_id: @span_id,
155156
parent_span_id: @parent_span_id,
@@ -161,6 +162,11 @@ def to_hash
161162
tags: @tags,
162163
data: @data
163164
}
165+
166+
summary = metrics_summary
167+
hash[:_metrics_summary] = summary if summary
168+
169+
hash
164170
end
165171

166172
# Returns the span's context that can be used to embed in an Event.
@@ -268,5 +274,14 @@ def set_data(key, value)
268274
def set_tag(key, value)
269275
@tags[key] = value
270276
end
277+
278+
# Collects gauge metrics on the span for metric summaries.
279+
def metrics_local_aggregator
280+
@metrics_local_aggregator ||= Sentry::Metrics::LocalAggregator.new
281+
end
282+
283+
def metrics_summary
284+
@metrics_local_aggregator&.to_hash
285+
end
271286
end
272287
end

sentry-ruby/lib/sentry/transaction_event.rb

+5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ class TransactionEvent < Event
1717
# @return [Hash, nil]
1818
attr_accessor :profile
1919

20+
# @return [Hash, nil]
21+
attr_accessor :metrics_summary
22+
2023
def initialize(transaction:, **options)
2124
super(**options)
2225

@@ -29,6 +32,7 @@ def initialize(transaction:, **options)
2932
self.tags = transaction.tags
3033
self.dynamic_sampling_context = transaction.get_baggage.dynamic_sampling_context
3134
self.measurements = transaction.measurements
35+
self.metrics_summary = transaction.metrics_summary
3236

3337
finished_spans = transaction.span_recorder.spans.select { |span| span.timestamp && span != transaction }
3438
self.spans = finished_spans.map(&:to_hash)
@@ -49,6 +53,7 @@ def to_hash
4953
data[:spans] = @spans.map(&:to_hash) if @spans
5054
data[:start_timestamp] = @start_timestamp
5155
data[:measurements] = @measurements
56+
data[:_metrics_summary] = @metrics_summary if @metrics_summary
5257
data
5358
end
5459

sentry-ruby/spec/sentry/client_spec.rb

+10
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,16 @@ def sentry_context
195195
event = subject.event_from_transaction(transaction)
196196
expect(event.contexts).to include({ foo: { bar: 42 } })
197197
end
198+
199+
it 'adds metric summary on transaction if any' do
200+
key = [:c, 'incr', 'none', []]
201+
transaction.metrics_local_aggregator.add(key, 10)
202+
hash = subject.event_from_transaction(transaction).to_hash
203+
204+
expect(hash[:_metrics_summary]).to eq({
205+
'c:incr@none' => { count: 1, max: 10.0, min: 10.0, sum: 10.0, tags: {} }
206+
})
207+
end
198208
end
199209

200210
describe "#event_from_exception" do

sentry-ruby/spec/sentry/metrics/aggregator_spec.rb

+51
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,57 @@
187187
expect(metric).to be_a(Sentry::Metrics::SetMetric)
188188
expect(metric.value).to eq(Set[1])
189189
end
190+
191+
describe 'local aggregation for span metric summaries' do
192+
it 'does nothing without an active scope span' do
193+
expect_any_instance_of(Sentry::Metrics::LocalAggregator).not_to receive(:add)
194+
subject.add(:c, 'incr', 1)
195+
end
196+
197+
context 'with running transaction and active span' do
198+
let(:span) { Sentry.start_transaction }
199+
200+
before do
201+
Sentry.get_current_scope.set_span(span)
202+
Sentry.get_current_scope.set_transaction_name('metric', source: :view)
203+
end
204+
205+
it 'does nothing if transaction name is low quality' do
206+
expect_any_instance_of(Sentry::Metrics::LocalAggregator).not_to receive(:add)
207+
208+
Sentry.get_current_scope.set_transaction_name('/123', source: :url)
209+
subject.add(:c, 'incr', 1)
210+
end
211+
212+
it 'proxies bucket key and value to local aggregator' do
213+
expect(span.metrics_local_aggregator).to receive(:add).with(
214+
array_including(:c, 'incr', 'none'),
215+
1
216+
)
217+
subject.add(:c, 'incr', 1)
218+
end
219+
220+
context 'for set metrics' do
221+
before { subject.add(:s, 'set', 'foo') }
222+
223+
it 'proxies bucket key and value 0 when existing element' do
224+
expect(span.metrics_local_aggregator).to receive(:add).with(
225+
array_including(:s, 'set', 'none'),
226+
0
227+
)
228+
subject.add(:s, 'set', 'foo')
229+
end
230+
231+
it 'proxies bucket key and value 1 when new element' do
232+
expect(span.metrics_local_aggregator).to receive(:add).with(
233+
array_including(:s, 'set', 'none'),
234+
1
235+
)
236+
subject.add(:s, 'set', 'bar')
237+
end
238+
end
239+
end
240+
end
190241
end
191242

192243
describe '#flush' do
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
require 'spec_helper'
2+
3+
RSpec.describe Sentry::Metrics::LocalAggregator do
4+
let(:tags) { [['foo', 1], ['foo', 2], ['bar', 'baz']] }
5+
let(:key) { [:c, 'incr', 'second', tags] }
6+
let(:key2) { [:s, 'set', 'none', []] }
7+
8+
describe '#add' do
9+
it 'creates new GaugeMetric and adds it to bucket if key not existing' do
10+
expect(Sentry::Metrics::GaugeMetric).to receive(:new).with(10).and_call_original
11+
12+
subject.add(key, 10)
13+
14+
metric = subject.buckets[key]
15+
expect(metric).to be_a(Sentry::Metrics::GaugeMetric)
16+
expect(metric.last).to eq(10.0)
17+
expect(metric.min).to eq(10.0)
18+
expect(metric.max).to eq(10.0)
19+
expect(metric.sum).to eq(10.0)
20+
expect(metric.count).to eq(1)
21+
end
22+
23+
it 'adds value to existing GaugeMetric' do
24+
subject.add(key, 10)
25+
26+
metric = subject.buckets[key]
27+
expect(metric).to be_a(Sentry::Metrics::GaugeMetric)
28+
expect(metric).to receive(:add).with(20).and_call_original
29+
expect(Sentry::Metrics::GaugeMetric).not_to receive(:new)
30+
31+
subject.add(key, 20)
32+
expect(metric.last).to eq(20.0)
33+
expect(metric.min).to eq(10.0)
34+
expect(metric.max).to eq(20.0)
35+
expect(metric.sum).to eq(30.0)
36+
expect(metric.count).to eq(2)
37+
end
38+
end
39+
40+
describe '#to_hash' do
41+
it 'returns nil if empty buckets' do
42+
expect(subject.to_hash).to eq(nil)
43+
end
44+
45+
context 'with filled buckets' do
46+
before do
47+
subject.add(key, 10)
48+
subject.add(key, 20)
49+
subject.add(key2, 1)
50+
end
51+
52+
it 'has the correct payload keys in the hash' do
53+
expect(subject.to_hash.keys).to eq([
54+
'c:incr@second',
55+
's:set@none'
56+
])
57+
end
58+
59+
it 'has the tags deserialized correctly with array values' do
60+
expect(subject.to_hash['c:incr@second'][:tags]).to eq({
61+
'foo' => [1, 2],
62+
'bar' => 'baz'
63+
})
64+
end
65+
66+
it 'has the correct gauge metric values' do
67+
expect(subject.to_hash['c:incr@second']).to include({
68+
min: 10.0,
69+
max: 20.0,
70+
count: 2,
71+
sum: 30.0
72+
})
73+
74+
expect(subject.to_hash['s:set@none']).to include({
75+
min: 1.0,
76+
max: 1.0,
77+
count: 1,
78+
sum: 1.0
79+
})
80+
end
81+
end
82+
end
83+
end

0 commit comments

Comments
 (0)