Skip to content

Commit 9ce51e3

Browse files
authored
Automatic collection of Sequel queries (#2814)
* feat(sequel): add Sequel database extension for query instrumentation * tests(rails): add integration tests for Sequel/Rails tracing * Update CHANGELOG.md
1 parent 7d10c02 commit 9ce51e3

File tree

8 files changed

+494
-0
lines changed

8 files changed

+494
-0
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
## Unreleased
22

3+
### Features
4+
5+
- Support for tracing `Sequel` queries ([#2814](https://github.com/getsentry/sentry-ruby/pull/2814))
6+
7+
```ruby
8+
require "sentry"
9+
require "sentry/sequel"
10+
11+
Sentry.init do |config|
12+
config.enabled_patches << :sequel
13+
end
14+
15+
DB = Sequel.sqlite
16+
DB.extension(:sentry)
17+
```
18+
319
### Bug Fixes
420

521
- Handle empty frames case gracefully with local vars ([#2807](https://github.com/getsentry/sentry-ruby/pull/2807))

sentry-rails/Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,5 @@ gem "benchmark-ips"
6262
gem "benchmark_driver"
6363
gem "benchmark-ipsa"
6464
gem "benchmark-memory"
65+
66+
gem "sequel"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
require "sequel"
4+
require "sentry/sequel"
5+
6+
class SequelUsersController < ActionController::Base
7+
def index
8+
users = SEQUEL_DB[:users].all
9+
render json: users
10+
end
11+
12+
def create
13+
id = SEQUEL_DB[:users].insert(name: params[:name], email: params[:email])
14+
render json: { id: id, name: params[:name], email: params[:email] }, status: :created
15+
end
16+
17+
def show
18+
user = SEQUEL_DB[:users].where(id: params[:id]).first
19+
if user
20+
render json: user
21+
else
22+
render json: { error: "Not found" }, status: :not_found
23+
end
24+
end
25+
26+
def update
27+
SEQUEL_DB[:users].where(id: params[:id]).update(name: params[:name])
28+
user = SEQUEL_DB[:users].where(id: params[:id]).first
29+
render json: user
30+
end
31+
32+
def destroy
33+
SEQUEL_DB[:users].where(id: params[:id]).delete
34+
head :no_content
35+
end
36+
37+
def exception
38+
SEQUEL_DB[:users].all
39+
raise "Something went wrong!"
40+
end
41+
end

sentry-rails/spec/dummy/test_rails_app/config/application.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ def configure
122122
end
123123
end
124124

125+
# Sequel-specific routes for testing Sequel tracing
126+
scope "/sequel" do
127+
get "/users", to: "sequel_users#index"
128+
post "/users", to: "sequel_users#create"
129+
get "/users/:id", to: "sequel_users#show"
130+
put "/users/:id", to: "sequel_users#update"
131+
delete "/users/:id", to: "sequel_users#destroy"
132+
get "/exception", to: "sequel_users#exception"
133+
end
134+
125135
get "500", to: "hello#reporting"
126136

127137
root to: "hello#world"
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# frozen_string_literal: true
2+
3+
begin
4+
require "simplecov"
5+
SimpleCov.command_name "SequelTracing"
6+
rescue LoadError
7+
end
8+
9+
require "sequel"
10+
require "sentry/sequel"
11+
12+
require_relative "../dummy/test_rails_app/app/controllers/sequel_users_controller"
13+
14+
RSpec.describe "Sequel Tracing with Rails", type: :request do
15+
before(:all) do
16+
if RUBY_ENGINE == "jruby"
17+
SEQUEL_DB = Sequel.connect("jdbc:sqlite::memory:")
18+
else
19+
SEQUEL_DB = Sequel.sqlite
20+
end
21+
22+
SEQUEL_DB.create_table :users do
23+
primary_key :id
24+
String :name
25+
String :email
26+
end
27+
28+
SEQUEL_DB[:users].count
29+
SEQUEL_DB.extension(:sentry)
30+
end
31+
32+
after(:all) do
33+
SEQUEL_DB.drop_table?(:users)
34+
Object.send(:remove_const, :SEQUEL_DB)
35+
end
36+
37+
before do
38+
make_basic_app do |config, app|
39+
config.traces_sample_rate = 1.0
40+
config.enabled_patches << :sequel
41+
end
42+
end
43+
44+
let(:transport) { Sentry.get_current_client.transport }
45+
46+
describe "SELECT queries" do
47+
it "creates a transaction with Sequel span for index action" do
48+
get "/sequel/users"
49+
50+
expect(response).to have_http_status(:ok)
51+
expect(transport.events.count).to eq(1)
52+
53+
transaction = transport.events.last.to_h
54+
55+
expect(transaction[:type]).to eq("transaction")
56+
expect(transaction.dig(:contexts, :trace, :op)).to eq("http.server")
57+
58+
sequel_span = transaction[:spans].find { |span| span[:op] == "db.sql.sequel" }
59+
60+
expect(sequel_span).not_to be_nil
61+
expect(sequel_span[:description]).to include("SELECT")
62+
expect(sequel_span[:description]).to include("users")
63+
expect(sequel_span[:origin]).to eq("auto.db.sequel")
64+
expect(sequel_span[:data]["db.system"]).to eq("sqlite")
65+
end
66+
end
67+
68+
describe "INSERT queries" do
69+
it "creates a transaction with Sequel span for create action" do
70+
post "/sequel/users", params: { name: "John Doe", email: "[email protected]" }
71+
72+
expect(response).to have_http_status(:created)
73+
74+
transaction = transport.events.last.to_h
75+
expect(transaction[:type]).to eq("transaction")
76+
77+
insert_span = transaction[:spans].find do |span|
78+
span[:op] == "db.sql.sequel" && span[:description]&.include?("INSERT")
79+
end
80+
81+
expect(insert_span).not_to be_nil
82+
expect(insert_span[:description]).to include("INSERT")
83+
expect(insert_span[:description]).to include("users")
84+
expect(insert_span[:origin]).to eq("auto.db.sequel")
85+
end
86+
end
87+
88+
describe "UPDATE queries" do
89+
it "creates a transaction with Sequel span for update action" do
90+
SEQUEL_DB[:users].insert(name: "Jane Doe", email: "[email protected]")
91+
92+
put "/sequel/users/1", params: { name: "Jane Smith" }
93+
94+
expect(response).to have_http_status(:ok)
95+
96+
transaction = transport.events.last.to_h
97+
98+
update_span = transaction[:spans].find do |span|
99+
span[:op] == "db.sql.sequel" && span[:description]&.include?("UPDATE")
100+
end
101+
102+
expect(update_span).not_to be_nil
103+
expect(update_span[:description]).to include("UPDATE")
104+
expect(update_span[:description]).to include("users")
105+
end
106+
end
107+
108+
describe "DELETE queries" do
109+
it "creates a transaction with Sequel span for delete action" do
110+
SEQUEL_DB[:users].insert(name: "Delete Me", email: "[email protected]")
111+
112+
delete "/sequel/users/1"
113+
114+
expect(response).to have_http_status(:no_content)
115+
116+
transaction = transport.events.last.to_h
117+
118+
delete_span = transaction[:spans].find do |span|
119+
span[:op] == "db.sql.sequel" && span[:description]&.include?("DELETE")
120+
end
121+
122+
expect(delete_span).not_to be_nil
123+
expect(delete_span[:description]).to include("DELETE")
124+
expect(delete_span[:description]).to include("users")
125+
end
126+
end
127+
128+
describe "exception handling" do
129+
it "creates both error event and transaction with Sequel span" do
130+
get "/sequel/exception"
131+
132+
expect(response).to have_http_status(:internal_server_error)
133+
134+
expect(transport.events.count).to eq(2)
135+
136+
error_event = transport.events.first.to_h
137+
transaction = transport.events.last.to_h
138+
139+
expect(error_event[:exception][:values].first[:type]).to eq("RuntimeError")
140+
expect(error_event[:exception][:values].first[:value]).to include("Something went wrong!")
141+
142+
sequel_span = transaction[:spans].find { |span| span[:op] == "db.sql.sequel" }
143+
expect(sequel_span).not_to be_nil
144+
expect(sequel_span[:description]).to include("SELECT")
145+
146+
expect(error_event.dig(:contexts, :trace, :trace_id)).to eq(
147+
transaction.dig(:contexts, :trace, :trace_id)
148+
)
149+
end
150+
end
151+
152+
describe "span timing" do
153+
it "records proper start and end timestamps" do
154+
get "/sequel/users"
155+
156+
transaction = transport.events.last.to_h
157+
sequel_span = transaction[:spans].find { |span| span[:op] == "db.sql.sequel" }
158+
159+
expect(sequel_span[:start_timestamp]).not_to be_nil
160+
expect(sequel_span[:timestamp]).not_to be_nil
161+
expect(sequel_span[:start_timestamp]).to be < sequel_span[:timestamp]
162+
end
163+
end
164+
165+
describe "Sequel and ActiveRecord coexistence" do
166+
it "records spans for both database systems in the same application" do
167+
Post.create!(title: "Test Post")
168+
169+
SEQUEL_DB[:users].insert(name: "Sequel User", email: "[email protected]")
170+
171+
transport.events.clear
172+
173+
get "/sequel/users"
174+
175+
expect(response).to have_http_status(:ok)
176+
177+
transaction = transport.events.last.to_h
178+
sequel_spans = transaction[:spans].select { |span| span[:op] == "db.sql.sequel" }
179+
180+
expect(sequel_spans.length).to be >= 1
181+
expect(sequel_spans.first[:data]["db.system"]).to eq("sqlite")
182+
end
183+
184+
it "records ActiveRecord spans separately from Sequel spans" do
185+
transport.events.clear
186+
187+
get "/posts"
188+
189+
expect(response).to have_http_status(:internal_server_error) # raises "foo" in PostsController#index
190+
191+
transaction = transport.events.last.to_h
192+
193+
ar_spans = transaction[:spans].select { |span| span[:op] == "db.sql.active_record" }
194+
sequel_spans = transaction[:spans].select { |span| span[:op] == "db.sql.sequel" }
195+
196+
expect(ar_spans.length).to be >= 1
197+
expect(sequel_spans.length).to eq(0)
198+
end
199+
end
200+
end

sentry-ruby/Gemfile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ eval_gemfile "../Gemfile.dev"
77

88
gem "sentry-ruby", path: "./"
99

10+
ruby_version = Gem::Version.new(RUBY_VERSION)
11+
1012
rack_version = ENV["RACK_VERSION"]
1113
rack_version = "3.0.0" if rack_version.nil?
14+
1215
gem "rack", "~> #{Gem::Version.new(rack_version)}" unless rack_version == "0"
1316

1417
redis_rb_version = ENV.fetch("REDIS_RB_VERSION", "5.0")
@@ -32,3 +35,24 @@ gem "webrick"
3235
gem "faraday"
3336
gem "excon"
3437
gem "webmock"
38+
39+
group :sequel do
40+
gem "sequel"
41+
42+
sqlite_version = if ruby_version >= Gem::Version.new("3.2")
43+
"2.1.0"
44+
elsif ruby_version >= Gem::Version.new("3.0")
45+
"1.4.0"
46+
else
47+
"1.3.0"
48+
end
49+
50+
platform :ruby do
51+
gem "sqlite3", "~> #{sqlite_version}"
52+
end
53+
54+
platform :jruby do
55+
gem "activerecord-jdbcmysql-adapter"
56+
gem "jdbc-sqlite3"
57+
end
58+
end

sentry-ruby/lib/sentry/sequel.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
module Sequel
5+
OP_NAME = "db.sql.sequel"
6+
SPAN_ORIGIN = "auto.db.sequel"
7+
8+
# Sequel Database extension module that instruments queries
9+
module DatabaseExtension
10+
def log_connection_yield(sql, conn, args = nil)
11+
return super unless Sentry.initialized?
12+
13+
Sentry.with_child_span(op: OP_NAME, start_timestamp: Sentry.utc_now.to_f, origin: SPAN_ORIGIN) do |span|
14+
result = super
15+
16+
if span
17+
span.set_description(sql)
18+
span.set_data(Span::DataConventions::DB_SYSTEM, database_type.to_s)
19+
span.set_data(Span::DataConventions::DB_NAME, opts[:database]) if opts[:database]
20+
span.set_data(Span::DataConventions::SERVER_ADDRESS, opts[:host]) if opts[:host]
21+
span.set_data(Span::DataConventions::SERVER_PORT, opts[:port]) if opts[:port]
22+
end
23+
24+
result
25+
end
26+
end
27+
end
28+
end
29+
30+
::Sequel::Database.register_extension(:sentry, Sentry::Sequel::DatabaseExtension)
31+
end
32+
33+
Sentry.register_patch(:sequel) do
34+
::Sequel::Database.extension(:sentry) if defined?(::Sequel::Database)
35+
end

0 commit comments

Comments
 (0)