Skip to content
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

Ethon instrumentation #2260

Merged
merged 19 commits into from
Oct 20, 2023
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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

## dev

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.
Version <dev> adds instrumentation for Async::HTTP and Ethon, 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: Add instrumentation for Ethon**

Instrumentation has been added for the [Ethon](https://github.com/typhoeus/ethon) HTTP client gem. Versions 0.12.0 and above are supported. The agent will now record external request segments for invocations of `Ethon::Easy#perform` and `Ethon::Multi#perform`. NOTE: The [Typhoeus](https://github.com/typhoeus/typhoeus) gem is maintained by the same team that maintains Ethon and depends on Ethon for its functionality. To prevent duplicate reporting for each HTTP request, the Ethon instrumentation will be disabled when Typhoeus is detected. [PR#2260](https://github.com/newrelic/newrelic-ruby-agent/pull/2260)

- **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 @@ -1437,6 +1437,14 @@ def self.enforce_fallback(allowed_values: nil, fallback: nil)
:allowed_from_server => false,
:description => 'Controls auto-instrumentation of the elasticsearch library at start-up. May be one of: `auto`, `prepend`, `chain`, `disabled`.'
},
:'instrumentation.ethon' => {
:default => 'auto',
:public => true,
:type => String,
:dynamic_name => true,
:allowed_from_server => false,
:description => 'Controls auto-instrumentation of ethon at start up. May be one of [auto|prepend|chain|disabled]'
},
:'instrumentation.excon' => {
:default => 'enabled',
:documentation_default => 'enabled',
Expand Down
111 changes: 111 additions & 0 deletions lib/new_relic/agent/http_clients/ethon_wrappers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# 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 'uri'
require_relative 'abstract'

module NewRelic
module Agent
module HTTPClients
class EthonHTTPResponse < AbstractResponse
def initialize(easy)
@easy = easy
end

def status_code
@easy.response_code
end

def [](key)
headers[format_key(key)]
end

def headers
# Ethon::Easy#response_headers will return '' if headers are unset
@easy.response_headers.scan(/\n([^:]+?): ([^:\n]+?)\r/).each_with_object({}) do |pair, hash|
hash[format_key(pair[0])] = pair[1]
end
end
alias to_hash headers

private

def format_key(key)
key.tr('-', '_').downcase
end
end

class EthonHTTPRequest < AbstractRequest
attr_reader :uri

DEFAULT_ACTION = 'GET'
DEFAULT_HOST = 'UNKNOWN_HOST'
ETHON = 'Ethon'
LHOST = 'host'.freeze
UHOST = 'Host'.freeze

def initialize(easy)
@easy = easy
@uri = uri_from_easy
end

def type
ETHON
end

def host_from_header
self[LHOST] || self[UHOST]
end

def uri_from_easy
# anticipate `Ethon::Easy#url` being `example.com` without a protocol
# defined and use an 'http' protocol prefix for `URI.parse` to work
# with the URL as desired
url_str = @easy.url.match?(':') ? @easy.url : "http://#{@easy.url}"
begin
URI.parse(url_str)
rescue URI::InvalidURIError => e
NewRelic::Agent.logger.debug("Failed to parse URI '#{url_str}': #{e.class} - #{e.message}")
URI.parse(NewRelic::EMPTY_STR)
end
end

def host
host_from_header || uri.host&.downcase || DEFAULT_HOST
end

def method
return DEFAULT_ACTION unless @easy.instance_variable_defined?(action_instance_var)

@easy.instance_variable_get(action_instance_var)
end

def action_instance_var
NewRelic::Agent::Instrumentation::Ethon::Easy::ACTION_INSTANCE_VAR
end

def []=(key, value)
headers[key] = value
@easy.headers = headers
end

def headers
@headers ||= if @easy.instance_variable_defined?(headers_instance_var)
@easy.instance_variable_get(headers_instance_var)
else
{}
end
end

def headers_instance_var
NewRelic::Agent::Instrumentation::Ethon::Easy::HEADERS_INSTANCE_VAR
end

def [](key)
headers[key]
end
end
end
end
end
39 changes: 39 additions & 0 deletions lib/new_relic/agent/instrumentation/ethon.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 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 'ethon/instrumentation'
require_relative 'ethon/chain'
require_relative 'ethon/prepend'

DependencyDetection.defer do
named :ethon

# If Ethon is being used as a dependency of Typhoeus, allow the Typhoeus
# instrumentation to handle everything. Otherwise each external network call
# will confusingly result in "Ethon" segments duplicating the information
# already provided by "Typhoeus" segments.
depends_on do
!defined?(Typhoeus)
end

depends_on do
defined?(Ethon) && Gem::Version.new(Ethon::VERSION) >= Gem::Version.new('0.12.0')
end

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

executes do
if use_prepend?
# NOTE: by default prepend_instrument will go with the module name that
# precedes 'Prepend' (so 'Easy' and 'Multi'), but we want to use
# 'Ethon::Easy' and 'Ethon::Multi' so 3rd argument is supplied
prepend_instrument Ethon::Easy, NewRelic::Agent::Instrumentation::Ethon::Easy::Prepend, Ethon::Easy.name
prepend_instrument Ethon::Multi, NewRelic::Agent::Instrumentation::Ethon::Multi::Prepend, Ethon::Multi.name
else
chain_instrument NewRelic::Agent::Instrumentation::Ethon::Chain
end
end
end
39 changes: 39 additions & 0 deletions lib/new_relic/agent/instrumentation/ethon/chain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 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

module NewRelic::Agent::Instrumentation
module Ethon
module Chain
def self.instrument!
::Ethon::Easy.class_eval do
include NewRelic::Agent::Instrumentation::Ethon::Easy

alias_method(:fabricate_without_tracing, :fabricate)
def fabricate(url, action_name, options)
fabricate_with_tracing(url, action_name, options) { fabricate_without_tracing(url, action_name, options) }
end

alias_method(:headers_equals_without_tracing, :headers=)
def headers=(headers)
headers_equals_with_tracing(headers) { headers_equals_without_tracing(headers) }
end

alias_method(:perform_without_tracing, :perform)
def perform(*args)
perform_with_tracing(*args) { perform_without_tracing(*args) }
end
end

::Ethon::Multi.class_eval do
include NewRelic::Agent::Instrumentation::Ethon::Multi

alias_method(:perform_without_tracing, :perform)
def perform(*args)
perform_with_tracing(*args) { perform_without_tracing(*args) }
end
end
end
end
end
end
105 changes: 105 additions & 0 deletions lib/new_relic/agent/instrumentation/ethon/instrumentation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# 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/ethon_wrappers'

module NewRelic::Agent::Instrumentation
module Ethon
module NRShared
INSTRUMENTATION_NAME = 'Ethon'
NOTICEABLE_ERROR_CLASS = 'Ethon::Errors::EthonError'

def prep_easy(easy, parent = nil)
wrapped_request = NewRelic::Agent::HTTPClients::EthonHTTPRequest.new(easy)
segment = NewRelic::Agent::Tracer.start_external_request_segment(
library: wrapped_request.type,
uri: wrapped_request.uri,
procedure: wrapped_request.method,
parent: parent
)
segment.add_request_headers(wrapped_request)

callback = proc do
wrapped_response = NewRelic::Agent::HTTPClients::EthonHTTPResponse.new(easy)
segment.process_response_headers(wrapped_response)

if easy.return_code != :ok
e = NewRelic::Agent::NoticeableError.new(NOTICEABLE_ERROR_CLASS,
"return_code: >>#{easy.return_code}<<, response_code: >>#{easy.response_code}<<")
segment.notice_error(e)
end

::NewRelic::Agent::Transaction::Segment.finish(segment)
end

easy.on_complete { callback.call }

segment
end

def wrap_with_tracing(segment, &block)
NewRelic::Agent.record_instrumentation_invocation(INSTRUMENTATION_NAME)

NewRelic::Agent::Tracer.capture_segment_error(segment) do
yield
end
ensure
NewRelic::Agent::Transaction::Segment.finish(segment)
end
end

module Easy
include NRShared

ACTION_INSTANCE_VAR = :@nr_action
HEADERS_INSTANCE_VAR = :@nr_headers

# `Ethon::Easy` doesn't expose the "action name" ('GET', 'POST', etc.)
# and Ethon's fabrication of HTTP classes uses
# `Ethon::Easy::Http::Custom` for non-standard actions. To be able to
# know the action name at `#perform` time, we set a new instance variable
# on the `Ethon::Easy` instance with the base name of the fabricated
# class, respecting the 'Custom' name where appropriate.
def fabricate_with_tracing(_url, action_name, _options)
fabbed = yield
instance_variable_set(ACTION_INSTANCE_VAR, NewRelic::Agent.base_name(fabbed.class.name).upcase)
fabbed
end

# `Ethon::Easy` uses `Ethon::Easy::Header` to set request headers on
# libcurl with `#headers=`. After they are set, they aren't easy to get
# at again except via FFI so set a new instance variable on the
# `Ethon::Easy` instance to store them in Ruby hash format.
def headers_equals_with_tracing(headers)
instance_variable_set(HEADERS_INSTANCE_VAR, headers)
yield
end

def perform_with_tracing(*args)
return unless NewRelic::Agent::Tracer.state.is_execution_traced?

segment = prep_easy(self)
wrap_with_tracing(segment) { yield }
end
end

module Multi
include NRShared

MULTI_SEGMENT_NAME = 'External/Multiple/Ethon::Multi/perform'

def perform_with_tracing(*args)
return unless NewRelic::Agent::Tracer.state.is_execution_traced?

segment = NewRelic::Agent::Tracer.start_segment(name: MULTI_SEGMENT_NAME)

wrap_with_tracing(segment) do
easy_handles.each { |easy| prep_easy(easy, segment) }

yield
end
end
end
end
end
35 changes: 35 additions & 0 deletions lib/new_relic/agent/instrumentation/ethon/prepend.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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

module NewRelic::Agent::Instrumentation
module Ethon
module Easy
module Prepend
include NewRelic::Agent::Instrumentation::Ethon::Easy

def fabricate(url, action_name, options)
fabricate_with_tracing(url, action_name, options) { super }
end

def headers=(headers)
headers_equals_with_tracing(headers) { super }
end

def perform(*args)
perform_with_tracing(*args) { super }
end
end
end

module Multi
module Prepend
include NewRelic::Agent::Instrumentation::Ethon::Multi

def perform(*args)
perform_with_tracing(*args) { super }
end
end
end
end
end
4 changes: 1 addition & 3 deletions lib/new_relic/agent/tracer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -357,9 +357,7 @@ def capture_segment_error(segment)
yield
rescue => exception
# needs else branch coverage
if segment && segment.is_a?(Transaction::AbstractSegment) # rubocop:disable Style/SafeNavigation
segment.notice_error(exception)
end
segment.notice_error(exception) if segment&.is_a?(Transaction::AbstractSegment)
raise
end

Expand Down
Loading
Loading