diff --git a/CHANGELOG.md b/CHANGELOG.md index c43d8a7f4..6245440e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/sentry-ruby/lib/sentry-ruby.rb b/sentry-ruby/lib/sentry-ruby.rb index a4e6d358c..fbdbc6b50 100644 --- a/sentry-ruby/lib/sentry-ruby.rb +++ b/sentry-ruby/lib/sentry-ruby.rb @@ -27,6 +27,7 @@ require "sentry/backpressure_monitor" require "sentry/cron/monitor_check_ins" require "sentry/vernier/profiler" +require "sentry/metrics" [ "sentry/rake", @@ -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 diff --git a/sentry-ruby/lib/sentry/client.rb b/sentry-ruby/lib/sentry/client.rb index 6da454721..80279361f 100644 --- a/sentry-ruby/lib/sentry/client.rb +++ b/sentry-ruby/lib/sentry/client.rb @@ -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" @@ -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 @@ -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. @@ -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] @@ -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. @@ -322,6 +342,57 @@ def send_logs(log_events) end end + # Send an envelope with batched metrics + # @param metrics [Array] 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] diff --git a/sentry-ruby/lib/sentry/configuration.rb b/sentry-ruby/lib/sentry/configuration.rb index aae16487d..6c8788fc9 100644 --- a/sentry-ruby/lib/sentry/configuration.rb +++ b/sentry-ruby/lib/sentry/configuration.rb @@ -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 @@ -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 @@ -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 @@ -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) @@ -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) diff --git a/sentry-ruby/lib/sentry/envelope/item.rb b/sentry-ruby/lib/sentry/envelope/item.rb index d1a061560..9e9f73834 100644 --- a/sentry-ruby/lib/sentry/envelope/item.rb +++ b/sentry-ruby/lib/sentry/envelope/item.rb @@ -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" diff --git a/sentry-ruby/lib/sentry/hub.rb b/sentry-ruby/lib/sentry/hub.rb index b44a2d467..f3ca5c3ba 100644 --- a/sentry-ruby/lib/sentry/hub.rb +++ b/sentry-ruby/lib/sentry/hub.rb @@ -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) diff --git a/sentry-ruby/lib/sentry/metric_event.rb b/sentry-ruby/lib/sentry/metric_event.rb new file mode 100644 index 000000000..24e962703 --- /dev/null +++ b/sentry-ruby/lib/sentry/metric_event.rb @@ -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) + 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 diff --git a/sentry-ruby/lib/sentry/metric_event_buffer.rb b/sentry-ruby/lib/sentry/metric_event_buffer.rb new file mode 100644 index 000000000..64eb42156 --- /dev/null +++ b/sentry-ruby/lib/sentry/metric_event_buffer.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "sentry/threaded_periodic_worker" + +module Sentry + # MetricEventBuffer buffers metric events and sends them to Sentry in a single envelope. + # + # This is used internally by the `Sentry::Client`. + # + # @!visibility private + class MetricEventBuffer < ThreadedPeriodicWorker + FLUSH_INTERVAL = 5 # seconds + DEFAULT_MAX_METRICS = 100 + + # @!visibility private + attr_reader :pending_metrics + + def initialize(configuration, client) + super(configuration.sdk_logger, FLUSH_INTERVAL) + + @client = client + @pending_metrics = [] + @max_metrics = configuration.max_metric_events || DEFAULT_MAX_METRICS + @mutex = Mutex.new + + log_debug("[Metrics] Initialized buffer with max_metrics=#{@max_metrics}, flush_interval=#{FLUSH_INTERVAL}s") + end + + def start + ensure_thread + self + end + + def flush + @mutex.synchronize do + return if empty? + + log_debug("[MetricEventBuffer] flushing #{size} metrics") + + send_metrics + end + end + alias_method :run, :flush + + def add_metric(metric) + raise ArgumentError, "expected a MetricEvent, got #{metric.class}" unless metric.is_a?(MetricEvent) + + @mutex.synchronize do + @pending_metrics << metric + send_metrics if size >= @max_metrics + end + + self + end + + def empty? + @pending_metrics.empty? + end + + def size + @pending_metrics.size + end + + def clear! + @pending_metrics.clear + end + + private + + def send_metrics + @client.send_metrics(@pending_metrics) + rescue => e + log_debug("[MetricEventBuffer] Failed to send metrics: #{e.message}") + ensure + clear! + end + end +end diff --git a/sentry-ruby/lib/sentry/metrics.rb b/sentry-ruby/lib/sentry/metrics.rb new file mode 100644 index 000000000..5b7e53871 --- /dev/null +++ b/sentry-ruby/lib/sentry/metrics.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "sentry/metric_event" + +module Sentry + module Metrics + class << self + # Increments a counter metric + # @param name [String] the metric name + # @param value [Numeric] the value to increment by (default: 1) + # @param attributes [Hash, nil] additional attributes for the metric (optional) + # @return [void] + def count(name, value: 1, attributes: nil) + return unless Sentry.initialized? + + Sentry.get_current_hub.capture_metric( + name: name, + type: :counter, + value: value, + attributes: attributes + ) + end + + # Records a gauge metric + # @param name [String] the metric name + # @param value [Numeric] the gauge value + # @param unit [String, nil] the metric unit (optional) + # @param attributes [Hash, nil] additional attributes for the metric (optional) + # @return [void] + def gauge(name, value, unit: nil, attributes: nil) + return unless Sentry.initialized? + + Sentry.get_current_hub.capture_metric( + name: name, + type: :gauge, + value: value, + unit: unit, + attributes: attributes + ) + end + + # Records a distribution metric + # @param name [String] the metric name + # @param value [Numeric] the distribution value + # @param unit [String, nil] the metric unit (optional) + # @param attributes [Hash, nil] additional attributes for the metric (optional) + # @return [void] + def distribution(name, value, unit: nil, attributes: nil) + return unless Sentry.initialized? + + Sentry.get_current_hub.capture_metric( + name: name, + type: :distribution, + value: value, + unit: unit, + attributes: attributes + ) + end + end + end +end diff --git a/sentry-ruby/lib/sentry/scope.rb b/sentry-ruby/lib/sentry/scope.rb index cb34a6276..ab7681ea1 100644 --- a/sentry-ruby/lib/sentry/scope.rb +++ b/sentry-ruby/lib/sentry/scope.rb @@ -89,6 +89,24 @@ def apply_to_event(event, hint = nil) event end + # A leaner version of apply_to_event that applies to + # lightweight payloads like Logs and Metrics. + # + # Only adds trace_id, span_id and user from the scope. + # + # @param telemetry [MetricEvent] + # @return [MetricEvent] + def apply_to_telemetry(telemetry) + # TODO-neel when new scope set_attribute api is added: add them here + telemetry.user = user.merge(telemetry.user) + + trace_context = span ? span.get_trace_context : propagation_context.get_trace_context + telemetry.trace_id = trace_context[:trace_id] + telemetry.span_id = trace_context[:span_id] + + telemetry + end + # Adds the breadcrumb to the scope's breadcrumbs buffer. # @param breadcrumb [Breadcrumb] # @return [void] diff --git a/sentry-ruby/lib/sentry/test_helper.rb b/sentry-ruby/lib/sentry/test_helper.rb index 288bbc383..48c4ff244 100644 --- a/sentry-ruby/lib/sentry/test_helper.rb +++ b/sentry-ruby/lib/sentry/test_helper.rb @@ -102,6 +102,13 @@ def sentry_logs .flat_map { |item| item.payload[:items] } end + def sentry_metrics + sentry_envelopes + .flat_map(&:items) + .select { |item| item.headers[:type] == "trace_metric" } + .flat_map { |item| item.payload[:items] } + end + # Returns the last captured event object. # @return [Event, nil] def last_sentry_event diff --git a/sentry-ruby/spec/sentry/configuration_spec.rb b/sentry-ruby/spec/sentry/configuration_spec.rb index 17a7d9730..df41d76d0 100644 --- a/sentry-ruby/spec/sentry/configuration_spec.rb +++ b/sentry-ruby/spec/sentry/configuration_spec.rb @@ -757,4 +757,52 @@ class SentryConfigurationSample < Sentry::Configuration expect { subject.trace_ignore_status_codes = [[400, 600]] }.to raise_error(ArgumentError, /must be.* between \(100-599\)/) end end + + describe "#enable_metrics" do + it "returns false by default" do + expect(subject.enable_metrics).to eq(false) + end + + it "can be set to true" do + subject.enable_metrics = true + expect(subject.enable_metrics).to eq(true) + end + end + + describe "#max_metric_events" do + it "returns 100 by default" do + expect(subject.max_metric_events).to eq(100) + end + + it "can be set to an integer value" do + subject.max_metric_events = 50 + expect(subject.max_metric_events).to eq(50) + end + end + + describe "#before_send_metric" do + it "returns nil by default" do + expect(subject.before_send_metric).to eq(nil) + end + + it "accepts a callable value" do + callable = lambda { |metric| metric } + subject.before_send_metric = callable + expect(subject.before_send_metric).to eq(callable) + end + + it "accepts a proc" do + subject.before_send_metric = proc { |metric| metric } + expect(subject.before_send_metric).to be_a(Proc) + end + + it "accepts nil value" do + subject.before_send_metric = nil + expect(subject.before_send_metric).to eq(nil) + end + + it "raises error when setting to anything other than callable or nil" do + expect { subject.before_send_metric = true }.to raise_error(ArgumentError, "before_send_metric must be callable (or nil to disable)") + end + end end diff --git a/sentry-ruby/spec/sentry/envelope/item_spec.rb b/sentry-ruby/spec/sentry/envelope/item_spec.rb index 516dfd465..a4ed3c6ec 100644 --- a/sentry-ruby/spec/sentry/envelope/item_spec.rb +++ b/sentry-ruby/spec/sentry/envelope/item_spec.rb @@ -10,6 +10,7 @@ ['span', 'span'], ['profile', 'profile'], ['log', 'log'], + ['trace_metric', 'trace_metric'], ['check_in', 'monitor'], ['event', 'error'], ['client_report', 'internal'], diff --git a/sentry-ruby/spec/sentry/hub_spec.rb b/sentry-ruby/spec/sentry/hub_spec.rb index 737cb7edb..2538f8fbc 100644 --- a/sentry-ruby/spec/sentry/hub_spec.rb +++ b/sentry-ruby/spec/sentry/hub_spec.rb @@ -652,6 +652,32 @@ end end + describe "#capture_metric" do + context "when metrics are disabled" do + before do + configuration.enable_metrics = false + end + + it "doesn't buffer the metric" do + expect(subject.current_client).not_to receive(:buffer_metric_event) + subject.capture_metric(name: "test", type: :counter, value: 1) + end + end + + context "when metrics are enabled" do + before do + configuration.enable_metrics = true + end + + it "creates and buffers a MetricEvent" do + expect(subject.current_client).to receive(:buffer_metric_event).and_call_original + expect do + subject.capture_metric(name: "test", type: :counter, value: 1) + end.to change { subject.current_client.metric_event_buffer.pending_metrics.count }.by(1) + end + end + end + describe "#continue_trace" do before do configuration.traces_sample_rate = 1.0 diff --git a/sentry-ruby/spec/sentry/metric_event_buffer_spec.rb b/sentry-ruby/spec/sentry/metric_event_buffer_spec.rb new file mode 100644 index 000000000..b0a089586 --- /dev/null +++ b/sentry-ruby/spec/sentry/metric_event_buffer_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +RSpec.describe Sentry::MetricEventBuffer do + subject(:metric_event_buffer) { described_class.new(Sentry.configuration, client) } + + let(:string_io) { StringIO.new } + let(:logger) { ::Logger.new(string_io) } + let(:client) { double(Sentry::Client) } + let(:metric_event) do + Sentry::MetricEvent.new( + name: "test.metric", + type: :counter, + value: 1 + ) + end + + before do + perform_basic_setup do |config| + config.sdk_logger = logger + config.background_worker_threads = 0 + config.max_metric_events = max_metric_events + config.enable_metrics = true + end + + Sentry.background_worker = Sentry::BackgroundWorker.new(Sentry.configuration) + end + + after do + Sentry.background_worker = Class.new { def shutdown; end; }.new + end + + describe "#add_metric" do + let(:max_metric_events) { 3 } + + it "does nothing when there are no pending metrics" do + expect(client).not_to receive(:capture_envelope) + + metric_event_buffer.flush + + expect(sentry_envelopes.size).to be(0) + end + + it "does nothing when the number of metrics is less than max_metrics" do + expect(client).to_not receive(:send_metrics) + + 2.times { metric_event_buffer.add_metric(metric_event) } + end + + it "auto-flushes pending metrics to the client when the number of metrics reaches max_metrics" do + expect(client).to receive(:send_metrics) + + 3.times { metric_event_buffer.add_metric(metric_event) } + + expect(metric_event_buffer).to be_empty + end + end + + describe "multi-threaded access" do + let(:max_metric_events) { 30 } + + it "thread-safely handles concurrent access" do + expect(client).to receive(:send_metrics).exactly(3).times + + threads = 3.times.map do + Thread.new do + (20..30).to_a.sample.times { metric_event_buffer.add_metric(metric_event) } + end + end + + threads.each(&:join) + + metric_event_buffer.flush + + expect(metric_event_buffer).to be_empty + end + end + + describe "error handling" do + let(:max_metric_events) { 3 } + + let(:error) { Errno::ECONNREFUSED.new("Connection refused") } + + context "when send_metrics raises an exception" do + before do + allow(client).to receive(:send_metrics).and_raise(error) + end + + it "does not propagate exception from add_metric when buffer is full" do + expect { + 3.times { metric_event_buffer.add_metric(metric_event) } + }.not_to raise_error + end + + it "does not propagate exception from flush" do + 2.times { metric_event_buffer.add_metric(metric_event) } + + expect { + metric_event_buffer.flush + }.not_to raise_error + end + + it "logs the error to sdk_logger" do + 3.times { metric_event_buffer.add_metric(metric_event) } + + expect(string_io.string).to include("Failed to send metrics") + end + + it "clears the buffer after a failed send to avoid memory buildup" do + 3.times { metric_event_buffer.add_metric(metric_event) } + + expect(metric_event_buffer).to be_empty + end + end + + context "when background thread encounters an error" do + let(:max_metric_events) { 100 } + + before do + allow(client).to receive(:send_metrics).and_raise(error) + end + + it "keeps the background thread alive after an error" do + metric_event_buffer.add_metric(metric_event) + metric_event_buffer.start + + thread = metric_event_buffer.instance_variable_get(:@thread) + + expect(thread).to be_alive + expect { metric_event_buffer.flush }.not_to raise_error + expect(thread).to be_alive + end + end + end +end diff --git a/sentry-ruby/spec/sentry/metric_event_spec.rb b/sentry-ruby/spec/sentry/metric_event_spec.rb new file mode 100644 index 000000000..dfde488b8 --- /dev/null +++ b/sentry-ruby/spec/sentry/metric_event_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::MetricEvent do + let(:metric_event) do + described_class.new( + name: "test.metric", + type: :distribution, + value: 5.0, + unit: 'seconds', + ) + end + + before do + perform_basic_setup do |config| + config.environment = "test" + config.release = "1.0.0" + config.server_name = "test-server" + end + end + + describe "#initialize" do + it "initializes with required parameters" do + expect(metric_event.name).to eq("test.metric") + expect(metric_event.type).to eq(:distribution) + expect(metric_event.value).to eq(5.0) + expect(metric_event.unit).to eq('seconds') + expect(metric_event.attributes).to eq({}) + + expect(metric_event.timestamp).to be_a(Time) + expect(metric_event.trace_id).to be_nil + expect(metric_event.span_id).to be_nil + expect(metric_event.user).to eq({}) + end + + it "accepts custom attributes" do + event = described_class.new( + name: "test.metric_attributes", + type: :counter, + value: 1, + attributes: { "foo" => "bar" } + ) + + expect(event.attributes).to eq({ "foo" => "bar" }) + end + end + + describe "#to_h" do + it "returns a hash with basic metric data" do + hash = metric_event.to_h + expect(hash[:name]).to eq("test.metric") + expect(hash[:type]).to eq(:distribution) + expect(hash[:value]).to eq(5.0) + expect(hash[:unit]).to eq("seconds") + expect(hash[:timestamp]).to be_a(Time) + end + + it "includes trace info if provided" do + metric_event.trace_id = "000" + metric_event.span_id = "00" + hash = metric_event.to_h + + expect(hash[:trace_id]).to eq("000") + expect(hash[:span_id]).to eq("00") + end + + it "excludes trace info if not provided (compact)" do + hash = metric_event.to_h + + expect(hash.key?(:trace_id)).to eq(false) + expect(hash.key?(:span_id)).to eq(false) + end + + it "includes default attributes from configuration" do + hash = metric_event.to_h + attributes = hash[:attributes] + + expect(attributes["sentry.environment"]).to eq({ type: "string", value: "test" }) + expect(attributes["sentry.release"]).to eq({ type: "string", value: "1.0.0" }) + expect(attributes["sentry.sdk.name"]).to eq({ type: "string", value: Sentry.sdk_meta["name"] }) + expect(attributes["sentry.sdk.version"]).to eq({ type: "string", value: Sentry.sdk_meta["version"] }) + expect(attributes["server.address"]).to eq({ type: "string", value: "test-server" }) + end + + it "includes custom attributes with inferred types" do + event = described_class.new( + name: "test.metric", + type: "counter", + value: 1.0, + attributes: { + "str" => "foo", + "float" => 1.23, + "int" => 99, + "bool_t" => true, + "bool_f" => false, + "unknown" => Object.new + } + ) + + hash = event.to_h + attributes = hash[:attributes] + + expect(attributes["str"]).to eq({ type: "string", value: "foo" }) + expect(attributes["float"]).to eq({ type: "double", value: 1.23 }) + expect(attributes["int"]).to eq({ type: "integer", value: 99 }) + expect(attributes["bool_t"]).to eq({ type: "boolean", value: true }) + expect(attributes["bool_f"]).to eq({ type: "boolean", value: false }) + + expect(attributes["unknown"][:type]).to eq("string") + expect(attributes["unknown"][:value]).to include("Object") + end + + it "merges custom attributes with default attributes" do + event = described_class.new( + name: "test.metric", + type: "counter", + value: 1.0, + attributes: { "custom" => "value" } + ) + hash = event.to_h + attributes = hash[:attributes] + + expect(attributes["sentry.environment"]).to eq({ type: "string", value: "test" }) + expect(attributes["custom"]).to eq({ type: "string", value: "value" }) + end + + context "with user data" do + before do + metric_event.user = { id: "123", username: "jane", email: "jane.doe@email.com" } + end + + context "when send_default_pii is true" do + before do + Sentry.configuration.send_default_pii = true + end + + it "includes user.id attribute" do + hash = metric_event.to_h + + expect(hash[:attributes]["user.id"]).to eq({ type: "string", value: "123" }) + expect(hash[:attributes]["user.name"]).to eq({ type: "string", value: "jane" }) + expect(hash[:attributes]["user.email"]).to eq({ type: "string", value: "jane.doe@email.com" }) + end + end + + context "when send_default_pii is false" do + before do + Sentry.configuration.send_default_pii = false + end + + it "does not include user attributes" do + hash = metric_event.to_h + + expect(hash[:attributes].key?("user.id")).to eq(false) + expect(hash[:attributes].key?("user.name")).to eq(false) + expect(hash[:attributes].key?("user.email")).to eq(false) + end + end + end + end +end diff --git a/sentry-ruby/spec/sentry/metrics_spec.rb b/sentry-ruby/spec/sentry/metrics_spec.rb new file mode 100644 index 000000000..248fd628d --- /dev/null +++ b/sentry-ruby/spec/sentry/metrics_spec.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +RSpec.describe "Sentry Metrics" do + before do + perform_basic_setup do |config| + config.enable_metrics = true + config.traces_sample_rate = 1.0 + config.release = "test-release" + config.environment = "test" + config.server_name = "my-server" + end + end + + describe "Sentry.metrics" do + context "when metrics are disabled" do + before do + Sentry.configuration.enable_metrics = false + end + + it "doesn't send metrics" do + Sentry.metrics.count("test.counter") + Sentry.metrics.gauge("test.gauge", 42.5, unit: "seconds") + Sentry.metrics.distribution("test.gauge", 42.5, attributes: { foo: "bar" }) + Sentry.get_current_client.flush + + expect(sentry_metrics).to be_empty + end + end + + context "when metrics are enabled" do + describe ".count" do + it "sends a counter metric with default value" do + Sentry.metrics.count("test.counter") + + Sentry.get_current_client.flush + + expect(sentry_envelopes.count).to eq(1) + expect(sentry_metrics.count).to eq(1) + + metric = sentry_metrics.first + expect(metric[:name]).to eq("test.counter") + expect(metric[:type]).to eq(:counter) + expect(metric[:value]).to eq(1) + end + + it "sends a counter metric with custom value" do + Sentry.metrics.count("test.counter", value: 5) + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + expect(metric[:name]).to eq("test.counter") + expect(metric[:type]).to eq(:counter) + expect(metric[:value]).to eq(5) + end + + it "includes custom attributes" do + Sentry.metrics.count("test.counter", attributes: { "foo" => "bar", "count" => 42 }) + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + attributes = metric[:attributes] + + expect(attributes["foo"]).to eq({ type: "string", value: "bar" }) + expect(attributes["count"]).to eq({ type: "integer", value: 42 }) + end + end + + describe ".gauge" do + it "sends a gauge metric" do + Sentry.metrics.gauge("test.gauge", 42.5) + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + expect(metric[:name]).to eq("test.gauge") + expect(metric[:type]).to eq(:gauge) + expect(metric[:value]).to eq(42.5) + end + + it "includes custom unit" do + Sentry.metrics.gauge("test.memory", 1024, unit: "bytes") + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + expect(metric[:unit]).to eq("bytes") + end + + it "includes custom attributes" do + Sentry.metrics.gauge("test.gauge", 100, attributes: { "region" => "us-west" }) + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + attributes = metric[:attributes] + + expect(attributes["region"]).to eq({ type: "string", value: "us-west" }) + end + end + + describe ".distribution" do + it "sends a distribution metric" do + Sentry.metrics.distribution("test.distribution", 3.14) + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + expect(metric[:name]).to eq("test.distribution") + expect(metric[:type]).to eq(:distribution) + expect(metric[:value]).to eq(3.14) + end + + it "includes custom unit" do + Sentry.metrics.distribution("test.duration", 1.5, unit: "seconds") + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + expect(metric[:unit]).to eq("seconds") + end + + it "includes custom attributes" do + Sentry.metrics.distribution("test.latency", 250, unit: "milliseconds", attributes: { "endpoint" => "/api/users" }) + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + attributes = metric[:attributes] + + expect(attributes["endpoint"]).to eq({ type: "string", value: "/api/users" }) + end + end + + it "includes trace_id from the scope's propagation context when no span is set" do + Sentry.metrics.count("test.counter") + + Sentry.get_current_client.flush + + propagation_context = Sentry.get_current_scope.propagation_context + + metric = sentry_metrics.first + expect(metric[:trace_id]).to eq(propagation_context.trace_id) + expect(metric[:span_id]).to eq(propagation_context.span_id) + end + + context "with active transaction" do + it "includes trace_id and span_id from the active span" do + transaction = Sentry.start_transaction(name: "test_transaction", op: "test.op") + span = transaction.start_child(op: "child span") + + Sentry.get_current_scope.set_span(span) + + Sentry.metrics.count("test.counter") + + transaction.finish + + Sentry.get_current_client.flush + + # 2 envelopes: metric and transaction + expect(sentry_envelopes.size).to eq(2) + + metric = sentry_metrics.first + + expect(metric[:trace_id]).to eq(span.trace_id) + expect(metric[:span_id]).to eq(span.span_id) + end + end + + context "with user data on scope" do + before do + Sentry.configure_scope do |scope| + scope.set_user({ id: 123, username: "jane", email: "jane@example.com" }) + end + end + + context "when send_default_pii is true" do + before do + Sentry.configuration.send_default_pii = true + end + + it "includes user attributes in the metric" do + Sentry.metrics.count("test.counter") + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + attributes = metric[:attributes] + + expect(attributes["user.id"]).to eq({ type: "integer", value: 123 }) + expect(attributes["user.name"]).to eq({ type: "string", value: "jane" }) + expect(attributes["user.email"]).to eq({ type: "string", value: "jane@example.com" }) + end + end + + context "when send_default_pii is false" do + it "does not include user attributes" do + Sentry.metrics.count("test.counter") + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + attributes = metric[:attributes] + + expect(attributes).not_to have_key("user.id") + expect(attributes).not_to have_key("user.name") + expect(attributes).not_to have_key("user.email") + end + end + end + + it "includes default attributes from configuration" do + Sentry.metrics.count("test.counter") + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + attributes = metric[:attributes] + + expect(attributes["sentry.environment"]).to eq({ type: "string", value: "test" }) + expect(attributes["sentry.release"]).to eq({ type: "string", value: "test-release" }) + expect(attributes["server.address"]).to eq({ type: "string", value: "my-server" }) + expect(attributes["sentry.sdk.name"]).to eq({ type: "string", value: Sentry.sdk_meta["name"] }) + expect(attributes["sentry.sdk.version"]).to eq({ type: "string", value: Sentry.sdk_meta["version"] }) + end + + it "batches multiple metrics into a single envelope" do + Sentry.metrics.count("test.counter1", value: 1) + Sentry.metrics.count("test.counter2", value: 2) + Sentry.metrics.gauge("test.gauge", 42) + + Sentry.get_current_client.flush + + expect(sentry_envelopes.count).to eq(1) + expect(sentry_metrics.count).to eq(3) + + metric_names = sentry_metrics.map { |m| m[:name] } + expect(metric_names).to contain_exactly("test.counter1", "test.counter2", "test.gauge") + end + + describe "envelope structure" do + it "includes correct envelope headers" do + Sentry.metrics.count("test.counter") + Sentry.get_current_client.flush + + envelope = sentry_envelopes.first + headers = envelope.headers + + expect(headers[:event_id]).to match(/\A[0-9a-f]{32}\z/) # UUID format + expect(headers[:sent_at]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) # ISO8601 timestamp + expect(headers[:dsn]).to eq(Sentry.configuration.dsn) + expect(headers[:sdk]).to eq(Sentry.sdk_meta) + end + + it "includes correct envelope item headers" do + Sentry.metrics.count("test.counter1") + Sentry.metrics.gauge("test.gauge", 42) + Sentry.get_current_client.flush + + envelope = sentry_envelopes.first + item = envelope.items.first + + # Verify envelope item headers + expect(item.headers[:type]).to eq("trace_metric") + expect(item.headers[:item_count]).to eq(2) + expect(item.headers[:content_type]).to eq("application/vnd.sentry.items.trace-metric+json") + end + + it "includes correct payload structure" do + Sentry.metrics.count("test.counter") + Sentry.get_current_client.flush + + envelope = sentry_envelopes.first + item = envelope.items.first + payload = item.payload + + # Verify payload structure + expect(payload).to have_key(:items) + expect(payload[:items]).to be_an(Array) + expect(payload[:items].size).to eq(1) + + metric_item = payload[:items].first + expect(metric_item).to be_a(Hash) + expect(metric_item).to have_key(:name) + expect(metric_item).to have_key(:type) + expect(metric_item).to have_key(:value) + expect(metric_item).to have_key(:attributes) + expect(metric_item).to have_key(:trace_id) + expect(metric_item).to have_key(:span_id) + end + end + + context "with before_send_metric callback" do + it "receives MetricEvent" do + Sentry.configuration.before_send_metric = lambda do |metric| + expect(metric).to be_a(Sentry::MetricEvent) + metric + end + + Sentry.metrics.gauge("test.gauge", 42.5, unit: "seconds", attributes: { "foo" => "bar" }) + Sentry.get_current_client.flush + end + + it "allows modifying metrics before sending" do + Sentry.configuration.before_send_metric = lambda do |metric| + metric.attributes["modified"] = true + metric + end + + Sentry.metrics.count("test.counter") + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + expect(metric[:attributes]["modified"]).to eq({ type: "boolean", value: true }) + end + + it "filters out metrics when callback returns nil" do + Sentry.configuration.before_send_metric = lambda do |metric| + metric.name == "test.filtered" ? nil : metric + end + + Sentry.metrics.count("test.filtered") + Sentry.metrics.gauge("test.filtered", 42) + Sentry.metrics.count("test.allowed") + + Sentry.get_current_client.flush + + expect(sentry_metrics.count).to eq(1) + expect(sentry_metrics.first[:name]).to eq("test.allowed") + expect(Sentry.get_current_client.transport).to have_recorded_lost_event(:before_send, 'metric', num: 2) + end + end + end + end +end diff --git a/sentry-ruby/spec/sentry/scope_spec.rb b/sentry-ruby/spec/sentry/scope_spec.rb index 5b24dbcd0..70d7896f2 100644 --- a/sentry-ruby/spec/sentry/scope_spec.rb +++ b/sentry-ruby/spec/sentry/scope_spec.rb @@ -413,4 +413,51 @@ expect(attachment.filename).to eq("test.txt") end end + + describe "#apply_to_telemetry" do + before { perform_basic_setup } + + let(:metric_event) do + Sentry::MetricEvent.new(name: "test.metric", type: :counter, value: 1) + end + + context "with user data" do + before { subject.set_user({ id: 1, username: "test_user" }) } + + it "merges user data from scope to telemetry" do + subject.apply_to_telemetry(metric_event) + expect(metric_event.user).to eq({ id: 1, username: "test_user" }) + end + + it "doesn't override telemetry's pre-existing user data" do + metric_event.user = { id: 2, email: "test@example.com" } + subject.apply_to_telemetry(metric_event) + expect(metric_event.user).to eq({ id: 2, username: "test_user", email: "test@example.com" }) + end + end + + it "sets trace_id and span_id from propagation context when no span is set" do + subject.apply_to_telemetry(metric_event) + + trace_context = subject.propagation_context.get_trace_context + expect(metric_event.trace_id).to eq(trace_context[:trace_id]) + expect(metric_event.span_id).to eq(trace_context[:span_id]) + end + + it "sets trace_id and span_id from span when span is set" do + transaction = Sentry::Transaction.new(op: "test_op") + subject.set_span(transaction) + + subject.apply_to_telemetry(metric_event) + + trace_context = transaction.get_trace_context + expect(metric_event.trace_id).to eq(trace_context[:trace_id]) + expect(metric_event.span_id).to eq(trace_context[:span_id]) + end + + it "returns the telemetry object" do + result = subject.apply_to_telemetry(metric_event) + expect(result).to eq(metric_event) + end + end end