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
| [](https://rubygems.org/gems/sentry-delayed_job) | [](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml) | [](https://codecov.io/gh/getsentry/sentry-ruby) | [](https://www.rubydoc.info/gems/sentry-delayed_job) |
| [](https://rubygems.org/gems/sentry-resque) | [](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml) | [](https://codecov.io/gh/getsentry/sentry-ruby) | [](https://www.rubydoc.info/gems/sentry-resque) |
| [](https://rubygems.org/gems/sentry-opentelemetry) | [](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml) | [](https://codecov.io/gh/getsentry/sentry-ruby) | [](https://www.rubydoc.info/gems/sentry-opentelemetry) |
+| [](https://rubygems.org/gems/sentry-yabeda) | [](https://github.com/getsentry/sentry-ruby/actions/workflows/tests.yml) | [](https://codecov.io/gh/getsentry/sentry-ruby) | [](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
+
+---
+
+[](https://rubygems.org/gems/sentry-yabeda)
+
+[](https://codecov.io/gh/getsentry/sentry-ruby/branch/master)
+[](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