Skip to content

Commit

Permalink
Merge pull request #2272 from newrelic/add_async_http_instrumentation
Browse files Browse the repository at this point in the history
Add async http instrumentation
  • Loading branch information
tannalynn authored Oct 20, 2023
2 parents ba0f9c5 + 57c81fe commit aab41ab
Show file tree
Hide file tree
Showing 12 changed files with 373 additions and 3 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

## dev

Version <dev> gleans Docker container IDs from cgroups v2-based containers, records additional synthetics attributes, fixes an issue with Rails 7.1 that could cause duplicate log records to be sent to New Relic, and fixes a deprecation warning for the Sidekiq error handler.
Version <dev> adds instrumentation for Async::HTTP, gleans Docker container IDs from cgroups v2-based containers, records additional synthetics attributes, fixes an issue with Rails 7.1 that could cause duplicate log records to be sent to New Relic, and fixes a deprecation warning for the Sidekiq error handler.

- **Feature: Add instrumentation for Async::HTTP**

The agent will now record spans for Async::HTTP requests. Versions 0.59.0 and above of the async-http gem are supported. [PR#2272](https://github.com/newrelic/newrelic-ruby-agent/pull/2272)


- **Feature: Prevent the agent from starting in rails commands in Rails 7**

Expand Down
8 changes: 8 additions & 0 deletions lib/new_relic/agent/configuration/default_source.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1379,6 +1379,14 @@ def self.enforce_fallback(allowed_values: nil, fallback: nil)
:allowed_from_server => false,
:description => 'Controls auto-instrumentation of `ActiveSupport::Logger` at start up. May be one of: `auto`, `prepend`, `chain`, `disabled`. Used in Rails versions below 7.1.'
},
:'instrumentation.async_http' => {
:default => 'auto',
:public => true,
:type => String,
:dynamic_name => true,
:allowed_from_server => false,
:description => 'Controls auto-instrumentation of Async::HTTP at start up. May be one of: `auto`, `prepend`, `chain`, `disabled`.'
},
:'instrumentation.bunny' => {
:default => 'auto',
:public => true,
Expand Down
83 changes: 83 additions & 0 deletions lib/new_relic/agent/http_clients/async_http_wrappers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

require_relative 'abstract'
require 'resolv'

module NewRelic
module Agent
module HTTPClients
class AsyncHTTPResponse < AbstractResponse
def get_status_code
get_status_code_using(:status)
end

def [](key)
to_hash[key.downcase]&.first
end

def to_hash
@wrapped_response.headers.to_h
end
end

class AsyncHTTPRequest < AbstractRequest
def initialize(connection, method, url, headers)
@connection = connection
@method = method
@url = ::NewRelic::Agent::HTTPClients::URIUtil.parse_and_normalize_url(url)
@headers = headers
end

ASYNC_HTTP = 'Async::HTTP'
LHOST = 'host'
UHOST = 'Host'
COLON = ':'

def type
ASYNC_HTTP
end

def host_from_header
if hostname = (self[LHOST] || self[UHOST])
hostname.split(COLON).first
end
end

def host
host_from_header || uri.host.to_s
end

def [](key)
return headers[key] unless headers.is_a?(Array)

headers.each do |header|
return header[1] if header[0].casecmp?(key)
end
nil
end

def []=(key, value)
if headers.is_a?(Array)
headers << [key, value]
else
headers[key] = value
end
end

def uri
@url
end

def headers
@headers
end

def method
@method
end
end
end
end
end
26 changes: 26 additions & 0 deletions lib/new_relic/agent/instrumentation/async_http.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

require_relative 'async_http/instrumentation'
require_relative 'async_http/chain'
require_relative 'async_http/prepend'

DependencyDetection.defer do
named :async_http

depends_on do
defined?(Async::HTTP) && Gem::Version.new(Async::HTTP::VERSION) >= Gem::Version.new('0.59.0')
end

executes do
NewRelic::Agent.logger.info('Installing async_http instrumentation')

require 'async/http/internet'
if use_prepend?
prepend_instrument Async::HTTP::Internet, NewRelic::Agent::Instrumentation::AsyncHttp::Prepend
else
chain_instrument NewRelic::Agent::Instrumentation::AsyncHttp::Chain
end
end
end
23 changes: 23 additions & 0 deletions lib/new_relic/agent/instrumentation/async_http/chain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

require_relative 'instrumentation'

module NewRelic::Agent::Instrumentation
module AsyncHttp::Chain
def self.instrument!
::Async::HTTP::Internet.class_eval do
include NewRelic::Agent::Instrumentation::AsyncHttp

alias_method(:call_without_new_relic, :call)

def call(method, url, headers = nil, body = nil)
call_with_new_relic(method, url, headers, body) do |hdr|
call_without_new_relic(method, url, hdr, body)
end
end
end
end
end
end
37 changes: 37 additions & 0 deletions lib/new_relic/agent/instrumentation/async_http/instrumentation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

require 'new_relic/agent/http_clients/async_http_wrappers'

module NewRelic::Agent::Instrumentation
module AsyncHttp
def call_with_new_relic(method, url, headers = nil, body = nil)
headers ||= {} # if it is nil, we need to make it a hash so we can insert headers
wrapped_request = NewRelic::Agent::HTTPClients::AsyncHTTPRequest.new(self, method, url, headers)

segment = NewRelic::Agent::Tracer.start_external_request_segment(
library: wrapped_request.type,
uri: wrapped_request.uri,
procedure: wrapped_request.method
)

begin
response = nil
segment.add_request_headers(wrapped_request)

NewRelic::Agent.disable_all_tracing do
response = NewRelic::Agent::Tracer.capture_segment_error(segment) do
yield(headers)
end
end

wrapped_response = NewRelic::Agent::HTTPClients::AsyncHTTPResponse.new(response)
segment.process_response_headers(wrapped_response)
response
ensure
segment&.finish
end
end
end
end
15 changes: 15 additions & 0 deletions lib/new_relic/agent/instrumentation/async_http/prepend.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

require_relative 'instrumentation'

module NewRelic::Agent::Instrumentation
module AsyncHttp::Prepend
include NewRelic::Agent::Instrumentation::AsyncHttp

def call(method, url, headers = nil, body = nil)
call_with_new_relic(method, url, headers, body) { |hdr| super(method, url, hdr, body) }
end
end
end
6 changes: 5 additions & 1 deletion newrelic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,11 @@ common: &default_settings
# prepend, chain, disabled.
# instrumentation.bunny: auto

# Controls auto-instrumentation of the concurrent-ruby library at start-up. May be
# Controls auto-instrumentation of Async::HTTP at start up.
# May be one of [auto|prepend|chain|disabled]
# instrumentation.async_http: auto

# Controls auto-instrumentation of the concurrent-ruby library at start up. May be
# one of: auto, prepend, chain, disabled.
# instrumentation.concurrent_ruby: auto

Expand Down
2 changes: 1 addition & 1 deletion test/multiverse/lib/multiverse/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def execute_suites(filter, opts)
'database' => %w[elasticsearch mongo redis sequel],
'rails' => %w[active_record active_record_pg active_support_broadcast_logger active_support_logger rails rails_prepend activemerchant],
'frameworks' => %w[grape padrino roda sinatra],
'httpclients' => %w[curb excon httpclient],
'httpclients' => %w[async_http curb excon httpclient],
'httpclients_2' => %w[typhoeus net_http httprb],
'infinite_tracing' => ['infinite_tracing'],

Expand Down
19 changes: 19 additions & 0 deletions test/multiverse/suites/async_http/Envfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

instrumentation_methods :chain, :prepend

ASYNC_HTTP_VERSIONS = [
[nil, 2.5],
['0.59.0', 2.5]
]

def gem_list(async_http_version = nil)
<<~GEM_LIST
gem 'async-http'#{async_http_version}
gem 'rack'
GEM_LIST
end

create_gemfiles(ASYNC_HTTP_VERSIONS)
131 changes: 131 additions & 0 deletions test/multiverse/suites/async_http/async_http_instrumentation_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

require 'http_client_test_cases'

class AsyncHttpInstrumentationTest < Minitest::Test
include HttpClientTestCases

def client_name
'Async::HTTP'
end

def timeout_error_class
Async::TimeoutError
end

def simulate_error_response
Async::HTTP::Client.any_instance.stubs(:call).raises(timeout_error_class.new('read timeout reached'))
get_response
end

def get_response(url = nil, headers = nil)
request_and_wait(:get, url || default_url, headers)
end

def request_and_wait(method, url, headers = nil, body = nil)
resp = nil
Async do
begin
internet = Async::HTTP::Internet.new
resp = internet.send(method, url, headers)
@read_resp = resp&.read
ensure
internet&.close
end
end
resp
end

def get_wrapped_response(url)
NewRelic::Agent::HTTPClients::AsyncHTTPResponse.new(get_response(url))
end

def head_response
request_and_wait(:head, default_url)
end

def post_response
request_and_wait(:post, default_url, nil, '')
end

def put_response
request_and_wait(:put, default_url, nil, '')
end

def delete_response
request_and_wait(:delete, default_url, nil, '')
end

def request_instance
NewRelic::Agent::HTTPClients::AsyncHTTPRequest.new(Async::HTTP::Internet.new, 'GET', default_url, {})
end

def response_instance(headers = {})
resp = get_response(default_url, headers)
headers.each do |k, v|
resp.headers[k] = v
end

NewRelic::Agent::HTTPClients::AsyncHTTPResponse.new(resp)
end

def body(res)
@read_resp
end

def test_noticed_error_at_segment_and_txn_on_error
# skipping this test
# Async gem does not allow the errors to escape the async block
# so the errors will never end up on the transaction, only ever the async http segment
end

def test_raw_synthetics_header_is_passed_along_if_present_array
with_config(:"cross_application_tracer.enabled" => true) do
in_transaction do
NewRelic::Agent::Tracer.current_transaction.raw_synthetics_header = 'boo'

get_response(default_url, [%w[itsaheader itsavalue]])

assert_equal 'boo', server.requests.last['HTTP_X_NEWRELIC_SYNTHETICS']
end
end
end

def test_raw_synthetics_header_is_passed_along_if_present_hash
with_config(:"cross_application_tracer.enabled" => true) do
in_transaction do
NewRelic::Agent::Tracer.current_transaction.raw_synthetics_header = 'boo'

get_response(default_url, {'itsaheader' => 'itsavalue'})

assert_equal 'boo', server.requests.last['HTTP_X_NEWRELIC_SYNTHETICS']
end
end
end

def test_raw_synthetics_header_is_passed_along_if_present_protocol_header_hash
with_config(:"cross_application_tracer.enabled" => true) do
in_transaction do
NewRelic::Agent::Tracer.current_transaction.raw_synthetics_header = 'boo'

get_response(default_url, ::Protocol::HTTP::Headers[{'itsaheader' => 'itsavalue'}])

assert_equal 'boo', server.requests.last['HTTP_X_NEWRELIC_SYNTHETICS']
end
end
end

def test_raw_synthetics_header_is_passed_along_if_present_protocol_header_array
with_config(:"cross_application_tracer.enabled" => true) do
in_transaction do
NewRelic::Agent::Tracer.current_transaction.raw_synthetics_header = 'boo'

get_response(default_url, ::Protocol::HTTP::Headers[%w[itsaheader itsavalue]])

assert_equal 'boo', server.requests.last['HTTP_X_NEWRELIC_SYNTHETICS']
end
end
end
end
Loading

0 comments on commit aab41ab

Please sign in to comment.