diff --git a/instrumentation/excon/Appraisals b/instrumentation/excon/Appraisals index 4538446e41..f365e15983 100644 --- a/instrumentation/excon/Appraisals +++ b/instrumentation/excon/Appraisals @@ -2,12 +2,22 @@ # add more tests for excon -%w[0.71 0.109].each do |version| - appraise "excon-#{version}" do - gem 'excon', "~> #{version}.0" +# To faclitate HTTP semantic convention stability migration, we are using +# appraisal to test the different semantic convention modes along with different +# gem versions. For more information on the semantic convention modes, see: +# https://opentelemetry.io/docs/specs/semconv/non-normative/http-migration/ + +versions = %w[0.71 0.109] +semconv_stability = %w[dup stable old] + +semconv_stability.each do |mode| + versions.each do |version| + appraise "excon-#{version}-#{mode}" do + gem 'excon', "~> #{version}.0" + end end -end -appraise 'excon-latest' do - gem 'excon' + appraise "excon-latest-#{mode}" do + gem 'excon' + end end diff --git a/instrumentation/excon/README.md b/instrumentation/excon/README.md index 10851d8002..7a592829f2 100644 --- a/instrumentation/excon/README.md +++ b/instrumentation/excon/README.md @@ -48,3 +48,20 @@ The `opentelemetry-instrumentation-all` gem is distributed under the Apache 2.0 [community-meetings]: https://github.com/open-telemetry/community#community-meetings [slack-channel]: https://cloud-native.slack.com/archives/C01NWKKMKMY [discussions-url]: https://github.com/open-telemetry/opentelemetry-ruby/discussions + + +## HTTP semantic convention stability + +In the OpenTelemetry ecosystem, HTTP semantic conventions have now reached a stable state. However, the initial Excon instrumentation was introduced before this stability was achieved, which resulted in HTTP attributes being based on an older version of the semantic conventions. + +To facilitate the migration to stable semantic conventions, you can use the `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable. This variable allows you to opt-in to the new stable conventions, ensuring compatibility and future-proofing your instrumentation. + +When setting the value for `OTEL_SEMCONV_STABILITY_OPT_IN`, you can specify which conventions you wish to adopt: + +- `http` - Emits the stable HTTP and networking conventions and ceases emitting the old conventions previously emitted by the instrumentation. +- `http/dup` - Emits both the old and stable HTTP and networking conventions, enabling a phased rollout of the stable semantic conventions. +- Default behavior (in the absence of either value) is to continue emitting the old HTTP and networking conventions the instrumentation previously emitted. + +During the transition from old to stable conventions, Excon instrumentation code comes in three patch versions: `dup`, `old`, and `stable`. These versions are identical except for the attributes they send. Any changes to Excon instrumentation should consider all three patches. + +For additional information on migration, please refer to our [documentation](https://opentelemetry.io/docs/specs/semconv/non-normative/http-migration/). \ No newline at end of file diff --git a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/instrumentation.rb b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/instrumentation.rb index e20ca9b2de..c031078920 100644 --- a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/instrumentation.rb +++ b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/instrumentation.rb @@ -15,9 +15,10 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base include OpenTelemetry::Instrumentation::Concerns::UntracedHosts install do |_config| - require_dependencies - add_middleware - patch + patch_type = determine_semconv + send(:"require_dependencies_#{patch_type}") + send(:"add_middleware_#{patch_type}") + send(:"patch_#{patch_type}") end present do @@ -28,17 +29,56 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base private - def require_dependencies - require_relative 'middlewares/tracer_middleware' - require_relative 'patches/socket' + def determine_semconv + stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') + values = stability_opt_in.split(',').map(&:strip) + + if values.include?('http/dup') + 'dup' + elsif values.include?('http') + 'stable' + else + 'old' + end + end + + def require_dependencies_dup + require_relative 'middlewares/dup/tracer_middleware' + require_relative 'patches/dup/socket' + end + + def require_dependencies_stable + require_relative 'middlewares/stable/tracer_middleware' + require_relative 'patches/stable/socket' + end + + def require_dependencies_old + require_relative 'middlewares/old/tracer_middleware' + require_relative 'patches/old/socket' + end + + def add_middleware_dup + ::Excon.defaults[:middlewares] = Middlewares::Dup::TracerMiddleware.around_default_stack + end + + def add_middleware_stable + ::Excon.defaults[:middlewares] = Middlewares::Stable::TracerMiddleware.around_default_stack + end + + def add_middleware_old + ::Excon.defaults[:middlewares] = Middlewares::Old::TracerMiddleware.around_default_stack + end + + def patch_dup + ::Excon::Socket.prepend(Patches::Dup::Socket) end - def add_middleware - ::Excon.defaults[:middlewares] = Middlewares::TracerMiddleware.around_default_stack + def patch_stable + ::Excon::Socket.prepend(Patches::Stable::Socket) end - def patch - ::Excon::Socket.prepend(Patches::Socket) + def patch_old + ::Excon::Socket.prepend(Patches::Old::Socket) end end end diff --git a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/dup/tracer_middleware.rb b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/dup/tracer_middleware.rb new file mode 100644 index 0000000000..e8f100fa67 --- /dev/null +++ b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/dup/tracer_middleware.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Excon + module Middlewares + module Dup + # Excon middleware for instrumentation + class TracerMiddleware < ::Excon::Middleware::Base + HTTP_METHODS_TO_UPPERCASE = %w[connect delete get head options patch post put trace].each_with_object({}) do |method, hash| + uppercase_method = method.upcase + hash[method] = uppercase_method + hash[method.to_sym] = uppercase_method + hash[uppercase_method] = uppercase_method + end.freeze + + HTTP_METHODS_TO_SPAN_NAMES = HTTP_METHODS_TO_UPPERCASE.values.each_with_object({}) do |uppercase_method, hash| + hash[uppercase_method] ||= uppercase_method + end.freeze + + # Constant for the HTTP status range + HTTP_STATUS_SUCCESS_RANGE = (100..399) + + def request_call(datum) + return @stack.request_call(datum) if untraced?(datum) + + http_method = HTTP_METHODS_TO_UPPERCASE[datum[:method]] + cleansed_url = OpenTelemetry::Common::Utilities.cleanse_url(::Excon::Utils.request_uri(datum)) + attributes = { + OpenTelemetry::SemanticConventions::Trace::HTTP_HOST => datum[:host], + OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => http_method, + OpenTelemetry::SemanticConventions::Trace::HTTP_SCHEME => datum[:scheme], + OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => datum[:path], + OpenTelemetry::SemanticConventions::Trace::HTTP_URL => cleansed_url, + OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => datum[:hostname], + OpenTelemetry::SemanticConventions::Trace::NET_PEER_PORT => datum[:port], + 'http.request.method' => http_method, + 'url.scheme' => datum[:scheme], + 'url.path' => datum[:path], + 'url.full' => cleansed_url, + 'server.address' => datum[:hostname], + 'server.port' => datum[:port] + } + attributes['url.query'] = datum[:query] if datum[:query] + peer_service = Excon::Instrumentation.instance.config[:peer_service] + attributes[OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] = peer_service if peer_service + attributes.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) + span = tracer.start_span(HTTP_METHODS_TO_SPAN_NAMES[http_method], attributes: attributes, kind: :client) + ctx = OpenTelemetry::Trace.context_with_span(span) + datum[:otel_span] = span + datum[:otel_token] = OpenTelemetry::Context.attach(ctx) + OpenTelemetry.propagation.inject(datum[:headers]) + @stack.request_call(datum) + end + + def response_call(datum) + @stack.response_call(datum).tap do |d| + handle_response(d) + end + end + + def error_call(datum) + handle_response(datum) + @stack.error_call(datum) + end + + # Returns a copy of the default stack with the trace middleware injected + def self.around_default_stack + ::Excon.defaults[:middlewares].dup.tap do |default_stack| + # If the default stack contains a version of the trace middleware already... + existing_trace_middleware = default_stack.find { |m| m <= TracerMiddleware } + default_stack.delete(existing_trace_middleware) if existing_trace_middleware + # Inject after the ResponseParser middleware + response_middleware_index = default_stack.index(::Excon::Middleware::ResponseParser).to_i + default_stack.insert(response_middleware_index + 1, self) + end + end + + private + + def handle_response(datum) + datum.delete(:otel_span)&.tap do |span| + return unless span.recording? + + if datum.key?(:response) + response = datum[:response] + span.set_attribute(OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE, response[:status]) + span.set_attribute('http.response.status_code', response[:status]) + span.status = OpenTelemetry::Trace::Status.error unless HTTP_STATUS_SUCCESS_RANGE.cover?(response[:status].to_i) + end + + if datum.key?(:error) + span.status = OpenTelemetry::Trace::Status.error('Request has failed') + span.record_exception(datum[:error]) + end + + span.finish + OpenTelemetry::Context.detach(datum.delete(:otel_token)) if datum.include?(:otel_token) + end + rescue StandardError => e + OpenTelemetry.handle_error(e) + end + + def tracer + Excon::Instrumentation.instance.tracer + end + + def untraced?(datum) + datum.key?(:otel_span) || Excon::Instrumentation.instance.untraced?(datum[:host]) + end + end + end + end + end + end +end diff --git a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/old/tracer_middleware.rb b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/old/tracer_middleware.rb new file mode 100644 index 0000000000..552d39b01f --- /dev/null +++ b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/old/tracer_middleware.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Excon + module Middlewares + module Old + # Excon middleware for instrumentation + class TracerMiddleware < ::Excon::Middleware::Base + HTTP_METHODS_TO_UPPERCASE = %w[connect delete get head options patch post put trace].each_with_object({}) do |method, hash| + uppercase_method = method.upcase + hash[method] = uppercase_method + hash[method.to_sym] = uppercase_method + hash[uppercase_method] = uppercase_method + end.freeze + + HTTP_METHODS_TO_SPAN_NAMES = HTTP_METHODS_TO_UPPERCASE.values.each_with_object({}) do |uppercase_method, hash| + hash[uppercase_method] ||= "HTTP #{uppercase_method}" + end.freeze + + # Constant for the HTTP status range + HTTP_STATUS_SUCCESS_RANGE = (100..399) + + def request_call(datum) + return @stack.request_call(datum) if untraced?(datum) + + http_method = HTTP_METHODS_TO_UPPERCASE[datum[:method]] + attributes = { + OpenTelemetry::SemanticConventions::Trace::HTTP_HOST => datum[:host], + OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => http_method, + OpenTelemetry::SemanticConventions::Trace::HTTP_SCHEME => datum[:scheme], + OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => datum[:path], + OpenTelemetry::SemanticConventions::Trace::HTTP_URL => OpenTelemetry::Common::Utilities.cleanse_url(::Excon::Utils.request_uri(datum)), + OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => datum[:hostname], + OpenTelemetry::SemanticConventions::Trace::NET_PEER_PORT => datum[:port] + } + peer_service = Excon::Instrumentation.instance.config[:peer_service] + attributes[OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] = peer_service if peer_service + attributes.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) + span = tracer.start_span(HTTP_METHODS_TO_SPAN_NAMES[http_method], attributes: attributes, kind: :client) + ctx = OpenTelemetry::Trace.context_with_span(span) + datum[:otel_span] = span + datum[:otel_token] = OpenTelemetry::Context.attach(ctx) + OpenTelemetry.propagation.inject(datum[:headers]) + @stack.request_call(datum) + end + + def response_call(datum) + @stack.response_call(datum).tap do |d| + handle_response(d) + end + end + + def error_call(datum) + handle_response(datum) + @stack.error_call(datum) + end + + # Returns a copy of the default stack with the trace middleware injected + def self.around_default_stack + ::Excon.defaults[:middlewares].dup.tap do |default_stack| + # If the default stack contains a version of the trace middleware already... + existing_trace_middleware = default_stack.find { |m| m <= TracerMiddleware } + default_stack.delete(existing_trace_middleware) if existing_trace_middleware + # Inject after the ResponseParser middleware + response_middleware_index = default_stack.index(::Excon::Middleware::ResponseParser).to_i + default_stack.insert(response_middleware_index + 1, self) + end + end + + private + + def handle_response(datum) + datum.delete(:otel_span)&.tap do |span| + return unless span.recording? + + if datum.key?(:response) + response = datum[:response] + span.set_attribute(OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE, response[:status]) + span.status = OpenTelemetry::Trace::Status.error unless HTTP_STATUS_SUCCESS_RANGE.cover?(response[:status].to_i) + end + + if datum.key?(:error) + span.status = OpenTelemetry::Trace::Status.error('Request has failed') + span.record_exception(datum[:error]) + end + + span.finish + OpenTelemetry::Context.detach(datum.delete(:otel_token)) if datum.include?(:otel_token) + end + rescue StandardError => e + OpenTelemetry.handle_error(e) + end + + def tracer + Excon::Instrumentation.instance.tracer + end + + def untraced?(datum) + datum.key?(:otel_span) || Excon::Instrumentation.instance.untraced?(datum[:host]) + end + end + end + end + end + end +end diff --git a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/stable/tracer_middleware.rb b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/stable/tracer_middleware.rb new file mode 100644 index 0000000000..967edeb703 --- /dev/null +++ b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/stable/tracer_middleware.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Excon + module Middlewares + module Stable + # Excon middleware for instrumentation + class TracerMiddleware < ::Excon::Middleware::Base + HTTP_METHODS_TO_UPPERCASE = %w[connect delete get head options patch post put trace].each_with_object({}) do |method, hash| + uppercase_method = method.upcase + hash[method] = uppercase_method + hash[method.to_sym] = uppercase_method + hash[uppercase_method] = uppercase_method + end.freeze + + HTTP_METHODS_TO_SPAN_NAMES = HTTP_METHODS_TO_UPPERCASE.values.each_with_object({}) do |uppercase_method, hash| + hash[uppercase_method] ||= uppercase_method + end.freeze + + # Constant for the HTTP status range + HTTP_STATUS_SUCCESS_RANGE = (100..399) + + def request_call(datum) + return @stack.request_call(datum) if untraced?(datum) + + http_method = HTTP_METHODS_TO_UPPERCASE[datum[:method]] + attributes = { + 'http.request.method' => http_method, + 'url.scheme' => datum[:scheme], + 'url.path' => datum[:path], + 'url.full' => OpenTelemetry::Common::Utilities.cleanse_url(::Excon::Utils.request_uri(datum)), + 'server.address' => datum[:hostname], + 'server.port' => datum[:port] + } + attributes['url.query'] = datum[:query] if datum[:query] + peer_service = Excon::Instrumentation.instance.config[:peer_service] + attributes[OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] = peer_service if peer_service + attributes.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) + span = tracer.start_span(HTTP_METHODS_TO_SPAN_NAMES[http_method], attributes: attributes, kind: :client) + ctx = OpenTelemetry::Trace.context_with_span(span) + datum[:otel_span] = span + datum[:otel_token] = OpenTelemetry::Context.attach(ctx) + OpenTelemetry.propagation.inject(datum[:headers]) + @stack.request_call(datum) + end + + def response_call(datum) + @stack.response_call(datum).tap do |d| + handle_response(d) + end + end + + def error_call(datum) + handle_response(datum) + @stack.error_call(datum) + end + + # Returns a copy of the default stack with the trace middleware injected + def self.around_default_stack + ::Excon.defaults[:middlewares].dup.tap do |default_stack| + # If the default stack contains a version of the trace middleware already... + existing_trace_middleware = default_stack.find { |m| m <= TracerMiddleware } + default_stack.delete(existing_trace_middleware) if existing_trace_middleware + # Inject after the ResponseParser middleware + response_middleware_index = default_stack.index(::Excon::Middleware::ResponseParser).to_i + default_stack.insert(response_middleware_index + 1, self) + end + end + + private + + def handle_response(datum) + datum.delete(:otel_span)&.tap do |span| + return unless span.recording? + + if datum.key?(:response) + response = datum[:response] + span.set_attribute('http.response.status_code', response[:status]) + span.status = OpenTelemetry::Trace::Status.error unless HTTP_STATUS_SUCCESS_RANGE.cover?(response[:status].to_i) + end + + if datum.key?(:error) + span.status = OpenTelemetry::Trace::Status.error('Request has failed') + span.record_exception(datum[:error]) + end + + span.finish + OpenTelemetry::Context.detach(datum.delete(:otel_token)) if datum.include?(:otel_token) + end + rescue StandardError => e + OpenTelemetry.handle_error(e) + end + + def tracer + Excon::Instrumentation.instance.tracer + end + + def untraced?(datum) + datum.key?(:otel_span) || Excon::Instrumentation.instance.untraced?(datum[:host]) + end + end + end + end + end + end +end diff --git a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/tracer_middleware.rb b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/tracer_middleware.rb deleted file mode 100644 index b17bfc6629..0000000000 --- a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/middlewares/tracer_middleware.rb +++ /dev/null @@ -1,109 +0,0 @@ -# frozen_string_literal: true - -# Copyright The OpenTelemetry Authors -# -# SPDX-License-Identifier: Apache-2.0 - -module OpenTelemetry - module Instrumentation - module Excon - module Middlewares - # Excon middleware for instrumentation - class TracerMiddleware < ::Excon::Middleware::Base - HTTP_METHODS_TO_UPPERCASE = %w[connect delete get head options patch post put trace].each_with_object({}) do |method, hash| - uppercase_method = method.upcase - hash[method] = uppercase_method - hash[method.to_sym] = uppercase_method - hash[uppercase_method] = uppercase_method - end.freeze - - HTTP_METHODS_TO_SPAN_NAMES = HTTP_METHODS_TO_UPPERCASE.values.each_with_object({}) do |uppercase_method, hash| - hash[uppercase_method] ||= "HTTP #{uppercase_method}" - end.freeze - - # Constant for the HTTP status range - HTTP_STATUS_SUCCESS_RANGE = (100..399) - - def request_call(datum) - return @stack.request_call(datum) if untraced?(datum) - - http_method = HTTP_METHODS_TO_UPPERCASE[datum[:method]] - attributes = { - OpenTelemetry::SemanticConventions::Trace::HTTP_HOST => datum[:host], - OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => http_method, - OpenTelemetry::SemanticConventions::Trace::HTTP_SCHEME => datum[:scheme], - OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => datum[:path], - OpenTelemetry::SemanticConventions::Trace::HTTP_URL => OpenTelemetry::Common::Utilities.cleanse_url(::Excon::Utils.request_uri(datum)), - OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => datum[:hostname], - OpenTelemetry::SemanticConventions::Trace::NET_PEER_PORT => datum[:port] - } - peer_service = Excon::Instrumentation.instance.config[:peer_service] - attributes[OpenTelemetry::SemanticConventions::Trace::PEER_SERVICE] = peer_service if peer_service - attributes.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) - span = tracer.start_span(HTTP_METHODS_TO_SPAN_NAMES[http_method], attributes: attributes, kind: :client) - ctx = OpenTelemetry::Trace.context_with_span(span) - datum[:otel_span] = span - datum[:otel_token] = OpenTelemetry::Context.attach(ctx) - OpenTelemetry.propagation.inject(datum[:headers]) - @stack.request_call(datum) - end - - def response_call(datum) - @stack.response_call(datum).tap do |d| - handle_response(d) - end - end - - def error_call(datum) - handle_response(datum) - @stack.error_call(datum) - end - - # Returns a copy of the default stack with the trace middleware injected - def self.around_default_stack - ::Excon.defaults[:middlewares].dup.tap do |default_stack| - # If the default stack contains a version of the trace middleware already... - existing_trace_middleware = default_stack.find { |m| m <= TracerMiddleware } - default_stack.delete(existing_trace_middleware) if existing_trace_middleware - # Inject after the ResponseParser middleware - response_middleware_index = default_stack.index(::Excon::Middleware::ResponseParser).to_i - default_stack.insert(response_middleware_index + 1, self) - end - end - - private - - def handle_response(datum) - datum.delete(:otel_span)&.tap do |span| - return unless span.recording? - - if datum.key?(:response) - response = datum[:response] - span.set_attribute(OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE, response[:status]) - span.status = OpenTelemetry::Trace::Status.error unless HTTP_STATUS_SUCCESS_RANGE.cover?(response[:status].to_i) - end - - if datum.key?(:error) - span.status = OpenTelemetry::Trace::Status.error('Request has failed') - span.record_exception(datum[:error]) - end - - span.finish - OpenTelemetry::Context.detach(datum.delete(:otel_token)) if datum.include?(:otel_token) - end - rescue StandardError => e - OpenTelemetry.handle_error(e) - end - - def tracer - Excon::Instrumentation.instance.tracer - end - - def untraced?(datum) - datum.key?(:otel_span) || Excon::Instrumentation.instance.untraced?(datum[:host]) - end - end - end - end - end -end diff --git a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/dup/socket.rb b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/dup/socket.rb new file mode 100644 index 0000000000..8ed9c921f3 --- /dev/null +++ b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/dup/socket.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Excon + module Patches + module Dup + # Module to prepend to an Excon Socket for instrumentation + module Socket + private + + def connect + return super if untraced? + + if @data[:proxy] + conn_address = @data.dig(:proxy, :hostname) + conn_port = @data.dig(:proxy, :port) + else + conn_address = @data[:hostname] + conn_port = @port + end + + attributes = { + OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => conn_address, + OpenTelemetry::SemanticConventions::Trace::NET_PEER_PORT => conn_port, + 'server.address' => conn_address, + 'server.port' => conn_port + }.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) + + if is_a?(::Excon::SSLSocket) && @data[:proxy] + span_name = 'CONNECT' + span_kind = :client + else + span_name = 'connect' + span_kind = :internal + end + + tracer.in_span(span_name, attributes: attributes, kind: span_kind) do + super + end + end + + def tracer + Excon::Instrumentation.instance.tracer + end + + def untraced? + address = if @data[:proxy] + @data.dig(:proxy, :hostname) + else + @data[:hostname] + end + + Excon::Instrumentation.instance.untraced?(address) + end + end + end + end + end + end +end diff --git a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/old/socket.rb b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/old/socket.rb new file mode 100644 index 0000000000..01e20a1e16 --- /dev/null +++ b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/old/socket.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Excon + module Patches + module Old + # Module to prepend to an Excon Socket for instrumentation + module Socket + private + + def connect + return super if untraced? + + if @data[:proxy] + conn_address = @data.dig(:proxy, :hostname) + conn_port = @data.dig(:proxy, :port) + else + conn_address = @data[:hostname] + conn_port = @port + end + + attributes = { OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => conn_address, OpenTelemetry::SemanticConventions::Trace::NET_PEER_PORT => conn_port }.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) + + if is_a?(::Excon::SSLSocket) && @data[:proxy] + span_name = 'HTTP CONNECT' + span_kind = :client + else + span_name = 'connect' + span_kind = :internal + end + + tracer.in_span(span_name, attributes: attributes, kind: span_kind) do + super + end + end + + def tracer + Excon::Instrumentation.instance.tracer + end + + def untraced? + address = if @data[:proxy] + @data.dig(:proxy, :hostname) + else + @data[:hostname] + end + + Excon::Instrumentation.instance.untraced?(address) + end + end + end + end + end + end +end diff --git a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/socket.rb b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/socket.rb deleted file mode 100644 index b96ed88331..0000000000 --- a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/socket.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -# Copyright The OpenTelemetry Authors -# -# SPDX-License-Identifier: Apache-2.0 - -module OpenTelemetry - module Instrumentation - module Excon - module Patches - # Module to prepend to an Excon Socket for instrumentation - module Socket - private - - def connect - return super if untraced? - - if @data[:proxy] - conn_address = @data.dig(:proxy, :hostname) - conn_port = @data.dig(:proxy, :port) - else - conn_address = @data[:hostname] - conn_port = @port - end - - attributes = { OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => conn_address, OpenTelemetry::SemanticConventions::Trace::NET_PEER_PORT => conn_port }.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) - - if is_a?(::Excon::SSLSocket) && @data[:proxy] - span_name = 'HTTP CONNECT' - span_kind = :client - else - span_name = 'connect' - span_kind = :internal - end - - tracer.in_span(span_name, attributes: attributes, kind: span_kind) do - super - end - end - - def tracer - Excon::Instrumentation.instance.tracer - end - - def untraced? - address = if @data[:proxy] - @data.dig(:proxy, :hostname) - else - @data[:hostname] - end - - Excon::Instrumentation.instance.untraced?(address) - end - end - end - end - end -end diff --git a/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/stable/socket.rb b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/stable/socket.rb new file mode 100644 index 0000000000..bdbd65267c --- /dev/null +++ b/instrumentation/excon/lib/opentelemetry/instrumentation/excon/patches/stable/socket.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Excon + module Patches + module Stable + # Module to prepend to an Excon Socket for instrumentation + module Socket + private + + def connect + return super if untraced? + + if @data[:proxy] + conn_address = @data.dig(:proxy, :hostname) + conn_port = @data.dig(:proxy, :port) + else + conn_address = @data[:hostname] + conn_port = @port + end + + attributes = { 'server.address' => conn_address, 'server.port' => conn_port }.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) + + if is_a?(::Excon::SSLSocket) && @data[:proxy] + span_name = 'CONNECT' + span_kind = :client + else + span_name = 'connect' + span_kind = :internal + end + + tracer.in_span(span_name, attributes: attributes, kind: span_kind) do + super + end + end + + def tracer + Excon::Instrumentation.instance.tracer + end + + def untraced? + address = if @data[:proxy] + @data.dig(:proxy, :hostname) + else + @data[:hostname] + end + + Excon::Instrumentation.instance.untraced?(address) + end + end + end + end + end + end +end diff --git a/instrumentation/excon/test/opentelemetry/instrumentation/excon/dup/instrumentation_test.rb b/instrumentation/excon/test/opentelemetry/instrumentation/excon/dup/instrumentation_test.rb new file mode 100644 index 0000000000..963853dc1a --- /dev/null +++ b/instrumentation/excon/test/opentelemetry/instrumentation/excon/dup/instrumentation_test.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +require_relative '../../../../../lib/opentelemetry/instrumentation/excon' +require_relative '../../../../../lib/opentelemetry/instrumentation/excon/middlewares/dup/tracer_middleware' +require_relative '../../../../../lib/opentelemetry/instrumentation/excon/patches/dup/socket' + +describe OpenTelemetry::Instrumentation::Excon::Instrumentation do + let(:instrumentation) { OpenTelemetry::Instrumentation::Excon::Instrumentation.instance } + let(:exporter) { EXPORTER } + let(:span) { exporter.finished_spans.first } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('dup') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http/dup' + exporter.reset + stub_request(:get, 'http://example.com/success').to_return(status: 200) + stub_request(:get, 'http://example.com/success?hello=there').to_return(status: 200) + stub_request(:get, 'http://example.com/failure').to_return(status: 500) + stub_request(:get, 'http://example.com/timeout').to_timeout + + # this is currently a noop but this will future proof the test + @orig_propagation = OpenTelemetry.propagation + propagator = OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator + OpenTelemetry.propagation = propagator + end + + after do + # Force re-install of instrumentation + instrumentation.instance_variable_set(:@installed, false) + + OpenTelemetry.propagation = @orig_propagation + end + + describe 'tracing' do + before do + instrumentation.install + end + + it 'before request' do + _(exporter.finished_spans).must_be_empty + end + + it 'after request with success code' do + Excon.get('http://example.com/success') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.host']).must_equal 'example.com' + _(span.attributes['http.method']).must_equal 'GET' + _(span.attributes['http.scheme']).must_equal 'http' + _(span.attributes['http.status_code']).must_equal 200 + _(span.attributes['http.target']).must_equal '/success' + _(span.attributes['http.url']).must_equal 'http://example.com/success' + # stable semconv + _(span.attributes['http.request.method']).must_equal 'GET' + _(span.attributes['url.scheme']).must_equal 'http' + _(span.attributes['http.response.status_code']).must_equal 200 + _(span.attributes['url.path']).must_equal '/success' + _(span.attributes['url.query']).must_be_nil + _(span.attributes['url.full']).must_equal 'http://example.com/success' + assert_requested( + :get, + 'http://example.com/success', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + specify 'after request with capital-letters HTTP method' do + Excon.new('http://example.com/success').request(method: 'GET') + + _(span.attributes['http.method']).must_equal 'GET' + end + + it 'after request with failure code' do + Excon.get('http://example.com/failure') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.host']).must_equal 'example.com' + _(span.attributes['http.method']).must_equal 'GET' + _(span.attributes['http.scheme']).must_equal 'http' + _(span.attributes['http.status_code']).must_equal 500 + _(span.attributes['http.target']).must_equal '/failure' + _(span.attributes['http.url']).must_equal 'http://example.com/failure' + # stable semconv + _(span.attributes['http.request.method']).must_equal 'GET' + _(span.attributes['url.scheme']).must_equal 'http' + _(span.attributes['http.response.status_code']).must_equal 500 + _(span.attributes['url.path']).must_equal '/failure' + _(span.attributes['url.full']).must_equal 'http://example.com/failure' + _(span.attributes['url.query']).must_be_nil + assert_requested( + :get, + 'http://example.com/failure', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'after request timeout' do + expect do + Excon.get('http://example.com/timeout') + end.must_raise Excon::Error::Timeout + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.host']).must_equal 'example.com' + _(span.attributes['http.method']).must_equal 'GET' + _(span.attributes['http.scheme']).must_equal 'http' + _(span.attributes['http.target']).must_equal '/timeout' + _(span.attributes['http.url']).must_equal 'http://example.com/timeout' + # stable semconv + _(span.attributes['http.request.method']).must_equal 'GET' + _(span.attributes['url.scheme']).must_equal 'http' + _(span.attributes['url.path']).must_equal '/timeout' + _(span.attributes['url.full']).must_equal 'http://example.com/timeout' + _(span.attributes['url.query']).must_be_nil + _(span.status.code).must_equal( + OpenTelemetry::Trace::Status::ERROR + ) + _(span.status.description).must_equal('Request has failed') + assert_requested( + :get, + 'http://example.com/timeout', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + + exception_event = span.events.first + _(exception_event.attributes['exception.type']).must_equal('Excon::Error::Timeout') + _(exception_event.attributes['exception.message']).must_equal('Excon::Error::Timeout') + end + + it 'merges HTTP client context' do + client_context_attrs = { + 'test.attribute' => 'test.value', 'http.method' => 'OVERRIDE', 'http.request.method' => 'OVERRIDE' + } + OpenTelemetry::Common::HTTP::ClientContext.with_attributes(client_context_attrs) do + Excon.get('http://example.com/success') + end + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.host']).must_equal 'example.com' + _(span.attributes['http.method']).must_equal 'OVERRIDE' + _(span.attributes['http.scheme']).must_equal 'http' + _(span.attributes['http.status_code']).must_equal 200 + _(span.attributes['http.target']).must_equal '/success' + _(span.attributes['http.url']).must_equal 'http://example.com/success' + _(span.attributes['test.attribute']).must_equal 'test.value' + # stable semconv + _(span.attributes['http.request.method']).must_equal 'OVERRIDE' + _(span.attributes['url.scheme']).must_equal 'http' + _(span.attributes['http.response.status_code']).must_equal 200 + _(span.attributes['url.path']).must_equal '/success' + _(span.attributes['url.full']).must_equal 'http://example.com/success' + _(span.attributes['url.query']).must_be_nil + assert_requested( + :get, + 'http://example.com/success', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'records url query' do + Excon.get('http://example.com/success?hello=there') + + _(exporter.finished_spans.size).must_equal 1 + _(span.attributes['url.query']).must_equal 'hello=there' + assert_requested( + :get, + 'http://example.com/success?hello=there', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'accepts peer service name from config' do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(peer_service: 'example:faraday') + + Excon.get('http://example.com/success') + + _(span.attributes['peer.service']).must_equal 'example:faraday' + end + + it 'prioritizes context attributes over config for peer service name' do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(peer_service: 'example:faraday') + + client_context_attrs = { 'peer.service' => 'example:custom' } + OpenTelemetry::Common::HTTP::ClientContext.with_attributes(client_context_attrs) do + Excon.get('http://example.com/success') + end + + _(span.attributes['peer.service']).must_equal 'example:custom' + end + end + + describe 'untraced?' do + before do + instrumentation.install(untraced_hosts: ['foobar.com', /bazqux\.com/]) + + stub_request(:get, 'http://example.com/body').to_return(status: 200) + stub_request(:get, 'http://foobar.com/body').to_return(status: 200) + stub_request(:get, 'http://bazqux.com/body').to_return(status: 200) + end + + it 'does not create a span when request ignored using a string' do + Excon.get('http://foobar.com/body') + _(exporter.finished_spans).must_be_empty + end + + it 'does not create a span when request ignored using a regexp' do + Excon.get('http://bazqux.com/body') + _(exporter.finished_spans).must_be_empty + end + + it 'does not create a span on connect when request ignored using a regexp' do + uri = URI.parse('http://bazqux.com') + + Excon::Socket.new(hostname: uri.host, port: uri.port) + + _(exporter.finished_spans).must_be_empty + end + + it 'creates a span for a non-ignored request' do + Excon.get('http://example.com/body') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.host']).must_equal 'example.com' + _(span.attributes['http.method']).must_equal 'GET' + _(span.attributes['http.request.method']).must_equal 'GET' + end + + it 'creates a span on connect for a non-ignored request' do + uri = URI.parse('http://example.com') + + Excon::Socket.new(hostname: uri.host, port: uri.port) + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal('connect') + _(span.kind).must_equal(:internal) + _(span.attributes['net.peer.name']).must_equal('example.com') + _(span.attributes['net.peer.port']).must_equal(80) + _(span.attributes['server.address']).must_equal('example.com') + _(span.attributes['server.port']).must_equal(80) + end + end + + # NOTE: WebMock introduces an extra HTTP request span due to the way the mocking is implemented. + describe '#connect' do + before do + instrumentation.install + WebMock.allow_net_connect! + end + + after do + WebMock.disable_net_connect! + end + + it 'emits span on connect' do + port = nil + + TCPServer.open('localhost', 0) do |server| + Thread.start do + server.accept + rescue IOError + nil + end + + port = server.addr[1] + + _(-> { Excon.get("http://localhost:#{port}/example", read_timeout: 0) }).must_raise(Excon::Error::Timeout) + end + + _(exporter.finished_spans.size).must_equal(3) + _(span.name).must_equal 'connect' + _(span.attributes['net.peer.name']).must_equal('localhost') + _(span.attributes['net.peer.port']).wont_be_nil + _(span.attributes['net.peer.port']).must_equal(port) + # stable semconv + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).wont_be_nil + _(span.attributes['server.port']).must_equal(port) + + assert_http_spans(port: port, target: '/example', exception: 'Excon::Error::Timeout') + end + + it 'captures errors' do + _(-> { Excon.get('http://invalid.com:99999/example') }).must_raise + + _(exporter.finished_spans.size).must_equal(3) + _(span.name).must_equal 'connect' + _(span.attributes['net.peer.name']).must_equal('invalid.com') + _(span.attributes['net.peer.port']).must_equal(99_999) + # stable semconv + _(span.attributes['server.address']).must_equal('invalid.com') + _(span.attributes['server.port']).must_equal(99_999) + + span_event = span.events.first + _(span_event.name).must_equal 'exception' + # Depending on the Ruby and Excon Version this will be a SocketError, Socket::ResolutionError or Resolv::ResolvError + _(span_event.attributes['exception.type']).must_match(/(Socket|Resolv)/) + + assert_http_spans(host: 'invalid.com', port: 99_999, target: '/example') + end + + it '[BUG] fails to emit an HTTP CONNECT span when connecting through an SSL proxy for an HTTP service' do + _(-> { Excon.get('http://localhost/', proxy: 'https://proxy_user:proxy_pass@localhost') }).must_raise(Excon::Error::Socket) + + _(exporter.finished_spans.size).must_equal(3) + _(span.name).must_equal 'connect' + _(span.kind).must_equal(:internal) + _(span.attributes['net.peer.name']).must_equal('localhost') + _(span.attributes['net.peer.port']).must_equal(443) + # stable semconv + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).must_equal(443) + + assert_http_spans + end + + it 'emits an HTTP CONNECT span when connecting through an SSL proxy' do + _(-> { Excon.get('https://localhost/', proxy: 'https://proxy_user:proxy_pass@localhost') }).must_raise(Excon::Error::Socket) + + _(exporter.finished_spans.size).must_equal(3) + _(span.name).must_equal 'CONNECT' + _(span.kind).must_equal(:client) + _(span.attributes['net.peer.name']).must_equal('localhost') + _(span.attributes['net.peer.port']).must_equal(443) + # stable semconv + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).must_equal(443) + + assert_http_spans(scheme: 'https') + end + + it 'emits a "connect" span when connecting through an non-ssl proxy' do + _(-> { Excon.get('http://localhost', proxy: 'https://proxy_user:proxy_pass@localhost') }).must_raise(Excon::Error::Socket) + + _(exporter.finished_spans.size).must_equal(3) + _(span.name).must_equal 'connect' + _(span.kind).must_equal(:internal) + _(span.attributes['net.peer.name']).must_equal('localhost') + _(span.attributes['net.peer.port']).must_equal(443) + # stable semconv + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).must_equal(443) + + assert_http_spans(exception: 'Excon::Error::Socket') + end + + it 'emits no spans when untraced' do + OpenTelemetry::Common::Utilities.untraced do + _(-> { Excon.get('http://localhost', proxy: 'https://proxy_user:proxy_pass@localhost') }).must_raise(Excon::Error::Socket) + + _(exporter.finished_spans.size).must_equal(0) + end + end + end + + def assert_http_spans(scheme: 'http', host: 'localhost', port: nil, target: '/', exception: nil) + exporter.finished_spans[1..].each do |http_span| + _(http_span.name).must_equal 'GET' + _(http_span.attributes['http.host']).must_equal host + _(http_span.attributes['http.method']).must_equal 'GET' + _(http_span.attributes['http.scheme']).must_equal scheme + _(http_span.attributes['http.target']).must_equal target + _(http_span.attributes['http.url']).must_equal "#{scheme}://#{host}#{port&.to_s&.prepend(':')}#{target}" + # stable semconv + _(http_span.attributes['http.request.method']).must_equal 'GET' + _(http_span.attributes['url.scheme']).must_equal scheme + _(http_span.attributes['url.path']).must_equal target + _(http_span.attributes['url.full']).must_equal "#{scheme}://#{host}#{port&.to_s&.prepend(':')}#{target}" + _(http_span.status.code).must_equal( + OpenTelemetry::Trace::Status::ERROR + ) + + if exception + exception_event = http_span.events.first + _(exception_event.attributes['exception.type']).must_equal(exception) + end + end + end +end diff --git a/instrumentation/excon/test/opentelemetry/instrumentation/excon/instrumentation_test.rb b/instrumentation/excon/test/opentelemetry/instrumentation/excon/old/instrumentation_test.rb similarity index 97% rename from instrumentation/excon/test/opentelemetry/instrumentation/excon/instrumentation_test.rb rename to instrumentation/excon/test/opentelemetry/instrumentation/excon/old/instrumentation_test.rb index 6f085689bb..ece401c659 100644 --- a/instrumentation/excon/test/opentelemetry/instrumentation/excon/instrumentation_test.rb +++ b/instrumentation/excon/test/opentelemetry/instrumentation/excon/old/instrumentation_test.rb @@ -6,9 +6,9 @@ require 'test_helper' -require_relative '../../../../lib/opentelemetry/instrumentation/excon' -require_relative '../../../../lib/opentelemetry/instrumentation/excon/middlewares/tracer_middleware' -require_relative '../../../../lib/opentelemetry/instrumentation/excon/patches/socket' +require_relative '../../../../../lib/opentelemetry/instrumentation/excon' +require_relative '../../../../../lib/opentelemetry/instrumentation/excon/middlewares/old/tracer_middleware' +require_relative '../../../../../lib/opentelemetry/instrumentation/excon/patches/old/socket' describe OpenTelemetry::Instrumentation::Excon::Instrumentation do let(:instrumentation) { OpenTelemetry::Instrumentation::Excon::Instrumentation.instance } @@ -16,6 +16,8 @@ let(:span) { exporter.finished_spans.first } before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + exporter.reset stub_request(:get, 'http://example.com/success').to_return(status: 200) stub_request(:get, 'http://example.com/failure').to_return(status: 500) diff --git a/instrumentation/excon/test/opentelemetry/instrumentation/excon/stable/instrumentation_test.rb b/instrumentation/excon/test/opentelemetry/instrumentation/excon/stable/instrumentation_test.rb new file mode 100644 index 0000000000..9f962693a9 --- /dev/null +++ b/instrumentation/excon/test/opentelemetry/instrumentation/excon/stable/instrumentation_test.rb @@ -0,0 +1,338 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +require_relative '../../../../../lib/opentelemetry/instrumentation/excon' +require_relative '../../../../../lib/opentelemetry/instrumentation/excon/middlewares/stable/tracer_middleware' +require_relative '../../../../../lib/opentelemetry/instrumentation/excon/patches/stable/socket' + +describe OpenTelemetry::Instrumentation::Excon::Instrumentation do + let(:instrumentation) { OpenTelemetry::Instrumentation::Excon::Instrumentation.instance } + let(:exporter) { EXPORTER } + let(:span) { exporter.finished_spans.first } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('stable') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http' + exporter.reset + stub_request(:get, 'http://example.com/success').to_return(status: 200) + stub_request(:get, 'http://example.com/success?hello=there').to_return(status: 200) + stub_request(:get, 'http://example.com/failure').to_return(status: 500) + stub_request(:get, 'http://example.com/timeout').to_timeout + + # this is currently a noop but this will future proof the test + @orig_propagation = OpenTelemetry.propagation + propagator = OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator + OpenTelemetry.propagation = propagator + end + + after do + # Force re-install of instrumentation + instrumentation.instance_variable_set(:@installed, false) + + OpenTelemetry.propagation = @orig_propagation + end + + describe 'tracing' do + before do + instrumentation.install + end + + it 'before request' do + _(exporter.finished_spans).must_be_empty + end + + it 'after request with success code' do + Excon.get('http://example.com/success') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.request.method']).must_equal 'GET' + _(span.attributes['url.scheme']).must_equal 'http' + _(span.attributes['http.response.status_code']).must_equal 200 + _(span.attributes['url.path']).must_equal '/success' + _(span.attributes['url.query']).must_be_nil + _(span.attributes['url.full']).must_equal 'http://example.com/success' + assert_requested( + :get, + 'http://example.com/success', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + specify 'after request with capital-letters HTTP method' do + Excon.new('http://example.com/success').request(method: 'GET') + + _(span.attributes['http.request.method']).must_equal 'GET' + end + + it 'after request with failure code' do + Excon.get('http://example.com/failure') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.request.method']).must_equal 'GET' + _(span.attributes['url.scheme']).must_equal 'http' + _(span.attributes['http.response.status_code']).must_equal 500 + _(span.attributes['url.path']).must_equal '/failure' + _(span.attributes['url.full']).must_equal 'http://example.com/failure' + _(span.attributes['url.query']).must_be_nil + assert_requested( + :get, + 'http://example.com/failure', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'after request timeout' do + expect do + Excon.get('http://example.com/timeout') + end.must_raise Excon::Error::Timeout + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.request.method']).must_equal 'GET' + _(span.attributes['url.scheme']).must_equal 'http' + _(span.attributes['url.path']).must_equal '/timeout' + _(span.attributes['url.full']).must_equal 'http://example.com/timeout' + _(span.attributes['url.query']).must_be_nil + _(span.status.code).must_equal( + OpenTelemetry::Trace::Status::ERROR + ) + _(span.status.description).must_equal('Request has failed') + assert_requested( + :get, + 'http://example.com/timeout', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + + exception_event = span.events.first + _(exception_event.attributes['exception.type']).must_equal('Excon::Error::Timeout') + _(exception_event.attributes['exception.message']).must_equal('Excon::Error::Timeout') + end + + it 'merges HTTP client context' do + client_context_attrs = { + 'test.attribute' => 'test.value', 'http.request.method' => 'OVERRIDE' + } + OpenTelemetry::Common::HTTP::ClientContext.with_attributes(client_context_attrs) do + Excon.get('http://example.com/success') + end + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.request.method']).must_equal 'OVERRIDE' + _(span.attributes['url.scheme']).must_equal 'http' + _(span.attributes['http.response.status_code']).must_equal 200 + _(span.attributes['url.path']).must_equal '/success' + _(span.attributes['url.full']).must_equal 'http://example.com/success' + _(span.attributes['url.query']).must_be_nil + _(span.attributes['test.attribute']).must_equal 'test.value' + assert_requested( + :get, + 'http://example.com/success', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'records url query' do + Excon.get('http://example.com/success?hello=there') + + _(exporter.finished_spans.size).must_equal 1 + _(span.attributes['url.query']).must_equal 'hello=there' + assert_requested( + :get, + 'http://example.com/success?hello=there', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'accepts peer service name from config' do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(peer_service: 'example:faraday') + + Excon.get('http://example.com/success') + + _(span.attributes['peer.service']).must_equal 'example:faraday' + end + + it 'prioritizes context attributes over config for peer service name' do + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(peer_service: 'example:faraday') + + client_context_attrs = { 'peer.service' => 'example:custom' } + OpenTelemetry::Common::HTTP::ClientContext.with_attributes(client_context_attrs) do + Excon.get('http://example.com/success') + end + + _(span.attributes['peer.service']).must_equal 'example:custom' + end + end + + describe 'untraced?' do + before do + instrumentation.install(untraced_hosts: ['foobar.com', /bazqux\.com/]) + + stub_request(:get, 'http://example.com/body').to_return(status: 200) + stub_request(:get, 'http://foobar.com/body').to_return(status: 200) + stub_request(:get, 'http://bazqux.com/body').to_return(status: 200) + end + + it 'does not create a span when request ignored using a string' do + Excon.get('http://foobar.com/body') + _(exporter.finished_spans).must_be_empty + end + + it 'does not create a span when request ignored using a regexp' do + Excon.get('http://bazqux.com/body') + _(exporter.finished_spans).must_be_empty + end + + it 'does not create a span on connect when request ignored using a regexp' do + uri = URI.parse('http://bazqux.com') + + Excon::Socket.new(hostname: uri.host, port: uri.port) + + _(exporter.finished_spans).must_be_empty + end + + it 'creates a span for a non-ignored request' do + Excon.get('http://example.com/body') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.request.method']).must_equal 'GET' + end + + it 'creates a span on connect for a non-ignored request' do + uri = URI.parse('http://example.com') + + Excon::Socket.new(hostname: uri.host, port: uri.port) + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal('connect') + _(span.kind).must_equal(:internal) + _(span.attributes['server.address']).must_equal('example.com') + _(span.attributes['server.port']).must_equal(80) + end + end + + # NOTE: WebMock introduces an extra HTTP request span due to the way the mocking is implemented. + describe '#connect' do + before do + instrumentation.install + WebMock.allow_net_connect! + end + + after do + WebMock.disable_net_connect! + end + + it 'emits span on connect' do + port = nil + + TCPServer.open('localhost', 0) do |server| + Thread.start do + server.accept + rescue IOError + nil + end + + port = server.addr[1] + + _(-> { Excon.get("http://localhost:#{port}/example", read_timeout: 0) }).must_raise(Excon::Error::Timeout) + end + + _(exporter.finished_spans.size).must_equal(3) + _(span.name).must_equal 'connect' + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).wont_be_nil + _(span.attributes['server.port']).must_equal(port) + + assert_http_spans(port: port, target: '/example', exception: 'Excon::Error::Timeout') + end + + it 'captures errors' do + _(-> { Excon.get('http://invalid.com:99999/example') }).must_raise + + _(exporter.finished_spans.size).must_equal(3) + _(span.name).must_equal 'connect' + _(span.attributes['server.address']).must_equal('invalid.com') + _(span.attributes['server.port']).must_equal(99_999) + + span_event = span.events.first + _(span_event.name).must_equal 'exception' + # Depending on the Ruby and Excon Version this will be a SocketError, Socket::ResolutionError or Resolv::ResolvError + _(span_event.attributes['exception.type']).must_match(/(Socket|Resolv)/) + + assert_http_spans(host: 'invalid.com', port: 99_999, target: '/example') + end + + it '[BUG] fails to emit an HTTP CONNECT span when connecting through an SSL proxy for an HTTP service' do + _(-> { Excon.get('http://localhost/', proxy: 'https://proxy_user:proxy_pass@localhost') }).must_raise(Excon::Error::Socket) + + _(exporter.finished_spans.size).must_equal(3) + _(span.name).must_equal 'connect' + _(span.kind).must_equal(:internal) + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).must_equal(443) + + assert_http_spans + end + + it 'emits an HTTP CONNECT span when connecting through an SSL proxy' do + _(-> { Excon.get('https://localhost/', proxy: 'https://proxy_user:proxy_pass@localhost') }).must_raise(Excon::Error::Socket) + + _(exporter.finished_spans.size).must_equal(3) + _(span.name).must_equal 'CONNECT' + _(span.kind).must_equal(:client) + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).must_equal(443) + + assert_http_spans(scheme: 'https') + end + + it 'emits a "connect" span when connecting through an non-ssl proxy' do + _(-> { Excon.get('http://localhost', proxy: 'https://proxy_user:proxy_pass@localhost') }).must_raise(Excon::Error::Socket) + + _(exporter.finished_spans.size).must_equal(3) + _(span.name).must_equal 'connect' + _(span.kind).must_equal(:internal) + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).must_equal(443) + + assert_http_spans(exception: 'Excon::Error::Socket') + end + + it 'emits no spans when untraced' do + OpenTelemetry::Common::Utilities.untraced do + _(-> { Excon.get('http://localhost', proxy: 'https://proxy_user:proxy_pass@localhost') }).must_raise(Excon::Error::Socket) + + _(exporter.finished_spans.size).must_equal(0) + end + end + end + + def assert_http_spans(scheme: 'http', host: 'localhost', port: nil, target: '/', exception: nil) + exporter.finished_spans[1..].each do |http_span| + _(http_span.name).must_equal 'GET' + _(http_span.attributes['http.request.method']).must_equal 'GET' + _(http_span.attributes['url.scheme']).must_equal scheme + _(http_span.attributes['url.path']).must_equal target + _(http_span.attributes['url.full']).must_equal "#{scheme}://#{host}#{port&.to_s&.prepend(':')}#{target}" + _(http_span.status.code).must_equal( + OpenTelemetry::Trace::Status::ERROR + ) + + if exception + exception_event = http_span.events.first + _(exception_event.attributes['exception.type']).must_equal(exception) + end + end + end +end