airbrake-ruby 4.5.1 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/airbrake-ruby.rb +2 -0
- data/lib/airbrake-ruby/async_sender.rb +21 -83
- data/lib/airbrake-ruby/config.rb +24 -3
- data/lib/airbrake-ruby/file_cache.rb +6 -0
- data/lib/airbrake-ruby/filters/sql_filter.rb +22 -1
- data/lib/airbrake-ruby/performance_breakdown.rb +2 -1
- data/lib/airbrake-ruby/performance_notifier.rb +44 -16
- data/lib/airbrake-ruby/query.rb +2 -1
- data/lib/airbrake-ruby/request.rb +2 -1
- data/lib/airbrake-ruby/thread_pool.rb +128 -0
- data/lib/airbrake-ruby/version.rb +1 -1
- data/spec/async_sender_spec.rb +32 -115
- data/spec/config_spec.rb +45 -0
- data/spec/{file_cache.rb → file_cache_spec.rb} +2 -4
- data/spec/filters/sql_filter_spec.rb +47 -4
- data/spec/filters/thread_filter_spec.rb +2 -2
- data/spec/{notice_notifier_spec → notice_notifier}/options_spec.rb +0 -0
- data/spec/performance_notifier_spec.rb +64 -28
- data/spec/thread_pool_spec.rb +158 -0
- metadata +9 -6
@@ -0,0 +1,128 @@
|
|
1
|
+
module Airbrake
|
2
|
+
# ThreadPool implements a simple thread pool that can configure the number of
|
3
|
+
# worker threads and the size of the queue to process.
|
4
|
+
#
|
5
|
+
# @example
|
6
|
+
# # Initialize a new thread pool with 5 workers and a queue size of 100. Set
|
7
|
+
# # the block to be run concurrently.
|
8
|
+
# thread_pool = ThreadPool.new(
|
9
|
+
# worker_size: 5,
|
10
|
+
# queue_size: 100,
|
11
|
+
# block: proc { |message| print "ECHO: #{message}..."}
|
12
|
+
# )
|
13
|
+
#
|
14
|
+
# # Send work.
|
15
|
+
# 10.times { |i| thread_pool << i }
|
16
|
+
# #=> ECHO: 0...ECHO: 1...ECHO: 2...
|
17
|
+
#
|
18
|
+
# @api private
|
19
|
+
# @since v4.6.1
|
20
|
+
class ThreadPool
|
21
|
+
include Loggable
|
22
|
+
|
23
|
+
# @return [ThreadGroup] the list of workers
|
24
|
+
# @note This is exposed for eaiser unit testing
|
25
|
+
attr_reader :workers
|
26
|
+
|
27
|
+
def initialize(worker_size:, queue_size:, block:)
|
28
|
+
@worker_size = worker_size
|
29
|
+
@queue_size = queue_size
|
30
|
+
@block = block
|
31
|
+
|
32
|
+
@queue = SizedQueue.new(queue_size)
|
33
|
+
@workers = ThreadGroup.new
|
34
|
+
@mutex = Mutex.new
|
35
|
+
@pid = nil
|
36
|
+
@closed = false
|
37
|
+
|
38
|
+
has_workers?
|
39
|
+
end
|
40
|
+
|
41
|
+
# Adds a new message to the thread pool. Rejects messages if the queue is at
|
42
|
+
# its capacity.
|
43
|
+
#
|
44
|
+
# @param [Object] message The message that gets passed to the block
|
45
|
+
# @return [Boolean] true if the message was successfully sent to the pool,
|
46
|
+
# false if the queue is full
|
47
|
+
def <<(message)
|
48
|
+
return false if backlog >= @queue_size
|
49
|
+
@queue << message
|
50
|
+
true
|
51
|
+
end
|
52
|
+
|
53
|
+
# @return [Integer] how big the queue is at the moment
|
54
|
+
def backlog
|
55
|
+
@queue.size
|
56
|
+
end
|
57
|
+
|
58
|
+
# Checks if a thread pool has any workers. A thread pool doesn't have any
|
59
|
+
# workers only in two cases: when it was closed or when all workers
|
60
|
+
# crashed. An *active* thread pool doesn't have any workers only when
|
61
|
+
# something went wrong.
|
62
|
+
#
|
63
|
+
# Workers are expected to crash when you +fork+ the process the workers are
|
64
|
+
# living in. In this case we detect a +fork+ and try to revive them here.
|
65
|
+
#
|
66
|
+
# Another possible scenario that crashes workers is when you close the
|
67
|
+
# instance on +at_exit+, but some other +at_exit+ hook prevents the process
|
68
|
+
# from exiting.
|
69
|
+
#
|
70
|
+
# @return [Boolean] true if an instance wasn't closed, but has no workers
|
71
|
+
# @see https://goo.gl/oydz8h Example of at_exit that prevents exit
|
72
|
+
def has_workers?
|
73
|
+
@mutex.synchronize do
|
74
|
+
return false if @closed
|
75
|
+
|
76
|
+
if @pid != Process.pid && @workers.list.empty?
|
77
|
+
@pid = Process.pid
|
78
|
+
spawn_workers
|
79
|
+
end
|
80
|
+
|
81
|
+
!@closed && @workers.list.any?
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Closes the thread pool making it a no-op (it shut downs all worker
|
86
|
+
# threads). Before closing, waits on all unprocessed tasks to be processed.
|
87
|
+
#
|
88
|
+
# @return [void]
|
89
|
+
# @raise [Airbrake::Error] when invoked more than one time
|
90
|
+
def close
|
91
|
+
threads = @mutex.synchronize do
|
92
|
+
raise Airbrake::Error, 'this thread pool is closed already' if @closed
|
93
|
+
|
94
|
+
unless @queue.empty?
|
95
|
+
msg = "#{LOG_LABEL} waiting to process #{@queue.size} task(s)..."
|
96
|
+
logger.debug(msg + ' (Ctrl-C to abort)')
|
97
|
+
end
|
98
|
+
|
99
|
+
@worker_size.times { @queue << :stop }
|
100
|
+
@closed = true
|
101
|
+
@workers.list.dup
|
102
|
+
end
|
103
|
+
|
104
|
+
threads.each(&:join)
|
105
|
+
logger.debug("#{LOG_LABEL} thread pool closed")
|
106
|
+
end
|
107
|
+
|
108
|
+
def closed?
|
109
|
+
@closed
|
110
|
+
end
|
111
|
+
|
112
|
+
def spawn_workers
|
113
|
+
@worker_size.times { @workers.add(spawn_worker) }
|
114
|
+
@workers.enclose
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def spawn_worker
|
120
|
+
Thread.new do
|
121
|
+
while (message = @queue.pop)
|
122
|
+
break if message == :stop
|
123
|
+
@block.call(message)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
data/spec/async_sender_spec.rb
CHANGED
@@ -8,148 +8,65 @@ RSpec.describe Airbrake::AsyncSender do
|
|
8
8
|
Airbrake::Config.instance = Airbrake::Config.new(
|
9
9
|
project_id: '1',
|
10
10
|
workers: 3,
|
11
|
-
queue_size:
|
11
|
+
queue_size: 10
|
12
12
|
)
|
13
|
-
|
14
|
-
allow(Airbrake::Loggable.instance).to receive(:debug)
|
15
|
-
expect(subject).to have_workers
|
16
13
|
end
|
17
14
|
|
18
15
|
describe "#send" do
|
19
|
-
|
20
|
-
|
21
|
-
subject.send(notice, Airbrake::Promise.new)
|
22
|
-
end
|
23
|
-
subject.close
|
24
|
-
|
25
|
-
expect(a_request(:post, endpoint)).to have_been_made.twice
|
26
|
-
end
|
27
|
-
|
28
|
-
context "when the queue is full" do
|
29
|
-
before do
|
30
|
-
allow(subject.unsent).to receive(:size).and_return(queue_size)
|
31
|
-
end
|
32
|
-
|
33
|
-
it "discards payload" do
|
34
|
-
200.times do
|
35
|
-
subject.send(notice, Airbrake::Promise.new)
|
36
|
-
end
|
16
|
+
context "when sender has the capacity to send" do
|
17
|
+
it "sends notices to Airbrake" do
|
18
|
+
2.times { subject.send(notice, Airbrake::Promise.new) }
|
37
19
|
subject.close
|
38
20
|
|
39
|
-
expect(a_request(:post, endpoint)).
|
21
|
+
expect(a_request(:post, endpoint)).to have_been_made.twice
|
40
22
|
end
|
41
23
|
|
42
|
-
it "
|
43
|
-
expect(Airbrake::Loggable.instance).to receive(:error).with(
|
44
|
-
/reached its capacity/
|
45
|
-
).exactly(15).times
|
46
|
-
|
47
|
-
15.times do
|
48
|
-
subject.send(notice, Airbrake::Promise.new)
|
49
|
-
end
|
50
|
-
subject.close
|
51
|
-
end
|
52
|
-
|
53
|
-
it "returns a rejected promise" do
|
24
|
+
it "returns a resolved promise" do
|
54
25
|
promise = Airbrake::Promise.new
|
55
|
-
|
56
|
-
expect(promise.value).to eq(
|
57
|
-
'error' => "AsyncSender has reached its capacity of #{queue_size}"
|
58
|
-
)
|
59
|
-
end
|
60
|
-
end
|
61
|
-
end
|
62
|
-
|
63
|
-
describe "#close" do
|
64
|
-
context "when there are no unsent notices" do
|
65
|
-
it "joins the spawned thread" do
|
66
|
-
workers = subject.workers.list
|
67
|
-
expect(workers).to all(be_alive)
|
68
|
-
|
26
|
+
subject.send(notice, promise)
|
69
27
|
subject.close
|
70
|
-
|
28
|
+
|
29
|
+
expect(promise).to be_resolved
|
71
30
|
end
|
72
31
|
end
|
73
32
|
|
74
|
-
context "when
|
75
|
-
|
76
|
-
|
77
|
-
|
33
|
+
context "when sender has exceeded the capacity to send" do
|
34
|
+
before do
|
35
|
+
Airbrake::Config.instance = Airbrake::Config.new(
|
36
|
+
project_id: '1',
|
37
|
+
workers: 0,
|
38
|
+
queue_size: 1
|
78
39
|
)
|
79
|
-
expect(Airbrake::Loggable.instance).to receive(:debug).with(/closed/)
|
80
|
-
|
81
|
-
300.times { subject.send(notice, Airbrake::Promise.new) }
|
82
|
-
subject.close
|
83
40
|
end
|
84
41
|
|
85
|
-
it "
|
42
|
+
it "doesn't send the exceeded notices to Airbrake" do
|
43
|
+
15.times { subject.send(notice, Airbrake::Promise.new) }
|
86
44
|
subject.close
|
87
|
-
expect(subject.unsent.size).to be_zero
|
88
|
-
end
|
89
|
-
end
|
90
45
|
|
91
|
-
|
92
|
-
it "doesn't increase the unsent queue size" do
|
93
|
-
begin
|
94
|
-
subject.close
|
95
|
-
rescue Airbrake::Error
|
96
|
-
nil
|
97
|
-
end
|
98
|
-
|
99
|
-
expect(subject.unsent.size).to be_zero
|
46
|
+
expect(a_request(:post, endpoint)).not_to have_been_made
|
100
47
|
end
|
101
48
|
|
102
|
-
it "
|
49
|
+
it "returns a rejected promise" do
|
50
|
+
promise = nil
|
51
|
+
15.times do
|
52
|
+
promise = subject.send(notice, Airbrake::Promise.new)
|
53
|
+
end
|
103
54
|
subject.close
|
104
55
|
|
105
|
-
expect(
|
106
|
-
expect
|
107
|
-
|
56
|
+
expect(promise).to be_rejected
|
57
|
+
expect(promise.value).to eq(
|
58
|
+
'error' => "AsyncSender has reached its capacity of 1"
|
108
59
|
)
|
109
60
|
end
|
110
|
-
end
|
111
61
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
end
|
117
|
-
end
|
118
|
-
end
|
62
|
+
it "logs discarded notice" do
|
63
|
+
expect(Airbrake::Loggable.instance).to receive(:error).with(
|
64
|
+
/reached its capacity/
|
65
|
+
).at_least(:once)
|
119
66
|
|
120
|
-
|
121
|
-
|
122
|
-
subject.workers.list.each do |worker|
|
123
|
-
worker.kill.join
|
67
|
+
15.times { subject.send(notice, Airbrake::Promise.new) }
|
68
|
+
subject.close
|
124
69
|
end
|
125
|
-
expect(subject).not_to have_workers
|
126
|
-
end
|
127
|
-
|
128
|
-
it "returns false when the sender is closed" do
|
129
|
-
subject.close
|
130
|
-
expect(subject).not_to have_workers
|
131
|
-
end
|
132
|
-
|
133
|
-
it "respawns workers on fork()", skip: %w[jruby rbx].include?(RUBY_ENGINE) do
|
134
|
-
pid = fork { expect(subject).to have_workers }
|
135
|
-
Process.wait(pid)
|
136
|
-
subject.close
|
137
|
-
expect(subject).not_to have_workers
|
138
|
-
end
|
139
|
-
end
|
140
|
-
|
141
|
-
describe "#spawn_workers" do
|
142
|
-
it "spawns alive threads in an enclosed ThreadGroup" do
|
143
|
-
expect(subject.workers).to be_a(ThreadGroup)
|
144
|
-
expect(subject.workers.list).to all(be_alive)
|
145
|
-
expect(subject.workers).to be_enclosed
|
146
|
-
|
147
|
-
subject.close
|
148
|
-
end
|
149
|
-
|
150
|
-
it "spawns exactly config.workers workers" do
|
151
|
-
expect(subject.workers.list.size).to eq(Airbrake::Config.instance.workers)
|
152
|
-
subject.close
|
153
70
|
end
|
154
71
|
end
|
155
72
|
end
|
data/spec/config_spec.rb
CHANGED
@@ -21,6 +21,7 @@ RSpec.describe Airbrake::Config do
|
|
21
21
|
its(:whitelist_keys) { is_expected.to be_empty }
|
22
22
|
its(:performance_stats) { is_expected.to eq(true) }
|
23
23
|
its(:performance_stats_flush_period) { is_expected.to eq(15) }
|
24
|
+
its(:query_stats) { is_expected.to eq(false) }
|
24
25
|
|
25
26
|
describe "#new" do
|
26
27
|
context "when user config is passed" do
|
@@ -106,4 +107,48 @@ RSpec.describe Airbrake::Config do
|
|
106
107
|
its(:check_configuration) { is_expected.not_to be_rejected }
|
107
108
|
end
|
108
109
|
end
|
110
|
+
|
111
|
+
describe "#check_performance_options" do
|
112
|
+
it "returns a promise" do
|
113
|
+
resource = Airbrake::Query.new(
|
114
|
+
method: '', route: '', query: '', start_time: Time.now
|
115
|
+
)
|
116
|
+
expect(subject.check_performance_options(resource))
|
117
|
+
.to be_an(Airbrake::Promise)
|
118
|
+
end
|
119
|
+
|
120
|
+
context "when performance stats are disabled" do
|
121
|
+
before { subject.performance_stats = false }
|
122
|
+
|
123
|
+
let(:resource) do
|
124
|
+
Airbrake::Request.new(
|
125
|
+
method: 'GET', route: '/foo', status_code: 200, start_time: Time.new
|
126
|
+
)
|
127
|
+
end
|
128
|
+
|
129
|
+
it "returns a rejected promise" do
|
130
|
+
promise = subject.check_performance_options(resource)
|
131
|
+
expect(promise.value).to eq(
|
132
|
+
'error' => "The Performance Stats feature is disabled"
|
133
|
+
)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
context "when query stats are disabled" do
|
138
|
+
before { subject.query_stats = false }
|
139
|
+
|
140
|
+
let(:resource) do
|
141
|
+
Airbrake::Query.new(
|
142
|
+
method: 'GET', route: '/foo', query: '', start_time: Time.new
|
143
|
+
)
|
144
|
+
end
|
145
|
+
|
146
|
+
it "returns a rejected promise" do
|
147
|
+
promise = subject.check_performance_options(resource)
|
148
|
+
expect(promise.value).to eq(
|
149
|
+
'error' => "The Query Stats feature is disabled"
|
150
|
+
)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
109
154
|
end
|
@@ -1,8 +1,6 @@
|
|
1
1
|
RSpec.describe Airbrake::FileCache do
|
2
|
-
|
3
|
-
|
4
|
-
expect(described_class).to be_empty
|
5
|
-
end
|
2
|
+
before { described_class.reset }
|
3
|
+
after { described_class.reset }
|
6
4
|
|
7
5
|
describe ".[]=" do
|
8
6
|
context "when cache limit isn't reached" do
|
@@ -10,6 +10,18 @@ RSpec.describe Airbrake::Filters::SqlFilter do
|
|
10
10
|
end
|
11
11
|
end
|
12
12
|
|
13
|
+
shared_examples "query blacklisting" do |query, opts|
|
14
|
+
it "ignores '#{query}'" do
|
15
|
+
filter = described_class.new('postgres')
|
16
|
+
q = Airbrake::Query.new(
|
17
|
+
query: query, method: 'GET', route: '/', start_time: Time.now
|
18
|
+
)
|
19
|
+
filter.call(q)
|
20
|
+
|
21
|
+
expect(q.ignored?).to eq(opts[:should_ignore])
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
13
25
|
ALL_DIALECTS = %i[mysql postgres sqlite cassandra oracle].freeze
|
14
26
|
|
15
27
|
# rubocop:disable Metrics/LineLength
|
@@ -60,7 +72,11 @@ RSpec.describe Airbrake::Filters::SqlFilter do
|
|
60
72
|
dialects: %i[postgres]
|
61
73
|
}, {
|
62
74
|
input: "INSERT INTO `X` values(\"test\",0, 1 , 2, 'test')",
|
63
|
-
output: "INSERT INTO `X` values(
|
75
|
+
output: "INSERT INTO `X` values(?)",
|
76
|
+
dialects: %i[mysql]
|
77
|
+
}, {
|
78
|
+
input: "INSERT INTO `X` values(\"test\",0, 1 , 2, 'test')",
|
79
|
+
output: "INSERT INTO `X` values(?)",
|
64
80
|
dialects: %i[mysql]
|
65
81
|
}, {
|
66
82
|
input: "SELECT c11.col1, c22.col2 FROM table c11, table c22 WHERE value='nothing'",
|
@@ -68,7 +84,7 @@ RSpec.describe Airbrake::Filters::SqlFilter do
|
|
68
84
|
dialects: ALL_DIALECTS
|
69
85
|
}, {
|
70
86
|
input: "INSERT INTO X VALUES(1, 23456, 123.456, 99+100)",
|
71
|
-
output: "INSERT INTO X VALUES(
|
87
|
+
output: "INSERT INTO X VALUES(?)",
|
72
88
|
dialects: ALL_DIALECTS
|
73
89
|
}, {
|
74
90
|
input: "SELECT * FROM table WHERE name=\"foo\" AND value=\"don't\"",
|
@@ -117,7 +133,7 @@ RSpec.describe Airbrake::Filters::SqlFilter do
|
|
117
133
|
dialects: ALL_DIALECTS
|
118
134
|
}, {
|
119
135
|
input: "INSERT INTO X values('', 'a''b c',0, 1 , 'd''e f''s h')",
|
120
|
-
output: "INSERT INTO X values(
|
136
|
+
output: "INSERT INTO X values(?)",
|
121
137
|
dialects: ALL_DIALECTS
|
122
138
|
}, {
|
123
139
|
input: "SELECT * FROM t WHERE -- '\n bar='baz' -- '",
|
@@ -153,7 +169,7 @@ RSpec.describe Airbrake::Filters::SqlFilter do
|
|
153
169
|
dialects: %i[postgres]
|
154
170
|
}, {
|
155
171
|
input: "INSERT INTO \"foo\" (\"bar\", \"baz\", \"qux\") VALUES ($1, $2, $3) RETURNING \"id\"",
|
156
|
-
output: "INSERT INTO \"foo\" (
|
172
|
+
output: "INSERT INTO \"foo\" (?) RETURNING \"id\"",
|
157
173
|
dialects: %i[postgres]
|
158
174
|
}, {
|
159
175
|
input: "select * from foo where bar = 'some\\tthing' and baz = 10",
|
@@ -216,4 +232,31 @@ RSpec.describe Airbrake::Filters::SqlFilter do
|
|
216
232
|
include_examples 'query filtering', test
|
217
233
|
end
|
218
234
|
# rubocop:enable Metrics/LineLength
|
235
|
+
|
236
|
+
[
|
237
|
+
'COMMIT',
|
238
|
+
'commit',
|
239
|
+
'BEGIN',
|
240
|
+
'begin',
|
241
|
+
'SET time zone ?',
|
242
|
+
'set time zone ?',
|
243
|
+
'SHOW max_identifier_length',
|
244
|
+
'show max_identifier_length',
|
245
|
+
|
246
|
+
'WITH pk_constraint AS ( SELECT conrelid, unnest(conkey) AS connum ' \
|
247
|
+
'FROM pg_constraint WHERE contype = ? AND conrelid = ?::regclass ), ' \
|
248
|
+
'cons AS ( SELECT conrelid, connum, row_number() OVER() AS rownum FROM ' \
|
249
|
+
'pk_constraint ) SELECT attr.attname FROM pg_attribute attr INNER JOIN ' \
|
250
|
+
'cons ON attr.attrelid = cons.conrelid AND attr.attnum = cons.connum ' \
|
251
|
+
'ORDER BY cons.rownum'
|
252
|
+
|
253
|
+
].each do |query|
|
254
|
+
include_examples 'query blacklisting', query, should_ignore: true
|
255
|
+
end
|
256
|
+
|
257
|
+
[
|
258
|
+
'UPDATE "users" SET "last_sign_in_at" = ? WHERE "users"."id" = ?'
|
259
|
+
].each do |query|
|
260
|
+
include_examples 'query blacklisting', query, should_ignore: false
|
261
|
+
end
|
219
262
|
end
|