Skip to content

Commit 892aef6

Browse files
committed
Implemnet new Sentry.metrics functionality
1 parent 5b5b6fc commit 892aef6

18 files changed

+1158
-1
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
### Features
44

5+
- Implement new `Sentry.metrics` functionality ([#2818](https://github.com/getsentry/sentry-ruby/pull/2818))
6+
7+
The SDK now supports Sentry's new [Trace Connected Metrics](https://docs.sentry.io/product/explore/metrics/) product.
8+
9+
```ruby
10+
Sentry.init do |config|
11+
# ...
12+
config.enable_metrics = true
13+
end
14+
15+
Sentry.metrics.count("button.click", 1, attributes: { button_id: "submit" })
16+
Sentry.metrics.distribution("response.time", 120.5, unit: "millisecond")
17+
Sentry.metrics.gauge("cpu.usage", 75.2, unit: "percent")
18+
```
19+
520
- Support for tracing `Sequel` queries ([#2814](https://github.com/getsentry/sentry-ruby/pull/2814))
621

722
```ruby

sentry-ruby/lib/sentry-ruby.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
require "sentry/backpressure_monitor"
2828
require "sentry/cron/monitor_check_ins"
2929
require "sentry/vernier/profiler"
30+
require "sentry/metrics"
3031

3132
[
3233
"sentry/rake",
@@ -627,6 +628,24 @@ def logger
627628
@logger ||= configuration.structured_logging.logger_class.new(configuration)
628629
end
629630

631+
# Returns the metrics API for capturing custom metrics.
632+
#
633+
# @example Enable metrics
634+
# Sentry.init do |config|
635+
# config.dsn = "YOUR_DSN"
636+
# config.enable_metrics = true
637+
# end
638+
#
639+
# @example Usage
640+
# Sentry.metrics.count("button.click", 1, attributes: { button_id: "submit" })
641+
# Sentry.metrics.distribution("response.time", 120.5, unit: "millisecond")
642+
# Sentry.metrics.gauge("cpu.usage", 75.2, unit: "percent")
643+
#
644+
# @return [Metrics] The metrics API
645+
def metrics
646+
Metrics
647+
end
648+
630649
##### Helpers #####
631650

632651
# @!visibility private

sentry-ruby/lib/sentry/client.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
require "sentry/transport"
44
require "sentry/log_event"
55
require "sentry/log_event_buffer"
6+
require "sentry/metric_event"
7+
require "sentry/metric_event_buffer"
68
require "sentry/utils/uuid"
79
require "sentry/utils/encoding_helper"
810

@@ -21,6 +23,9 @@ class Client
2123
# @!visibility private
2224
attr_reader :log_event_buffer
2325

26+
# @!visibility private
27+
attr_reader :metric_event_buffer
28+
2429
# @!macro configuration
2530
attr_reader :configuration
2631

@@ -46,6 +51,10 @@ def initialize(configuration)
4651
if configuration.enable_logs
4752
@log_event_buffer = LogEventBuffer.new(configuration, self).start
4853
end
54+
55+
if configuration.enable_metrics
56+
@metric_event_buffer = MetricEventBuffer.new(configuration, self).start
57+
end
4958
end
5059

5160
# Applies the given scope's data to the event and sends it to Sentry.
@@ -102,6 +111,16 @@ def buffer_log_event(event, scope)
102111
event
103112
end
104113

114+
# Buffer a metric event to be sent later with other metrics in a single envelope
115+
# @param event [MetricEvent] the metric event to be buffered
116+
# @return [MetricEvent]
117+
def buffer_metric_event(event, scope)
118+
return unless event.is_a?(MetricEvent)
119+
event = scope.apply_to_telemetry(event)
120+
@metric_event_buffer.add_metric(event)
121+
event
122+
end
123+
105124
# Capture an envelope directly.
106125
# @param envelope [Envelope] the envelope to be captured.
107126
# @return [void]
@@ -115,6 +134,7 @@ def flush
115134
transport.flush if configuration.sending_to_dsn_allowed?
116135
spotlight_transport.flush if spotlight_transport
117136
@log_event_buffer&.flush
137+
@metric_event_buffer&.flush
118138
end
119139

120140
# Initializes an Event object with the given exception. Returns `nil` if the exception's class is excluded from reporting.
@@ -322,6 +342,55 @@ def send_logs(log_events)
322342
end
323343
end
324344

345+
# Send an envelope with batched metrics
346+
# @param metrics [Array<MetricEvent>] the metrics to send
347+
# @api private
348+
# @return [void]
349+
def send_metrics(metrics)
350+
return if metrics.nil? || metrics.empty?
351+
352+
envelope = Envelope.new(
353+
event_id: Sentry::Utils.uuid,
354+
sent_at: Sentry.utc_now.iso8601,
355+
dsn: configuration.dsn,
356+
sdk: Sentry.sdk_meta
357+
)
358+
359+
discarded_count = 0
360+
envelope_items = []
361+
362+
if configuration.before_send_metric
363+
metrics.each do |metric|
364+
processed_metric = configuration.before_send_metric.call(metric)
365+
366+
if processed_metric
367+
envelope_items << processed_metric.to_h
368+
else
369+
discarded_count += 1
370+
end
371+
end
372+
373+
envelope_items
374+
else
375+
envelope_items = metrics.map(&:to_h)
376+
end
377+
378+
envelope.add_item(
379+
{
380+
type: "trace_metric",
381+
item_count: envelope_items.size,
382+
content_type: "application/vnd.sentry.items.trace-metric+json"
383+
},
384+
{ items: envelope_items }
385+
)
386+
387+
send_envelope(envelope)
388+
389+
unless discarded_count.zero?
390+
transport.record_lost_event(:before_send, "metric", num: discarded_count)
391+
end
392+
end
393+
325394
# Send an envelope directly to Sentry.
326395
# @param envelope [Envelope] the envelope to be sent.
327396
# @return [void]

sentry-ruby/lib/sentry/configuration.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
require "sentry/logger"
1515
require "sentry/structured_logger"
1616
require "sentry/log_event_buffer"
17+
require "sentry/metric_event_buffer"
1718

1819
module Sentry
1920
class Configuration
@@ -337,6 +338,23 @@ class Configuration
337338
# @return [Integer]
338339
attr_accessor :max_log_events
339340

341+
# Enable metrics collection
342+
# @return [Boolean]
343+
attr_accessor :enable_metrics
344+
345+
# Maximum number of metric events to buffer before sending
346+
# @return [Integer]
347+
attr_accessor :max_metric_events
348+
349+
# Optional Proc, called before sending a metric
350+
# @example
351+
# config.before_send_metric = lambda do |metric|
352+
# # return nil to drop the metric
353+
# metric
354+
# end
355+
# @return [Proc, nil]
356+
attr_reader :before_send_metric
357+
340358
# these are not config options
341359
# @!visibility private
342360
attr_reader :errors, :gem_specs
@@ -499,9 +517,11 @@ def initialize
499517
self.before_send_transaction = nil
500518
self.before_send_check_in = nil
501519
self.before_send_log = nil
520+
self.before_send_metric = nil
502521
self.rack_env_whitelist = RACK_ENV_WHITELIST_DEFAULT
503522
self.traces_sampler = nil
504523
self.enable_logs = false
524+
self.enable_metrics = false
505525

506526
self.profiler_class = Sentry::Profiler
507527
self.profiles_sample_interval = DEFAULT_PROFILES_SAMPLE_INTERVAL
@@ -512,6 +532,7 @@ def initialize
512532
@gem_specs = Hash[Gem::Specification.map { |spec| [spec.name, spec.version.to_s] }] if Gem::Specification.respond_to?(:map)
513533

514534
self.max_log_events = LogEventBuffer::DEFAULT_MAX_EVENTS
535+
self.max_metric_events = MetricEventBuffer::DEFAULT_MAX_METRICS
515536

516537
run_callbacks(:after, :initialize)
517538

@@ -581,6 +602,12 @@ def before_send_check_in=(value)
581602
@before_send_check_in = value
582603
end
583604

605+
def before_send_metric=(value)
606+
check_callable!("before_send_metric", value)
607+
608+
@before_send_metric = value
609+
end
610+
584611
def before_breadcrumb=(value)
585612
check_callable!("before_breadcrumb", value)
586613

sentry-ruby/lib/sentry/envelope/item.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class Envelope::Item
1515
# rate limits and client reports use the data_category rather than envelope item type
1616
def self.data_category(type)
1717
case type
18-
when "session", "attachment", "transaction", "profile", "span", "log" then type
18+
when "session", "attachment", "transaction", "profile", "span", "log", "trace_metric" then type
1919
when "sessions" then "session"
2020
when "check_in" then "monitor"
2121
when "event" then "error"

sentry-ruby/lib/sentry/hub.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,29 @@ def capture_log_event(message, **options)
227227
current_client.buffer_log_event(event, current_scope)
228228
end
229229

230+
# Captures a metric and sends it to Sentry
231+
#
232+
# @param name [String] the metric name
233+
# @param type [Symbol] the metric type (:counter, :gauge, :distribution)
234+
# @param value [Numeric] the metric value
235+
# @param unit [String, nil] (optional) the metric unit
236+
# @param attributes [Hash, nil] (optional) additional attributes for the metric
237+
# @return [void]
238+
def capture_metric(name:, type:, value:, unit: nil, attributes: nil)
239+
return unless current_client && current_client.configuration.enable_metrics
240+
241+
metric = MetricEvent.new(
242+
name: name,
243+
value: value,
244+
type: type,
245+
unit: unit,
246+
attributes: attributes,
247+
)
248+
249+
current_client.buffer_metric_event(metric, current_scope)
250+
end
251+
252+
230253
def capture_event(event, **options, &block)
231254
check_argument_type!(event, Sentry::Event)
232255

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
class MetricEvent
5+
attr_reader :name, :type, :value, :unit, :timestamp, :trace_id, :span_id, :attributes, :user
6+
attr_writer :trace_id, :span_id, :attributes, :user
7+
8+
def initialize(
9+
name:,
10+
type:,
11+
value:,
12+
unit: nil,
13+
attributes: nil
14+
)
15+
@name = name
16+
@type = type
17+
@value = value
18+
@unit = unit
19+
@attributes = attributes || {}
20+
21+
@timestamp = Sentry.utc_now
22+
@trace_id = nil
23+
@span_id = nil
24+
@user = {}
25+
end
26+
27+
def to_h
28+
populate_default_attributes!
29+
populate_user_attributes!
30+
31+
{
32+
name: @name,
33+
type: @type,
34+
value: @value,
35+
unit: @unit,
36+
timestamp: @timestamp,
37+
trace_id: @trace_id,
38+
span_id: @span_id,
39+
attributes: serialize_attributes
40+
}.compact
41+
end
42+
43+
private
44+
45+
def populate_default_attributes!
46+
configuration = Sentry.configuration
47+
return unless configuration
48+
49+
default_attributes = {
50+
"sentry.environment" => configuration.environment,
51+
"sentry.release" => configuration.release,
52+
"sentry.sdk.name" => Sentry.sdk_meta["name"],
53+
"sentry.sdk.version" => Sentry.sdk_meta["version"],
54+
"server.address" => configuration.server_name
55+
}.compact
56+
57+
@attributes = default_attributes.merge(@attributes)
58+
end
59+
60+
def populate_user_attributes!
61+
return unless @user
62+
return unless Sentry.initialized? && Sentry.configuration.send_default_pii
63+
64+
user_attributes = {
65+
"user.id" => @user[:id],
66+
"user.name" => @user[:username],
67+
"user.email" => @user[:email]
68+
}.compact
69+
70+
@attributes = user_attributes.merge(@attributes)
71+
end
72+
73+
def serialize_attributes
74+
@attributes.transform_values do |v|
75+
case v
76+
when Integer then { type: "integer", value: v }
77+
when Float then { type: "double", value: v }
78+
when TrueClass, FalseClass then { type: "boolean", value: v }
79+
when String then { type: "string", value: v }
80+
else { type: "string", value: v.to_s }
81+
end
82+
end
83+
end
84+
end
85+
end

0 commit comments

Comments
 (0)