diff --git a/instrumentation/net_http/Appraisals b/instrumentation/net_http/Appraisals new file mode 100644 index 0000000000..6d089d73f9 --- /dev/null +++ b/instrumentation/net_http/Appraisals @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +# To faclitate HTTP semantic convention stability migration, we are using +# appraisal to test the different semantic convention modes along with different +# HTTP gem versions. For more information on the semantic convention modes, see: +# https://opentelemetry.io/docs/specs/semconv/non-normative/http-migration/ + +semconv_stability = %w[dup stable old] + +semconv_stability.each do |mode| + appraise "net-http-#{mode}" do + # NOOP - net-http is part of the Ruby standard library. + # We are only using Appraisals to allow testing of the + # different stability modes. The file and the Appraisal + # gem can be removed once semconv migration is complete. + end +end diff --git a/instrumentation/net_http/Gemfile b/instrumentation/net_http/Gemfile index 9738151df4..e6d47e102d 100644 --- a/instrumentation/net_http/Gemfile +++ b/instrumentation/net_http/Gemfile @@ -9,6 +9,7 @@ source 'https://rubygems.org' gemspec group :test do + gem 'appraisal', '~> 2.5' gem 'bundler', '~> 2.4' gem 'minitest', '~> 5.0' gem 'opentelemetry-sdk', '~> 1.1' diff --git a/instrumentation/net_http/README.md b/instrumentation/net_http/README.md index bc6eacded6..9217b77f28 100644 --- a/instrumentation/net_http/README.md +++ b/instrumentation/net_http/README.md @@ -52,3 +52,19 @@ Apache 2.0 license. See [LICENSE][license-github] for more information. [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 Net::HTTP 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, Net::HTTP instrumentation code comes in three patch versions: `dup`, `old`, and `stable`. These versions are identical except for the attributes they send. Any changes to Net::HTTP 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/net_http/lib/opentelemetry/instrumentation/net/http/instrumentation.rb b/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/instrumentation.rb index 6e4a7616bc..1c145a0124 100644 --- a/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/instrumentation.rb +++ b/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/instrumentation.rb @@ -12,8 +12,9 @@ module HTTP # instrumentation class Instrumentation < OpenTelemetry::Instrumentation::Base install do |_config| - require_dependencies - patch + patch_type = determine_semconv + send(:"require_dependencies_#{patch_type}") + send(:"patch_#{patch_type}") end present do @@ -30,12 +31,41 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base private - def require_dependencies - require_relative 'patches/instrumentation' + 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 'patches/dup/instrumentation' + end + + def require_dependencies_old + require_relative 'patches/old/instrumentation' + end + + def require_dependencies_stable + require_relative 'patches/stable/instrumentation' + end + + def patch_dup + ::Net::HTTP.prepend(Patches::Dup::Instrumentation) + end + + def patch_old + ::Net::HTTP.prepend(Patches::Old::Instrumentation) end - def patch - ::Net::HTTP.prepend(Patches::Instrumentation) + def patch_stable + ::Net::HTTP.prepend(Patches::Stable::Instrumentation) end end end diff --git a/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/dup/instrumentation.rb b/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/dup/instrumentation.rb new file mode 100644 index 0000000000..9bda0080ab --- /dev/null +++ b/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/dup/instrumentation.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Net + module HTTP + module Patches + module Dup + # Module to prepend to Net::HTTP for instrumentation + module Instrumentation + USE_SSL_TO_SCHEME = { false => 'http', true => 'https' }.freeze + + # Constant for the HTTP status range + HTTP_STATUS_SUCCESS_RANGE = (100..399) + + def request(req, body = nil, &) + # Do not trace recursive call for starting the connection + return super unless started? + + return super if untraced? + + attributes = { + OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => req.method, + OpenTelemetry::SemanticConventions::Trace::HTTP_SCHEME => USE_SSL_TO_SCHEME[use_ssl?], + OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => req.path, + OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => @address, + OpenTelemetry::SemanticConventions::Trace::NET_PEER_PORT => @port, + 'http.request.method' => req.method, + 'url.scheme' => USE_SSL_TO_SCHEME[use_ssl?], + 'server.address' => @address, + 'server.port' => @port + } + path, query = split_path_and_query(req.path) + attributes['url.path'] = path + attributes['url.query'] = query if query + + attributes.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) + + tracer.in_span( + req.method, + attributes: attributes, + kind: :client + ) do |span| + OpenTelemetry.propagation.inject(req) + + super.tap do |response| + annotate_span_with_response!(span, response) + end + end + end + + private + + def connect + return super if untraced? + + if proxy? + conn_address = proxy_address + conn_port = proxy_port + else + conn_address = address + 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 use_ssl? && 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 annotate_span_with_response!(span, response) + return unless response&.code + + status_code = response.code.to_i + + span.set_attribute(OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE, status_code) + span.set_attribute('http.response.status_code', status_code) + span.status = OpenTelemetry::Trace::Status.error unless HTTP_STATUS_SUCCESS_RANGE.cover?(status_code) + end + + def tracer + Net::HTTP::Instrumentation.instance.tracer + end + + def untraced? + untraced_context? || untraced_host? + end + + def untraced_host? + return true if Net::HTTP::Instrumentation.instance.config[:untraced_hosts]&.any? do |host| + host.is_a?(Regexp) ? host.match?(@address) : host == @address + end + + false + end + + def untraced_context? + OpenTelemetry::Common::Utilities.untraced? + end + + def split_path_and_query(path) + path_and_query = path.split('?') + + [path_and_query[0], path_and_query[1]] + end + end + end + end + end + end + end +end diff --git a/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/instrumentation.rb b/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/instrumentation.rb deleted file mode 100644 index d5ed8a5923..0000000000 --- a/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/instrumentation.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -# Copyright The OpenTelemetry Authors -# -# SPDX-License-Identifier: Apache-2.0 - -module OpenTelemetry - module Instrumentation - module Net - module HTTP - module Patches - # Module to prepend to Net::HTTP for instrumentation - module Instrumentation - HTTP_METHODS_TO_SPAN_NAMES = Hash.new { |h, k| h[k] = "HTTP #{k}" } - USE_SSL_TO_SCHEME = { false => 'http', true => 'https' }.freeze - - # Constant for the HTTP status range - HTTP_STATUS_SUCCESS_RANGE = (100..399) - - def request(req, body = nil, &) - # Do not trace recursive call for starting the connection - return super unless started? - - return super if untraced? - - attributes = { - OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => req.method, - OpenTelemetry::SemanticConventions::Trace::HTTP_SCHEME => USE_SSL_TO_SCHEME[use_ssl?], - OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => req.path, - OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => @address, - OpenTelemetry::SemanticConventions::Trace::NET_PEER_PORT => @port - }.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) - - tracer.in_span( - HTTP_METHODS_TO_SPAN_NAMES[req.method], - attributes: attributes, - kind: :client - ) do |span| - OpenTelemetry.propagation.inject(req) - - super.tap do |response| - annotate_span_with_response!(span, response) - end - end - end - - private - - def connect - return super if untraced? - - if proxy? - conn_address = proxy_address - conn_port = proxy_port - else - conn_address = address - 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 use_ssl? && 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 annotate_span_with_response!(span, response) - return unless response&.code - - status_code = response.code.to_i - - span.set_attribute(OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE, status_code) - span.status = OpenTelemetry::Trace::Status.error unless HTTP_STATUS_SUCCESS_RANGE.cover?(status_code) - end - - def tracer - Net::HTTP::Instrumentation.instance.tracer - end - - def untraced? - untraced_context? || untraced_host? - end - - def untraced_host? - return true if Net::HTTP::Instrumentation.instance.config[:untraced_hosts]&.any? do |host| - host.is_a?(Regexp) ? host.match?(@address) : host == @address - end - - false - end - - def untraced_context? - OpenTelemetry::Common::Utilities.untraced? - end - end - end - end - end - end -end diff --git a/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/old/instrumentation.rb b/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/old/instrumentation.rb new file mode 100644 index 0000000000..2ec4c65767 --- /dev/null +++ b/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/old/instrumentation.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Net + module HTTP + module Patches + module Old + # Module to prepend to Net::HTTP for instrumentation + module Instrumentation + HTTP_METHODS_TO_SPAN_NAMES = Hash.new { |h, k| h[k] = "HTTP #{k}" } + USE_SSL_TO_SCHEME = { false => 'http', true => 'https' }.freeze + + # Constant for the HTTP status range + HTTP_STATUS_SUCCESS_RANGE = (100..399) + + def request(req, body = nil, &) + # Do not trace recursive call for starting the connection + return super unless started? + + return super if untraced? + + attributes = { + OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => req.method, + OpenTelemetry::SemanticConventions::Trace::HTTP_SCHEME => USE_SSL_TO_SCHEME[use_ssl?], + OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => req.path, + OpenTelemetry::SemanticConventions::Trace::NET_PEER_NAME => @address, + OpenTelemetry::SemanticConventions::Trace::NET_PEER_PORT => @port + }.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) + + tracer.in_span( + HTTP_METHODS_TO_SPAN_NAMES[req.method], + attributes: attributes, + kind: :client + ) do |span| + OpenTelemetry.propagation.inject(req) + + super.tap do |response| + annotate_span_with_response!(span, response) + end + end + end + + private + + def connect + return super if untraced? + + if proxy? + conn_address = proxy_address + conn_port = proxy_port + else + conn_address = address + 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 use_ssl? && 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 annotate_span_with_response!(span, response) + return unless response&.code + + status_code = response.code.to_i + + span.set_attribute(OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE, status_code) + span.status = OpenTelemetry::Trace::Status.error unless HTTP_STATUS_SUCCESS_RANGE.cover?(status_code) + end + + def tracer + Net::HTTP::Instrumentation.instance.tracer + end + + def untraced? + untraced_context? || untraced_host? + end + + def untraced_host? + return true if Net::HTTP::Instrumentation.instance.config[:untraced_hosts]&.any? do |host| + host.is_a?(Regexp) ? host.match?(@address) : host == @address + end + + false + end + + def untraced_context? + OpenTelemetry::Common::Utilities.untraced? + end + end + end + end + end + end + end +end diff --git a/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/stable/instrumentation.rb b/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/stable/instrumentation.rb new file mode 100644 index 0000000000..4923aad083 --- /dev/null +++ b/instrumentation/net_http/lib/opentelemetry/instrumentation/net/http/patches/stable/instrumentation.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Net + module HTTP + module Patches + module Stable + # Module to prepend to Net::HTTP for instrumentation + module Instrumentation + USE_SSL_TO_SCHEME = { false => 'http', true => 'https' }.freeze + + # Constant for the HTTP status range + HTTP_STATUS_SUCCESS_RANGE = (100..399) + + def request(req, body = nil, &) + # Do not trace recursive call for starting the connection + return super unless started? + + return super if untraced? + + attributes = { + 'http.request.method' => req.method, + 'url.scheme' => USE_SSL_TO_SCHEME[use_ssl?], + 'server.address' => @address, + 'server.port' => @port + } + path, query = split_path_and_query(req.path) + attributes['url.path'] = path + attributes['url.query'] = query if query + + attributes.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) + + tracer.in_span( + req.method.to_s, + attributes: attributes, + kind: :client + ) do |span| + OpenTelemetry.propagation.inject(req) + + super.tap do |response| + annotate_span_with_response!(span, response) + end + end + end + + private + + def connect + return super if untraced? + + if proxy? + conn_address = proxy_address + conn_port = proxy_port + else + conn_address = address + conn_port = port + end + + attributes = { + 'server.address' => conn_address, + 'server.port' => conn_port + }.merge!(OpenTelemetry::Common::HTTP::ClientContext.attributes) + + if use_ssl? && 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 annotate_span_with_response!(span, response) + return unless response&.code + + status_code = response.code.to_i + + span.set_attribute('http.response.status_code', status_code) + span.status = OpenTelemetry::Trace::Status.error unless HTTP_STATUS_SUCCESS_RANGE.cover?(status_code) + end + + def tracer + Net::HTTP::Instrumentation.instance.tracer + end + + def untraced? + untraced_context? || untraced_host? + end + + def untraced_host? + return true if Net::HTTP::Instrumentation.instance.config[:untraced_hosts]&.any? do |host| + host.is_a?(Regexp) ? host.match?(@address) : host == @address + end + + false + end + + def untraced_context? + OpenTelemetry::Common::Utilities.untraced? + end + + def split_path_and_query(path) + path_and_query = path.split('?') + + [path_and_query[0], path_and_query[1]] + end + end + end + end + end + end + end +end diff --git a/instrumentation/net_http/test/opentelemetry/instrumentation/net/http/dup/instrumentation_test.rb b/instrumentation/net_http/test/opentelemetry/instrumentation/net/http/dup/instrumentation_test.rb new file mode 100644 index 0000000000..e3dff1aebe --- /dev/null +++ b/instrumentation/net_http/test/opentelemetry/instrumentation/net/http/dup/instrumentation_test.rb @@ -0,0 +1,380 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +require_relative '../../../../../../lib/opentelemetry/instrumentation/net/http' +require_relative '../../../../../../lib/opentelemetry/instrumentation/net/http/patches/dup/instrumentation' + +describe OpenTelemetry::Instrumentation::Net::HTTP::Instrumentation do + let(:instrumentation) { OpenTelemetry::Instrumentation::Net::HTTP::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(:post, 'http://example.com/failure').to_return(status: 500) + stub_request(:get, 'https://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 + instrumentation.install + end + + after do + # Force re-install of instrumentation + instrumentation.instance_variable_set(:@installed, false) + + OpenTelemetry.propagation = @orig_propagation + end + + describe '#request' do + it 'before request' do + _(exporter.finished_spans.size).must_equal 0 + end + + it 'after request with success code' do + Net::HTTP.get('example.com', '/success') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(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['net.peer.name']).must_equal 'example.com' + _(span.attributes['net.peer.port']).must_equal 80 + # stable semantic conventions + _(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['server.address']).must_equal 'example.com' + _(span.attributes['server.port']).must_equal 80 + assert_requested( + :get, + 'http://example.com/success', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'after request with failure code' do + Net::HTTP.post(URI('http://example.com/failure'), 'q' => 'ruby') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'POST' + _(span.attributes['http.method']).must_equal 'POST' + _(span.attributes['http.scheme']).must_equal 'http' + _(span.attributes['http.status_code']).must_equal 500 + _(span.attributes['http.target']).must_equal '/failure' + _(span.attributes['net.peer.name']).must_equal 'example.com' + _(span.attributes['net.peer.port']).must_equal 80 + # stable semantic conventions + _(span.attributes['http.request.method']).must_equal 'POST' + _(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['server.address']).must_equal 'example.com' + _(span.attributes['server.port']).must_equal 80 + assert_requested( + :post, + 'http://example.com/failure', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'after request timeout' do + expect do + Net::HTTP.get(URI('https://example.com/timeout')) + end.must_raise Net::OpenTimeout + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.method']).must_equal 'GET' + _(span.attributes['http.scheme']).must_equal 'https' + _(span.attributes['http.status_code']).must_be_nil + _(span.attributes['http.target']).must_equal '/timeout' + _(span.attributes['net.peer.name']).must_equal 'example.com' + _(span.attributes['net.peer.port']).must_equal 443 + # stable semantic conventions + _(span.attributes['http.request.method']).must_equal 'GET' + _(span.attributes['url.scheme']).must_equal 'https' + _(span.attributes['http.response.status_code']).must_be_nil + _(span.attributes['url.path']).must_equal '/timeout' + _(span.attributes['server.address']).must_equal 'example.com' + _(span.attributes['server.port']).must_equal 443 + _(span.status.code).must_equal( + OpenTelemetry::Trace::Status::ERROR + ) + _(span.status.description).must_equal( + 'Unhandled exception of type: Net::OpenTimeout' + ) + assert_requested( + :get, + 'https://example.com/timeout', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'merges http client attributes' do + OpenTelemetry::Common::HTTP::ClientContext.with_attributes('peer.service' => 'foo', 'http.target' => 'REDACTED', 'url.path' => 'REDACTED') do + Net::HTTP.get('example.com', '/success') + end + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(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 'REDACTED' + _(span.attributes['net.peer.name']).must_equal 'example.com' + _(span.attributes['net.peer.port']).must_equal 80 + _(span.attributes['peer.service']).must_equal 'foo' + # stable semantic conventions + _(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 'REDACTED' + _(span.attributes['server.address']).must_equal 'example.com' + _(span.attributes['server.port']).must_equal 80 + _(span.attributes['peer.service']).must_equal 'foo' + assert_requested( + :get, + 'http://example.com/success', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'merges http client attribute url query' do + Net::HTTP.get('example.com', '/success?hello=there') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['url.query']).must_equal 'hello=there' + _(span.attributes['url.path']).must_equal '/success' + assert_requested( + :get, + 'http://example.com/success?hello=there', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + end + + describe 'untraced?' do + before do + 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 + + describe 'untraced_hosts option' do + before do + instrumentation.instance_variable_set(:@installed, false) + config = { + untraced_hosts: ['foobar.com', /bazqux\.com/] + } + + instrumentation.install(config) + end + + it 'does not create a span when request ignored using a string' do + Net::HTTP.get('foobar.com', '/body') + _(exporter.finished_spans.size).must_equal 0 + end + + it 'does not create a span when request ignored using a regexp' do + Net::HTTP.get('bazqux.com', '/body') + _(exporter.finished_spans.size).must_equal 0 + end + + it 'does not create a span on connect when request ignored using a regexp' do + # this works because http://bazqux.com is reachable site; try http://asdfasdfsef.com will fail + uri = URI.parse('http://bazqux.com') + http = Net::HTTP.new(uri.host, uri.port) + + fake_socket = Object.new + def fake_socket.setsockopt(*args); end + def fake_socket.close; end + + # Replace the TCP socket creation with our fake socket + TCPSocket.stub(:open, fake_socket) do + http.send(:connect) + end + + http.send(:do_finish) + _(exporter.finished_spans.size).must_equal 0 + end + + it 'creates a span for a non-ignored request' do + Net::HTTP.get('example.com', '/body') + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.method']).must_equal 'GET' + _(span.attributes['net.peer.name']).must_equal 'example.com' + # stable semantic conventions + _(span.attributes['http.request.method']).must_equal 'GET' + _(span.attributes['server.address']).must_equal 'example.com' + end + + it 'creates a span on connect for a non-ignored request' do + uri = URI.parse('http://example.com') + http = Net::HTTP.new(uri.host, uri.port) + http.send(:connect) + http.send(:do_finish) + _(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) + # stable semantic conventions + _(span.attributes['server.address']).must_equal('example.com') + _(span.attributes['server.port']).must_equal(80) + end + end + + describe 'untraced context' do + it 'no-ops on #request' do + # Calling `tracer.in_span` within an untraced context causes the logging of "called + # finish on an ended Span" messages. To avoid log noise, the instrumentation must + # no-op (i.e., not call `tracer.in_span`) when the context is untraced. + expect(instrumentation.tracer).not_to receive(:in_span) + + OpenTelemetry::Common::Utilities.untraced do + Net::HTTP.get('example.com', '/body') + end + + _(exporter.finished_spans.size).must_equal 0 + end + + it 'no-ops on #connect' do + expect(instrumentation.tracer).not_to receive(:in_span) + + OpenTelemetry::Common::Utilities.untraced do + uri = URI.parse('http://example.com/body') + http = Net::HTTP.new(uri.host, uri.port) + + # Mock the connect + http.define_singleton_method(:connect) { true } + + http.send(:connect) + http.send(:do_finish) + end + + _(exporter.finished_spans.size).must_equal 0 + end + end + end + + describe '#connect' do + it 'emits span on connect' do + WebMock.allow_net_connect! + TCPServer.open('localhost', 0) do |server| + Thread.start { server.accept } + port = server.addr[1] + + uri = URI.parse("http://localhost:#{port}/example") + http = Net::HTTP.new(uri.host, uri.port) + http.read_timeout = 0 + _(-> { http.request(Net::HTTP::Get.new(uri.request_uri)) }).must_raise(Net::ReadTimeout) + end + + _(exporter.finished_spans.size).must_equal(2) + _(span.name).must_equal 'connect' + _(span.attributes['net.peer.name']).must_equal('localhost') + _(span.attributes['net.peer.port']).wont_be_nil + # stable semantic conventions + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).wont_be_nil + ensure + WebMock.disable_net_connect! + end + + it 'captures errors' do + WebMock.allow_net_connect! + + uri = URI.parse('http://invalid.com:99999/example') + http = Net::HTTP.new(uri.host, uri.port) + _(-> { http.request(Net::HTTP::Get.new(uri.request_uri)) }).must_raise + + _(exporter.finished_spans.size).must_equal(1) + _(span.name).must_equal 'connect' + _(span.attributes['net.peer.name']).must_equal('invalid.com') + _(span.attributes['net.peer.port']).must_equal(99_999) + # stable semantic conventions + _(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' + _(span_event.attributes['exception.type']).wont_be_nil + _(span_event.attributes['exception.message']).must_match(/Failed to open TCP connection to invalid.com:99999/) + ensure + WebMock.disable_net_connect! + end + + it 'emits an HTTP CONNECT span when connecting through an SSL proxy' do + WebMock.allow_net_connect! + + uri = URI.parse('http://localhost') + proxy_uri = URI.parse('https://localhost') + + # rubocop:disable Lint/SuppressedException + begin + Net::HTTP.start(uri.host, uri.port, proxy_uri.host, proxy_uri.port, 'proxy_user', 'proxy_pass', use_ssl: true) do |http| + http.get('/') + end + rescue StandardError + end + # rubocop:enable Lint/SuppressedException + + _(exporter.finished_spans.size).must_equal(2) + _(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 semantic conventions + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).must_equal(443) + ensure + WebMock.disable_net_connect! + end + + it 'emits a "connect" span when connecting through an non-ssl proxy' do + WebMock.allow_net_connect! + + uri = URI.parse('http://localhost') + proxy_uri = URI.parse('https://localhost') + + # rubocop:disable Lint/SuppressedException + begin + Net::HTTP.start(uri.host, uri.port, proxy_uri.host, proxy_uri.port, 'proxy_user', 'proxy_pass', use_ssl: false) do |http| + http.get('/') + end + rescue StandardError + end + # rubocop:enable Lint/SuppressedException + + _(exporter.finished_spans.size).must_equal(2) + _(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 semantic conventions + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).must_equal(443) + ensure + WebMock.disable_net_connect! + end + end +end diff --git a/instrumentation/net_http/test/opentelemetry/instrumentation/net/http/instrumentation_test.rb b/instrumentation/net_http/test/opentelemetry/instrumentation/net/http/old/instrumentation_test.rb similarity index 95% rename from instrumentation/net_http/test/opentelemetry/instrumentation/net/http/instrumentation_test.rb rename to instrumentation/net_http/test/opentelemetry/instrumentation/net/http/old/instrumentation_test.rb index e3961349d5..782b07a2e2 100644 --- a/instrumentation/net_http/test/opentelemetry/instrumentation/net/http/instrumentation_test.rb +++ b/instrumentation/net_http/test/opentelemetry/instrumentation/net/http/old/instrumentation_test.rb @@ -6,8 +6,8 @@ require 'test_helper' -require_relative '../../../../../lib/opentelemetry/instrumentation/net/http' -require_relative '../../../../../lib/opentelemetry/instrumentation/net/http/patches/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/net/http' +require_relative '../../../../../../lib/opentelemetry/instrumentation/net/http/patches/old/instrumentation' describe OpenTelemetry::Instrumentation::Net::HTTP::Instrumentation do let(:instrumentation) { OpenTelemetry::Instrumentation::Net::HTTP::Instrumentation.instance } @@ -15,6 +15,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(:post, 'http://example.com/failure').to_return(status: 500) @@ -154,7 +156,16 @@ # this works because http://bazqux.com is reachable site; try http://asdfasdfsef.com will fail uri = URI.parse('http://bazqux.com') http = Net::HTTP.new(uri.host, uri.port) - http.send(:connect) + + fake_socket = Object.new + def fake_socket.setsockopt(*args); end + def fake_socket.close; end + + # Replace the TCP socket creation with our fake socket + TCPSocket.stub(:open, fake_socket) do + http.send(:connect) + end + http.send(:do_finish) _(exporter.finished_spans.size).must_equal 0 end diff --git a/instrumentation/net_http/test/opentelemetry/instrumentation/net/http/stable/instrumentation_test.rb b/instrumentation/net_http/test/opentelemetry/instrumentation/net/http/stable/instrumentation_test.rb new file mode 100644 index 0000000000..2c24952972 --- /dev/null +++ b/instrumentation/net_http/test/opentelemetry/instrumentation/net/http/stable/instrumentation_test.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +require_relative '../../../../../../lib/opentelemetry/instrumentation/net/http' +require_relative '../../../../../../lib/opentelemetry/instrumentation/net/http/patches/stable/instrumentation' + +describe OpenTelemetry::Instrumentation::Net::HTTP::Instrumentation do + let(:instrumentation) { OpenTelemetry::Instrumentation::Net::HTTP::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(:post, 'http://example.com/failure').to_return(status: 500) + stub_request(:get, 'https://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 + instrumentation.install + end + + after do + # Force re-install of instrumentation + instrumentation.instance_variable_set(:@installed, false) + + OpenTelemetry.propagation = @orig_propagation + end + + describe '#request' do + it 'before request' do + _(exporter.finished_spans.size).must_equal 0 + end + + it 'after request with success code' do + Net::HTTP.get('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['server.address']).must_equal 'example.com' + _(span.attributes['server.port']).must_equal 80 + assert_requested( + :get, + 'http://example.com/success', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'after request with failure code' do + Net::HTTP.post(URI('http://example.com/failure'), 'q' => 'ruby') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'POST' + _(span.attributes['http.request.method']).must_equal 'POST' + _(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['server.address']).must_equal 'example.com' + _(span.attributes['server.port']).must_equal 80 + assert_requested( + :post, + 'http://example.com/failure', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'after request timeout' do + expect do + Net::HTTP.get(URI('https://example.com/timeout')) + end.must_raise Net::OpenTimeout + + _(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 'https' + _(span.attributes['http.response.status_code']).must_be_nil + _(span.attributes['url.path']).must_equal '/timeout' + _(span.attributes['server.address']).must_equal 'example.com' + _(span.attributes['server.port']).must_equal 443 + _(span.status.code).must_equal( + OpenTelemetry::Trace::Status::ERROR + ) + _(span.status.description).must_equal( + 'Unhandled exception of type: Net::OpenTimeout' + ) + assert_requested( + :get, + 'https://example.com/timeout', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'merges http client attributes' do + OpenTelemetry::Common::HTTP::ClientContext.with_attributes('peer.service' => 'foo', 'url.path' => 'REDACTED') do + Net::HTTP.get('example.com', '/success') + end + + _(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 'REDACTED' + _(span.attributes['server.address']).must_equal 'example.com' + _(span.attributes['server.port']).must_equal 80 + _(span.attributes['peer.service']).must_equal 'foo' + assert_requested( + :get, + 'http://example.com/success', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + + it 'merges http client attribute url query' do + Net::HTTP.get('example.com', '/success?hello=there') + + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['url.query']).must_equal 'hello=there' + _(span.attributes['url.path']).must_equal '/success' + assert_requested( + :get, + 'http://example.com/success?hello=there', + headers: { 'Traceparent' => "00-#{span.hex_trace_id}-#{span.hex_span_id}-01" } + ) + end + end + + describe 'untraced?' do + before do + 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 + + describe 'untraced_hosts option' do + before do + instrumentation.instance_variable_set(:@installed, false) + config = { + untraced_hosts: ['foobar.com', /bazqux\.com/] + } + + instrumentation.install(config) + end + + it 'does not create a span when request ignored using a string' do + Net::HTTP.get('foobar.com', '/body') + _(exporter.finished_spans.size).must_equal 0 + end + + it 'does not create a span when request ignored using a regexp' do + Net::HTTP.get('bazqux.com', '/body') + _(exporter.finished_spans.size).must_equal 0 + end + + it 'does not create a span on connect when request ignored using a regexp' do + # this works because http://bazqux.com is reachable site; try http://asdfasdfsef.com will fail + uri = URI.parse('http://bazqux.com') + http = Net::HTTP.new(uri.host, uri.port) + + fake_socket = Object.new + def fake_socket.setsockopt(*args); end + def fake_socket.close; end + + # Replace the TCP socket creation with our fake socket + TCPSocket.stub(:open, fake_socket) do + http.send(:connect) + end + + http.send(:do_finish) + _(exporter.finished_spans.size).must_equal 0 + end + + it 'creates a span for a non-ignored request' do + Net::HTTP.get('example.com', '/body') + _(exporter.finished_spans.size).must_equal 1 + _(span.name).must_equal 'GET' + _(span.attributes['http.request.method']).must_equal 'GET' + _(span.attributes['server.address']).must_equal 'example.com' + end + + it 'creates a span on connect for a non-ignored request' do + uri = URI.parse('http://example.com') + http = Net::HTTP.new(uri.host, uri.port) + http.send(:connect) + http.send(:do_finish) + _(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 + + describe 'untraced context' do + it 'no-ops on #request' do + # Calling `tracer.in_span` within an untraced context causes the logging of "called + # finish on an ended Span" messages. To avoid log noise, the instrumentation must + # no-op (i.e., not call `tracer.in_span`) when the context is untraced. + expect(instrumentation.tracer).not_to receive(:in_span) + + OpenTelemetry::Common::Utilities.untraced do + Net::HTTP.get('example.com', '/body') + end + + _(exporter.finished_spans.size).must_equal 0 + end + + it 'no-ops on #connect' do + expect(instrumentation.tracer).not_to receive(:in_span) + + OpenTelemetry::Common::Utilities.untraced do + uri = URI.parse('http://example.com/body') + http = Net::HTTP.new(uri.host, uri.port) + + # Mock the connect + http.define_singleton_method(:connect) { true } + + http.send(:connect) + http.send(:do_finish) + end + + _(exporter.finished_spans.size).must_equal 0 + end + end + end + + describe '#connect' do + it 'emits span on connect' do + WebMock.allow_net_connect! + TCPServer.open('localhost', 0) do |server| + Thread.start { server.accept } + port = server.addr[1] + + uri = URI.parse("http://localhost:#{port}/example") + http = Net::HTTP.new(uri.host, uri.port) + http.read_timeout = 0 + _(-> { http.request(Net::HTTP::Get.new(uri.request_uri)) }).must_raise(Net::ReadTimeout) + end + + _(exporter.finished_spans.size).must_equal(2) + _(span.name).must_equal 'connect' + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).wont_be_nil + ensure + WebMock.disable_net_connect! + end + + it 'captures errors' do + WebMock.allow_net_connect! + + uri = URI.parse('http://invalid.com:99999/example') + http = Net::HTTP.new(uri.host, uri.port) + _(-> { http.request(Net::HTTP::Get.new(uri.request_uri)) }).must_raise + + _(exporter.finished_spans.size).must_equal(1) + _(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' + _(span_event.attributes['exception.type']).wont_be_nil + _(span_event.attributes['exception.message']).must_match(/Failed to open TCP connection to invalid.com:99999/) + ensure + WebMock.disable_net_connect! + end + + it 'emits an HTTP CONNECT span when connecting through an SSL proxy' do + WebMock.allow_net_connect! + + uri = URI.parse('http://localhost') + proxy_uri = URI.parse('https://localhost') + + # rubocop:disable Lint/SuppressedException + begin + Net::HTTP.start(uri.host, uri.port, proxy_uri.host, proxy_uri.port, 'proxy_user', 'proxy_pass', use_ssl: true) do |http| + http.get('/') + end + rescue StandardError + end + # rubocop:enable Lint/SuppressedException + + _(exporter.finished_spans.size).must_equal(2) + _(span.name).must_equal 'CONNECT' + _(span.kind).must_equal(:client) + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).must_equal(443) + ensure + WebMock.disable_net_connect! + end + + it 'emits a "connect" span when connecting through an non-ssl proxy' do + WebMock.allow_net_connect! + + uri = URI.parse('http://localhost') + proxy_uri = URI.parse('https://localhost') + + # rubocop:disable Lint/SuppressedException + begin + Net::HTTP.start(uri.host, uri.port, proxy_uri.host, proxy_uri.port, 'proxy_user', 'proxy_pass', use_ssl: false) do |http| + http.get('/') + end + rescue StandardError + end + # rubocop:enable Lint/SuppressedException + + _(exporter.finished_spans.size).must_equal(2) + _(span.name).must_equal 'connect' + _(span.kind).must_equal(:internal) + _(span.attributes['server.address']).must_equal('localhost') + _(span.attributes['server.port']).must_equal(443) + ensure + WebMock.disable_net_connect! + end + end +end