diff --git a/.rubocop.yml b/.rubocop.yml index 5039443f6..ff6921f7f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -22,6 +22,8 @@ Metrics/AbcSize: Enabled: false Metrics/BlockLength: Enabled: false +Metrics/ClassLength: + Enabled: false Metrics/MethodLength: Max: 25 Naming/FileName: diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack.rb index 0274c8975..fb175ce14 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack.rb @@ -14,7 +14,6 @@ module Rack extend self CURRENT_SPAN_KEY = Context.create_key('current-span') - private_constant :CURRENT_SPAN_KEY # Returns the current span from the current or provided context @@ -50,4 +49,5 @@ def with_span(span) end require_relative './rack/instrumentation' +require_relative './rack/util' require_relative './rack/version' diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb index cd9a831d6..b1c6f8763 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb @@ -13,7 +13,6 @@ module Rack # instrumentation class Instrumentation < OpenTelemetry::Instrumentation::Base install do |_config| - # TODO: move logic that configures allow lists here require_dependencies end @@ -29,12 +28,56 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base option :url_quantization, default: nil, validate: :callable option :untraced_requests, default: nil, validate: :callable option :response_propagators, default: [], validate: :array + # This option is only valid for applicaitons using Rack 2.0 or greater + option :use_rack_events, default: false, validate: :boolean + + # Temporary Helper for Sinatra and ActionPack middleware to use during installation + # + # @example Default usage + # Rack::Builder.new do + # use *OpenTelemetry::Instrumentation::Rack::Instrumenation.instance.middleware_args + # run lambda { |_arg| [200, { 'Content-Type' => 'text/plain' }, body] } + # end + # @return [Array] consisting of a middleware and arguments used in rack builders + def middleware_args + if config.fetch(:use_rack_events, false) == true && defined?(::Rack::Events) + [::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new]] + else + [OpenTelemetry::Instrumentation::Rack::Middlewares::TracerMiddleware] + end + end private def require_dependencies + require_relative 'middlewares/event_handler' if defined?(Rack::Events) require_relative 'middlewares/tracer_middleware' end + + def config_options(user_config) + config = super(user_config) + config[:allowed_rack_request_headers] = config[:allowed_request_headers].compact.each_with_object({}) do |header, memo| + key = header.to_s.upcase.gsub(/[-\s]/, '_') + case key + when 'CONTENT_TYPE', 'CONTENT_LENGTH' + memo[key] = build_attribute_name('http.request.header.', header) + else + memo["HTTP_#{key}"] = build_attribute_name('http.request.header.', header) + end + end + + config[:allowed_rack_response_headers] = config[:allowed_response_headers].each_with_object({}) do |header, memo| + memo[header] = build_attribute_name('http.response.header.', header) + memo[header.to_s.upcase] = build_attribute_name('http.response.header.', header) + end + + config[:untraced_endpoints]&.compact! + config + end + + def build_attribute_name(prefix, suffix) + prefix + suffix.to_s.downcase.gsub(/[-\s]/, '_') + end end end end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/event_handler.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/event_handler.rb new file mode 100644 index 000000000..04c3985b1 --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/event_handler.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../util' +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + # OTel Rack Event Handler + # + # This seeds the root context for this service with the server span as the `current_span` + # allowing for callers later in the stack to reference it using {OpenTelemetry::Trace.current_span} + # + # It also registers the server span in a context dedicated to this instrumentation that users may look up + # using {OpenTelemetry::Instrumentation::Rack.current_span}, which makes it possible for users to mutate the span, + # e.g. add events or update the span name like in the {ActionPack} instrumentation. + # + # @example Rack App Using BodyProxy + # GLOBAL_LOGGER = Logger.new($stderr) + # APP_TRACER = OpenTelemetry.tracer_provider.tracer('my-app', '1.0.0') + # + # Rack::Builder.new do + # use Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new] + # run lambda { |_arg| + # APP_TRACER.in_span('hello-world') do |_span| + # body = Rack::BodyProxy.new(['hello world!']) do + # rack_span = OpenTelemetry::Instrumentation::Rack.current_span + # GLOBAL_LOGGER.info("otel.trace_id=#{rack_span.context.hex_trace_id} otel.span_id=#{rack_span.context.hex_span_id}") + # end + # [200, { 'Content-Type' => 'text/plain' }, body] + # end + # } + # end + # + # @see Rack::Events + # @see OpenTelemetry::Instrumentation::Rack.current_span + class EventHandler + include ::Rack::Events::Abstract + + TOKENS_KEY = 'otel.context.tokens' + GOOD_HTTP_STATUSES = (100..499).freeze + + # Creates a server span for this current request using the incoming parent context + # and registers them as the {current_span} + # + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] This is nil in practice + # @return [void] + def on_start(request, _) + return if untraced_request?(request.env) + + parent_context = extract_remote_context(request) + span = create_span(parent_context, request) + request.env[TOKENS_KEY] = register_current_span(span) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Optionally adds debugging response headers injected from {response_propagators} + # + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] This current HTTP response + # @return [void] + def on_commit(request, response) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + response_propagators&.each do |propagator| + propagator.inject(response.headers) + rescue StandardError => e + OpenTelemetry.handle_error(message: 'Unable to inject response propagation headers', exception: e) + end + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Records Unexpected Exceptions on the Rack span and set the Span Status to Error + # + # @note does nothing if the span is a non-recording span + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] The current HTTP response + # @param [Exception] An unxpected error raised by the application + def on_error(request, _, error) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + span.record_exception(error) + span.status = OpenTelemetry::Trace::Status.error + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Finishes the span making it eligible to be exported and cleans up existing contexts + # + # @note does nothing if the span is a non-recording span + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] The current HTTP response + def on_finish(request, response) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + add_response_attributes(span, response) if response + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + ensure + detach_contexts(request) + end + + private + + EMPTY_HASH = {}.freeze + def extract_request_headers(env) + return EMPTY_HASH if allowed_request_headers.empty? + + allowed_request_headers.each_with_object({}) do |(key, value), result| + result[value] = env[key] if env.key?(key) + end + end + + def extract_response_attributes(response) + attributes = { 'http.status_code' => response.status.to_i } + attributes.merge!(extract_response_headers(response.headers)) + attributes + end + + def extract_response_headers(headers) + return EMPTY_HASH if allowed_response_headers.empty? + + allowed_response_headers.each_with_object({}) do |(key, value), result| + if headers.key?(key) + result[value] = headers[key] + else + # do case-insensitive match: + headers.each do |k, v| + if k.upcase == key + result[value] = v + break + end + end + end + end + end + + def untraced_request?(env) + return true if untraced_endpoints.include?(env['PATH_INFO']) + return true if untraced_requests&.call(env) + + false + end + + # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name + # + # recommendation: span.name(s) should be low-cardinality (e.g., + # strip off query param value, keep param name) + # + # see http://github.com/open-telemetry/opentelemetry-specification/pull/416/files + def create_request_span_name(request) + # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) + # see Datadog::Quantization::HTTP.url + + if (implementation = url_quantization) + request_uri_or_path_info = request.env['REQUEST_URI'] || request.path_info + implementation.call(request_uri_or_path_info, request.env) + else + "HTTP #{request.request_method}" + end + end + + def extract_remote_context(request) + OpenTelemetry.propagation.extract( + request.env, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + end + + def request_span_attributes(env) + attributes = { + 'http.method' => env['REQUEST_METHOD'], + 'http.host' => env['HTTP_HOST'] || 'unknown', + 'http.scheme' => env['rack.url_scheme'], + 'http.target' => env['QUERY_STRING'].empty? ? env['PATH_INFO'] : "#{env['PATH_INFO']}?#{env['QUERY_STRING']}" + } + + attributes['http.user_agent'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] + attributes.merge!(extract_request_headers(env)) + attributes + end + + def detach_contexts(request) + request.env[TOKENS_KEY]&.reverse&.each do |token| + OpenTelemetry::Context.detach(token) + OpenTelemetry::Trace.current_span.finish + end + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def add_response_attributes(span, response) + span.status = OpenTelemetry::Trace::Status.error unless GOOD_HTTP_STATUSES.include?(response.status.to_i) + attributes = extract_response_attributes(response) + span.add_attributes(attributes) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def record_frontend_span? + config[:record_frontend_span] == true + end + + def untraced_endpoints + config[:untraced_endpoints] + end + + def untraced_requests + config[:untraced_requests] + end + + def url_quantization + config[:url_quantization] + end + + def response_propagators + config[:response_propagators] + end + + def allowed_request_headers + config[:allowed_rack_request_headers] + end + + def allowed_response_headers + config[:allowed_rack_response_headers] + end + + def tracer + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer + end + + def config + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.config + end + + def register_current_span(span) + ctx = OpenTelemetry::Trace.context_with_span(span) + rack_ctx = OpenTelemetry::Instrumentation::Rack.context_with_span(span, parent_context: ctx) + + contexts = [ctx, rack_ctx] + contexts.compact! + contexts.map { |context| OpenTelemetry::Context.attach(context) } + end + + def create_span(parent_context, request) + span = tracer.start_span( + create_request_span_name(request), + with_parent: parent_context, + kind: :server, + attributes: request_span_attributes(request.env) + ) + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(request.env) + span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? + span + end + end + end + end + end +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware.rb index e413ecabd..9075a6a53 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware.rb @@ -6,8 +6,6 @@ require 'opentelemetry/trace/status' -require_relative '../util/queue_time' - module OpenTelemetry module Instrumentation module Rack diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/util.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/util.rb new file mode 100644 index 000000000..d9373cbcf --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/util.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Rack + # Provides utilities methods for creating Rack spans + module Util + require_relative 'util/queue_time' + end + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb index 52b20789b..2814bde0d 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb @@ -14,6 +14,7 @@ before do # simulate a fresh install: instrumentation.instance_variable_set('@installed', false) + instrumentation.config.clear end describe 'given default config options' do @@ -31,6 +32,7 @@ _(instrumentation.config[:url_quantization]).must_be_nil _(instrumentation.config[:untraced_requests]).must_be_nil _(instrumentation.config[:response_propagators]).must_be_empty + _(instrumentation.config[:use_rack_events]).must_equal false end end @@ -44,4 +46,29 @@ _(instrumentation).wont_be :installed? end end + + describe '#middleware_args' do + before do + instrumentation.install(config) + end + + describe 'when rack events are configured' do + let(:config) { Hash(use_rack_events: true) } + + it 'instantiates a custom event handler' do + args = instrumentation.middleware_args + _(args[0]).must_equal Rack::Events + _(args[1][0]).must_be_instance_of OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler + end + end + + describe 'when rack events are disabled' do + let(:config) { Hash(use_rack_events: false) } + + it 'instantiates a custom middleware' do + args = instrumentation.middleware_args + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::TracerMiddleware] + end + end + end end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_reseliency_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_reseliency_test.rb new file mode 100644 index 000000000..863f77b47 --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_reseliency_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' +require_relative '../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../lib/opentelemetry/instrumentation/rack/middlewares/event_handler' + +describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler::ResiliencyTest' do + let(:handler) do + OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new + end + + it 'reports unexpected errors without causing request errors' do + allow(OpenTelemetry::Instrumentation::Rack).to receive(:current_span).and_raise('Bad news!') + expect(OpenTelemetry).to receive(:handle_error).exactly(5).times + + handler.on_start(nil, nil) + handler.on_commit(nil, nil) + handler.on_send(nil, nil) + handler.on_error(nil, nil, nil) + handler.on_finish(nil, nil) + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_test.rb new file mode 100644 index 000000000..ff43da7d1 --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_test.rb @@ -0,0 +1,413 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' +require_relative '../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../lib/opentelemetry/instrumentation/rack/middlewares/event_handler' + +describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler' do + include Rack::Test::Methods + + let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } + let(:instrumentation_class) { instrumentation_module::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + let(:instrumentation_enabled) { true } + + let(:config) do + { + untraced_endpoints: untraced_endpoints, + untraced_requests: untraced_requests, + allowed_request_headers: allowed_request_headers, + allowed_response_headers: allowed_response_headers, + url_quantization: url_quantization, + response_propagators: response_propagators, + enabled: instrumentation_enabled, + use_rack_events: true + } + end + + let(:exporter) { EXPORTER } + let(:finished_spans) { exporter.finished_spans } + let(:rack_span) { exporter.finished_spans.first } + let(:proxy_event) { rack_span.events&.first } + let(:uri) { '/' } + let(:handler) do + OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new + end + + let(:after_close) { nil } + let(:response_body) { Rack::BodyProxy.new(['Hello World']) { after_close&.call } } + + let(:service) do + ->(_arg) { [200, { 'Content-Type' => 'text/plain' }, response_body] } + end + let(:untraced_endpoints) { [] } + let(:untraced_requests) { nil } + let(:allowed_request_headers) { nil } + let(:allowed_response_headers) { nil } + let(:response_propagators) { nil } + let(:url_quantization) { nil } + let(:headers) { {} } + let(:app) do + Rack::Builder.new.tap do |builder| + builder.use Rack::Events, [handler] + builder.run service + end + end + + before do + exporter.reset + + # simulate a fresh install: + instrumentation.instance_variable_set('@installed', false) + instrumentation.install(config) + end + + describe '#call' do + before do + get uri, {}, headers + end + + it 'record a span' do + _(rack_span.attributes['http.method']).must_equal 'GET' + _(rack_span.attributes['http.status_code']).must_equal 200 + _(rack_span.attributes['http.target']).must_equal '/' + _(rack_span.attributes['http.url']).must_be_nil + _(rack_span.name).must_equal 'HTTP GET' + _(rack_span.kind).must_equal :server + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + _(rack_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + _(proxy_event).must_be_nil + end + + describe 'when a query is passed in' do + let(:uri) { '/endpoint?query=true' } + + it 'records the query path' do + _(rack_span.attributes['http.target']).must_equal '/endpoint?query=true' + _(rack_span.name).must_equal 'HTTP GET' + end + end + + describe 'config[:untraced_endpoints]' do + describe 'when an array is passed in' do + let(:untraced_endpoints) { ['/ping'] } + + it 'does not trace paths listed in the array' do + get '/ping' + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + _(ping_span).must_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + _(root_span).wont_be_nil + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_endpoints: nil } } + + it 'traces everything' do + get '/ping' + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + _(ping_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + _(root_span).wont_be_nil + end + end + end + + describe 'config[:untraced_requests]' do + describe 'when a callable is passed in' do + let(:untraced_requests) do + ->(env) { env['PATH_INFO'] =~ %r{^\/assets} } + end + + it 'does not trace requests in which the callable returns true' do + get '/assets' + + assets_span = finished_spans.find { |s| s.attributes['http.target'] == '/assets' } + _(assets_span).must_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + _(root_span).wont_be_nil + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_requests: nil } } + + it 'traces everything' do + get '/assets' + + asset_span = finished_spans.find { |s| s.attributes['http.target'] == '/assets' } + _(asset_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + _(root_span).wont_be_nil + end + end + end + + describe 'config[:allowed_request_headers]' do + let(:headers) do + Hash( + 'CONTENT_LENGTH' => '123', + 'CONTENT_TYPE' => 'application/json', + 'HTTP_FOO_BAR' => 'http foo bar value' + ) + end + + it 'defaults to nil' do + _(rack_span.attributes['http.request.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:allowed_request_headers) do + ['foo_BAR'] + end + + it 'returns attribute' do + _(rack_span.attributes['http.request.header.foo_bar']).must_equal 'http foo bar value' + end + end + + describe 'when content-type' do + let(:allowed_request_headers) { ['CONTENT_TYPE'] } + + it 'returns attribute' do + _(rack_span.attributes['http.request.header.content_type']).must_equal 'application/json' + end + end + + describe 'when content-length' do + let(:allowed_request_headers) { ['CONTENT_LENGTH'] } + + it 'returns attribute' do + _(rack_span.attributes['http.request.header.content_length']).must_equal '123' + end + end + end + + describe 'config[:allowed_response_headers]' do + let(:service) do + ->(_env) { [200, { 'Foo-Bar' => 'foo bar response header' }, ['OK']] } + end + + it 'defaults to nil' do + _(rack_span.attributes['http.response.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:allowed_response_headers) { ['Foo-Bar'] } + + it 'returns attribute' do + _(rack_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + + describe 'case-sensitively' do + let(:allowed_response_headers) { ['fOO-bAR'] } + + it 'returns attribute' do + _(rack_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + end + end + end + + describe 'given request proxy headers' do + let(:headers) { Hash('HTTP_X_REQUEST_START' => '1677723466') } + + it 'records an event' do + _(proxy_event.name).must_equal 'http.proxy.request.started' + _(proxy_event.timestamp).must_equal 1_677_723_466_000_000_000 + end + end + + describe '#called with 400 level http status code' do + let(:service) do + ->(_env) { [404, { 'Foo-Bar' => 'foo bar response header' }, ['Not Found']] } + end + + it 'leaves status code unset' do + _(rack_span.attributes['http.status_code']).must_equal 404 + _(rack_span.kind).must_equal :server + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + end + + describe 'url quantization' do + describe 'when using standard Rack environment variables' do + describe 'without quantization' do + it 'span.name defaults to low cardinality name HTTP method' do + get '/really_long_url' + + _(rack_span.name).must_equal 'HTTP GET' + _(rack_span.attributes['http.target']).must_equal '/really_long_url' + end + end + + describe 'with simple quantization' do + let(:quantization_example) do + ->(url, _env) { url.to_s } + end + + let(:url_quantization) { quantization_example } + + it 'sets the span.name to the full path' do + get '/really_long_url' + + _(rack_span.name).must_equal '/really_long_url' + _(rack_span.attributes['http.target']).must_equal '/really_long_url' + end + end + + describe 'with quantization' do + let(:quantization_example) do + # demonstrate simple shortening of URL: + ->(url, _env) { url.to_s[0..5] } + end + let(:url_quantization) { quantization_example } + + it 'mutates url according to url_quantization' do + get '/really_long_url' + + _(rack_span.name).must_equal '/reall' + end + end + end + + describe 'when using Action Dispatch custom environment variables' do + describe 'without quantization' do + it 'span.name defaults to low cardinality name HTTP method' do + get '/really_long_url', {}, { 'REQUEST_URI' => '/action-dispatch-uri' } + + _(rack_span.name).must_equal 'HTTP GET' + _(rack_span.attributes['http.target']).must_equal '/really_long_url' + end + end + + describe 'with simple quantization' do + let(:quantization_example) do + ->(url, _env) { url.to_s } + end + + let(:url_quantization) { quantization_example } + + it 'sets the span.name to the full path' do + get '/really_long_url', {}, { 'REQUEST_URI' => '/action-dispatch-uri' } + + _(rack_span.name).must_equal '/action-dispatch-uri' + _(rack_span.attributes['http.target']).must_equal '/really_long_url' + end + end + + describe 'with quantization' do + let(:quantization_example) do + # demonstrate simple shortening of URL: + ->(url, _env) { url.to_s[0..5] } + end + let(:url_quantization) { quantization_example } + + it 'mutates url according to url_quantization' do + get '/really_long_url', {}, { 'REQUEST_URI' => '/action-dispatch-uri' } + + _(rack_span.name).must_equal '/actio' + end + end + end + end + + describe 'response_propagators' do + describe 'with default options' do + it 'does not inject the traceresponse header' do + get '/ping' + _(last_response.headers).wont_include('traceresponse') + end + end + + describe 'with ResponseTextMapPropagator' do + let(:response_propagators) { [OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator.new] } + + it 'injects the traceresponse header' do + get '/ping' + _(last_response.headers).must_include('traceresponse') + end + end + + describe 'response propagators that raise errors' do + class EventMockPropagator < OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator + CustomError = Class.new(StandardError) + def inject(carrier) + raise CustomError, 'Injection failed' + end + end + + let(:response_propagators) { [EventMockPropagator.new, OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator.new] } + + it 'is fault tolerant' do + expect(OpenTelemetry).to receive(:handle_error).with(exception: instance_of(EventMockPropagator::CustomError), message: /Unable/) + + get '/ping' + _(last_response.headers).must_include('traceresponse') + end + end + end + + describe '#call with error' do + EventHandlerError = Class.new(StandardError) + + let(:service) do + ->(_env) { raise EventHandlerError } + end + + it 'records error in span and then re-raises' do + assert_raises EventHandlerError do + get '/' + end + + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + end + end + + describe 'when the instrumentation is disabled' do + let(:instrumenation_enabled) { false } + + it 'does nothing' do + _(rack_span).must_be_nil + end + end + + describe 'when response body is called' do + let(:after_close) { -> { OpenTelemetry::Instrumentation::Rack.current_span.add_event('after-response-called') } } + + it 'has access to a Rack read/write span' do + get '/' + _(rack_span.events.map(&:name)).must_include('after-response-called') + end + end + + describe 'when response body is called' do + let(:response_body) { ['Simple, Hello World!'] } + + it 'has access to a Rack read/write span' do + get '/' + _(rack_span.attributes['http.method']).must_equal 'GET' + _(rack_span.attributes['http.status_code']).must_equal 200 + _(rack_span.attributes['http.target']).must_equal '/' + _(rack_span.attributes['http.url']).must_be_nil + _(rack_span.name).must_equal 'HTTP GET' + _(rack_span.kind).must_equal :server + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + _(rack_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + _(proxy_event).must_be_nil + end + end +end diff --git a/instrumentation/rack/test/test_helper.rb b/instrumentation/rack/test/test_helper.rb index 478b15c9c..d8970b076 100644 --- a/instrumentation/rack/test/test_helper.rb +++ b/instrumentation/rack/test/test_helper.rb @@ -6,12 +6,13 @@ require 'bundler/setup' Bundler.require(:default, :development, :test) +require 'rack/events' +require 'opentelemetry-instrumentation-rack' require 'minitest/autorun' require 'rspec/mocks/minitest_integration' require 'webmock/minitest' - -require 'opentelemetry-instrumentation-rack' +require 'rspec/mocks/minitest_integration' # global opentelemetry-sdk setup: EXPORTER = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new