diff --git a/Gemfile b/Gemfile index cb91af18..f87a3bce 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,7 @@ group :test do gem "minitest" gem "minitest-proveit" gem "ruby-ntlm" + gem "sentry-ruby" if RUBY_VERSION >= "2.4" gem "spy" gem "webmock" gem "websocket-driver" diff --git a/integration_tests/sentry_test.rb b/integration_tests/sentry_test.rb new file mode 100644 index 00000000..3b49e07a --- /dev/null +++ b/integration_tests/sentry_test.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "logger" +require "stringio" +require "test_helper" +require "support/http_helpers" +begin + require "httpx/adapters/sentry" +rescue LoadError +end + +class SentryTest < Minitest::Test + include HTTPHelpers + + DUMMY_DSN = "http://12345:67890@sentry.localdomain/sentry/42" + + def test_sentry_send_yes_pii + before_pii = Sentry.configuration.send_default_pii + begin + Sentry.configuration.send_default_pii = true + + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + uri = build_uri("/get") + + response = HTTPX.get(uri, params: { "foo" => "bar" }) + + verify_status(response, 200) + verify_spans(transaction, response, description: "GET #{uri}?foo=bar") + ensure + Sentry.configuration.send_default_pii = before_pii + end + end + + def test_sentry_send_no_pii + before_pii = Sentry.configuration.send_default_pii + begin + Sentry.configuration.send_default_pii = false + + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + uri = build_uri("/get") + + response = HTTPX.get(uri, params: { "foo" => "bar" }) + + verify_status(response, 200) + verify_spans(transaction, response, description: "GET #{uri}") + ensure + Sentry.configuration.send_default_pii = before_pii + end + end + + def test_sentry_post_request + before_pii = Sentry.configuration.send_default_pii + begin + Sentry.configuration.send_default_pii = true + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + response = HTTPX.post(build_uri("/post"), form: { foo: "bar" }) + verify_status(response, 200) + verify_spans(transaction, response, verb: "POST") + ensure + Sentry.configuration.send_default_pii = before_pii + end + end + + def test_sentry_multiple_requests + transaction = Sentry.start_transaction + Sentry.get_current_scope.set_span(transaction) + + responses = HTTPX.get(build_uri("/status/200"), build_uri("/status/404")) + verify_status(responses[0], 200) + verify_status(responses[1], 404) + verify_spans(transaction, *responses) + end + + private + + def verify_spans(transaction, *responses, verb: nil, description: nil) + assert transaction.span_recorder.spans.count == responses.size + 1 + assert transaction.span_recorder.spans[0] == transaction + + response_spans = transaction.span_recorder.spans[1..-1] + + responses.each_with_index do |response, idx| + request_span = response_spans[idx] + assert request_span.op == "httpx.client" + assert !request_span.start_timestamp.nil? + assert !request_span.timestamp.nil? + assert request_span.start_timestamp != request_span.timestamp + assert request_span.description == (description || "#{verb || "GET"} #{response.uri}") + assert request_span.data == { status: response.status } + end + end + + def setup + super + + mock_io = StringIO.new + mock_logger = Logger.new(mock_io) + + Sentry.init do |config| + config.traces_sample_rate = 1.0 + config.logger = mock_logger + config.dsn = DUMMY_DSN + config.transport.transport_class = Sentry::DummyTransport + # so the events will be sent synchronously for testing + config.background_worker_threads = 0 + end + end + + def origin + "https://#{httpbin}" + end +end if RUBY_VERSION >= "2.4.0" diff --git a/lib/httpx/adapters/sentry.rb b/lib/httpx/adapters/sentry.rb new file mode 100644 index 00000000..3f73548f --- /dev/null +++ b/lib/httpx/adapters/sentry.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "sentry-ruby" + +module HTTPX::Plugins + module Sentry + module Tracer + module_function + + def call(request) + sentry_span = start_sentry_span + + return unless sentry_span + + set_sentry_trace_header(request, sentry_span) + + request.on(:response, &method(:finish_sentry_span).curry(3)[sentry_span, request]) + end + + def start_sentry_span + return unless ::Sentry.initialized? && (span = ::Sentry.get_current_scope.get_span) + return if span.sampled == false + + span.start_child(op: "httpx.client", start_timestamp: ::Sentry.utc_now.to_f) + end + + def set_sentry_trace_header(request, sentry_span) + return unless sentry_span + + trace = ::Sentry.get_current_client.generate_sentry_trace(sentry_span) + request.headers[::Sentry::SENTRY_TRACE_HEADER_NAME] = trace if trace + end + + def finish_sentry_span(span, request, response) + return unless ::Sentry.initialized? + + record_sentry_breadcrumb(request, response) + record_sentry_span(request, response, span) + end + + def record_sentry_breadcrumb(req, res) + return unless ::Sentry.configuration.breadcrumbs_logger.include?(:http_logger) + + request_info = extract_request_info(req) + + data = if response.is_a?(HTTPX::ErrorResponse) + { error: res.message, **request_info } + else + { status: res.status, **request_info } + end + + crumb = ::Sentry::Breadcrumb.new( + level: :info, + category: "httpx", + type: :info, + data: data + ) + ::Sentry.add_breadcrumb(crumb) + end + + def record_sentry_span(req, res, sentry_span) + return unless sentry_span + + request_info = extract_request_info(req) + sentry_span.set_description("#{request_info[:method]} #{request_info[:url]}") + sentry_span.set_data(:status, res.status) + sentry_span.set_timestamp(::Sentry.utc_now.to_f) + end + + def extract_request_info(req) + uri = req.uri + + result = { + method: req.verb.to_s.upcase, + } + + if ::Sentry.configuration.send_default_pii + uri += "?#{req.query}" unless req.query.empty? + result[:body] = req.body.to_s unless req.body.empty? || req.body.unbounded_body? + end + + result[:url] = uri.to_s + + result + end + end + + module ConnectionMethods + def send(request) + Tracer.call(request) + super + end + end + end +end + +Sentry.register_patch do + sentry_session = ::HTTPX.plugin(HTTPX::Plugins::Sentry) + + HTTPX.send(:remove_const, :Session) + HTTPX.send(:const_set, :Session, sentry_session.class) +end