diff --git a/.github/workflows/sentry_yabeda_test.yml b/.github/workflows/sentry_yabeda_test.yml new file mode 100644 index 000000000..8cdcd88bd --- /dev/null +++ b/.github/workflows/sentry_yabeda_test.yml @@ -0,0 +1,58 @@ +name: sentry-yabeda Test + +on: + workflow_dispatch: + workflow_call: + outputs: + matrix-result: + description: "Matrix job result" + value: ${{ jobs.test.outputs.matrix-result }} + inputs: + versions: + required: true + type: string +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: sentry-yabeda-test-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +jobs: + test: + defaults: + run: + working-directory: sentry-yabeda + name: Ruby ${{ matrix.ruby_version }}, options - ${{ toJson(matrix.options) }} + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + RUBYOPT: ${{ matrix.options.rubyopt }} + BUNDLE_GEMFILE: ${{ github.workspace }}/sentry-yabeda/Gemfile + BUNDLE_WITHOUT: rubocop + JRUBY_OPTS: "--debug" # for more accurate test coverage + strategy: + fail-fast: false + matrix: + ruby_version: ${{ fromJson(inputs.versions) }} + include: + - ruby_version: "3.2" + options: + rubyopt: "--enable-frozen-string-literal --debug=frozen-string-literal" + exclude: + - ruby_version: 'jruby' + - ruby_version: 'jruby-head' + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Ruby ${{ matrix.ruby_version }} + uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + + - name: Run specs + run: bundle exec rake + + - name: Upload Coverage + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6e3aaa3ab..c49e2b2da 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,6 +63,13 @@ jobs: versions: ${{ needs.ruby-versions.outputs.versions }} secrets: inherit + yabeda-tests: + needs: ruby-versions + uses: ./.github/workflows/sentry_yabeda_test.yml + with: + versions: ${{ needs.ruby-versions.outputs.versions }} + secrets: inherit + codecov: name: CodeCov runs-on: ubuntu-latest @@ -73,6 +80,7 @@ jobs: - delayed_job-tests - resque-tests - opentelemetry-tests + - yabeda-tests steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 diff --git a/.gitignore b/.gitignore index 9216173e1..bbcd6f70d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,15 @@ Gemfile.lock node_modules .vite +.DS_Store + +mise.toml + .devcontainer/.env vendor/gems + sentry-rails/Gemfile-*.lock -mise.toml + +sentry-yabeda/.DS_Store +sentry-yabeda/.rspec_status +sentry-yabeda/Gemfile-*.lock diff --git a/sentry-ruby/README.md b/sentry-ruby/README.md index d6f1ef44b..16f9f5c18 100644 --- a/sentry-ruby/README.md +++ b/sentry-ruby/README.md @@ -21,6 +21,7 @@ Sentry SDK for Ruby | [![Gem Version](https://img.shields.io/gem/v/sentry-delayed_job?label=sentry-delayed_job)](https://rubygems.org/gems/sentry-delayed_job) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml) | [![codecov](https://codecov.io/gh/getsentry/sentry-ruby/graph/badge.svg?token=ZePzrpZFP6&component=sentry-delayed_job)](https://codecov.io/gh/getsentry/sentry-ruby) | [![API doc](https://img.shields.io/badge/API%20doc-rubydoc.info-blue)](https://www.rubydoc.info/gems/sentry-delayed_job) | | [![Gem Version](https://img.shields.io/gem/v/sentry-resque?label=sentry-resque)](https://rubygems.org/gems/sentry-resque) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml) | [![codecov](https://codecov.io/gh/getsentry/sentry-ruby/graph/badge.svg?token=ZePzrpZFP6&component=sentry-resque)](https://codecov.io/gh/getsentry/sentry-ruby) | [![API doc](https://img.shields.io/badge/API%20doc-rubydoc.info-blue)](https://www.rubydoc.info/gems/sentry-resque) | | [![Gem Version](https://img.shields.io/gem/v/sentry-opentelemetry?label=sentry-opentelemetry)](https://rubygems.org/gems/sentry-opentelemetry) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml) | [![codecov](https://codecov.io/gh/getsentry/sentry-ruby/graph/badge.svg?token=ZePzrpZFP6&component=sentry-opentelemetry)](https://codecov.io/gh/getsentry/sentry-ruby) | [![API doc](https://img.shields.io/badge/API%20doc-rubydoc.info-blue)](https://www.rubydoc.info/gems/sentry-opentelemetry) | +| [![Gem Version](https://img.shields.io/gem/v/sentry-yabeda?label=sentry-yabeda)](https://rubygems.org/gems/sentry-yabeda) | [![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml/badge.svg)](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml) | [![codecov](https://codecov.io/gh/getsentry/sentry-ruby/graph/badge.svg?token=ZePzrpZFP6&component=sentry-yabeda)](https://codecov.io/gh/getsentry/sentry-ruby) | [![API doc](https://img.shields.io/badge/API%20doc-rubydoc.info-blue)](https://www.rubydoc.info/gems/sentry-yabeda) | @@ -53,6 +54,7 @@ gem "sentry-sidekiq" gem "sentry-delayed_job" gem "sentry-resque" gem "sentry-opentelemetry" +gem "sentry-yabeda" ``` ### Configuration @@ -93,6 +95,7 @@ To learn more about sampling transactions, please visit the [official documentat - [DelayedJob](https://docs.sentry.io/platforms/ruby/guides/delayed_job/) - [Resque](https://docs.sentry.io/platforms/ruby/guides/resque/) - [OpenTelemetry](https://docs.sentry.io/platforms/ruby/performance/instrumentation/opentelemetry/) +- [Yabeda](https://docs.sentry.io/platforms/ruby/guides/yabeda/) ### Enriching Events diff --git a/sentry-yabeda/Gemfile b/sentry-yabeda/Gemfile new file mode 100644 index 000000000..57690ab55 --- /dev/null +++ b/sentry-yabeda/Gemfile @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +source "https://rubygems.org" +git_source(:github) { |name| "https://github.com/#{name}.git" } + +eval_gemfile "../Gemfile.dev" + +# Specify your gem's dependencies in sentry-yabeda.gemspec +gemspec + +gem "sentry-ruby", path: "../sentry-ruby" + +gem "timecop" diff --git a/sentry-yabeda/LICENSE.txt b/sentry-yabeda/LICENSE.txt new file mode 100644 index 000000000..a53c2869c --- /dev/null +++ b/sentry-yabeda/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/sentry-yabeda/README.md b/sentry-yabeda/README.md new file mode 100644 index 000000000..97129eb49 --- /dev/null +++ b/sentry-yabeda/README.md @@ -0,0 +1,41 @@ +

