From c41fc31a0f6271319e28989613410179bc3c48ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Daxb=C3=B6ck?= Date: Tue, 31 Mar 2026 10:49:52 +0200 Subject: [PATCH 1/4] feat(yabeda): Add sentry-yabeda adapter gem Introduces sentry-yabeda, a Yabeda adapter that forwards metrics to Sentry. Covers all four Yabeda metric types (counter, gauge, histogram, summary), a periodic collector to drive gauge collection in push-based environments, and a full spec suite including unit and integration tests. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 10 +- sentry-yabeda/Gemfile | 13 + sentry-yabeda/LICENSE.txt | 21 ++ sentry-yabeda/README.md | 79 ++++++ sentry-yabeda/Rakefile | 11 + sentry-yabeda/lib/sentry-yabeda.rb | 37 +++ sentry-yabeda/lib/sentry/yabeda/adapter.rb | 79 ++++++ sentry-yabeda/lib/sentry/yabeda/collector.rb | 30 +++ sentry-yabeda/lib/sentry/yabeda/version.rb | 7 + sentry-yabeda/sentry-yabeda.gemspec | 35 +++ .../spec/sentry/yabeda/adapter_spec.rb | 209 +++++++++++++++ .../spec/sentry/yabeda/collector_spec.rb | 51 ++++ .../spec/sentry/yabeda/integration_spec.rb | 147 +++++++++++ sentry-yabeda/spec/spec_helper.rb | 61 +++++ spec/apps/yabeda-mini/Gemfile | 19 ++ spec/apps/yabeda-mini/app.rb | 248 ++++++++++++++++++ 16 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 sentry-yabeda/Gemfile create mode 100644 sentry-yabeda/LICENSE.txt create mode 100644 sentry-yabeda/README.md create mode 100644 sentry-yabeda/Rakefile create mode 100644 sentry-yabeda/lib/sentry-yabeda.rb create mode 100644 sentry-yabeda/lib/sentry/yabeda/adapter.rb create mode 100644 sentry-yabeda/lib/sentry/yabeda/collector.rb create mode 100644 sentry-yabeda/lib/sentry/yabeda/version.rb create mode 100644 sentry-yabeda/sentry-yabeda.gemspec create mode 100644 sentry-yabeda/spec/sentry/yabeda/adapter_spec.rb create mode 100644 sentry-yabeda/spec/sentry/yabeda/collector_spec.rb create mode 100644 sentry-yabeda/spec/sentry/yabeda/integration_spec.rb create mode 100644 sentry-yabeda/spec/spec_helper.rb create mode 100644 spec/apps/yabeda-mini/Gemfile create mode 100644 spec/apps/yabeda-mini/app.rb 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-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..8b080c628 --- /dev/null +++ b/sentry-yabeda/README.md @@ -0,0 +1,79 @@ +# sentry-yabeda + +A [Yabeda](https://github.com/yabeda-rb/yabeda) adapter that forwards Ruby application metrics to [Sentry](https://sentry.io). + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem "sentry-yabeda" +``` + +## Usage + +Require `sentry-yabeda` in your application. If you're using Bundler (most cases), simply adding it to your Gemfile is enough. + +```ruby +# config/initializers/sentry.rb +Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] + config.enable_metrics = true +end + +# config/initializers/yabeda.rb (or wherever Yabeda is configured) +require "sentry-yabeda" +``` + +That's it! All Yabeda metrics will automatically flow to Sentry. + +### Periodic Gauge Collection + +Many Yabeda plugins (puma, gc, gvl\_metrics) measure process-level state using **gauge metrics** with `collect` blocks. These blocks are designed for Prometheus's pull model. A scrape request triggers `Yabeda.collect!`, which reads the current state and sets gauge values. + +In a push-based system like Sentry, there's no scrape request. `sentry-yabeda` solves this with a built-in **periodic collector** that calls `Yabeda.collect!` on a background thread: + +```ruby +require "sentry-yabeda" + +# Start the collector (default: every 15 seconds) +Sentry::Yabeda.start_collector! + +# Or with a custom interval +Sentry::Yabeda.start_collector!(interval: 30) + +# Stop the collector +Sentry::Yabeda.stop_collector! +``` + +Without starting the collector, only **event-driven metrics** (counters incremented on each request, histograms measured per-operation) will flow to Sentry. Gauges that depend on periodic collection (e.g. GC stats, GVL contention, and Puma thread pool utilization) require the collector. + +** How it works ** + +Every 15s (or set interval) +1. Collector calls Yabeda.collect! +2. Plugin collect blocks fire (read GC.stat, fetch Puma /stats, etc.) +3. gauge.set(value) calls flow through the adapter +4. Sentry.metrics.gauge(name, value, attributes: tags) +5. Sentry buffers and sends in the next envelope flush + +### Metric Type Mapping + +| Yabeda Type | Sentry Type | +|-------------|-------------| +| Counter | `Sentry.metrics.count` | +| Gauge | `Sentry.metrics.gauge` | +| Histogram | `Sentry.metrics.distribution` | +| Summary | `Sentry.metrics.distribution` | + +### Tags + +Yabeda tags are passed directly as Sentry metric attributes, enabling filtering and grouping in the Sentry UI. + +### Metric Naming + +Metrics are named using the pattern `{group}.{name}` (e.g., `rails.request_duration`). Metrics without a group use just the name. + +### Trace Integration + +Since Sentry metrics carry trace context automatically, metrics emitted via the adapter are connected to active traces when `sentry-rails` or other Sentry integrations are active. This enables pivoting from metric spikes to relevant traces in the Sentry UI. 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..783ea3eb6 --- /dev/null +++ b/sentry-yabeda/lib/sentry-yabeda.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "yabeda" +require "sentry-ruby" +require "sentry/integrable" +require "sentry/yabeda/version" +require "sentry/yabeda/adapter" +require "sentry/yabeda/collector" + +module Sentry + module Yabeda + extend Sentry::Integrable + + register_integration name: "yabeda", version: Sentry::Yabeda::VERSION + + class << self + attr_accessor :collector + + # Start periodic collection of Yabeda gauge metrics. + # Call this after Sentry.init to begin pushing runtime metrics + # (GC, GVL, Puma stats, etc.) to Sentry. + def start_collector!(interval: Collector::DEFAULT_INTERVAL) + raise ArgumentError, "call start_collector! after Sentry.init" unless Sentry.initialized? + + @collector&.kill + @collector = Collector.new(interval: interval) + end + + def stop_collector! + @collector&.kill + @collector = nil + end + 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..a3722765e --- /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) = nil + def register_gauge!(_metric) = nil + def register_histogram!(_metric) = nil + def register_summary!(_metric) = nil + + 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..22ad97b2b --- /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(interval: DEFAULT_INTERVAL) + super(Sentry.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/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..04ba98be5 --- /dev/null +++ b/sentry-yabeda/spec/sentry/yabeda/collector_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Sentry::Yabeda::Collector do + before { perform_basic_setup } + + after { Sentry::Yabeda.stop_collector! } + + describe "#run" do + it "calls Yabeda.collect!" do + collector = described_class.new(interval: 999) + + expect(::Yabeda).to receive(:collect!) + collector.run + end + + it "does not raise when Yabeda.collect! fails" do + collector = described_class.new(interval: 999) + + allow(::Yabeda).to receive(:collect!).and_raise(RuntimeError, "boom") + expect { collector.run }.not_to raise_error + end + end + + describe ".start_collector! / .stop_collector!" do + it "creates and stops a collector" do + Sentry::Yabeda.start_collector!(interval: 999) + expect(Sentry::Yabeda.collector).to be_a(described_class) + + Sentry::Yabeda.stop_collector! + expect(Sentry::Yabeda.collector).to be_nil + end + + it "replaces the collector when called again" do + Sentry::Yabeda.start_collector!(interval: 999) + first = Sentry::Yabeda.collector + + Sentry::Yabeda.start_collector!(interval: 999) + second = Sentry::Yabeda.collector + + expect(second).not_to equal(first) + end + + it "raises when called before Sentry.init" do + reset_sentry_globals! + + expect { Sentry::Yabeda.start_collector! }.to raise_error(ArgumentError, /Sentry\.init/) + 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..2f9105194 --- /dev/null +++ b/sentry-yabeda/spec/spec_helper.rb @@ -0,0 +1,61 @@ +# 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 + 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 diff --git a/spec/apps/yabeda-mini/Gemfile b/spec/apps/yabeda-mini/Gemfile new file mode 100644 index 000000000..60fa0947a --- /dev/null +++ b/spec/apps/yabeda-mini/Gemfile @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "rake" +gem "puma" + +gem "railties" +gem "actionpack" +gem "activerecord" +gem "activejob" +gem "sqlite3" + +gem "yabeda" +gem "yabeda-rails" + +gem "sentry-ruby", path: Pathname(__dir__).join("../../..").realpath +gem "sentry-rails", path: Pathname(__dir__).join("../../..").realpath +gem "sentry-yabeda", path: Pathname(__dir__).join("../../..").realpath diff --git a/spec/apps/yabeda-mini/app.rb b/spec/apps/yabeda-mini/app.rb new file mode 100644 index 000000000..4fdc016dc --- /dev/null +++ b/spec/apps/yabeda-mini/app.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +# Yabeda-Mini: A minimal Rails app for testing the sentry-yabeda adapter. +# +# Usage: +# cd spec/apps/yabeda-mini +# bundle install +# bundle exec ruby app.rb +# +# Then hit: +# GET /health — Check Sentry & Yabeda status +# GET /posts — List posts (increments request counter, measures response time) +# POST /posts — Create a post (increments counter with payment_method tag) +# GET /error — Trigger an error (increments error counter) +# GET /metrics — Inspect buffered Sentry metric envelopes +# POST /clear_metrics — Clear the metric log file +# +# Metrics log is written to log/sentry_debug_events.log + +require "bundler/setup" +Bundler.require + +ENV["RAILS_ENV"] = "development" +ENV["DATABASE_URL"] = "sqlite3:tmp/yabeda_mini_development.sqlite3" + +require "action_controller/railtie" +require "active_record/railtie" +require "active_job/railtie" + +# --------------------------------------------------------------------------- +# Yabeda metric definitions +# --------------------------------------------------------------------------- +Yabeda.configure do + group :app do + counter :requests_total, comment: "Total HTTP requests", tags: %i[controller action status] + counter :errors_total, comment: "Total unhandled errors", tags: %i[error_class] + gauge :queue_depth, comment: "Simulated job queue depth", tags: %i[queue_name] + histogram :request_duration, comment: "Request duration", tags: %i[controller action], + unit: :milliseconds, + buckets: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000] + end + + group :business do + counter :posts_created, comment: "Posts created", tags: %i[source] + end +end + +# --------------------------------------------------------------------------- +# Rails application +# --------------------------------------------------------------------------- +# Patch DebugTransport to also log envelopes sent via send_envelope. +# DebugTransport inherits from SimpleDelegator so send_envelope is forwarded +# via method_missing to the HTTP backend. We define it directly to intercept. +class Sentry::DebugTransport + def send_envelope(envelope) + log_envelope(envelope) + __getobj__.send_envelope(envelope) + rescue => e + # Swallow HTTP errors when using a fake DSN — the envelope is already logged. + end +end + +class YabedaMiniApp < Rails::Application + config.hosts = nil + config.secret_key_base = "yabeda_mini_secret_key_base" + config.eager_load = false + config.logger = Logger.new($stdout) + config.log_level = :debug + config.api_only = true + config.force_ssl = false + + def debug_log_path + @log_path ||= begin + path = Pathname(__dir__).join("log") + FileUtils.mkdir_p(path) unless path.exist? + path.realpath + end + end + + initializer :configure_sentry do + Sentry.init do |config| + config.dsn = ENV.fetch("SENTRY_DSN", "http://examplePublicKey@o0.ingest.sentry.io/0") + config.traces_sample_rate = 1.0 + config.send_default_pii = true + config.debug = true + config.sdk_logger = Sentry::Logger.new($stdout) + config.sdk_logger.level = ::Logger::DEBUG + config.transport.transport_class = Sentry::DebugTransport + config.sdk_debug_transport_log_file = debug_log_path.join("sentry_debug_events.log") + config.background_worker_threads = 0 + config.enable_metrics = true + config.release = "yabeda-mini@0.1.0" + config.environment = "development" + end + end +end + +# Models +class Post < ActiveRecord::Base +end + +# Controllers +class ApplicationController < ActionController::Base + around_action :track_metrics + + private + + def track_metrics + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + ensure + duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000 + Yabeda.app.request_duration.measure( + { controller: controller_name, action: action_name }, + duration_ms + ) + Yabeda.app.requests_total.increment( + { controller: controller_name, action: action_name, status: response.status.to_s } + ) + end +end + +class HealthController < ApplicationController + skip_around_action :track_metrics + + def show + render json: { + status: "ok", + sentry_initialized: Sentry.initialized?, + sentry_metrics_enabled: Sentry.initialized? && Sentry.configuration.enable_metrics, + yabeda_configured: Yabeda.configured?, + yabeda_adapters: Yabeda.adapters.keys, + registered_metrics: Yabeda.metrics.keys + } + end +end + +class PostsController < ApplicationController + def index + posts = Post.all.to_a + # Simulate varying queue depth + Yabeda.app.queue_depth.set({ queue_name: "default" }, rand(0..20)) + render json: { posts: posts.map { |p| { id: p.id, title: p.title } } } + end + + def create + post = Post.create!(title: params[:title] || "Untitled", content: params[:content]) + Yabeda.business.posts_created.increment({ source: params[:source] || "api" }) + render json: { post: { id: post.id, title: post.title } }, status: :created + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.message }, status: :unprocessable_entity + end +end + +class ErrorController < ApplicationController + def trigger + Yabeda.app.errors_total.increment({ error_class: "ZeroDivisionError" }) + 1 / 0 + end +end + +class MetricsController < ApplicationController + skip_around_action :track_metrics + + def index + log_file = YabedaMiniApp.new.debug_log_path.join("sentry_debug_events.log") + + envelopes = if File.exist?(log_file) + File.readlines(log_file).filter_map do |line| + data = JSON.parse(line) + metric_items = data["items"]&.select { |i| i.dig("headers", "type") == "trace_metric" } + next if metric_items.nil? || metric_items.empty? + + { + timestamp: data["timestamp"], + metrics: metric_items.flat_map { |i| i.dig("payload", "items") || [] } + } + end + else + [] + end + + total_metrics = envelopes.sum { |e| e[:metrics].size } + + render json: { + total_envelopes: envelopes.size, + total_metrics: total_metrics, + envelopes: envelopes + } + end + + def flush + buffer = Sentry.get_current_client.instance_variable_get(:@metric_event_buffer) + count = buffer&.size || 0 + buffer&.flush + render json: { status: "flushed", metrics_flushed: count } + end + + def clear + log_file = YabedaMiniApp.new.debug_log_path.join("sentry_debug_events.log") + File.write(log_file, "") if File.exist?(log_file) + render json: { status: "cleared" } + end +end + +# StartUp +YabedaMiniApp.initialize! + +ActiveRecord::Schema.define do + create_table :posts, force: true do |t| + t.string :title, null: false + t.text :content + t.timestamps + end +end + +Post.create!(title: "Welcome", content: "First post in yabeda-mini") +Post.create!(title: "Metrics Test", content: "This post exists so /posts has data") + +YabedaMiniApp.routes.draw do + get "/health", to: "health#show" + get "/posts", to: "posts#index" + post "/posts", to: "posts#create" + get "/error", to: "error#trigger" + get "/metrics", to: "metrics#index" + post "/flush_metrics", to: "metrics#flush" + post "/clear_metrics", to: "metrics#clear" +end + +if __FILE__ == $0 + require "rack" + require "rack/handler/puma" + + port = ENV.fetch("SENTRY_E2E_YABEDA_APP_PORT", "4002").to_i + puts "\n#{"=" * 60}" + puts " Yabeda-Mini running on http://0.0.0.0:#{port}" + puts " Endpoints:" + puts " GET /health — Sentry & Yabeda status" + puts " GET /posts — List posts (emits metrics)" + puts " POST /posts — Create post (emits business counter)" + puts " GET /error — Trigger error (emits error counter)" + puts " GET /metrics — Inspect captured Sentry metric envelopes" + puts " POST /flush_metrics — Flush metric buffer to log" + puts " POST /clear_metrics — Clear metric log" + puts "#{"=" * 60}\n\n" + + Rack::Handler::Puma.run(YabedaMiniApp, Host: "0.0.0.0", Port: port) +end From 2ad73c0c64787c6a2e2f8f31e09328bb21a3b8d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Daxb=C3=B6ck?= Date: Tue, 31 Mar 2026 11:01:30 +0200 Subject: [PATCH 2/4] fix(yabeda): Replace endless method syntax for Ruby 2.7 compatibility Endless method syntax (def m() = val) requires Ruby 3.0+. Replace with conventional empty method bodies (def m; end) so RuboCop using the Ruby 2.7 parser does not reject the file. Co-Authored-By: Claude Sonnet 4.6 --- sentry-yabeda/lib/sentry/yabeda/adapter.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry-yabeda/lib/sentry/yabeda/adapter.rb b/sentry-yabeda/lib/sentry/yabeda/adapter.rb index a3722765e..114fc0a5c 100644 --- a/sentry-yabeda/lib/sentry/yabeda/adapter.rb +++ b/sentry-yabeda/lib/sentry/yabeda/adapter.rb @@ -6,10 +6,10 @@ module Sentry module Yabeda class Adapter < ::Yabeda::BaseAdapter # Sentry does not require pre-registration of metrics - def register_counter!(_metric) = nil - def register_gauge!(_metric) = nil - def register_histogram!(_metric) = nil - def register_summary!(_metric) = nil + 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? From f890d483477222de8e38aa1c0c852e80badca485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Daxb=C3=B6ck?= Date: Tue, 31 Mar 2026 11:52:54 +0200 Subject: [PATCH 3/4] fix(yabeda): Use ActionController::API in yabeda-mini app The app is configured as api_only but inherited from ActionController::Base, which includes CSRF protection middleware. Switch to ActionController::API to align with the api_only setting and eliminate the CSRF warning. Co-Authored-By: Claude Sonnet 4.6 --- spec/apps/yabeda-mini/app.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/apps/yabeda-mini/app.rb b/spec/apps/yabeda-mini/app.rb index 4fdc016dc..e3f6c7607 100644 --- a/spec/apps/yabeda-mini/app.rb +++ b/spec/apps/yabeda-mini/app.rb @@ -100,7 +100,7 @@ class Post < ActiveRecord::Base end # Controllers -class ApplicationController < ActionController::Base +class ApplicationController < ActionController::API around_action :track_metrics private From 8261a6244330ed763cf659e738da110de2f42b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Daxb=C3=B6ck?= Date: Wed, 1 Apr 2026 09:54:10 +0200 Subject: [PATCH 4/4] Potential fix for code scanning alert no. 22: CSRF protection not enabled Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- spec/apps/yabeda-mini/app.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spec/apps/yabeda-mini/app.rb b/spec/apps/yabeda-mini/app.rb index e3f6c7607..815da73a0 100644 --- a/spec/apps/yabeda-mini/app.rb +++ b/spec/apps/yabeda-mini/app.rb @@ -101,6 +101,8 @@ class Post < ActiveRecord::Base # Controllers class ApplicationController < ActionController::API + include ActionController::RequestForgeryProtection + protect_from_forgery with: :exception around_action :track_metrics private