From 281ac03cda04ae2e0933454f21e56f3ab99418de Mon Sep 17 00:00:00 2001 From: HoneyryderChuck Date: Thu, 26 May 2022 23:45:20 +0100 Subject: [PATCH 1/2] added tests and scaffold --- integration_tests/sentry_test.rb | 101 +++++++++++++++++++++++++++++++ lib/httpx/adapters/sentry.rb | 13 ++++ 2 files changed, 114 insertions(+) create mode 100644 integration_tests/sentry_test.rb create mode 100644 lib/httpx/adapters/sentry.rb diff --git a/integration_tests/sentry_test.rb b/integration_tests/sentry_test.rb new file mode 100644 index 00000000..dcb4888f --- /dev/null +++ b/integration_tests/sentry_test.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "logger" +require "stringio" +require "test_helper" +require "support/http_helpers" +require "sentry-ruby" +require "httpx/adapters/sentry" + +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_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) + 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 == 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 diff --git a/lib/httpx/adapters/sentry.rb b/lib/httpx/adapters/sentry.rb new file mode 100644 index 00000000..9b00ec1d --- /dev/null +++ b/lib/httpx/adapters/sentry.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module HTTPX::Plugins + module Sentry + 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 From 7383347147efb348f12b50a8b109585509dfdaab Mon Sep 17 00:00:00 2001 From: HoneyryderChuck Date: Fri, 27 May 2022 13:08:16 +0100 Subject: [PATCH 2/2] implementation of the sentry plugin integration --- Gemfile | 1 + integration_tests/sentry_test.rb | 27 ++++++++-- lib/httpx/adapters/sentry.rb | 89 ++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 5 deletions(-) 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 index dcb4888f..3b49e07a 100644 --- a/integration_tests/sentry_test.rb +++ b/integration_tests/sentry_test.rb @@ -4,8 +4,10 @@ require "logger" require "stringio" require "test_helper" require "support/http_helpers" -require "sentry-ruby" -require "httpx/adapters/sentry" +begin + require "httpx/adapters/sentry" +rescue LoadError +end class SentryTest < Minitest::Test include HTTPHelpers @@ -50,6 +52,21 @@ class SentryTest < Minitest::Test 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) @@ -62,7 +79,7 @@ class SentryTest < Minitest::Test private - def verify_spans(transaction, *responses) + 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 @@ -74,7 +91,7 @@ class SentryTest < Minitest::Test assert !request_span.start_timestamp.nil? assert !request_span.timestamp.nil? assert request_span.start_timestamp != request_span.timestamp - assert request_span.description == response.uri + assert request_span.description == (description || "#{verb || "GET"} #{response.uri}") assert request_span.data == { status: response.status } end end @@ -98,4 +115,4 @@ class SentryTest < Minitest::Test def origin "https://#{httpbin}" end -end +end if RUBY_VERSION >= "2.4.0" diff --git a/lib/httpx/adapters/sentry.rb b/lib/httpx/adapters/sentry.rb index 9b00ec1d..3f73548f 100644 --- a/lib/httpx/adapters/sentry.rb +++ b/lib/httpx/adapters/sentry.rb @@ -1,7 +1,96 @@ # 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