Skip to content

Support category-based rate limiting #1336

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

Merged
merged 11 commits into from
Apr 10, 2021
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Sentry.init do |config|
config.breadcrumbs_logger = [:active_support_logger]
config.background_worker_threads = 0
config.send_default_pii = true
config.traces_sample_rate = 1.0 # set a float between 0.0 and 1.0 to enable performance monitoring
config.dsn = 'https://2fb45f003d054a7ea47feb45898f7649@o447951.ingest.sentry.io/5434472'
Expand Down
4 changes: 4 additions & 0 deletions sentry-ruby/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- Support category-based rate limiting [#1336](https://github.com/getsentry/sentry-ruby/pull/1336)

### Refactorings

- Let Transaction constructor take an optional hub argument [#1384](https://github.com/getsentry/sentry-ruby/pull/1384)
Expand Down
1 change: 1 addition & 0 deletions sentry-ruby/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ gem "i18n", "<= 1.8.7"
gem "rake", "~> 12.0"
gem "rspec", "~> 3.0"
gem "rspec-retry"
gem "timecop"
gem "codecov", "0.2.12"

gem "pry"
Expand Down
52 changes: 49 additions & 3 deletions sentry-ruby/lib/sentry/transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,33 @@ class Transport
include LoggingHelper

attr_accessor :configuration
attr_reader :logger
attr_reader :logger, :rate_limits

def initialize(configuration)
@configuration = configuration
@logger = configuration.logger
@transport_configuration = configuration.transport
@dsn = configuration.dsn
@rate_limits = {}
end

def send_data(data, options = {})
raise NotImplementedError
end

def send_event(event)
event_hash = event.to_hash
item_type = get_item_type(event_hash)

unless configuration.sending_allowed?
log_debug("Event not sent: #{configuration.error_messages}")
log_debug("Envelope [#{item_type}] not sent: #{configuration.error_messages}")

return
end

if is_rate_limited?(item_type)
log_info("Envelope [#{item_type}] not sent: rate limiting")

return
end

Expand All @@ -37,6 +48,35 @@ def send_event(event)
event
end

def is_rate_limited?(item_type)
# check category-specific limit
category_delay =
case item_type
when "transaction"
@rate_limits["transaction"]
else
@rate_limits["error"]
end

# check universal limit if not category limit
universal_delay = @rate_limits[nil]

delay =
if category_delay && universal_delay
if category_delay > universal_delay
category_delay
else
universal_delay
end
elsif category_delay
category_delay
else
universal_delay
end

!!delay && delay > Time.now
end

def generate_auth_header
now = Sentry.utc_now.to_i
fields = {
Expand All @@ -54,7 +94,7 @@ def encode(event)
event_hash = event.to_hash

event_id = event_hash[:event_id] || event_hash["event_id"]
item_type = event_hash[:type] || event_hash["type"] || "event"
item_type = get_item_type(event_hash)

envelope = <<~ENVELOPE
{"event_id":"#{event_id}","dsn":"#{configuration.dsn.to_s}","sdk":#{Sentry.sdk_meta.to_json},"sent_at":"#{Sentry.utc_now.iso8601}"}
Expand All @@ -66,6 +106,12 @@ def encode(event)

envelope
end

private

def get_item_type(event_hash)
event_hash[:type] || event_hash["type"] || "event"
end
end
end

Expand Down
76 changes: 73 additions & 3 deletions sentry-ruby/lib/sentry/transport/http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ class HTTPTransport < Transport
GZIP_THRESHOLD = 1024 * 30
CONTENT_TYPE = 'application/x-sentry-envelope'

DEFAULT_DELAY = 60
RETRY_AFTER_HEADER = "retry-after"
RATE_LIMIT_HEADER = "x-sentry-rate-limits"

attr_reader :conn, :adapter

def initialize(*args)
Expand All @@ -24,25 +28,91 @@ def send_data(data)
encoding = GZIP_ENCODING
end

conn.post @endpoint do |req|
response = conn.post @endpoint do |req|
req.headers['Content-Type'] = CONTENT_TYPE
req.headers['Content-Encoding'] = encoding
req.headers['X-Sentry-Auth'] = generate_auth_header
req.body = data
end

if has_rate_limited_header?(response.headers)
handle_rate_limited_response(response.headers)
end
rescue Faraday::Error => e
error_info = e.message

if e.response
error_info += "\nbody: #{e.response[:body]}"
error_info += " Error in headers is: #{e.response[:headers]['x-sentry-error']}" if e.response[:headers]['x-sentry-error']
if e.response[:status] == 429
handle_rate_limited_response(e.response[:headers])
else
error_info += "\nbody: #{e.response[:body]}"
error_info += " Error in headers is: #{e.response[:headers]['x-sentry-error']}" if e.response[:headers]['x-sentry-error']
end
end

raise Sentry::ExternalError, error_info
end

private

def has_rate_limited_header?(headers)
headers[RETRY_AFTER_HEADER] || headers[RATE_LIMIT_HEADER]
end

def handle_rate_limited_response(headers)
rate_limits =
if rate_limits = headers[RATE_LIMIT_HEADER]
parse_rate_limit_header(rate_limits)
elsif retry_after = headers[RETRY_AFTER_HEADER]
# although Sentry doesn't send a date string back
# based on HTTP specification, this could be a date string (instead of an integer)
retry_after = retry_after.to_i
retry_after = DEFAULT_DELAY if retry_after == 0

{ nil => Time.now + retry_after }
else
{ nil => Time.now + DEFAULT_DELAY }
end

rate_limits.each do |category, limit|
if current_limit = @rate_limits[category]
if current_limit < limit
@rate_limits[category] = limit
end
else
@rate_limits[category] = limit
end
end
end

def parse_rate_limit_header(rate_limit_header)
time = Time.now

result = {}

limits = rate_limit_header.split(",")
limits.each do |limit|
next if limit.nil? || limit.empty?

begin
retry_after, categories = limit.strip.split(":").first(2)
retry_after = time + retry_after.to_i
categories = categories.split(";")

if categories.empty?
result[nil] = retry_after
else
categories.each do |category|
result[category] = retry_after
end
end
rescue StandardError
end
end

result
end

def should_compress?(data)
@transport_configuration.encoding == GZIP_ENCODING && data.bytesize >= GZIP_THRESHOLD
end
Expand Down
2 changes: 1 addition & 1 deletion sentry-ruby/spec/sentry/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

context "when traces_sample_rate == 0.0" do
it "returns false" do
subject.traces_sample_rate = 0.0
subject.traces_sample_rate = 0

expect(subject.tracing_enabled?).to eq(false)
end
Expand Down
1 change: 1 addition & 0 deletions sentry-ruby/spec/sentry/hub_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
end
end
end

describe '#capture_message' do
let(:message) { "Test message" }

Expand Down
12 changes: 4 additions & 8 deletions sentry-ruby/spec/sentry/transaction_spec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
require "spec_helper"

RSpec.describe Sentry::Transaction do
before do
perform_basic_setup
end

subject do
described_class.new(
op: "sql.query",
Expand All @@ -15,10 +19,6 @@
describe ".from_sentry_trace" do
let(:sentry_trace) { subject.to_sentry_trace }

before do
perform_basic_setup
end

let(:configuration) do
Sentry.configuration
end
Expand Down Expand Up @@ -302,10 +302,6 @@
end

describe "#finish" do
before do
perform_basic_setup
end

let(:events) do
Sentry.get_current_client.transport.events
end
Expand Down
Loading