+ + + +
+

+ +# sentry-yabeda, the Yabeda integration for Sentry's Ruby client + +--- + +[![Gem Version](https://img.shields.io/gem/v/sentry-yabeda.svg)](https://rubygems.org/gems/sentry-yabeda) +![Build Status](https://github.com/getsentry/sentry-ruby/actions/workflows/sentry_yabeda_test.yml/badge.svg) +[![Coverage Status](https://img.shields.io/codecov/c/github/getsentry/sentry-ruby/master?logo=codecov)](https://codecov.io/gh/getsentry/sentry-ruby/branch/master) +[![Gem](https://img.shields.io/gem/dt/sentry-yabeda.svg)](https://rubygems.org/gems/sentry-yabeda/) + + +[Documentation](https://docs.sentry.io/platforms/ruby/) | [Bug Tracker](https://github.com/getsentry/sentry-ruby/issues) | [Forum](https://forum.sentry.io/) | IRC: irc.freenode.net, #sentry + +The official Ruby-language client and integration layer for the [Sentry](https://github.com/getsentry/sentry) error reporting API. + + +## Getting Started + +### Install + +```ruby +gem "sentry-ruby" +gem "sentry-yabeda" +``` + +Then initialize Sentry with metrics enabled: + +```ruby +Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] + config.enable_metrics = true +end +``` + +That's it! All Yabeda metrics will automatically flow to Sentry. diff --git a/sentry-yabeda/Rakefile b/sentry-yabeda/Rakefile new file mode 100644 index 000000000..a6b6641da --- /dev/null +++ b/sentry-yabeda/Rakefile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require_relative "../lib/sentry/test/rake_tasks" + +Sentry::Test::RakeTasks.define_spec_tasks( + spec_pattern: "spec/sentry/**/*_spec.rb", + spec_rspec_opts: "--order rand --format progress" +) + +task default: :spec diff --git a/sentry-yabeda/lib/sentry-yabeda.rb b/sentry-yabeda/lib/sentry-yabeda.rb new file mode 100644 index 000000000..d65378c39 --- /dev/null +++ b/sentry-yabeda/lib/sentry-yabeda.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "yabeda" +require "sentry-ruby" +require "sentry/integrable" +require "sentry/yabeda/version" +require "sentry/yabeda/adapter" +require "sentry/yabeda/collector" +require "sentry/yabeda/configuration" + +module Sentry + module Yabeda + extend Sentry::Integrable + + register_integration name: "yabeda", version: Sentry::Yabeda::VERSION + + class << self + attr_accessor :collector + end + end +end + +::Yabeda.register_adapter(:sentry, Sentry::Yabeda::Adapter.new) diff --git a/sentry-yabeda/lib/sentry/yabeda/adapter.rb b/sentry-yabeda/lib/sentry/yabeda/adapter.rb new file mode 100644 index 000000000..114fc0a5c --- /dev/null +++ b/sentry-yabeda/lib/sentry/yabeda/adapter.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "yabeda/base_adapter" + +module Sentry + module Yabeda + class Adapter < ::Yabeda::BaseAdapter + # Sentry does not require pre-registration of metrics + def register_counter!(_metric); end + def register_gauge!(_metric); end + def register_histogram!(_metric); end + def register_summary!(_metric); end + + def perform_counter_increment!(counter, tags, increment) + return unless enabled? + + Sentry.metrics.count( + metric_name(counter), + value: increment, + attributes: attributes_for(tags) + ) + end + + def perform_gauge_set!(gauge, tags, value) + return unless enabled? + + Sentry.metrics.gauge( + metric_name(gauge), + value, + unit: unit_for(gauge), + attributes: attributes_for(tags) + ) + end + + def perform_histogram_measure!(histogram, tags, value) + return unless enabled? + + Sentry.metrics.distribution( + metric_name(histogram), + value, + unit: unit_for(histogram), + attributes: attributes_for(tags) + ) + end + + def perform_summary_observe!(summary, tags, value) + return unless enabled? + + Sentry.metrics.distribution( + metric_name(summary), + value, + unit: unit_for(summary), + attributes: attributes_for(tags) + ) + end + + private + + def enabled? + Sentry.initialized? && Sentry.configuration.enable_metrics + end + + def attributes_for(tags) + tags.empty? ? nil : tags + end + + def metric_name(metric) + [metric.group, metric.name].compact.join(".") + end + + # TODO: Normalize Yabeda unit symbols (e.g. :milliseconds) to Sentry's + # canonical singular strings (e.g. "millisecond") once units are visible + # in the Sentry product. See https://develop.sentry.dev/sdk/foundations/state-management/scopes/attributes/#units + def unit_for(metric) + metric.unit&.to_s + end + end + end +end diff --git a/sentry-yabeda/lib/sentry/yabeda/collector.rb b/sentry-yabeda/lib/sentry/yabeda/collector.rb new file mode 100644 index 000000000..bf40be7a5 --- /dev/null +++ b/sentry-yabeda/lib/sentry/yabeda/collector.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "sentry/threaded_periodic_worker" + +module Sentry + module Yabeda + # Periodically calls Yabeda.collect! to trigger gauge collection blocks + # registered by plugins like yabeda-puma-plugin, yabeda-gc, and + # yabeda-gvl_metrics. + # + # In a pull-based system (Prometheus), the scrape request triggers + # collection. In a push-based system (Sentry), we need this periodic + # worker to drive the collect → gauge.set → adapter.perform_gauge_set! + # pipeline. + class Collector < Sentry::ThreadedPeriodicWorker + DEFAULT_INTERVAL = 15 # seconds + + def initialize(configuration, interval: DEFAULT_INTERVAL) + super(configuration.sdk_logger, interval) + ensure_thread + end + + def run + ::Yabeda.collect! + rescue => e + log_warn("[Sentry::Yabeda::Collector] collection failed: #{e.message}") + end + end + end +end diff --git a/sentry-yabeda/lib/sentry/yabeda/configuration.rb b/sentry-yabeda/lib/sentry/yabeda/configuration.rb new file mode 100644 index 000000000..75e321532 --- /dev/null +++ b/sentry-yabeda/lib/sentry/yabeda/configuration.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Sentry + class Configuration + after(:configured) do + if enable_metrics + Sentry::Yabeda.collector&.kill + Sentry::Yabeda.collector = Sentry::Yabeda::Collector.new(self) + end + end + end +end diff --git a/sentry-yabeda/lib/sentry/yabeda/version.rb b/sentry-yabeda/lib/sentry/yabeda/version.rb new file mode 100644 index 000000000..fcfc3324c --- /dev/null +++ b/sentry-yabeda/lib/sentry/yabeda/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Sentry + module Yabeda + VERSION = "6.5.0" + end +end diff --git a/sentry-yabeda/sentry-yabeda.gemspec b/sentry-yabeda/sentry-yabeda.gemspec new file mode 100644 index 000000000..0a598e44e --- /dev/null +++ b/sentry-yabeda/sentry-yabeda.gemspec @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "lib/sentry/yabeda/version" + +Gem::Specification.new do |spec| + spec.name = "sentry-yabeda" + spec.version = Sentry::Yabeda::VERSION + spec.authors = ["Sentry Team"] + spec.description = spec.summary = "A gem that provides Yabeda integration for the Sentry error logger" + spec.email = "accounts@sentry.io" + spec.license = 'MIT' + + spec.platform = Gem::Platform::RUBY + spec.required_ruby_version = '>= 2.7' + spec.extra_rdoc_files = ["README.md", "LICENSE.txt"] + spec.files = `git ls-files | grep -Ev '^(spec|benchmarks|examples|\.rubocop\.yml)'`.split("\n") + + github_root_uri = 'https://github.com/getsentry/sentry-ruby' + spec.homepage = "#{github_root_uri}/tree/#{spec.version}/#{spec.name}" + + spec.metadata = { + "homepage_uri" => spec.homepage, + "source_code_uri" => spec.homepage, + "changelog_uri" => "#{github_root_uri}/blob/#{spec.version}/CHANGELOG.md", + "bug_tracker_uri" => "#{github_root_uri}/issues", + "documentation_uri" => "http://www.rubydoc.info/gems/#{spec.name}/#{spec.version}" + } + + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "sentry-ruby", "~> 6.5" + spec.add_dependency "yabeda", ">= 0.11" +end diff --git a/sentry-yabeda/spec/sentry/yabeda/adapter_spec.rb b/sentry-yabeda/spec/sentry/yabeda/adapter_spec.rb new file mode 100644 index 000000000..70ba42e46 --- /dev/null +++ b/sentry-yabeda/spec/sentry/yabeda/adapter_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::Yabeda::Adapter do + subject(:adapter) { described_class.new } + + let(:tags) { { region: "us-east", service: "api" } } + + def build_metric(type, name:, group: nil, unit: nil) + metric = double(type.to_s) + allow(metric).to receive(:name).and_return(name) + allow(metric).to receive(:group).and_return(group) + allow(metric).to receive(:unit).and_return(unit) + metric + end + + describe "metric name construction" do + it "combines group and name with a dot" do + perform_basic_setup + + counter = build_metric(:counter, name: :orders_created, group: :myapp) + expect(Sentry.metrics).to receive(:count).with("myapp.orders_created", value: 1, attributes: nil) + + adapter.perform_counter_increment!(counter, {}, 1) + end + + it "uses just the name when group is nil" do + perform_basic_setup + + counter = build_metric(:counter, name: :total_requests) + expect(Sentry.metrics).to receive(:count).with("total_requests", value: 1, attributes: nil) + + adapter.perform_counter_increment!(counter, {}, 1) + end + end + + describe "#perform_counter_increment!" do + it "calls Sentry.metrics.count with correct arguments" do + perform_basic_setup + + counter = build_metric(:counter, name: :requests, group: :rails) + expect(Sentry.metrics).to receive(:count).with( + "rails.requests", + value: 5, + attributes: tags + ) + + adapter.perform_counter_increment!(counter, tags, 5) + end + + it "passes nil attributes when tags are empty" do + perform_basic_setup + + counter = build_metric(:counter, name: :requests, group: :rails) + expect(Sentry.metrics).to receive(:count).with( + "rails.requests", + value: 1, + attributes: nil + ) + + adapter.perform_counter_increment!(counter, {}, 1) + end + end + + describe "#perform_gauge_set!" do + it "calls Sentry.metrics.gauge with correct arguments" do + perform_basic_setup + + gauge = build_metric(:gauge, name: :queue_depth, group: :sidekiq) + expect(Sentry.metrics).to receive(:gauge).with( + "sidekiq.queue_depth", + 42, + unit: nil, + attributes: tags + ) + + adapter.perform_gauge_set!(gauge, tags, 42) + end + + it "passes unit when available" do + perform_basic_setup + + gauge = build_metric(:gauge, name: :memory_usage, group: :process, unit: :bytes) + expect(Sentry.metrics).to receive(:gauge).with( + "process.memory_usage", + 1024, + unit: "bytes", + attributes: nil + ) + + adapter.perform_gauge_set!(gauge, {}, 1024) + end + end + + describe "#perform_histogram_measure!" do + it "calls Sentry.metrics.distribution with correct arguments" do + perform_basic_setup + + histogram = build_metric(:histogram, name: :request_duration, group: :rails, unit: :milliseconds) + expect(Sentry.metrics).to receive(:distribution).with( + "rails.request_duration", + 150.5, + unit: "milliseconds", + attributes: tags + ) + + adapter.perform_histogram_measure!(histogram, tags, 150.5) + end + end + + describe "#perform_summary_observe!" do + it "calls Sentry.metrics.distribution with correct arguments" do + perform_basic_setup + + summary = build_metric(:summary, name: :response_size, group: :http, unit: :bytes) + expect(Sentry.metrics).to receive(:distribution).with( + "http.response_size", + 2048, + unit: "bytes", + attributes: tags + ) + + adapter.perform_summary_observe!(summary, tags, 2048) + end + end + + describe "registration methods (no-ops)" do + it "accepts register_counter! without error" do + expect { adapter.register_counter!(double) }.not_to raise_error + end + + it "accepts register_gauge! without error" do + expect { adapter.register_gauge!(double) }.not_to raise_error + end + + it "accepts register_histogram! without error" do + expect { adapter.register_histogram!(double) }.not_to raise_error + end + + it "accepts register_summary! without error" do + expect { adapter.register_summary!(double) }.not_to raise_error + end + end + + describe "guard conditions" do + it "does not emit metrics when Sentry is not initialized" do + expect(Sentry.metrics).not_to receive(:count) + + counter = build_metric(:counter, name: :requests, group: :rails) + adapter.perform_counter_increment!(counter, {}, 1) + end + + it "does not emit metrics when metrics are disabled" do + perform_basic_setup do |config| + config.enable_metrics = false + end + + expect(Sentry.metrics).not_to receive(:count) + + counter = build_metric(:counter, name: :requests, group: :rails) + adapter.perform_counter_increment!(counter, {}, 1) + end + + it "does not emit gauge when metrics are disabled" do + perform_basic_setup { |c| c.enable_metrics = false } + + expect(Sentry.metrics).not_to receive(:gauge) + + gauge = build_metric(:gauge, name: :queue_depth) + adapter.perform_gauge_set!(gauge, {}, 1) + end + + it "does not emit histogram when metrics are disabled" do + perform_basic_setup { |c| c.enable_metrics = false } + + expect(Sentry.metrics).not_to receive(:distribution) + + histogram = build_metric(:histogram, name: :duration) + adapter.perform_histogram_measure!(histogram, {}, 1.0) + end + + it "does not emit summary when metrics are disabled" do + perform_basic_setup { |c| c.enable_metrics = false } + + expect(Sentry.metrics).not_to receive(:distribution) + + summary = build_metric(:summary, name: :response_size) + adapter.perform_summary_observe!(summary, {}, 100) + end + end + + describe "tag passthrough" do + it "passes all tags as Sentry attributes" do + perform_basic_setup + + complex_tags = { controller: "orders", action: "create", region: "eu-west", status: 200 } + counter = build_metric(:counter, name: :requests, group: :rails) + + expect(Sentry.metrics).to receive(:count).with( + "rails.requests", + value: 1, + attributes: complex_tags + ) + + adapter.perform_counter_increment!(counter, complex_tags, 1) + end + end +end diff --git a/sentry-yabeda/spec/sentry/yabeda/collector_spec.rb b/sentry-yabeda/spec/sentry/yabeda/collector_spec.rb new file mode 100644 index 000000000..48b136d5b --- /dev/null +++ b/sentry-yabeda/spec/sentry/yabeda/collector_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::Yabeda::Collector do + before { perform_basic_setup } + + describe "#run" do + it "calls Yabeda.collect!" do + collector = described_class.new(Sentry.configuration, interval: 999) + + expect(::Yabeda).to receive(:collect!) + collector.run + end + + it "does not raise when Yabeda.collect! fails" do + collector = described_class.new(Sentry.configuration, interval: 999) + + allow(::Yabeda).to receive(:collect!).and_raise(RuntimeError, "boom") + expect { collector.run }.not_to raise_error + end + end + + describe "auto-start" do + it "starts automatically when Sentry is initialized with enable_metrics" do + expect(Sentry::Yabeda.collector).to be_a(described_class) + end + + it "does not start when enable_metrics is false" do + Sentry.close + Sentry::Yabeda.collector = nil + + Sentry.init do |config| + config.dsn = DUMMY_DSN + config.sdk_logger = ::Logger.new(nil) + config.transport.transport_class = Sentry::DummyTransport + config.enable_metrics = false + end + + expect(Sentry::Yabeda.collector).to be_nil + end + + it "replaces an existing collector on re-initialization" do + first = Sentry::Yabeda.collector + + Sentry.close + perform_basic_setup + + expect(Sentry::Yabeda.collector).to be_a(described_class) + expect(Sentry::Yabeda.collector).not_to equal(first) + end + end +end diff --git a/sentry-yabeda/spec/sentry/yabeda/integration_spec.rb b/sentry-yabeda/spec/sentry/yabeda/integration_spec.rb new file mode 100644 index 000000000..5ae59654a --- /dev/null +++ b/sentry-yabeda/spec/sentry/yabeda/integration_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Integration test exercising real Yabeda metrics flowing through to Sentry. +# Yabeda's global state (singleton methods, metrics registry) can only be +# configured once per process, so we define all metrics up front and run +# assertions against them. + +::Yabeda.configure do + group :myapp do + counter :orders_created, comment: "Orders placed", tags: %i[region payment_method] + gauge :queue_depth, comment: "Jobs waiting", tags: %i[queue_name] + histogram :response_time, comment: "HTTP response time", unit: :milliseconds, tags: %i[controller action], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] + end + + counter :global_requests, comment: "Total requests (no group)" +end + +::Yabeda.configure! unless ::Yabeda.configured? + +RSpec.describe "Yabeda-Sentry integration" do + before do + perform_basic_setup do |config| + config.traces_sample_rate = 1.0 + config.release = "test-release" + config.environment = "test" + end + end + + it "forwards counter increments to Sentry" do + ::Yabeda.myapp.orders_created.increment({ region: "us-east", payment_method: "credit_card" }) + + Sentry.get_current_client.flush + + expect(sentry_metrics.count).to eq(1) + + metric = sentry_metrics.first + expect(metric[:name]).to eq("myapp.orders_created") + expect(metric[:type]).to eq(:counter) + expect(metric[:value]).to eq(1) + expect(metric[:attributes][:region]).to eq({ type: "string", value: "us-east" }) + expect(metric[:attributes][:payment_method]).to eq({ type: "string", value: "credit_card" }) + end + + it "forwards counter increments with custom value" do + ::Yabeda.myapp.orders_created.increment({ region: "eu-west" }, by: 5) + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + expect(metric[:value]).to eq(5) + end + + it "forwards gauge sets to Sentry" do + ::Yabeda.myapp.queue_depth.set({ queue_name: "default" }, 42) + + Sentry.get_current_client.flush + + expect(sentry_metrics.count).to eq(1) + + metric = sentry_metrics.first + expect(metric[:name]).to eq("myapp.queue_depth") + expect(metric[:type]).to eq(:gauge) + expect(metric[:value]).to eq(42) + expect(metric[:attributes][:queue_name]).to eq({ type: "string", value: "default" }) + end + + it "forwards histogram observations to Sentry as distributions" do + ::Yabeda.myapp.response_time.measure({ controller: "orders", action: "index" }, 150.5) + + Sentry.get_current_client.flush + + expect(sentry_metrics.count).to eq(1) + + metric = sentry_metrics.first + expect(metric[:name]).to eq("myapp.response_time") + expect(metric[:type]).to eq(:distribution) + expect(metric[:value]).to eq(150.5) + expect(metric[:unit]).to eq("milliseconds") + expect(metric[:attributes][:controller]).to eq({ type: "string", value: "orders" }) + expect(metric[:attributes][:action]).to eq({ type: "string", value: "index" }) + end + + it "handles metrics without a group" do + ::Yabeda.global_requests.increment({}) + + Sentry.get_current_client.flush + + metric = sentry_metrics.first + expect(metric[:name]).to eq("global_requests") + expect(metric[:type]).to eq(:counter) + end + + it "batches multiple Yabeda metrics into a single Sentry envelope" do + ::Yabeda.myapp.orders_created.increment({ region: "us-east" }) + ::Yabeda.myapp.queue_depth.set({ queue_name: "default" }, 10) + ::Yabeda.myapp.response_time.measure({ controller: "home", action: "index" }, 50.0) + + 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( + "myapp.orders_created", + "myapp.queue_depth", + "myapp.response_time" + ) + end + + it "carries trace context on metrics" do + transaction = Sentry.start_transaction(name: "test_transaction", op: "test.op") + Sentry.get_current_scope.set_span(transaction) + + ::Yabeda.myapp.orders_created.increment({ region: "us-east" }) + + transaction.finish + Sentry.get_current_client.flush + + metric = sentry_metrics.first + expect(metric[:trace_id]).to eq(transaction.trace_id) + end + + context "when metrics are disabled" do + before do + Sentry.configuration.enable_metrics = false + end + + it "does not send metrics to Sentry" do + ::Yabeda.myapp.orders_created.increment({ region: "us-east" }) + + Sentry.get_current_client.flush + + expect(sentry_metrics).to be_empty + end + end +end + +RSpec.describe "Yabeda-Sentry integration when Sentry is not initialized" do + it "does not raise errors when Yabeda metrics are emitted" do + # Sentry is not initialized (reset_sentry_globals! runs after each test) + expect { ::Yabeda.myapp.orders_created.increment({ region: "us-east" }) }.not_to raise_error + end +end diff --git a/sentry-yabeda/spec/spec_helper.rb b/sentry-yabeda/spec/spec_helper.rb new file mode 100644 index 000000000..1e3ab0cc2 --- /dev/null +++ b/sentry-yabeda/spec/spec_helper.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "bundler/setup" +begin + require "debug/prelude" +rescue LoadError +end + +require "sentry-ruby" +require "sentry/test_helper" + +require 'simplecov' + +SimpleCov.start do + project_name "sentry-yabeda" + root File.join(__FILE__, "../../../") + coverage_dir File.join(__FILE__, "../../coverage") +end + +if ENV["CI"] + require 'simplecov-cobertura' + SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter +end + +require "sentry-yabeda" + +DUMMY_DSN = 'http://12345:67890@sentry.localdomain/sentry/42' + +RSpec.configure do |config| + config.example_status_persistence_file_path = ".rspec_status" + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end + + config.before :each do + ENV.delete('SENTRY_DSN') + ENV.delete('SENTRY_CURRENT_ENV') + ENV.delete('SENTRY_ENVIRONMENT') + ENV.delete('SENTRY_RELEASE') + end + + config.include(Sentry::TestHelper) + + config.after :each do + Sentry::Yabeda.collector&.kill + Sentry::Yabeda.collector = nil + reset_sentry_globals! + end +end + +def perform_basic_setup + Sentry.init do |config| + config.dsn = DUMMY_DSN + config.sdk_logger = ::Logger.new(nil) + config.background_worker_threads = 0 + config.transport.transport_class = Sentry::DummyTransport + config.enable_metrics = true + + yield config if block_given? + end +end