Skip to content

Commit

Permalink
Support for tracing Faraday requests (#2345)
Browse files Browse the repository at this point in the history
* Support for Faraday instrumentation

* Require sentry/faraday by default

* Update CHANGELOG.md

* Use prepend in Faraday patch

* Simplify inserting faraday instrumentation

* Ensure none of the spans come from faraday when net/http is used
  • Loading branch information
solnic committed Jul 26, 2024
1 parent 94a8063 commit 09f348e
Show file tree
Hide file tree
Showing 5 changed files with 378 additions and 0 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## Unreleased

### Features

- Support for tracing Faraday requests ([#2345](https://github.com/getsentry/sentry-ruby/pull/2345))
- Closes [#1795](https://github.com/getsentry/sentry-ruby/issues/1795)
- Please note that the Faraday instrumentation has some limitations in case of async requests: https://github.com/lostisland/faraday/issues/1381

## 5.18.2

### Bug Fixes
Expand Down
1 change: 1 addition & 0 deletions sentry-ruby/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ gem "benchmark-memory"

gem "yard", github: "lsegal/yard"
gem "webrick"
gem "faraday"

eval_gemfile File.expand_path("../Gemfile", __dir__)
1 change: 1 addition & 0 deletions sentry-ruby/lib/sentry-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -601,3 +601,4 @@ def utc_now
require "sentry/redis"
require "sentry/puma"
require "sentry/graphql"
require "sentry/faraday"
111 changes: 111 additions & 0 deletions sentry-ruby/lib/sentry/faraday.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# frozen_string_literal: true

module Sentry
module Faraday
OP_NAME = "http.client"
SPAN_ORIGIN = "auto.http.faraday"
BREADCRUMB_CATEGORY = "http"

module Connection
# Since there's no way to preconfigure Faraday connections and add our instrumentation
# by default, we need to extend the connection constructor and do it there
#
# @see https://lostisland.github.io/faraday/#/customization/index?id=configuration
def initialize(url = nil, options = nil)
super

# Ensure that we attach instrumentation only if the adapter is not net/http
# because if is is, then the net/http instrumentation will take care of it
if builder.adapter.name != "Faraday::Adapter::NetHttp"
# Make sure that it's going to be the first middleware so that it can capture
# the entire request processing involving other middlewares
builder.insert(0, ::Faraday::Request::Instrumentation, name: OP_NAME, instrumenter: Instrumenter.new)
end
end
end

class Instrumenter
attr_reader :configuration

def initialize
@configuration = Sentry.configuration
end

def instrument(op_name, env, &block)
return unless Sentry.initialized?

Sentry.with_child_span(op: op_name, start_timestamp: Sentry.utc_now.to_f, origin: SPAN_ORIGIN) do |sentry_span|
request_info = extract_request_info(env)

if propagate_trace?(request_info[:url])
set_propagation_headers(env[:request_headers])
end

res = block.call

if record_sentry_breadcrumb?
record_sentry_breadcrumb(request_info, res)
end

if sentry_span
sentry_span.set_description("#{request_info[:method]} #{request_info[:url]}")
sentry_span.set_data(Span::DataConventions::URL, request_info[:url])
sentry_span.set_data(Span::DataConventions::HTTP_METHOD, request_info[:method])
sentry_span.set_data(Span::DataConventions::HTTP_QUERY, request_info[:query]) if request_info[:query]
sentry_span.set_data(Span::DataConventions::HTTP_STATUS_CODE, res.status)
end

res
end
end

private

def extract_request_info(env)
url = env[:url].scheme + "://" + env[:url].host + env[:url].path
result = { method: env[:method].to_s.upcase, url: url }

if configuration.send_default_pii
result[:query] = env[:url].query
result[:body] = env[:body]
end

result
end

def record_sentry_breadcrumb(request_info, res)
crumb = Sentry::Breadcrumb.new(
level: :info,
category: BREADCRUMB_CATEGORY,
type: :info,
data: {
status: res.status,
**request_info
}
)

Sentry.add_breadcrumb(crumb)
end

def record_sentry_breadcrumb?
configuration.breadcrumbs_logger.include?(:http_logger)
end

def propagate_trace?(url)
url &&
configuration.propagate_traces &&
configuration.trace_propagation_targets.any? { |target| url.match?(target) }
end

def set_propagation_headers(headers)
Sentry.get_trace_propagation_headers&.each { |k, v| headers[k] = v }
end
end
end
end

Sentry.register_patch(:faraday) do
if defined?(::Faraday)
::Faraday::Connection.prepend(Sentry::Faraday::Connection)
end
end
257 changes: 257 additions & 0 deletions sentry-ruby/spec/sentry/faraday_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
require "faraday"
require_relative "../spec_helper"

RSpec.describe Sentry::Faraday do
before(:all) do
perform_basic_setup do |config|
config.enabled_patches << :faraday
config.traces_sample_rate = 1.0
config.logger = ::Logger.new(StringIO.new)
end
end

after(:all) do
Sentry.configuration.enabled_patches = Sentry::Configuration::DEFAULT_PATCHES
end

context "with tracing enabled" do
let(:http) do
Faraday.new(url) do |f|
f.request :json

f.adapter Faraday::Adapter::Test do |stub|
stub.get("/test") do
[200, { "Content-Type" => "text/html" }, "<h1>hello world</h1>"]
end
end
end
end

let(:url) { "http://example.com" }

it "records the request's span" do
transaction = Sentry.start_transaction
Sentry.get_current_scope.set_span(transaction)

_response = http.get("/test")

request_span = transaction.span_recorder.spans.last

expect(request_span.op).to eq("http.client")
expect(request_span.origin).to eq("auto.http.faraday")
expect(request_span.start_timestamp).not_to be_nil
expect(request_span.timestamp).not_to be_nil
expect(request_span.start_timestamp).not_to eq(request_span.timestamp)
expect(request_span.description).to eq("GET http://example.com/test")

expect(request_span.data).to eq({
"http.response.status_code" => 200,
"url" => "http://example.com/test",
"http.request.method" => "GET"
})
end
end

context "with config.send_default_pii = true" do
let(:http) do
Faraday.new(url) do |f|
f.adapter Faraday::Adapter::Test do |stub|
stub.get("/test") do
[200, { "Content-Type" => "text/html" }, "<h1>hello world</h1>"]
end

stub.post("/test") do
[200, { "Content-Type" => "application/json" }, { hello: "world" }.to_json]
end
end
end
end

let(:url) { "http://example.com" }

before do
Sentry.configuration.send_default_pii = true
Sentry.configuration.breadcrumbs_logger = [:http_logger]
end

it "records the request's span with query string in data" do
transaction = Sentry.start_transaction
Sentry.get_current_scope.set_span(transaction)

_response = http.get("/test?foo=bar")

request_span = transaction.span_recorder.spans.last

expect(request_span.description).to eq("GET http://example.com/test")

expect(request_span.data).to eq({
"http.response.status_code" => 200,
"url" => "http://example.com/test",
"http.request.method" => "GET",
"http.query" => "foo=bar"
})
end

it "records breadcrumbs" do
transaction = Sentry.start_transaction
Sentry.get_current_scope.set_span(transaction)

_response = http.get("/test?foo=bar")

transaction.span_recorder.spans.last

crumb = Sentry.get_current_scope.breadcrumbs.peek

expect(crumb.category).to eq("http")
expect(crumb.data[:status]).to eq(200)
expect(crumb.data[:method]).to eq("GET")
expect(crumb.data[:url]).to eq("http://example.com/test")
expect(crumb.data[:query]).to eq("foo=bar")
expect(crumb.data[:body]).to be(nil)
end

it "records POST request body" do
transaction = Sentry.start_transaction
Sentry.get_current_scope.set_span(transaction)

body = { foo: "bar" }.to_json
_response = http.post("/test?foo=bar", body, "Content-Type" => "application/json")

request_span = transaction.span_recorder.spans.last

expect(request_span.description).to eq("POST http://example.com/test")

expect(request_span.data).to eq({
"http.response.status_code" => 200,
"url" => "http://example.com/test",
"http.request.method" => "POST",
"http.query" => "foo=bar"
})

crumb = Sentry.get_current_scope.breadcrumbs.peek

expect(crumb.data[:body]).to eq(body)
end

context "with custom trace_propagation_targets" do
let(:http) do
Faraday.new(url) do |f|
f.adapter Faraday::Adapter::Test do |stub|
stub.get("/test") do
[200, { "Content-Type" => "text/html" }, "<h1>hello world</h1>"]
end
end
end
end

before do
Sentry.configuration.trace_propagation_targets = ["example.com", /foobar.org\/api\/v2/]
end

context "when the request is not to the same target" do
let(:url) { "http://another.site" }

it "doesn't add sentry headers to outgoing requests to different target" do
transaction = Sentry.start_transaction
Sentry.get_current_scope.set_span(transaction)

response = http.get("/test")

request_span = transaction.span_recorder.spans.last

expect(request_span.description).to eq("GET #{url}/test")

expect(request_span.data).to eq({
"http.response.status_code" => 200,
"url" => "#{url}/test",
"http.request.method" => "GET"
})

expect(response.headers.key?("sentry-trace")).to eq(false)
expect(response.headers.key?("baggage")).to eq(false)
end
end

context "when the request is to the same target" do
let(:url) { "http://example.com" }

before do
Sentry.configuration.trace_propagation_targets = ["example.com"]
end

it "adds sentry headers to outgoing requests" do
transaction = Sentry.start_transaction
Sentry.get_current_scope.set_span(transaction)

response = http.get("/test")

request_span = transaction.span_recorder.spans.last

expect(request_span.description).to eq("GET #{url}/test")

expect(request_span.data).to eq({
"http.response.status_code" => 200,
"url" => "#{url}/test",
"http.request.method" => "GET"
})

expect(response.env.request_headers.key?("sentry-trace")).to eq(true)
expect(response.env.request_headers.key?("baggage")).to eq(true)
end
end

context "when the request's url configured target regexp" do
let(:url) { "http://example.com" }

before do
Sentry.configuration.trace_propagation_targets = [/example/]
end

it "adds sentry headers to outgoing requests" do
transaction = Sentry.start_transaction
Sentry.get_current_scope.set_span(transaction)

response = http.get("/test")

request_span = transaction.span_recorder.spans.last

expect(request_span.description).to eq("GET #{url}/test")

expect(request_span.data).to eq({
"http.response.status_code" => 200,
"url" => "#{url}/test",
"http.request.method" => "GET"
})

expect(response.env.request_headers.key?("sentry-trace")).to eq(true)
expect(response.env.request_headers.key?("baggage")).to eq(true)
end
end
end
end

context "when adapter is net/http" do
let(:http) do
Faraday.new(url) do |f|
f.request :json
f.adapter :net_http
end
end

let(:url) { "http://example.com" }

it "skips instrumentation" do
transaction = Sentry.start_transaction
Sentry.get_current_scope.set_span(transaction)

_response = http.get("/test")

request_span = transaction.span_recorder.spans.last

expect(request_span.op).to eq("http.client")
expect(request_span.origin).to eq("auto.http.net_http")

expect(transaction.span_recorder.spans.map(&:origin)).not_to include("auto.http.faraday")
end
end
end

0 comments on commit 09f348e

Please sign in to comment.