diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b58e45b6..c43d8a7f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ ## Unreleased +### Features + +- Support for tracing `Sequel` queries ([#2814](https://github.com/getsentry/sentry-ruby/pull/2814)) + + ```ruby + require "sentry" + require "sentry/sequel" + + Sentry.init do |config| + config.enabled_patches << :sequel + end + + DB = Sequel.sqlite + DB.extension(:sentry) + ``` + ### Bug Fixes - Handle empty frames case gracefully with local vars ([#2807](https://github.com/getsentry/sentry-ruby/pull/2807)) diff --git a/sentry-rails/Gemfile b/sentry-rails/Gemfile index d58657d5b..ea1d6ac51 100644 --- a/sentry-rails/Gemfile +++ b/sentry-rails/Gemfile @@ -62,3 +62,5 @@ gem "benchmark-ips" gem "benchmark_driver" gem "benchmark-ipsa" gem "benchmark-memory" + +gem "sequel" diff --git a/sentry-rails/spec/dummy/test_rails_app/app/controllers/sequel_users_controller.rb b/sentry-rails/spec/dummy/test_rails_app/app/controllers/sequel_users_controller.rb new file mode 100644 index 000000000..79dec46a1 --- /dev/null +++ b/sentry-rails/spec/dummy/test_rails_app/app/controllers/sequel_users_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "sequel" +require "sentry/sequel" + +class SequelUsersController < ActionController::Base + def index + users = SEQUEL_DB[:users].all + render json: users + end + + def create + id = SEQUEL_DB[:users].insert(name: params[:name], email: params[:email]) + render json: { id: id, name: params[:name], email: params[:email] }, status: :created + end + + def show + user = SEQUEL_DB[:users].where(id: params[:id]).first + if user + render json: user + else + render json: { error: "Not found" }, status: :not_found + end + end + + def update + SEQUEL_DB[:users].where(id: params[:id]).update(name: params[:name]) + user = SEQUEL_DB[:users].where(id: params[:id]).first + render json: user + end + + def destroy + SEQUEL_DB[:users].where(id: params[:id]).delete + head :no_content + end + + def exception + SEQUEL_DB[:users].all + raise "Something went wrong!" + end +end diff --git a/sentry-rails/spec/dummy/test_rails_app/config/application.rb b/sentry-rails/spec/dummy/test_rails_app/config/application.rb index b8c43b5c3..3d06d8e9a 100644 --- a/sentry-rails/spec/dummy/test_rails_app/config/application.rb +++ b/sentry-rails/spec/dummy/test_rails_app/config/application.rb @@ -122,6 +122,16 @@ def configure end end + # Sequel-specific routes for testing Sequel tracing + scope "/sequel" do + get "/users", to: "sequel_users#index" + post "/users", to: "sequel_users#create" + get "/users/:id", to: "sequel_users#show" + put "/users/:id", to: "sequel_users#update" + delete "/users/:id", to: "sequel_users#destroy" + get "/exception", to: "sequel_users#exception" + end + get "500", to: "hello#reporting" root to: "hello#world" diff --git a/sentry-rails/spec/isolated/sequel_tracing_spec.rb b/sentry-rails/spec/isolated/sequel_tracing_spec.rb new file mode 100644 index 000000000..c927df001 --- /dev/null +++ b/sentry-rails/spec/isolated/sequel_tracing_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +begin + require "simplecov" + SimpleCov.command_name "SequelTracing" +rescue LoadError +end + +require "sequel" +require "sentry/sequel" + +require_relative "../dummy/test_rails_app/app/controllers/sequel_users_controller" + +RSpec.describe "Sequel Tracing with Rails", type: :request do + before(:all) do + if RUBY_ENGINE == "jruby" + SEQUEL_DB = Sequel.connect("jdbc:sqlite::memory:") + else + SEQUEL_DB = Sequel.sqlite + end + + SEQUEL_DB.create_table :users do + primary_key :id + String :name + String :email + end + + SEQUEL_DB[:users].count + SEQUEL_DB.extension(:sentry) + end + + after(:all) do + SEQUEL_DB.drop_table?(:users) + Object.send(:remove_const, :SEQUEL_DB) + end + + before do + make_basic_app do |config, app| + config.traces_sample_rate = 1.0 + config.enabled_patches << :sequel + end + end + + let(:transport) { Sentry.get_current_client.transport } + + describe "SELECT queries" do + it "creates a transaction with Sequel span for index action" do + get "/sequel/users" + + expect(response).to have_http_status(:ok) + expect(transport.events.count).to eq(1) + + transaction = transport.events.last.to_h + + expect(transaction[:type]).to eq("transaction") + expect(transaction.dig(:contexts, :trace, :op)).to eq("http.server") + + sequel_span = transaction[:spans].find { |span| span[:op] == "db.sql.sequel" } + + expect(sequel_span).not_to be_nil + expect(sequel_span[:description]).to include("SELECT") + expect(sequel_span[:description]).to include("users") + expect(sequel_span[:origin]).to eq("auto.db.sequel") + expect(sequel_span[:data]["db.system"]).to eq("sqlite") + end + end + + describe "INSERT queries" do + it "creates a transaction with Sequel span for create action" do + post "/sequel/users", params: { name: "John Doe", email: "john@example.com" } + + expect(response).to have_http_status(:created) + + transaction = transport.events.last.to_h + expect(transaction[:type]).to eq("transaction") + + insert_span = transaction[:spans].find do |span| + span[:op] == "db.sql.sequel" && span[:description]&.include?("INSERT") + end + + expect(insert_span).not_to be_nil + expect(insert_span[:description]).to include("INSERT") + expect(insert_span[:description]).to include("users") + expect(insert_span[:origin]).to eq("auto.db.sequel") + end + end + + describe "UPDATE queries" do + it "creates a transaction with Sequel span for update action" do + SEQUEL_DB[:users].insert(name: "Jane Doe", email: "jane@example.com") + + put "/sequel/users/1", params: { name: "Jane Smith" } + + expect(response).to have_http_status(:ok) + + transaction = transport.events.last.to_h + + update_span = transaction[:spans].find do |span| + span[:op] == "db.sql.sequel" && span[:description]&.include?("UPDATE") + end + + expect(update_span).not_to be_nil + expect(update_span[:description]).to include("UPDATE") + expect(update_span[:description]).to include("users") + end + end + + describe "DELETE queries" do + it "creates a transaction with Sequel span for delete action" do + SEQUEL_DB[:users].insert(name: "Delete Me", email: "delete@example.com") + + delete "/sequel/users/1" + + expect(response).to have_http_status(:no_content) + + transaction = transport.events.last.to_h + + delete_span = transaction[:spans].find do |span| + span[:op] == "db.sql.sequel" && span[:description]&.include?("DELETE") + end + + expect(delete_span).not_to be_nil + expect(delete_span[:description]).to include("DELETE") + expect(delete_span[:description]).to include("users") + end + end + + describe "exception handling" do + it "creates both error event and transaction with Sequel span" do + get "/sequel/exception" + + expect(response).to have_http_status(:internal_server_error) + + expect(transport.events.count).to eq(2) + + error_event = transport.events.first.to_h + transaction = transport.events.last.to_h + + expect(error_event[:exception][:values].first[:type]).to eq("RuntimeError") + expect(error_event[:exception][:values].first[:value]).to include("Something went wrong!") + + sequel_span = transaction[:spans].find { |span| span[:op] == "db.sql.sequel" } + expect(sequel_span).not_to be_nil + expect(sequel_span[:description]).to include("SELECT") + + expect(error_event.dig(:contexts, :trace, :trace_id)).to eq( + transaction.dig(:contexts, :trace, :trace_id) + ) + end + end + + describe "span timing" do + it "records proper start and end timestamps" do + get "/sequel/users" + + transaction = transport.events.last.to_h + sequel_span = transaction[:spans].find { |span| span[:op] == "db.sql.sequel" } + + expect(sequel_span[:start_timestamp]).not_to be_nil + expect(sequel_span[:timestamp]).not_to be_nil + expect(sequel_span[:start_timestamp]).to be < sequel_span[:timestamp] + end + end + + describe "Sequel and ActiveRecord coexistence" do + it "records spans for both database systems in the same application" do + Post.create!(title: "Test Post") + + SEQUEL_DB[:users].insert(name: "Sequel User", email: "sequel@example.com") + + transport.events.clear + + get "/sequel/users" + + expect(response).to have_http_status(:ok) + + transaction = transport.events.last.to_h + sequel_spans = transaction[:spans].select { |span| span[:op] == "db.sql.sequel" } + + expect(sequel_spans.length).to be >= 1 + expect(sequel_spans.first[:data]["db.system"]).to eq("sqlite") + end + + it "records ActiveRecord spans separately from Sequel spans" do + transport.events.clear + + get "/posts" + + expect(response).to have_http_status(:internal_server_error) # raises "foo" in PostsController#index + + transaction = transport.events.last.to_h + + ar_spans = transaction[:spans].select { |span| span[:op] == "db.sql.active_record" } + sequel_spans = transaction[:spans].select { |span| span[:op] == "db.sql.sequel" } + + expect(ar_spans.length).to be >= 1 + expect(sequel_spans.length).to eq(0) + end + end +end diff --git a/sentry-ruby/Gemfile b/sentry-ruby/Gemfile index 8771374c8..e321c4c2b 100644 --- a/sentry-ruby/Gemfile +++ b/sentry-ruby/Gemfile @@ -7,8 +7,11 @@ eval_gemfile "../Gemfile.dev" gem "sentry-ruby", path: "./" +ruby_version = Gem::Version.new(RUBY_VERSION) + rack_version = ENV["RACK_VERSION"] rack_version = "3.0.0" if rack_version.nil? + gem "rack", "~> #{Gem::Version.new(rack_version)}" unless rack_version == "0" redis_rb_version = ENV.fetch("REDIS_RB_VERSION", "5.0") @@ -32,3 +35,24 @@ gem "webrick" gem "faraday" gem "excon" gem "webmock" + +group :sequel do + gem "sequel" + + sqlite_version = if ruby_version >= Gem::Version.new("3.2") + "2.1.0" + elsif ruby_version >= Gem::Version.new("3.0") + "1.4.0" + else + "1.3.0" + end + + platform :ruby do + gem "sqlite3", "~> #{sqlite_version}" + end + + platform :jruby do + gem "activerecord-jdbcmysql-adapter" + gem "jdbc-sqlite3" + end +end diff --git a/sentry-ruby/lib/sentry/sequel.rb b/sentry-ruby/lib/sentry/sequel.rb new file mode 100644 index 000000000..30da7f1dd --- /dev/null +++ b/sentry-ruby/lib/sentry/sequel.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Sentry + module Sequel + OP_NAME = "db.sql.sequel" + SPAN_ORIGIN = "auto.db.sequel" + + # Sequel Database extension module that instruments queries + module DatabaseExtension + def log_connection_yield(sql, conn, args = nil) + return super unless Sentry.initialized? + + Sentry.with_child_span(op: OP_NAME, start_timestamp: Sentry.utc_now.to_f, origin: SPAN_ORIGIN) do |span| + result = super + + if span + span.set_description(sql) + span.set_data(Span::DataConventions::DB_SYSTEM, database_type.to_s) + span.set_data(Span::DataConventions::DB_NAME, opts[:database]) if opts[:database] + span.set_data(Span::DataConventions::SERVER_ADDRESS, opts[:host]) if opts[:host] + span.set_data(Span::DataConventions::SERVER_PORT, opts[:port]) if opts[:port] + end + + result + end + end + end + end + + ::Sequel::Database.register_extension(:sentry, Sentry::Sequel::DatabaseExtension) +end + +Sentry.register_patch(:sequel) do + ::Sequel::Database.extension(:sentry) if defined?(::Sequel::Database) +end diff --git a/sentry-ruby/spec/sentry/sequel_spec.rb b/sentry-ruby/spec/sentry/sequel_spec.rb new file mode 100644 index 000000000..0d7cde1c8 --- /dev/null +++ b/sentry-ruby/spec/sentry/sequel_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "spec_helper" +require "sequel" + +# Load the sequel patch +require "sentry/sequel" + +RSpec.describe Sentry::Sequel do + let(:db) do + if RUBY_ENGINE == "jruby" + Sequel.connect("jdbc:sqlite::memory:") + else + Sequel.sqlite + end + end + + before do + # Create a simple test table + db.create_table :posts do + primary_key :id + String :title + end + + # Trigger Sequel's internal initialization (e.g., SELECT sqlite_version()) + db[:posts].count + end + + after do + db.drop_table?(:posts) + end + + context "with tracing enabled" do + before do + perform_basic_setup do |config| + config.traces_sample_rate = 1.0 + config.enabled_patches << :sequel + end + + # Apply patch to this specific database instance + db.extension(:sentry) + end + + it "records a span for SELECT queries" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + db[:posts].all + + spans = transaction.span_recorder.spans + db_span = spans.find { |span| span.op == "db.sql.sequel" } + + expect(db_span).not_to be_nil + expect(db_span.description).to include("SELECT") + expect(db_span.description).to include("posts") + expect(db_span.origin).to eq("auto.db.sequel") + end + + it "records a span for INSERT queries" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + db[:posts].insert(title: "Hello World") + + spans = transaction.span_recorder.spans + db_span = spans.find { |span| span.op == "db.sql.sequel" && span.description&.include?("INSERT") } + + expect(db_span).not_to be_nil + expect(db_span.description).to include("INSERT") + expect(db_span.description).to include("posts") + end + + it "records a span for UPDATE queries" do + db[:posts].insert(title: "Hello World") + + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + db[:posts].where(title: "Hello World").update(title: "Updated") + + spans = transaction.span_recorder.spans + db_span = spans.find { |span| span.op == "db.sql.sequel" && span.description&.include?("UPDATE") } + + expect(db_span).not_to be_nil + expect(db_span.description).to include("UPDATE") + expect(db_span.description).to include("posts") + end + + it "records a span for DELETE queries" do + db[:posts].insert(title: "Hello World") + + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + db[:posts].where(title: "Hello World").delete + + spans = transaction.span_recorder.spans + db_span = spans.find { |span| span.op == "db.sql.sequel" && span.description&.include?("DELETE") } + + expect(db_span).not_to be_nil + expect(db_span.description).to include("DELETE") + expect(db_span.description).to include("posts") + end + + it "sets span data with database information" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + db[:posts].all + + spans = transaction.span_recorder.spans + db_span = spans.find { |span| span.op == "db.sql.sequel" } + + expect(db_span.data["db.system"]).to eq("sqlite") + end + + it "sets correct timestamps on span" do + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + db[:posts].all + + spans = transaction.span_recorder.spans + db_span = spans.find { |span| span.op == "db.sql.sequel" } + + expect(db_span.start_timestamp).not_to be_nil + expect(db_span.timestamp).not_to be_nil + expect(db_span.start_timestamp).to be < db_span.timestamp + end + end + + context "without active transaction" do + before do + perform_basic_setup do |config| + config.traces_sample_rate = 1.0 + config.enabled_patches << :sequel + end + + db.extension(:sentry) + end + + it "does not create spans when no transaction is active" do + # No transaction started + result = db[:posts].all + + # Query should still work + expect(result).to eq([]) + end + end + + context "when Sentry is not initialized" do + before do + # Don't initialize Sentry + db.extension(:sentry) + end + + it "does not interfere with normal database operations" do + result = db[:posts].insert(title: "Test") + expect(result).to eq(1) + + posts = db[:posts].all + expect(posts.length).to eq(1) + expect(posts.first[:title]).to eq("Test") + end + end +end