Skip to content

Commit d13923b

Browse files
authored
Add Metrics API and aggregator (#2247)
* new `Sentry::Metrics` module with 4 apis that map to the new 4 `Sentry::Metrics::Metric` classes * `increment` - simple counter * `distribution` - array of observations * `gauge` - statistics (last/min/max/sum/count) * `set` - unique values * new `Sentry::Metrics::Aggregator` that starts a thread that flushes pending metric buckets in 5 second intervals * buckets are a nested hash of timestamp (rolled to 10 second intervals) -> metric keys -> actual metric instance * there is a random `flush_shift` once per startup to create jittering * flushable buckets are sent in a new `statsd` type envelope that is not json so made a small change to the `Envelope::Item` * tag key/values are sanitized for unicode/special characters according to the two regexes Reference spec - https://develop.sentry.dev/sdk/metrics/ part of #2246
1 parent 478c4cf commit d13923b

25 files changed

+979
-8
lines changed

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@
55
- Add support for distributed tracing in `sentry-delayed_job` [#2233](https://github.com/getsentry/sentry-ruby/pull/2233)
66
- Fix warning about default gems on Ruby 3.3.0 ([#2225](https://github.com/getsentry/sentry-ruby/pull/2225))
77
- Add `hint:` support to `Sentry::Rails::ErrorSubscriber` [#2235](https://github.com/getsentry/sentry-ruby/pull/2235)
8+
- Add [Metrics](https://docs.sentry.io/product/metrics/) support
9+
- Add main APIs and `Aggregator` thread [#2247](https://github.com/getsentry/sentry-ruby/pull/2247)
10+
11+
The SDK now supports recording and aggregating metrics. A new thread will be started
12+
for aggregation and will flush the pending data to Sentry every 5 seconds.
13+
14+
To enable this behavior, use:
15+
16+
```ruby
17+
Sentry.init do |config|
18+
# ...
19+
config.metrics.enabled = true
20+
end
21+
```
22+
23+
And then in your application code, collect metrics as follows:
24+
25+
```ruby
26+
# increment a simple counter
27+
Sentry::Metrics.increment('button_click')
28+
# set a value, unit and tags
29+
Sentry::Metrics.increment('time', 5, unit: 'second', tags: { browser:' firefox' })
30+
31+
# distribution - get statistical aggregates from an array of observations
32+
Sentry::Metrics.distribution('page_load', 15.0, unit: 'millisecond')
33+
34+
# gauge - record statistical aggregates directly on the SDK, more space efficient
35+
Sentry::Metrics.gauge('page_load', 15.0, unit: 'millisecond')
36+
37+
# set - get unique counts of elements
38+
Sentry::Metrics.set('user_view', 'jane')
39+
```
840

941
### Bug Fixes
1042

Gemfile

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ ruby_version = Gem::Version.new(RUBY_VERSION)
99
if ruby_version >= Gem::Version.new("2.7.0")
1010
gem "debug", github: "ruby/debug", platform: :ruby
1111
gem "irb"
12-
# new release breaks on jruby
13-
gem "io-console", "0.6.0"
1412

1513
if ruby_version >= Gem::Version.new("3.0.0")
1614
gem "ruby-lsp-rspec"

sentry-ruby/lib/sentry-ruby.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
require "sentry/session_flusher"
2424
require "sentry/backpressure_monitor"
2525
require "sentry/cron/monitor_check_ins"
26+
require "sentry/metrics"
2627

2728
[
2829
"sentry/rake",
@@ -77,6 +78,10 @@ def exception_locals_tp
7778
# @return [BackpressureMonitor, nil]
7879
attr_reader :backpressure_monitor
7980

81+
# @!attribute [r] metrics_aggregator
82+
# @return [Metrics::Aggregator, nil]
83+
attr_reader :metrics_aggregator
84+
8085
##### Patch Registration #####
8186

8287
# @!visibility private
@@ -224,6 +229,7 @@ def init(&block)
224229
@background_worker = Sentry::BackgroundWorker.new(config)
225230
@session_flusher = config.session_tracking? ? Sentry::SessionFlusher.new(config, client) : nil
226231
@backpressure_monitor = config.enable_backpressure_handling ? Sentry::BackpressureMonitor.new(config, client) : nil
232+
@metrics_aggregator = config.metrics.enabled ? Sentry::Metrics::Aggregator.new(config, client) : nil
227233
exception_locals_tp.enable if config.include_local_variables
228234
at_exit { close }
229235
end
@@ -244,6 +250,12 @@ def close
244250
@backpressure_monitor = nil
245251
end
246252

253+
if @metrics_aggregator
254+
@metrics_aggregator.flush(force: true)
255+
@metrics_aggregator.kill
256+
@metrics_aggregator = nil
257+
end
258+
247259
if client = get_current_client
248260
client.transport.flush
249261

sentry-ruby/lib/sentry/configuration.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require "sentry/release_detector"
99
require "sentry/transport/configuration"
1010
require "sentry/cron/configuration"
11+
require "sentry/metrics/configuration"
1112
require "sentry/linecache"
1213
require "sentry/interfaces/stacktrace_builder"
1314

@@ -235,6 +236,10 @@ def capture_exception_frame_locals=(value)
235236
# @return [Cron::Configuration]
236237
attr_reader :cron
237238

239+
# Metrics related configuration.
240+
# @return [Metrics::Configuration]
241+
attr_reader :metrics
242+
238243
# Take a float between 0.0 and 1.0 as the sample rate for tracing events (transactions).
239244
# @return [Float, nil]
240245
attr_reader :traces_sample_rate
@@ -386,6 +391,7 @@ def initialize
386391

387392
@transport = Transport::Configuration.new
388393
@cron = Cron::Configuration.new
394+
@metrics = Metrics::Configuration.new
389395
@gem_specs = Hash[Gem::Specification.map { |spec| [spec.name, spec.version.to_s] }] if Gem::Specification.respond_to?(:map)
390396

391397
run_post_initialization_callbacks

sentry-ruby/lib/sentry/envelope.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def type
1919
end
2020

2121
def to_s
22-
[JSON.generate(@headers), JSON.generate(@payload)].join("\n")
22+
[JSON.generate(@headers), @payload.is_a?(String) ? @payload : JSON.generate(@payload)].join("\n")
2323
end
2424

2525
def serialize

sentry-ruby/lib/sentry/metrics.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
require 'sentry/metrics/metric'
4+
require 'sentry/metrics/counter_metric'
5+
require 'sentry/metrics/distribution_metric'
6+
require 'sentry/metrics/gauge_metric'
7+
require 'sentry/metrics/set_metric'
8+
require 'sentry/metrics/aggregator'
9+
10+
module Sentry
11+
module Metrics
12+
class << self
13+
def increment(key, value = 1.0, unit: 'none', tags: {}, timestamp: nil)
14+
Sentry.metrics_aggregator&.add(:c, key, value, unit: unit, tags: tags, timestamp: timestamp)
15+
end
16+
17+
def distribution(key, value, unit: 'none', tags: {}, timestamp: nil)
18+
Sentry.metrics_aggregator&.add(:d, key, value, unit: unit, tags: tags, timestamp: timestamp)
19+
end
20+
21+
def set(key, value, unit: 'none', tags: {}, timestamp: nil)
22+
Sentry.metrics_aggregator&.add(:s, key, value, unit: unit, tags: tags, timestamp: timestamp)
23+
end
24+
25+
def gauge(key, value, unit: 'none', tags: {}, timestamp: nil)
26+
Sentry.metrics_aggregator&.add(:g, key, value, unit: unit, tags: tags, timestamp: timestamp)
27+
end
28+
end
29+
end
30+
end
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
module Metrics
5+
class Aggregator
6+
include LoggingHelper
7+
8+
FLUSH_INTERVAL = 5
9+
ROLLUP_IN_SECONDS = 10
10+
11+
KEY_SANITIZATION_REGEX = /[^a-zA-Z0-9_\/.-]+/
12+
VALUE_SANITIZATION_REGEX = /[^[[:word:]][[:digit:]][[:space:]]_:\/@\.{}\[\]$-]+/
13+
14+
METRIC_TYPES = {
15+
c: CounterMetric,
16+
d: DistributionMetric,
17+
g: GaugeMetric,
18+
s: SetMetric
19+
}
20+
21+
# exposed only for testing
22+
attr_reader :thread, :buckets, :flush_shift
23+
24+
def initialize(configuration, client)
25+
@client = client
26+
@logger = configuration.logger
27+
28+
@default_tags = {}
29+
@default_tags['release'] = configuration.release if configuration.release
30+
@default_tags['environment'] = configuration.environment if configuration.environment
31+
32+
@thread = nil
33+
@exited = false
34+
@mutex = Mutex.new
35+
36+
# buckets are a nested hash of timestamp -> bucket keys -> Metric instance
37+
@buckets = {}
38+
39+
# the flush interval needs to be shifted once per startup to create jittering
40+
@flush_shift = Random.rand * ROLLUP_IN_SECONDS
41+
end
42+
43+
def add(type,
44+
key,
45+
value,
46+
unit: 'none',
47+
tags: {},
48+
timestamp: nil)
49+
return unless ensure_thread
50+
return unless METRIC_TYPES.keys.include?(type)
51+
52+
timestamp = timestamp.to_i if timestamp.is_a?(Time)
53+
timestamp ||= Sentry.utc_now.to_i
54+
55+
# this is integer division and thus takes the floor of the division
56+
# and buckets into 10 second intervals
57+
bucket_timestamp = (timestamp / ROLLUP_IN_SECONDS) * ROLLUP_IN_SECONDS
58+
59+
serialized_tags = serialize_tags(get_updated_tags(tags))
60+
bucket_key = [type, key, unit, serialized_tags]
61+
62+
@mutex.synchronize do
63+
@buckets[bucket_timestamp] ||= {}
64+
65+
if @buckets[bucket_timestamp][bucket_key]
66+
@buckets[bucket_timestamp][bucket_key].add(value)
67+
else
68+
@buckets[bucket_timestamp][bucket_key] = METRIC_TYPES[type].new(value)
69+
end
70+
end
71+
end
72+
73+
def flush(force: false)
74+
flushable_buckets = get_flushable_buckets!(force)
75+
return if flushable_buckets.empty?
76+
77+
payload = serialize_buckets(flushable_buckets)
78+
envelope = Envelope.new
79+
envelope.add_item(
80+
{ type: 'statsd', length: payload.bytesize },
81+
payload
82+
)
83+
84+
Sentry.background_worker.perform do
85+
@client.transport.send_envelope(envelope)
86+
end
87+
end
88+
89+
def kill
90+
log_debug('[Metrics::Aggregator] killing thread')
91+
92+
@exited = true
93+
@thread&.kill
94+
end
95+
96+
private
97+
98+
def ensure_thread
99+
return false if @exited
100+
return true if @thread&.alive?
101+
102+
@thread = Thread.new do
103+
loop do
104+
# TODO-neel-metrics use event for force flush later
105+
sleep(FLUSH_INTERVAL)
106+
flush
107+
end
108+
end
109+
110+
true
111+
rescue ThreadError
112+
log_debug('[Metrics::Aggregator] thread creation failed')
113+
@exited = true
114+
false
115+
end
116+
117+
# important to sort for key consistency
118+
def serialize_tags(tags)
119+
tags.flat_map do |k, v|
120+
if v.is_a?(Array)
121+
v.map { |x| [k.to_s, x.to_s] }
122+
else
123+
[[k.to_s, v.to_s]]
124+
end
125+
end.sort
126+
end
127+
128+
def get_flushable_buckets!(force)
129+
@mutex.synchronize do
130+
flushable_buckets = {}
131+
132+
if force
133+
flushable_buckets = @buckets
134+
@buckets = {}
135+
else
136+
cutoff = Sentry.utc_now.to_i - ROLLUP_IN_SECONDS - @flush_shift
137+
flushable_buckets = @buckets.select { |k, _| k <= cutoff }
138+
@buckets.reject! { |k, _| k <= cutoff }
139+
end
140+
141+
flushable_buckets
142+
end
143+
end
144+
145+
# serialize buckets to statsd format
146+
def serialize_buckets(buckets)
147+
buckets.map do |timestamp, timestamp_buckets|
148+
timestamp_buckets.map do |metric_key, metric|
149+
type, key, unit, tags = metric_key
150+
values = metric.serialize.join(':')
151+
sanitized_tags = tags.map { |k, v| "#{sanitize_key(k)}:#{sanitize_value(v)}" }.join(',')
152+
153+
"#{sanitize_key(key)}@#{unit}:#{values}|#{type}|\##{sanitized_tags}|T#{timestamp}"
154+
end
155+
end.flatten.join("\n")
156+
end
157+
158+
def sanitize_key(key)
159+
key.gsub(KEY_SANITIZATION_REGEX, '_')
160+
end
161+
162+
def sanitize_value(value)
163+
value.gsub(VALUE_SANITIZATION_REGEX, '')
164+
end
165+
166+
def get_transaction_name
167+
scope = Sentry.get_current_scope
168+
return nil unless scope && scope.transaction_name
169+
return nil if scope.transaction_source_low_quality?
170+
171+
scope.transaction_name
172+
end
173+
174+
def get_updated_tags(tags)
175+
updated_tags = @default_tags.merge(tags)
176+
177+
transaction_name = get_transaction_name
178+
updated_tags['transaction'] = transaction_name if transaction_name
179+
180+
updated_tags
181+
end
182+
end
183+
end
184+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
module Metrics
5+
class Configuration
6+
# Enable metrics usage
7+
# Starts a new {Sentry::Metrics::Aggregator} instance to aggregate metrics
8+
# and a thread to aggregate flush every 5 seconds.
9+
# @return [Boolean]
10+
attr_accessor :enabled
11+
12+
def initialize
13+
@enabled = false
14+
end
15+
end
16+
end
17+
end
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
module Metrics
5+
class CounterMetric < Metric
6+
attr_reader :value
7+
8+
def initialize(value)
9+
@value = value.to_f
10+
end
11+
12+
def add(value)
13+
@value += value.to_f
14+
end
15+
16+
def serialize
17+
[value]
18+
end
19+
20+
def weight
21+
1
22+
end
23+
end
24+
end
25+
end

0 commit comments

Comments
 (0)