Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement RouteSender and Airbrake.inc_request #348

Merged
merged 1 commit into from
Oct 25, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,9 +402,19 @@ Airbrake.configure do |c|
end
```

Note: this feature is not available for the free plan. However, you still can
send code hunks. Once you upgrade to a paid plan, old code hunks that you've
sent while being on the free plan would appear in the dashboard.
#### route_stats_flush_period

By default, it's set to `15`. When `Airbrake.inc_request` is invoked, then
Airbrake Ruby waits 15 seconds trying to collect route stats. After this delay
it sends all route stats in a batch using only one web request. Setting this
value allows speeding up or slowing down this process. Zero value means no
delay.

```ruby
Airbrake.configure do |c|
c.route_stats_flush_period = 0
end
```

### Asynchronous Airbrake options

Expand Down
27 changes: 27 additions & 0 deletions lib/airbrake-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require 'thread'
require 'set'
require 'socket'
require 'time'

require 'airbrake-ruby/version'
require 'airbrake-ruby/config'
Expand Down Expand Up @@ -33,6 +34,7 @@
require 'airbrake-ruby/notifier'
require 'airbrake-ruby/code_hunk'
require 'airbrake-ruby/file_cache'
require 'airbrake-ruby/route_sender'

# This module defines the Airbrake API. The user is meant to interact with
# Airbrake via its public class methods. Before using the library, you must to
Expand Down Expand Up @@ -115,6 +117,9 @@ def configured?

# @macro see_public_api_method
def merge_context(_context); end

# @macro see_public_api_method
def inc_request(method, route, status_code, dur, time); end
end

# A Hash that holds all notifiers. The keys of the Hash are notifier
Expand Down Expand Up @@ -343,5 +348,27 @@ def create_deploy(deploy_params)
def merge_context(context)
@notifiers[:default].merge_context(context)
end

# Increments request count of a certain +route+ that was invoked with
# +method+, and returned +status_code+ at +time+ and took +dur+
# milliseconds.
#
# After a certain amount of time (n seconds) the aggregated route
# information will be sent to Airbrake.
#
# @example
# Airbrake.inc_request('POST', '/thing/:id/create', 200, 123, Time.now)
#
# @param [String] method The HTTP method that was invoked
# @param [String] route The route that was invoked
# @param [Integer] status_code The respose code that the route returned
# @param [Float] dur How much time the processing of the request took in
# milliseconds
# @param [Time] time When the request happened
# @return [void]
# @since v2.13.0
def inc_request(method, route, status_code, dur, time)
@notifiers[:default].inc_request(method, route, status_code, dur, time)
end
end
end
7 changes: 7 additions & 0 deletions lib/airbrake-ruby/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ class Config
# @since v2.5.0
attr_accessor :code_hunks

# @return [Integer] how many seconds to wait before sending collected route
# stats
# @api public
# @since v2.13.0
attr_accessor :route_stats_flush_period

# @param [Hash{Symbol=>Object}] user_config the hash to be used to build the
# config
def initialize(user_config = {})
Expand Down Expand Up @@ -113,6 +119,7 @@ def initialize(user_config = {})
)

self.versions = {}
self.route_stats_flush_period = 15

merge(user_config)
end
Expand Down
7 changes: 7 additions & 0 deletions lib/airbrake-ruby/notifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ def initialize(user_config)

@async_sender = AsyncSender.new(@config)
@sync_sender = SyncSender.new(@config)

@route_sender = RouteSender.new(@config)
end

# @macro see_public_api_method
Expand Down Expand Up @@ -95,6 +97,11 @@ def merge_context(context)
@context.merge!(context)
end

# @macro see_public_api_method
def inc_request(*args)
@route_sender.inc_request(*args)
end

private

def convert_to_exception(ex)
Expand Down
9 changes: 6 additions & 3 deletions lib/airbrake-ruby/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@ module Response
# @param [Net::HTTPResponse] response
# @param [Logger] logger
# @return [Hash{String=>String}] parsed response
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
def self.parse(response, logger)
code = response.code.to_i
body = response.body

begin
case code
when 200
logger.debug("#{LOG_LABEL} #{name} (#{code}): #{body}")
{ response.msg => response.body }
when 201
parsed_body = JSON.parse(body)
logger.debug("#{LOG_LABEL} #{parsed_body}")
logger.debug("#{LOG_LABEL} #{name} (#{code}): #{parsed_body}")
parsed_body
when 400, 401, 403, 420
parsed_body = JSON.parse(body)
Expand All @@ -47,7 +50,7 @@ def self.parse(response, logger)
{ 'error' => ex.inspect }
end
end
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize

def self.truncated_body(body)
if body.nil?
Expand Down
106 changes: 106 additions & 0 deletions lib/airbrake-ruby/route_sender.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
module Airbrake
# RouteSender aggregates information about requests and periodically sends
# collected data to Airbrake.
# @since v2.13.0
class RouteSender
# The key that represents a route.
RouteKey = Struct.new(:method, :route, :statusCode, :time)

# RouteStat holds data that describes a route's performance.
RouteStat = Struct.new(:count, :sum, :sumsq, :min, :max) do
# @param [Integer] count The number of requests
# @param [Float] sum The sum of request duration in milliseconds
# @param [Float] sumsq The squared sum of request duration in milliseconds
# @param [Float] min The minimal request duration
# @param [Float] max The maximum request duration
def initialize(count: 0, sum: 0.0, sumsq: 0.0, min: 0.0, max: 0.0)
super(count, sum, sumsq, min, max)
end
end

# @param [Airbrake::Config] config
def initialize(config)
@config = config
@flush_period = config.route_stats_flush_period
@sender = SyncSender.new(config, :put)
@routes = {}
@thread = nil
@mutex = Mutex.new
end

# @macro see_public_api_method
def inc_request(method, route, status_code, dur, tm)
route = create_route_key(method, route, status_code, tm)

promise = Airbrake::Promise.new

@mutex.synchronize do
@routes[route] ||= RouteStat.new
increment_stats(@routes[route], dur)

if @flush_period > 0
schedule_flush(promise)
else
send(@routes, promise)
end
end

promise
end

private

def create_route_key(method, route, status_code, tm)
# rubocop:disable Style/DateTime
time = DateTime.new(
tm.year, tm.month, tm.day, tm.hour, tm.min, 0, tm.zone || 0
)
# rubocop:enable Style/DateTime
RouteKey.new(method, route, status_code, time.rfc3339)
end

def increment_stats(stat, dur)
stat.count += 1

ms = dur.to_f
stat.sum += ms
stat.sumsq += ms * ms

stat.min = ms if ms < stat.min || stat.min == 0
stat.max = ms if ms > stat.max
end

def schedule_flush(promise)
@thread ||= Thread.new do
sleep(@flush_period)

routes = nil
@mutex.synchronize do
routes = @routes
@routes = {}
@thread = nil
end

send(routes, promise)
end

# Setting a name is needed to test the timer.
# Ruby <=2.2 doesn't support Thread#name, so we have this check.
@thread.name = 'route-stat-thread' if @thread.respond_to?(:name)
end

def send(routes, promise)
if routes.none?
raise "#{self.class.name}##{__method__}: routes cannot be empty. Race?"
end

@config.logger.debug("#{LOG_LABEL} RouteStats#send: #{routes}")

@sender.send(
{ routes: routes.map { |k, v| k.to_h.merge(v.to_h) } },
promise,
URI.join(@config.host, "api/v4/projects/#{@config.project_id}/routes-stats")
)
end
end
end
47 changes: 30 additions & 17 deletions lib/airbrake-ruby/sync_sender.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module Airbrake
# Responsible for sending notices to Airbrake synchronously. Supports proxies.
# Responsible for sending data to Airbrake synchronously via PUT or POST
# methods. Supports proxies.
#
# @see AsyncSender
# @api private
Expand All @@ -9,21 +10,22 @@ class SyncSender
CONTENT_TYPE = 'application/json'.freeze

# @param [Airbrake::Config] config
def initialize(config)
def initialize(config, method = :post)
@config = config
@method = method
@rate_limit_reset = Time.now
end

# Sends a POST request to the given +endpoint+ with the +notice+ payload.
# Sends a POST or PUT request to the given +endpoint+ with the +data+ payload.
#
# @param [Airbrake::Notice] notice
# @param [Airbrake::Notice] endpoint
# @param [#to_json] data
# @param [URI::HTTPS] endpoint
# @return [Hash{String=>String}] the parsed HTTP response
def send(notice, promise, endpoint = @config.endpoint)
def send(data, promise, endpoint = @config.endpoint)
return promise if rate_limited_ip?(promise)

response = nil
req = build_post_request(endpoint, notice)
req = build_request(endpoint, data)

return promise if missing_body?(req, promise)

Expand Down Expand Up @@ -58,16 +60,27 @@ def build_https(uri)
end
end

def build_post_request(uri, notice)
Net::HTTP::Post.new(uri.request_uri).tap do |req|
req.body = notice.to_json
def build_request(uri, data)
req =
if @method == :put
Net::HTTP::Put.new(uri.request_uri)
else
Net::HTTP::Post.new(uri.request_uri)
end

req['Authorization'] = "Bearer #{@config.project_key}"
req['Content-Type'] = CONTENT_TYPE
req['User-Agent'] =
"#{Airbrake::Notice::NOTIFIER[:name]}/#{Airbrake::AIRBRAKE_RUBY_VERSION}" \
" Ruby/#{RUBY_VERSION}"
end
build_request_body(req, data)
end

def build_request_body(req, data)
req.body = data.to_json

req['Authorization'] = "Bearer #{@config.project_key}"
req['Content-Type'] = CONTENT_TYPE
req['User-Agent'] =
"#{Airbrake::Notice::NOTIFIER[:name]}/#{Airbrake::AIRBRAKE_RUBY_VERSION}" \
" Ruby/#{RUBY_VERSION}"

req
end

def proxy_params
Expand All @@ -87,7 +100,7 @@ def missing_body?(req, promise)
missing = req.body.nil?

if missing
reason = "#{LOG_LABEL} notice was not sent because of missing body"
reason = "#{LOG_LABEL} data was not sent because of missing body"
@config.logger.error(reason)
promise.reject(reason)
end
Expand Down
10 changes: 10 additions & 0 deletions spec/airbrake_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,14 @@
described_class.merge_context(foo: 'bar')
end
end

describe ".inc_request" do
it "forwards 'inc_request' to the notifier" do
t = Time.now
expect(default_notifier).to receive(:inc_request).with(
'GET', '/foo', 200, 1000, t
)
described_class.inc_request('GET', '/foo', 200, 1000, t)
end
end
end
4 changes: 2 additions & 2 deletions spec/async_sender_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
sender.close

log = stdout.string.split("\n")
notices_sent = log.grep(/\*\*Airbrake: \{\}/).size
notices_sent = log.grep(/\*\*Airbrake: Airbrake::Response \(201\): \{\}/).size
notices_dropped = log.grep(/\*\*Airbrake:.*not.*delivered/).size
expect(notices_sent).to be >= queue_size
expect(notices_sent + notices_dropped).to eq(notices_count)
Expand Down Expand Up @@ -60,7 +60,7 @@

it "prints the correct number of log messages" do
log = @stderr.string.split("\n")
notices_sent = log.grep(/\*\*Airbrake: \{\}/).size
notices_sent = log.grep(/\*\*Airbrake: Airbrake::Response \(201\): \{\}/).size
notices_dropped = log.grep(/\*\*Airbrake:.*not.*delivered/).size
expect(notices_sent).to be >= @sender.instance_variable_get(:@unsent).max
expect(notices_sent + notices_dropped).to eq(300)
Expand Down
4 changes: 4 additions & 0 deletions spec/config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@
it "doesn't set default whitelist" do
expect(config.whitelist_keys).to be_empty
end

it "sets the default route_stats_flush_period" do
expect(config.route_stats_flush_period).to eq(15)
end
end
end

Expand Down
10 changes: 10 additions & 0 deletions spec/notifier_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -449,5 +449,15 @@
subject.merge_context(apples: 'oranges')
end
end

describe "#inc_request" do
it "forwards 'inc_request' to RouteSender" do
t = Time.now
expect_any_instance_of(Airbrake::RouteSender).to receive(:inc_request).with(
'GET', '/foo', 200, 1000, t
)
subject.inc_request('GET', '/foo', 200, 1000, t)
end
end
end
# rubocop:enable Layout/DotPosition
Loading