Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

### Features

- Implement new `Sentry.metrics` functionality ([#2818](https://github.com/getsentry/sentry-ruby/pull/2818))

The SDK now supports Sentry's new [Trace Connected Metrics](https://docs.sentry.io/product/explore/metrics/) product.

```ruby
Sentry.init do |config|
# ...
config.enable_metrics = true
end

Sentry.metrics.count("button.click", 1, attributes: { button_id: "submit" })
Sentry.metrics.distribution("response.time", 120.5, unit: "millisecond")
Sentry.metrics.gauge("cpu.usage", 75.2, unit: "percent")
```

- Support for tracing `Sequel` queries ([#2814](https://github.com/getsentry/sentry-ruby/pull/2814))

```ruby
Expand Down
19 changes: 19 additions & 0 deletions sentry-ruby/lib/sentry-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
require "sentry/backpressure_monitor"
require "sentry/cron/monitor_check_ins"
require "sentry/vernier/profiler"
require "sentry/metrics"

[
"sentry/rake",
Expand Down Expand Up @@ -627,6 +628,24 @@ def logger
@logger ||= configuration.structured_logging.logger_class.new(configuration)
end

# Returns the metrics API for capturing custom metrics.
#
# @example Enable metrics
# Sentry.init do |config|
# config.dsn = "YOUR_DSN"
# config.enable_metrics = true
# end
#
# @example Usage
# Sentry.metrics.count("button.click", 1, attributes: { button_id: "submit" })
# Sentry.metrics.distribution("response.time", 120.5, unit: "millisecond")
# Sentry.metrics.gauge("cpu.usage", 75.2, unit: "percent")
#
# @return [Metrics] The metrics API
def metrics
Metrics
end

##### Helpers #####

# @!visibility private
Expand Down
71 changes: 71 additions & 0 deletions sentry-ruby/lib/sentry/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
require "sentry/transport"
require "sentry/log_event"
require "sentry/log_event_buffer"
require "sentry/metric_event"
require "sentry/metric_event_buffer"
require "sentry/utils/uuid"
require "sentry/utils/encoding_helper"

Expand All @@ -21,6 +23,9 @@ class Client
# @!visibility private
attr_reader :log_event_buffer

# @!visibility private
attr_reader :metric_event_buffer

# @!macro configuration
attr_reader :configuration

Expand All @@ -46,6 +51,10 @@ def initialize(configuration)
if configuration.enable_logs
@log_event_buffer = LogEventBuffer.new(configuration, self).start
end

if configuration.enable_metrics
@metric_event_buffer = MetricEventBuffer.new(configuration, self).start
end
end

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

# Buffer a metric event to be sent later with other metrics in a single envelope
# @param event [MetricEvent] the metric event to be buffered
# @return [MetricEvent]
def buffer_metric_event(event, scope)
return unless event.is_a?(MetricEvent)
event = scope.apply_to_telemetry(event)
@metric_event_buffer.add_metric(event)
event
end

# Capture an envelope directly.
# @param envelope [Envelope] the envelope to be captured.
# @return [void]
Expand All @@ -115,6 +134,7 @@ def flush
transport.flush if configuration.sending_to_dsn_allowed?
spotlight_transport.flush if spotlight_transport
@log_event_buffer&.flush
@metric_event_buffer&.flush
end

# Initializes an Event object with the given exception. Returns `nil` if the exception's class is excluded from reporting.
Expand Down Expand Up @@ -322,6 +342,57 @@ def send_logs(log_events)
end
end

# Send an envelope with batched metrics
# @param metrics [Array<MetricEvent>] the metrics to send
# @api private
# @return [void]
def send_metrics(metrics)
return if metrics.nil? || metrics.empty?

envelope = Envelope.new(
event_id: Sentry::Utils.uuid,
sent_at: Sentry.utc_now.iso8601,
dsn: configuration.dsn,
sdk: Sentry.sdk_meta
)

discarded_count = 0
envelope_items = []

if configuration.before_send_metric
metrics.each do |metric|
processed_metric = configuration.before_send_metric.call(metric)

if processed_metric
envelope_items << processed_metric.to_h
else
discarded_count += 1
end
end

envelope_items
else
envelope_items = metrics.map(&:to_h)
end

return if envelope_items.empty?

envelope.add_item(
{
type: "trace_metric",
item_count: envelope_items.size,
content_type: "application/vnd.sentry.items.trace-metric+json"
},
{ items: envelope_items }
)

send_envelope(envelope)

unless discarded_count.zero?
transport.record_lost_event(:before_send, "metric", num: discarded_count)
end
end

# Send an envelope directly to Sentry.
# @param envelope [Envelope] the envelope to be sent.
# @return [void]
Expand Down
27 changes: 27 additions & 0 deletions sentry-ruby/lib/sentry/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
require "sentry/logger"
require "sentry/structured_logger"
require "sentry/log_event_buffer"
require "sentry/metric_event_buffer"

module Sentry
class Configuration
Expand Down Expand Up @@ -337,6 +338,23 @@ class Configuration
# @return [Integer]
attr_accessor :max_log_events

# Enable metrics collection
# @return [Boolean]
attr_accessor :enable_metrics

# Maximum number of metric events to buffer before sending
# @return [Integer]
attr_accessor :max_metric_events

# Optional Proc, called before sending a metric
# @example
# config.before_send_metric = lambda do |metric|
# # return nil to drop the metric
# metric
# end
# @return [Proc, nil]
attr_reader :before_send_metric

# these are not config options
# @!visibility private
attr_reader :errors, :gem_specs
Expand Down Expand Up @@ -499,9 +517,11 @@ def initialize
self.before_send_transaction = nil
self.before_send_check_in = nil
self.before_send_log = nil
self.before_send_metric = nil
self.rack_env_whitelist = RACK_ENV_WHITELIST_DEFAULT
self.traces_sampler = nil
self.enable_logs = false
self.enable_metrics = false

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

self.max_log_events = LogEventBuffer::DEFAULT_MAX_EVENTS
self.max_metric_events = MetricEventBuffer::DEFAULT_MAX_METRICS

run_callbacks(:after, :initialize)

Expand Down Expand Up @@ -581,6 +602,12 @@ def before_send_check_in=(value)
@before_send_check_in = value
end

def before_send_metric=(value)
check_callable!("before_send_metric", value)

@before_send_metric = value
end

def before_breadcrumb=(value)
check_callable!("before_breadcrumb", value)

Expand Down
2 changes: 1 addition & 1 deletion sentry-ruby/lib/sentry/envelope/item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class Envelope::Item
# rate limits and client reports use the data_category rather than envelope item type
def self.data_category(type)
case type
when "session", "attachment", "transaction", "profile", "span", "log" then type
when "session", "attachment", "transaction", "profile", "span", "log", "trace_metric" then type
when "sessions" then "session"
when "check_in" then "monitor"
when "event" then "error"
Expand Down
23 changes: 23 additions & 0 deletions sentry-ruby/lib/sentry/hub.rb
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,29 @@ def capture_log_event(message, **options)
current_client.buffer_log_event(event, current_scope)
end

# Captures a metric and sends it to Sentry
#
# @param name [String] the metric name
# @param type [Symbol] the metric type (:counter, :gauge, :distribution)
# @param value [Numeric] the metric value
# @param unit [String, nil] (optional) the metric unit
# @param attributes [Hash, nil] (optional) additional attributes for the metric
# @return [void]
def capture_metric(name:, type:, value:, unit: nil, attributes: nil)
return unless current_client&.configuration.enable_metrics

metric = MetricEvent.new(
name: name,
value: value,
type: type,
unit: unit,
attributes: attributes,
)

current_client.buffer_metric_event(metric, current_scope)
end


def capture_event(event, **options, &block)
check_argument_type!(event, Sentry::Event)

Expand Down
85 changes: 85 additions & 0 deletions sentry-ruby/lib/sentry/metric_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

module Sentry
class MetricEvent
attr_reader :name, :type, :value, :unit, :timestamp, :trace_id, :span_id, :attributes, :user
attr_writer :trace_id, :span_id, :attributes, :user

def initialize(
name:,
type:,
value:,
unit: nil,
attributes: nil
)
@name = name
@type = type
@value = value
@unit = unit
@attributes = attributes || {}

@timestamp = Sentry.utc_now
@trace_id = nil
@span_id = nil
@user = {}
end

def to_h
populate_default_attributes!
populate_user_attributes!

{
name: @name,
type: @type,
value: @value,
unit: @unit,
timestamp: @timestamp,
trace_id: @trace_id,
span_id: @span_id,
attributes: serialize_attributes
}.compact
end

private

def populate_default_attributes!
configuration = Sentry.configuration
return unless configuration

default_attributes = {
"sentry.environment" => configuration.environment,
"sentry.release" => configuration.release,
"sentry.sdk.name" => Sentry.sdk_meta["name"],
"sentry.sdk.version" => Sentry.sdk_meta["version"],
"server.address" => configuration.server_name
}.compact

@attributes = default_attributes.merge(@attributes)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could optimize this and make @attributes's default value to be the default attributes, then it won't allocate a default empty hash, and create intermediate hash just to merge defaults into the empty one.

This should remain lazy of course so that we're not building up the final attributes until serialization is invoked.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gonna move this logic to scope after unifying with logs

end

def populate_user_attributes!
return unless @user
return unless Sentry.initialized? && Sentry.configuration.send_default_pii

user_attributes = {
"user.id" => @user[:id],
"user.name" => @user[:username],
"user.email" => @user[:email]
}.compact

@attributes = user_attributes.merge(@attributes)
end

def serialize_attributes
@attributes.transform_values do |v|
case v
when Integer then { type: "integer", value: v }
when Float then { type: "double", value: v }
when TrueClass, FalseClass then { type: "boolean", value: v }
when String then { type: "string", value: v }
else { type: "string", value: v.to_s }
end
end
end
end
end
Loading
Loading