diff --git a/instrumentation/CONTRIBUTING.md b/instrumentation/CONTRIBUTING.md index 9a77d6d7ad..35ddea2c72 100644 --- a/instrumentation/CONTRIBUTING.md +++ b/instrumentation/CONTRIBUTING.md @@ -458,8 +458,11 @@ end # Set up fake Rack application builder = Rack::Builder.app do # Integration is automatic in web frameworks but plain Rack applications require this line. + # - middleware_args_old to emit old HTTP semantic conventions + # - middleware_args_stable to emit stable HTTP semantic conventions + # - middleware_args_dup to emit both old and stable HTTP semantic conventions # Enable it in your config.ru. - use *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args + use *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old run ExampleAPI end app = Rack::MockRequest.new(builder) diff --git a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb index 0c9d0fff5c..20df14fbf4 100644 --- a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb +++ b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb @@ -12,9 +12,20 @@ class Railtie < ::Rails::Railtie config.before_initialize do |app| OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.install({}) + stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') + values = stability_opt_in.split(',').map(&:strip) + + middleware_args = if values.include?('http/dup') + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup + elsif values.include?('http') + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable + else + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old + end + app.middleware.insert_before( 0, - *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args + *middleware_args ) end end diff --git a/instrumentation/rack/Appraisals b/instrumentation/rack/Appraisals index 6f96b35843..7ec5ed4dfc 100644 --- a/instrumentation/rack/Appraisals +++ b/instrumentation/rack/Appraisals @@ -4,22 +4,31 @@ # # SPDX-License-Identifier: Apache-2.0 -appraise 'rack-latest' do - gem 'rack' -end +# 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/ -appraise 'rack-3.0' do - gem 'rack', '~> 3.0.0' -end +semconv_stability = %w[stable old dup] -appraise 'rack-2.2.x' do - gem 'rack', '~> 2.2.0' -end +semconv_stability.each do |mode| + appraise "rack-latest-#{mode}" do + gem 'rack' + end -appraise 'rack-2.1' do - gem 'rack', '~> 2.1.2' -end + appraise "rack-3.0-#{mode}" do + gem 'rack', '~> 3.0.0' + end + + appraise "rack-2.2.x-#{mode}" do + gem 'rack', '~> 2.2.0' + end + + appraise "rack-2.1-#{mode}" do + gem 'rack', '~> 2.1.2' + end -appraise 'rack-2.0' do - gem 'rack', '~> 2.0.8' + appraise "rack-2.0-#{mode}" do + gem 'rack', '~> 2.0.8' + end end diff --git a/instrumentation/rack/README.md b/instrumentation/rack/README.md index 6373900448..72654474be 100644 --- a/instrumentation/rack/README.md +++ b/instrumentation/rack/README.md @@ -101,3 +101,19 @@ The `opentelemetry-instrumentation-rack` 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 Rack 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, Rack instrumentation code comes in three patch versions: `dup`, `old`, and `stable`. These versions are identical except for the attributes they send. Any changes to Rack 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/). diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb index 3bfb68a3c9..9c2e395726 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb @@ -13,7 +13,8 @@ module Rack # instrumentation class Instrumentation < OpenTelemetry::Instrumentation::Base install do |_config| - require_dependencies + patch_type = determine_semconv + send(:"require_dependencies_#{patch_type}") end present do @@ -35,23 +36,64 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base # # @example Default usage # Rack::Builder.new do - # use *OpenTelemetry::Instrumentation::Rack::Instrumenation.instance.middleware_args + # use *OpenTelemetry::Instrumentation::Rack::Instrumenation.instance.middleware_args_old # 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?(OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler) - [::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new]] + def middleware_args_old + if config.fetch(:use_rack_events, false) == true && defined?(OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler) + [::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler.new]] else - [OpenTelemetry::Instrumentation::Rack::Middlewares::TracerMiddleware] + [OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware] + end + end + + alias middleware_args middleware_args_old + + def middleware_args_dup + if config.fetch(:use_rack_events, false) == true && defined?(OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler) + [::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler.new]] + else + [OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::TracerMiddleware] + end + end + + def middleware_args_stable + if config.fetch(:use_rack_events, false) == true && defined?(OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::EventHandler) + [::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::EventHandler.new]] + else + [OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::TracerMiddleware] end end private - def require_dependencies - require_relative 'middlewares/event_handler' if defined?(::Rack::Events) - require_relative 'middlewares/tracer_middleware' + 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_old + require_relative 'middlewares/old/event_handler' if defined?(::Rack::Events) + require_relative 'middlewares/old/tracer_middleware' + end + + def require_dependencies_stable + require_relative 'middlewares/stable/event_handler' if defined?(::Rack::Events) + require_relative 'middlewares/stable/tracer_middleware' + end + + def require_dependencies_dup + require_relative 'middlewares/dup/event_handler' if defined?(::Rack::Events) + require_relative 'middlewares/dup/tracer_middleware' end def config_options(user_config) diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb new file mode 100644 index 0000000000..f97e721b3b --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb @@ -0,0 +1,281 @@ +# 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 + module Dup + # 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 + + OTEL_TOKEN_AND_SPAN = 'otel.rack.token_and_span' + EMPTY_HASH = {}.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, _) + parent_context = if untraced_request?(request.env) + extract_remote_context(request, OpenTelemetry::Common::Utilities.untraced) + else + extract_remote_context(request) + end + + span = create_span(parent_context, request) + span_ctx = OpenTelemetry::Trace.context_with_span(span, parent_context: parent_context) + rack_ctx = OpenTelemetry::Instrumentation::Rack.context_with_span(span, parent_context: span_ctx) + request.env[OTEL_TOKEN_AND_SPAN] = [OpenTelemetry::Context.attach(rack_ctx), 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(error.class.name) + 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_context(request) + end + + private + + 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, + 'http.response.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 + request.request_method.to_s + end + end + + def extract_remote_context(request, context = Context.current) + OpenTelemetry.propagation.extract( + request.env, + context: context, + 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', + 'server.address' => 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']}", + 'http.request.method' => env['REQUEST_METHOD'], + 'url.scheme' => env['rack.url_scheme'], + 'url.path' => env['PATH_INFO'] + } + + attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? + if env['HTTP_USER_AGENT'] + attributes['http.user_agent'] = env['HTTP_USER_AGENT'] + attributes['user_agent.original'] = env['HTTP_USER_AGENT'] + end + attributes.merge!(extract_request_headers(env)) + attributes + end + + def detach_context(request) + return nil unless request.env[OTEL_TOKEN_AND_SPAN] + + token, span = request.env[OTEL_TOKEN_AND_SPAN] + span.finish + OpenTelemetry::Context.detach(token) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def add_response_attributes(span, response) + span.status = OpenTelemetry::Trace::Status.error if response.server_error? + 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 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 +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb new file mode 100644 index 0000000000..ff1bbbd1ab --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + module Dup + # TracerMiddleware propagates context and instruments Rack requests + # by way of its middleware system + class TracerMiddleware + class << self + def allowed_rack_request_headers + @allowed_rack_request_headers ||= Array(config[:allowed_request_headers]).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 + end + + def allowed_response_headers + @allowed_response_headers ||= Array(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 + end + + def build_attribute_name(prefix, suffix) + prefix + suffix.to_s.downcase.gsub(/[-\s]/, '_') + end + + def config + Rack::Instrumentation.instance.config + end + + private + + def clear_cached_config + @allowed_rack_request_headers = nil + @allowed_response_headers = nil + end + end + + EMPTY_HASH = {}.freeze + + def initialize(app) + @app = app + @untraced_endpoints = config[:untraced_endpoints] + end + + def call(env) + if untraced_request?(env) + OpenTelemetry::Common::Utilities.untraced do + return @app.call(env) + end + end + + original_env = env.dup + extracted_context = OpenTelemetry.propagation.extract( + env, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + frontend_context = create_frontend_span(env, extracted_context) + + # restore extracted context in this process: + OpenTelemetry::Context.with_current(frontend_context || extracted_context) do + request_span_name = create_request_span_name(env['REQUEST_URI'] || original_env['PATH_INFO'], env) + request_span_kind = frontend_context.nil? ? :server : :internal + tracer.in_span(request_span_name, + attributes: request_span_attributes(env: env), + kind: request_span_kind) do |request_span| + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + request_span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? + OpenTelemetry::Instrumentation::Rack.with_span(request_span) do + @app.call(env).tap do |status, headers, response| + set_attributes_after_request(request_span, status, headers, response) + config[:response_propagators].each { |propagator| propagator.inject(headers) } + end + end + end + end + ensure + finish_span(frontend_context) + end + + private + + def untraced_request?(env) + return true if @untraced_endpoints.include?(env['PATH_INFO']) + return true if config[:untraced_requests]&.call(env) + + false + end + + # return Context with the frontend span as the current span + def create_frontend_span(env, extracted_context) + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + + return unless config[:record_frontend_span] && !request_start_time.nil? + + span = tracer.start_span('http_server.proxy', + with_parent: extracted_context, + start_timestamp: request_start_time, + kind: :server) + + OpenTelemetry::Trace.context_with_span(span, parent_context: extracted_context) + end + + def finish_span(context) + OpenTelemetry::Trace.current_span(context).finish if context + end + + def tracer + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer + end + + def request_span_attributes(env:) + attributes = { + 'http.method' => env['REQUEST_METHOD'], + 'http.host' => env['HTTP_HOST'] || 'unknown', + 'server.address' => 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']}", + 'http.request.method' => env['REQUEST_METHOD'], + 'url.scheme' => env['rack.url_scheme'], + 'url.path' => env['PATH_INFO'] + } + + attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? + if env['HTTP_USER_AGENT'] + attributes['http.user_agent'] = env['HTTP_USER_AGENT'] + attributes['user_agent.original'] = env['HTTP_USER_AGENT'] + end + attributes.merge!(allowed_request_headers(env)) + 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_uri_or_path_info, env) + # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) + # see Datadog::Quantization::HTTP.url + + if (implementation = config[:url_quantization]) + implementation.call(request_uri_or_path_info, env) + else + env['REQUEST_METHOD'] + end + end + + def set_attributes_after_request(span, status, headers, _response) + span.status = OpenTelemetry::Trace::Status.error if (500..599).cover?(status.to_i) + span.set_attribute('http.status_code', status) + span.set_attribute('http.response.status_code', status) + + # NOTE: if data is available, it would be good to do this: + # set_attribute('http.route', ... + # e.g., "/users/:userID? + + allowed_response_headers(headers).each { |k, v| span.set_attribute(k, v) } + end + + def allowed_request_headers(env) + return EMPTY_HASH if self.class.allowed_rack_request_headers.empty? + + {}.tap do |result| + self.class.allowed_rack_request_headers.each do |key, value| + result[value] = env[key] if env.key?(key) + end + end + end + + def allowed_response_headers(headers) + return EMPTY_HASH if headers.nil? + return EMPTY_HASH if self.class.allowed_response_headers.empty? + + {}.tap do |result| + self.class.allowed_response_headers.each do |key, value| + 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 + end + + def config + Rack::Instrumentation.instance.config + end + end + end + 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 deleted file mode 100644 index bd6899a72c..0000000000 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/event_handler.rb +++ /dev/null @@ -1,268 +0,0 @@ -# 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 - - OTEL_TOKEN_AND_SPAN = 'otel.rack.token_and_span' - EMPTY_HASH = {}.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, _) - parent_context = if untraced_request?(request.env) - extract_remote_context(request, OpenTelemetry::Common::Utilities.untraced) - else - extract_remote_context(request) - end - - span = create_span(parent_context, request) - span_ctx = OpenTelemetry::Trace.context_with_span(span, parent_context: parent_context) - rack_ctx = OpenTelemetry::Instrumentation::Rack.context_with_span(span, parent_context: span_ctx) - request.env[OTEL_TOKEN_AND_SPAN] = [OpenTelemetry::Context.attach(rack_ctx), 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(error.class.name) - 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_context(request) - end - - private - - 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, context = Context.current) - OpenTelemetry.propagation.extract( - request.env, - context: context, - 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_context(request) - return nil unless request.env[OTEL_TOKEN_AND_SPAN] - - token, span = request.env[OTEL_TOKEN_AND_SPAN] - span.finish - OpenTelemetry::Context.detach(token) - rescue StandardError => e - OpenTelemetry.handle_error(exception: e) - end - - def add_response_attributes(span, response) - span.status = OpenTelemetry::Trace::Status.error if response.server_error? - 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 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/old/event_handler.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler.rb new file mode 100644 index 0000000000..56dd6ddbe6 --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler.rb @@ -0,0 +1,270 @@ +# 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 + module Old + # 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 + + OTEL_TOKEN_AND_SPAN = 'otel.rack.token_and_span' + EMPTY_HASH = {}.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, _) + parent_context = if untraced_request?(request.env) + extract_remote_context(request, OpenTelemetry::Common::Utilities.untraced) + else + extract_remote_context(request) + end + + span = create_span(parent_context, request) + span_ctx = OpenTelemetry::Trace.context_with_span(span, parent_context: parent_context) + rack_ctx = OpenTelemetry::Instrumentation::Rack.context_with_span(span, parent_context: span_ctx) + request.env[OTEL_TOKEN_AND_SPAN] = [OpenTelemetry::Context.attach(rack_ctx), 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(error.class.name) + 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_context(request) + end + + private + + 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, context = Context.current) + OpenTelemetry.propagation.extract( + request.env, + context: context, + 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_context(request) + return nil unless request.env[OTEL_TOKEN_AND_SPAN] + + token, span = request.env[OTEL_TOKEN_AND_SPAN] + span.finish + OpenTelemetry::Context.detach(token) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def add_response_attributes(span, response) + span.status = OpenTelemetry::Trace::Status.error if response.server_error? + 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 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 +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware.rb new file mode 100644 index 0000000000..2b515c8697 --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + module Old + # TracerMiddleware propagates context and instruments Rack requests + # by way of its middleware system + class TracerMiddleware + class << self + def allowed_rack_request_headers + @allowed_rack_request_headers ||= Array(config[:allowed_request_headers]).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 + end + + def allowed_response_headers + @allowed_response_headers ||= Array(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 + end + + def build_attribute_name(prefix, suffix) + prefix + suffix.to_s.downcase.gsub(/[-\s]/, '_') + end + + def config + Rack::Instrumentation.instance.config + end + + private + + def clear_cached_config + @allowed_rack_request_headers = nil + @allowed_response_headers = nil + end + end + + EMPTY_HASH = {}.freeze + + def initialize(app) + @app = app + @untraced_endpoints = config[:untraced_endpoints] + end + + def call(env) + if untraced_request?(env) + OpenTelemetry::Common::Utilities.untraced do + return @app.call(env) + end + end + + original_env = env.dup + extracted_context = OpenTelemetry.propagation.extract( + env, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + frontend_context = create_frontend_span(env, extracted_context) + + # restore extracted context in this process: + OpenTelemetry::Context.with_current(frontend_context || extracted_context) do + request_span_name = create_request_span_name(env['REQUEST_URI'] || original_env['PATH_INFO'], env) + request_span_kind = frontend_context.nil? ? :server : :internal + tracer.in_span(request_span_name, + attributes: request_span_attributes(env: env), + kind: request_span_kind) do |request_span| + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + request_span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? + OpenTelemetry::Instrumentation::Rack.with_span(request_span) do + @app.call(env).tap do |status, headers, response| + set_attributes_after_request(request_span, status, headers, response) + config[:response_propagators].each { |propagator| propagator.inject(headers) } + end + end + end + end + ensure + finish_span(frontend_context) + end + + private + + def untraced_request?(env) + return true if @untraced_endpoints.include?(env['PATH_INFO']) + return true if config[:untraced_requests]&.call(env) + + false + end + + # return Context with the frontend span as the current span + def create_frontend_span(env, extracted_context) + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + + return unless config[:record_frontend_span] && !request_start_time.nil? + + span = tracer.start_span('http_server.proxy', + with_parent: extracted_context, + start_timestamp: request_start_time, + kind: :server) + + OpenTelemetry::Trace.context_with_span(span, parent_context: extracted_context) + end + + def finish_span(context) + OpenTelemetry::Trace.current_span(context).finish if context + end + + def tracer + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer + 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!(allowed_request_headers(env)) + 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_uri_or_path_info, env) + # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) + # see Datadog::Quantization::HTTP.url + + if (implementation = config[:url_quantization]) + implementation.call(request_uri_or_path_info, env) + else + "HTTP #{env['REQUEST_METHOD']}" + end + end + + def set_attributes_after_request(span, status, headers, _response) + span.status = OpenTelemetry::Trace::Status.error if (500..599).cover?(status.to_i) + span.set_attribute('http.status_code', status) + + # NOTE: if data is available, it would be good to do this: + # set_attribute('http.route', ... + # e.g., "/users/:userID? + + allowed_response_headers(headers).each { |k, v| span.set_attribute(k, v) } + end + + def allowed_request_headers(env) + return EMPTY_HASH if self.class.allowed_rack_request_headers.empty? + + {}.tap do |result| + self.class.allowed_rack_request_headers.each do |key, value| + result[value] = env[key] if env.key?(key) + end + end + end + + def allowed_response_headers(headers) + return EMPTY_HASH if headers.nil? + return EMPTY_HASH if self.class.allowed_response_headers.empty? + + {}.tap do |result| + self.class.allowed_response_headers.each do |key, value| + 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 + end + + def config + Rack::Instrumentation.instance.config + end + end + end + end + end + end +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb new file mode 100644 index 0000000000..3998aa504a --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb @@ -0,0 +1,269 @@ +# 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 + module Stable + # 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 + + OTEL_TOKEN_AND_SPAN = 'otel.rack.token_and_span' + EMPTY_HASH = {}.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, _) + parent_context = if untraced_request?(request.env) + extract_remote_context(request, OpenTelemetry::Common::Utilities.untraced) + else + extract_remote_context(request) + end + + span = create_span(parent_context, request) + span_ctx = OpenTelemetry::Trace.context_with_span(span, parent_context: parent_context) + rack_ctx = OpenTelemetry::Instrumentation::Rack.context_with_span(span, parent_context: span_ctx) + request.env[OTEL_TOKEN_AND_SPAN] = [OpenTelemetry::Context.attach(rack_ctx), 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(error.class.name) + 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_context(request) + end + + private + + 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.response.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://opentelemetry.io/docs/specs/semconv/http/http-spans/#name + # + # recommendation: span.name(s) should be low-cardinality (e.g., + # strip off query param value, keep param name) + 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 + request.request_method.to_s + end + end + + def extract_remote_context(request, context = Context.current) + OpenTelemetry.propagation.extract( + request.env, + context: context, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + end + + def request_span_attributes(env) + attributes = { + 'http.request.method' => env['REQUEST_METHOD'], + 'server.address' => env['HTTP_HOST'] || 'unknown', + 'url.scheme' => env['rack.url_scheme'], + 'url.path' => env['PATH_INFO'] + } + + attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? + attributes['user_agent.original'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] + attributes.merge!(extract_request_headers(env)) + attributes + end + + def detach_context(request) + return nil unless request.env[OTEL_TOKEN_AND_SPAN] + + token, span = request.env[OTEL_TOKEN_AND_SPAN] + span.finish + OpenTelemetry::Context.detach(token) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def add_response_attributes(span, response) + span.status = OpenTelemetry::Trace::Status.error if response.server_error? + 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 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 +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb new file mode 100644 index 0000000000..f14e819fcc --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + module Stable + # TracerMiddleware propagates context and instruments Rack requests + # by way of its middleware system + class TracerMiddleware + class << self + def allowed_rack_request_headers + @allowed_rack_request_headers ||= Array(config[:allowed_request_headers]).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 + end + + def allowed_response_headers + @allowed_response_headers ||= Array(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 + end + + def build_attribute_name(prefix, suffix) + prefix + suffix.to_s.downcase.gsub(/[-\s]/, '_') + end + + def config + Rack::Instrumentation.instance.config + end + + private + + def clear_cached_config + @allowed_rack_request_headers = nil + @allowed_response_headers = nil + end + end + + EMPTY_HASH = {}.freeze + + def initialize(app) + @app = app + @untraced_endpoints = config[:untraced_endpoints] + end + + def call(env) + if untraced_request?(env) + OpenTelemetry::Common::Utilities.untraced do + return @app.call(env) + end + end + + original_env = env.dup + extracted_context = OpenTelemetry.propagation.extract( + env, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + frontend_context = create_frontend_span(env, extracted_context) + + # restore extracted context in this process: + OpenTelemetry::Context.with_current(frontend_context || extracted_context) do + request_span_name = create_request_span_name(env['REQUEST_URI'] || original_env['PATH_INFO'], env) + request_span_kind = frontend_context.nil? ? :server : :internal + tracer.in_span(request_span_name, + attributes: request_span_attributes(env: env), + kind: request_span_kind) do |request_span| + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + request_span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? + OpenTelemetry::Instrumentation::Rack.with_span(request_span) do + @app.call(env).tap do |status, headers, response| + set_attributes_after_request(request_span, status, headers, response) + config[:response_propagators].each { |propagator| propagator.inject(headers) } + end + end + end + end + ensure + finish_span(frontend_context) + end + + private + + def untraced_request?(env) + return true if @untraced_endpoints.include?(env['PATH_INFO']) + return true if config[:untraced_requests]&.call(env) + + false + end + + # return Context with the frontend span as the current span + def create_frontend_span(env, extracted_context) + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + + return unless config[:record_frontend_span] && !request_start_time.nil? + + span = tracer.start_span('http_server.proxy', + with_parent: extracted_context, + start_timestamp: request_start_time, + kind: :server) + + OpenTelemetry::Trace.context_with_span(span, parent_context: extracted_context) + end + + def finish_span(context) + OpenTelemetry::Trace.current_span(context).finish if context + end + + def tracer + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer + end + + def request_span_attributes(env:) + attributes = { + 'http.request.method' => env['REQUEST_METHOD'], + 'server.address' => env['HTTP_HOST'] || 'unknown', + 'url.scheme' => env['rack.url_scheme'], + 'url.path' => env['PATH_INFO'] + } + + attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? + attributes['user_agent.original'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] + attributes.merge!(allowed_request_headers(env)) + 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_uri_or_path_info, env) + # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) + # see Datadog::Quantization::HTTP.url + + if (implementation = config[:url_quantization]) + implementation.call(request_uri_or_path_info, env) + else + env['REQUEST_METHOD'] + end + end + + def set_attributes_after_request(span, status, headers, _response) + span.status = OpenTelemetry::Trace::Status.error if (500..599).cover?(status.to_i) + span.set_attribute('http.response.status_code', status) + + # NOTE: if data is available, it would be good to do this: + # set_attribute('http.route', ... + # e.g., "/users/:userID? + + allowed_response_headers(headers).each { |k, v| span.set_attribute(k, v) } + end + + def allowed_request_headers(env) + return EMPTY_HASH if self.class.allowed_rack_request_headers.empty? + + {}.tap do |result| + self.class.allowed_rack_request_headers.each do |key, value| + result[value] = env[key] if env.key?(key) + end + end + end + + def allowed_response_headers(headers) + return EMPTY_HASH if headers.nil? + return EMPTY_HASH if self.class.allowed_response_headers.empty? + + {}.tap do |result| + self.class.allowed_response_headers.each do |key, value| + 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 + end + + def config + Rack::Instrumentation.instance.config + end + 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 deleted file mode 100644 index e6aceba7da..0000000000 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware.rb +++ /dev/null @@ -1,203 +0,0 @@ -# frozen_string_literal: true - -# Copyright The OpenTelemetry Authors -# -# SPDX-License-Identifier: Apache-2.0 - -require 'opentelemetry/trace/status' - -module OpenTelemetry - module Instrumentation - module Rack - module Middlewares - # TracerMiddleware propagates context and instruments Rack requests - # by way of its middleware system - class TracerMiddleware - class << self - def allowed_rack_request_headers - @allowed_rack_request_headers ||= Array(config[:allowed_request_headers]).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 - end - - def allowed_response_headers - @allowed_response_headers ||= Array(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 - end - - def build_attribute_name(prefix, suffix) - prefix + suffix.to_s.downcase.gsub(/[-\s]/, '_') - end - - def config - Rack::Instrumentation.instance.config - end - - private - - def clear_cached_config - @allowed_rack_request_headers = nil - @allowed_response_headers = nil - end - end - - EMPTY_HASH = {}.freeze - - def initialize(app) - @app = app - @untraced_endpoints = config[:untraced_endpoints] - end - - def call(env) - if untraced_request?(env) - OpenTelemetry::Common::Utilities.untraced do - return @app.call(env) - end - end - - original_env = env.dup - extracted_context = OpenTelemetry.propagation.extract( - env, - getter: OpenTelemetry::Common::Propagation.rack_env_getter - ) - frontend_context = create_frontend_span(env, extracted_context) - - # restore extracted context in this process: - OpenTelemetry::Context.with_current(frontend_context || extracted_context) do - request_span_name = create_request_span_name(env['REQUEST_URI'] || original_env['PATH_INFO'], env) - request_span_kind = frontend_context.nil? ? :server : :internal - tracer.in_span(request_span_name, - attributes: request_span_attributes(env: env), - kind: request_span_kind) do |request_span| - request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) - request_span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? - OpenTelemetry::Instrumentation::Rack.with_span(request_span) do - @app.call(env).tap do |status, headers, response| - set_attributes_after_request(request_span, status, headers, response) - config[:response_propagators].each { |propagator| propagator.inject(headers) } - end - end - end - end - ensure - finish_span(frontend_context) - end - - private - - def untraced_request?(env) - return true if @untraced_endpoints.include?(env['PATH_INFO']) - return true if config[:untraced_requests]&.call(env) - - false - end - - # return Context with the frontend span as the current span - def create_frontend_span(env, extracted_context) - request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) - - return unless config[:record_frontend_span] && !request_start_time.nil? - - span = tracer.start_span('http_server.proxy', - with_parent: extracted_context, - start_timestamp: request_start_time, - kind: :server) - - OpenTelemetry::Trace.context_with_span(span, parent_context: extracted_context) - end - - def finish_span(context) - OpenTelemetry::Trace.current_span(context).finish if context - end - - def tracer - OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer - 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!(allowed_request_headers(env)) - 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_uri_or_path_info, env) - # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) - # see Datadog::Quantization::HTTP.url - - if (implementation = config[:url_quantization]) - implementation.call(request_uri_or_path_info, env) - else - "HTTP #{env['REQUEST_METHOD']}" - end - end - - def set_attributes_after_request(span, status, headers, _response) - span.status = OpenTelemetry::Trace::Status.error if (500..599).cover?(status.to_i) - span.set_attribute('http.status_code', status) - - # NOTE: if data is available, it would be good to do this: - # set_attribute('http.route', ... - # e.g., "/users/:userID? - - allowed_response_headers(headers).each { |k, v| span.set_attribute(k, v) } - end - - def allowed_request_headers(env) - return EMPTY_HASH if self.class.allowed_rack_request_headers.empty? - - {}.tap do |result| - self.class.allowed_rack_request_headers.each do |key, value| - result[value] = env[key] if env.key?(key) - end - end - end - - def allowed_response_headers(headers) - return EMPTY_HASH if headers.nil? - return EMPTY_HASH if self.class.allowed_response_headers.empty? - - {}.tap do |result| - self.class.allowed_response_headers.each do |key, value| - 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 - end - - def config - Rack::Instrumentation.instance.config - end - end - end - end - end -end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb similarity index 88% rename from instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb rename to instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb index 2a0cf5d6dd..baca6f689b 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb @@ -12,6 +12,8 @@ let(:config) { {} } before do + skip unless ENV['BUNDLE_GEMFILE'].include?('dup') + # simulate a fresh install: instrumentation.instance_variable_set(:@installed, false) instrumentation.config.clear @@ -47,7 +49,7 @@ end end - describe '#middleware_args' do + describe '#middleware_args_old' do before do instrumentation.install(config) end @@ -56,9 +58,9 @@ let(:config) { Hash(use_rack_events: true) } it 'instantiates a custom event handler' do - args = instrumentation.middleware_args + args = instrumentation.middleware_args_dup _(args[0]).must_equal Rack::Events - _(args[1][0]).must_be_instance_of OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler + _(args[1][0]).must_be_instance_of OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler end end @@ -66,8 +68,8 @@ 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] + args = instrumentation.middleware_args_dup + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::TracerMiddleware] end end end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb new file mode 100644 index 0000000000..d3f8bcca38 --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::Instrumentation::Rack::Instrumentation do + let(:instrumentation_class) { OpenTelemetry::Instrumentation::Rack::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + let(:config) { {} } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.config.clear + end + + describe 'given default config options' do + before do + instrumentation.install(config) + end + + it 'is installed with default settings' do + _(instrumentation).must_be :installed? + _(instrumentation.config[:allowed_request_headers]).must_be_empty + _(instrumentation.config[:allowed_response_headers]).must_be_empty + _(instrumentation.config[:application]).must_be_nil + _(instrumentation.config[:record_frontend_span]).must_equal false + _(instrumentation.config[:untraced_endpoints]).must_be_empty + _(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 true + end + end + + describe 'when rack gem does not exist' do + before do + hide_const('Rack') + instrumentation.install(config) + end + + it 'skips installation' do + _(instrumentation).wont_be :installed? + end + end + + describe '#middleware_args_old' 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_old + _(args[0]).must_equal Rack::Events + _(args[1][0]).must_be_instance_of OpenTelemetry::Instrumentation::Rack::Middlewares::Old::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_old + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware] + end + end + + describe 'when previously defined middleware_args is called' do + let(:config) { Hash(use_rack_events: false) } + + it 'alias to middleware_args_old' do + args = instrumentation.middleware_args + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware] + end + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_stable_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_stable_test.rb new file mode 100644 index 0000000000..be80413912 --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_stable_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::Instrumentation::Rack::Instrumentation do + let(:instrumentation_class) { OpenTelemetry::Instrumentation::Rack::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + let(:config) { {} } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('http') + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.config.clear + end + + describe 'given default config options' do + before do + instrumentation.install(config) + end + + it 'is installed with default settings' do + _(instrumentation).must_be :installed? + _(instrumentation.config[:allowed_request_headers]).must_be_empty + _(instrumentation.config[:allowed_response_headers]).must_be_empty + _(instrumentation.config[:application]).must_be_nil + _(instrumentation.config[:record_frontend_span]).must_equal false + _(instrumentation.config[:untraced_endpoints]).must_be_empty + _(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 true + end + end + + describe 'when rack gem does not exist' do + before do + hide_const('Rack') + instrumentation.install(config) + end + + it 'skips installation' do + _(instrumentation).wont_be :installed? + end + end + + describe '#middleware_args_old' 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_stable + _(args[0]).must_equal Rack::Events + _(args[1][0]).must_be_instance_of OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::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_stable + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::TracerMiddleware] + end + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/event_handler_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/event_handler_test.rb new file mode 100644 index 0000000000..9b5dc929c2 --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/event_handler_test.rb @@ -0,0 +1,508 @@ +# 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/dup/event_handler' + +describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::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::Dup::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 + skip unless ENV['BUNDLE_GEMFILE'].include?('dup') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http/dup' + exporter.reset + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(config) + end + + # Simulating buggy instrumentation that starts a span, sets the ctx + # but fails to detach or close the span + describe 'broken instrumentation' do + let(:service) do + lambda do |_env| + span = OpenTelemetry.tracer_provider.tracer('buggy').start_span('I never close') + OpenTelemetry::Context.attach(OpenTelemetry::Trace.context_with_span(span)) + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + it 'still closes the rack span' do + assert_raises OpenTelemetry::Context::DetachError do + get uri, {}, headers + end + _(finished_spans.size).must_equal 1 + _(rack_span.name).must_equal 'GET' + OpenTelemetry::Context.clear + end + 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.attributes['http.request.method']).must_equal 'GET' + _(rack_span.attributes['http.response.status_code']).must_equal 200 + _(rack_span.attributes['url.path']).must_equal '/' + _(rack_span.attributes['url.full']).must_be_nil + _(rack_span.name).must_equal '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 'with a hijacked response' do + let(:service) do + lambda do |env| + env['rack.hijack?'] = true + [-1, {}, []] + end + end + + it 'sets the span status to "unset"' do + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + + describe 'when baggage is set' do + let(:headers) do + Hash( + 'baggage' => 'foo=123' + ) + end + + let(:service) do + lambda do |_env| + _(OpenTelemetry::Baggage.raw_entries['foo'].value).must_equal('123') + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + it 'sets baggage in the request context' do + _(rack_span.name).must_equal 'GET' + end + 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.attributes['url.path']).must_equal '/endpoint' + _(rack_span.attributes['url.query']).must_equal 'query=true' + _(rack_span.name).must_equal 'GET' + end + end + + describe 'config[:untraced_endpoints]' do + let(:service) do + lambda do |_env| + OpenTelemetry.tracer_provider.tracer('req').in_span('in_req_span') {} + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + describe 'when an array is passed in' do + let(:uri) { '/ping' } + let(:untraced_endpoints) { ['/ping'] } + + it 'does not trace paths listed in the array' do + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + + _(ping_span).must_be_nil + _(ping_span_stable).must_be_nil + + _(finished_spans.size).must_equal 0 + 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_stable = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + _(ping_span_stable).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + end + + describe 'config[:untraced_requests]' do + let(:service) do + lambda do |_env| + OpenTelemetry.tracer_provider.tracer('req').in_span('in_req_span') {} + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + describe 'when a callable is passed in' do + let(:uri) { '/assets' } + let(:untraced_requests) do + ->(env) { env['PATH_INFO'] =~ %r{^\/assets} } + end + + it 'does not trace requests in which the callable returns true' do + assets_span = finished_spans.find { |s| s.attributes['http.target'] == '/assets' } + assets_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(assets_span).must_be_nil + _(assets_span_stable).must_be_nil + + _(finished_spans.size).must_equal 0 + 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_stable = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(asset_span).wont_be_nil + _(asset_span_stable).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + + _(root_span).wont_be_nil + _(root_span_stable).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.attributes['http.response.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 'GET' + _(rack_span.attributes['http.target']).must_equal '/really_long_url' + _(rack_span.attributes['url.path']).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' + _(rack_span.attributes['url.path']).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 'GET' + _(rack_span.attributes['http.target']).must_equal '/really_long_url' + _(rack_span.attributes['url.path']).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' + _(rack_span.attributes['url.path']).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.attributes['http.request.method']).must_equal 'GET' + _(rack_span.attributes['http.response.status_code']).must_equal 200 + _(rack_span.attributes['url.path']).must_equal '/' + _(rack_span.attributes['url.full']).must_be_nil + _(rack_span.name).must_equal '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/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware_test.rb new file mode 100644 index 0000000000..aece2a3470 --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware_test.rb @@ -0,0 +1,417 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +# require Instrumentation so .install method is found: +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware' + +describe OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::TracerMiddleware do + let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } + let(:instrumentation_class) { instrumentation_module::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + + let(:described_class) { OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::TracerMiddleware } + + let(:app) { ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['OK']] } } + let(:middleware) { described_class.new(app) } + let(:rack_builder) { Rack::Builder.new } + + let(:exporter) { EXPORTER } + let(:finished_spans) { exporter.finished_spans } + let(:first_span) { exporter.finished_spans.first } + let(:proxy_event) { first_span.events&.first } + + let(:default_config) { {} } + let(:config) { default_config } + let(:env) { {} } + let(:uri) { '/' } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('dup') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http/dup' + # clear captured spans: + exporter.reset + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(config) + + # clear out cached config: + described_class.send(:clear_cached_config) + + # integrate tracer middleware: + rack_builder.run app + rack_builder.use described_class + end + + after do + # installation is 'global', so it should be reset: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(default_config) + end + + describe '#call' do + before do + Rack::MockRequest.new(rack_builder).get(uri, env) + end + + it 'records attributes' do + _(first_span.attributes['http.method']).must_equal 'GET' + _(first_span.attributes['http.status_code']).must_equal 200 + _(first_span.attributes['http.target']).must_equal '/' + _(first_span.attributes['http.url']).must_be_nil + _(first_span.attributes['http.request.method']).must_equal 'GET' + _(first_span.attributes['http.response.status_code']).must_equal 200 + _(first_span.attributes['url.path']).must_equal '/' + _(first_span.attributes['url.full']).must_be_nil + _(first_span.name).must_equal 'GET' + _(first_span.kind).must_equal :server + end + + it 'does not explicitly set status OK' do + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + + describe 'with a hijacked response' do + let(:app) do + lambda do |env| + env['rack.hijack?'] = true + [-1, {}, []] + end + end + + it 'sets the span status to "unset"' do + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + + it 'has no parent' do + _(first_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + end + + describe 'when a query is passed in' do + let(:uri) { '/endpoint?query=true' } + + it 'records the query path' do + _(first_span.attributes['http.target']).must_equal '/endpoint?query=true' + _(first_span.attributes['url.path']).must_equal '/endpoint' + _(first_span.attributes['url.query']).must_equal 'query=true' + _(first_span.name).must_equal 'GET' + end + end + + describe 'given request proxy headers' do + let(:env) { 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 'config[:untraced_endpoints]' do + describe 'when an array is passed in' do + let(:config) { { untraced_endpoints: ['/ping'] } } + + it 'does not trace paths listed in the array' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).must_be_nil + _(ping_span_stable).must_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + + describe 'when a string is passed in' do + let(:config) { { untraced_endpoints: '/ping' } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + _(ping_span_stable).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_endpoints: nil } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + _(ping_span_stable).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + end + + describe 'config[:untraced_requests]' do + describe 'when a callable is passed in' do + let(:untraced_callable) do + ->(env) { env['PATH_INFO'] =~ %r{^\/assets} } + end + let(:config) { default_config.merge(untraced_requests: untraced_callable) } + + it 'does not trace requests in which the callable returns true' do + Rack::MockRequest.new(rack_builder).get('/assets', env) + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/assets' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(ping_span).must_be_nil + _(ping_span_stable).must_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_requests: nil } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/assets', env) + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/assets' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(ping_span).wont_be_nil + _(ping_span_stable).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + end + + describe 'config[:allowed_request_headers]' do + let(:env) do + Hash( + 'CONTENT_LENGTH' => '123', + 'CONTENT_TYPE' => 'application/json', + 'HTTP_FOO_BAR' => 'http foo bar value' + ) + end + + it 'defaults to nil' do + _(first_span.attributes['http.request.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:config) { default_config.merge(allowed_request_headers: ['foo_BAR']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.foo_bar']).must_equal 'http foo bar value' + end + end + + describe 'when content-type' do + let(:config) { default_config.merge(allowed_request_headers: ['CONTENT_TYPE']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.content_type']).must_equal 'application/json' + end + end + + describe 'when content-length' do + let(:config) { default_config.merge(allowed_request_headers: ['CONTENT_LENGTH']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.content_length']).must_equal '123' + end + end + end + + describe 'config[:allowed_response_headers]' do + let(:app) do + ->(_env) { [200, { 'Foo-Bar' => 'foo bar response header' }, ['OK']] } + end + + it 'defaults to nil' do + _(first_span.attributes['http.response.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:config) { default_config.merge(allowed_response_headers: ['Foo-Bar']) } + + it 'returns attribute' do + _(first_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + + describe 'case-sensitively' do + let(:config) { default_config.merge(allowed_response_headers: ['fOO-bAR']) } + + it 'returns attribute' do + _(first_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + end + end + end + + describe 'config[:record_frontend_span]' do + let(:request_span) { exporter.finished_spans.first } + + describe 'default' do + it 'does not record span' do + _(exporter.finished_spans.size).must_equal 1 + end + + it 'does not parent the request_span' do + _(request_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + end + end + + describe 'when recordable' do + let(:config) { default_config.merge(record_frontend_span: true) } + let(:env) { Hash('HTTP_X_REQUEST_START' => Time.now.to_i) } + let(:frontend_span) { exporter.finished_spans[1] } + let(:request_span) { exporter.finished_spans[0] } + + it 'records span' do + _(exporter.finished_spans.size).must_equal 2 + _(frontend_span.name).must_equal 'http_server.proxy' + _(frontend_span.attributes['service']).must_be_nil + end + + it 'changes request_span kind' do + _(request_span.kind).must_equal :internal + end + + it 'frontend_span parents request_span' do + _(request_span.parent_span_id).must_equal frontend_span.span_id + end + end + end + + describe '#called with 400 level http status code' do + let(:app) do + ->(_env) { [404, { 'Foo-Bar' => 'foo bar response header' }, ['Not Found']] } + end + + it 'leaves status code unset' do + _(first_span.attributes['http.status_code']).must_equal 404 + _(first_span.attributes['http.response.status_code']).must_equal 404 + _(first_span.kind).must_equal :server + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + end + + describe 'config[:quantization]' do + before do + Rack::MockRequest.new(rack_builder).get('/really_long_url', env) + end + + describe 'without quantization' do + it 'span.name defaults to low cardinality name HTTP method' do + _(first_span.name).must_equal 'GET' + _(first_span.attributes['http.target']).must_equal '/really_long_url' + _(first_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with simple quantization' do + let(:quantization_example) do + ->(url, _env) { url.to_s } + end + + let(:config) { default_config.merge(url_quantization: quantization_example) } + + it 'sets the span.name to the full path' do + _(first_span.name).must_equal '/really_long_url' + _(first_span.attributes['http.target']).must_equal '/really_long_url' + _(first_span.attributes['url.path']).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(:config) { default_config.merge(url_quantization: quantization_example) } + + it 'mutates url according to url_quantization' do + _(first_span.name).must_equal '/reall' + end + end + end + + describe 'config[:response_propagators]' do + describe 'with default options' do + it 'does not inject the traceresponse header' do + res = Rack::MockRequest.new(rack_builder).get('/ping', env) + _(res.headers).wont_include('traceresponse') + end + end + + describe 'with ResponseTextMapPropagator' do + let(:config) { default_config.merge(response_propagators: [OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator.new]) } + + it 'injects the traceresponse header' do + res = Rack::MockRequest.new(rack_builder).get('/ping', env) + _(res.headers).must_include('traceresponse') + end + end + + describe 'propagator throws' do + class MockPropagator < OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator + def inject(carrier) + raise 'Injection failed' + end + end + + let(:config) { default_config.merge(response_propagators: [MockPropagator.new]) } + + it 'leads to application errors when there are exceptions' do + assert_raises RuntimeError do + Rack::MockRequest.new(rack_builder).get('/ping', env) + end + end + end + end + + describe '#call with error' do + SimulatedError = Class.new(StandardError) + + let(:app) do + ->(_env) { raise SimulatedError } + end + + it 'records error in span and then re-raises' do + assert_raises SimulatedError do + Rack::MockRequest.new(rack_builder).get('/', env) + end + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_resiliency_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_resiliency_test.rb index 863f77b478..e37370953e 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_resiliency_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_resiliency_test.rb @@ -7,13 +7,15 @@ 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' +require_relative '../../../../../lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler' describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler::ResiliencyTest' do let(:handler) do - OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new + OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler.new end + before { skip unless ENV['BUNDLE_GEMFILE'].include?('old') } + 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 diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/event_handler_test.rb similarity index 96% rename from instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_test.rb rename to instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/event_handler_test.rb index afb254bca9..3e8fdf2a8c 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/event_handler_test.rb @@ -5,11 +5,11 @@ # 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' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler' -describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler' do +describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler' do include Rack::Test::Methods let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } @@ -36,7 +36,7 @@ let(:proxy_event) { rack_span.events&.first } let(:uri) { '/' } let(:handler) do - OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new + OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler.new end let(:after_close) { nil } @@ -60,6 +60,8 @@ end before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + exporter.reset # simulate a fresh install: diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/tracer_middleware_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware_test.rb similarity index 96% rename from instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/tracer_middleware_test.rb rename to instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware_test.rb index b17f60ed93..4c332cba2c 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/tracer_middleware_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware_test.rb @@ -7,16 +7,16 @@ require 'test_helper' # require Instrumentation so .install method is found: -require_relative '../../../../../lib/opentelemetry/instrumentation/rack' -require_relative '../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' -require_relative '../../../../../lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware' -describe OpenTelemetry::Instrumentation::Rack::Middlewares::TracerMiddleware do +describe OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware do let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } let(:instrumentation_class) { instrumentation_module::Instrumentation } let(:instrumentation) { instrumentation_class.instance } - let(:described_class) { OpenTelemetry::Instrumentation::Rack::Middlewares::TracerMiddleware } + let(:described_class) { OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware } let(:app) { ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['OK']] } } let(:middleware) { described_class.new(app) } @@ -33,6 +33,8 @@ let(:uri) { '/' } before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + # clear captured spans: exporter.reset diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/event_handler_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/event_handler_test.rb new file mode 100644 index 0000000000..0a458fe6df --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/event_handler_test.rb @@ -0,0 +1,481 @@ +# 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/stable/event_handler' + +describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::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::Stable::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 + skip unless ENV['BUNDLE_GEMFILE'].include?('stable') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http' + + exporter.reset + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(config) + end + + # Simulating buggy instrumentation that starts a span, sets the ctx + # but fails to detach or close the span + describe 'broken instrumentation' do + let(:service) do + lambda do |_env| + span = OpenTelemetry.tracer_provider.tracer('buggy').start_span('I never close') + OpenTelemetry::Context.attach(OpenTelemetry::Trace.context_with_span(span)) + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + it 'still closes the rack span' do + assert_raises OpenTelemetry::Context::DetachError do + get uri, {}, headers + end + _(finished_spans.size).must_equal 1 + _(rack_span.name).must_equal 'GET' + OpenTelemetry::Context.clear + end + end + + describe '#call' do + before do + get uri, {}, headers + end + + it 'record a span' do + _(rack_span.attributes['http.request.method']).must_equal 'GET' + _(rack_span.attributes['http.response.status_code']).must_equal 200 + _(rack_span.attributes['url.path']).must_equal '/' + _(rack_span.attributes['url.full']).must_be_nil + _(rack_span.name).must_equal '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 'with a hijacked response' do + let(:service) do + lambda do |env| + env['rack.hijack?'] = true + [-1, {}, []] + end + end + + it 'sets the span status to "unset"' do + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + + describe 'when baggage is set' do + let(:headers) do + Hash( + 'baggage' => 'foo=123' + ) + end + + let(:service) do + lambda do |_env| + _(OpenTelemetry::Baggage.raw_entries['foo'].value).must_equal('123') + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + it 'sets baggage in the request context' do + _(rack_span.name).must_equal 'GET' + end + end + + describe 'when a query is passed in' do + let(:uri) { '/endpoint?query=true' } + + it 'records the query path' do + _(rack_span.attributes['url.path']).must_equal '/endpoint' + _(rack_span.attributes['url.query']).must_equal 'query=true' + _(rack_span.name).must_equal 'GET' + end + end + + describe 'config[:untraced_endpoints]' do + let(:service) do + lambda do |_env| + OpenTelemetry.tracer_provider.tracer('req').in_span('in_req_span') {} + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + describe 'when an array is passed in' do + let(:uri) { '/ping' } + let(:untraced_endpoints) { ['/ping'] } + + it 'does not trace paths listed in the array' do + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).must_be_nil + + _(finished_spans.size).must_equal 0 + 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['url.path'] == '/ping' } + _(ping_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + end + + describe 'config[:untraced_requests]' do + let(:service) do + lambda do |_env| + OpenTelemetry.tracer_provider.tracer('req').in_span('in_req_span') {} + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + describe 'when a callable is passed in' do + let(:uri) { '/assets' } + let(:untraced_requests) do + ->(env) { env['PATH_INFO'] =~ %r{^\/assets} } + end + + it 'does not trace requests in which the callable returns true' do + assets_span = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(assets_span).must_be_nil + + _(finished_spans.size).must_equal 0 + 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['url.path'] == '/assets' } + _(asset_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(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.response.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 'GET' + _(rack_span.attributes['url.path']).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['url.path']).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 'GET' + _(rack_span.attributes['url.path']).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['url.path']).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.request.method']).must_equal 'GET' + _(rack_span.attributes['http.response.status_code']).must_equal 200 + _(rack_span.attributes['url.path']).must_equal '/' + _(rack_span.attributes['url.full']).must_be_nil + _(rack_span.name).must_equal '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/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware_test.rb new file mode 100644 index 0000000000..f3c9f7e12a --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware_test.rb @@ -0,0 +1,390 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +# require Instrumentation so .install method is found: +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware' + +describe OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::TracerMiddleware do + let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } + let(:instrumentation_class) { instrumentation_module::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + + let(:described_class) { OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::TracerMiddleware } + + let(:app) { ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['OK']] } } + let(:middleware) { described_class.new(app) } + let(:rack_builder) { Rack::Builder.new } + + let(:exporter) { EXPORTER } + let(:finished_spans) { exporter.finished_spans } + let(:first_span) { exporter.finished_spans.first } + let(:proxy_event) { first_span.events&.first } + + let(:default_config) { {} } + let(:config) { default_config } + let(:env) { {} } + let(:uri) { '/' } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('stable') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http' + + # clear captured spans: + exporter.reset + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(config) + + # clear out cached config: + described_class.send(:clear_cached_config) + + # integrate tracer middleware: + rack_builder.run app + rack_builder.use described_class + end + + after do + # installation is 'global', so it should be reset: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(default_config) + end + + describe '#call' do + before do + Rack::MockRequest.new(rack_builder).get(uri, env) + end + + it 'records attributes' do + _(first_span.attributes['http.request.method']).must_equal 'GET' + _(first_span.attributes['http.response.status_code']).must_equal 200 + _(first_span.attributes['url.path']).must_equal '/' + _(first_span.attributes['url.full']).must_be_nil + _(first_span.name).must_equal 'GET' + _(first_span.kind).must_equal :server + end + + it 'does not explicitly set status OK' do + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + + describe 'with a hijacked response' do + let(:app) do + lambda do |env| + env['rack.hijack?'] = true + [-1, {}, []] + end + end + + it 'sets the span status to "unset"' do + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + + it 'has no parent' do + _(first_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + end + + describe 'when a query is passed in' do + let(:uri) { '/endpoint?query=true' } + + it 'records the query path' do + _(first_span.attributes['url.path']).must_equal '/endpoint' + _(first_span.attributes['url.query']).must_equal 'query=true' + _(first_span.name).must_equal 'GET' + end + end + + describe 'given request proxy headers' do + let(:env) { 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 'config[:untraced_endpoints]' do + describe 'when an array is passed in' do + let(:config) { { untraced_endpoints: ['/ping'] } } + + it 'does not trace paths listed in the array' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).must_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + + describe 'when a string is passed in' do + let(:config) { { untraced_endpoints: '/ping' } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_endpoints: nil } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + end + + describe 'config[:untraced_requests]' do + describe 'when a callable is passed in' do + let(:untraced_callable) do + ->(env) { env['PATH_INFO'] =~ %r{^\/assets} } + end + let(:config) { default_config.merge(untraced_requests: untraced_callable) } + + it 'does not trace requests in which the callable returns true' do + Rack::MockRequest.new(rack_builder).get('/assets', env) + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(ping_span).must_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_requests: nil } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/assets', env) + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(ping_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + end + + describe 'config[:allowed_request_headers]' do + let(:env) do + Hash( + 'CONTENT_LENGTH' => '123', + 'CONTENT_TYPE' => 'application/json', + 'HTTP_FOO_BAR' => 'http foo bar value' + ) + end + + it 'defaults to nil' do + _(first_span.attributes['http.request.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:config) { default_config.merge(allowed_request_headers: ['foo_BAR']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.foo_bar']).must_equal 'http foo bar value' + end + end + + describe 'when content-type' do + let(:config) { default_config.merge(allowed_request_headers: ['CONTENT_TYPE']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.content_type']).must_equal 'application/json' + end + end + + describe 'when content-length' do + let(:config) { default_config.merge(allowed_request_headers: ['CONTENT_LENGTH']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.content_length']).must_equal '123' + end + end + end + + describe 'config[:allowed_response_headers]' do + let(:app) do + ->(_env) { [200, { 'Foo-Bar' => 'foo bar response header' }, ['OK']] } + end + + it 'defaults to nil' do + _(first_span.attributes['http.response.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:config) { default_config.merge(allowed_response_headers: ['Foo-Bar']) } + + it 'returns attribute' do + _(first_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + + describe 'case-sensitively' do + let(:config) { default_config.merge(allowed_response_headers: ['fOO-bAR']) } + + it 'returns attribute' do + _(first_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + end + end + end + + describe 'config[:record_frontend_span]' do + let(:request_span) { exporter.finished_spans.first } + + describe 'default' do + it 'does not record span' do + _(exporter.finished_spans.size).must_equal 1 + end + + it 'does not parent the request_span' do + _(request_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + end + end + + describe 'when recordable' do + let(:config) { default_config.merge(record_frontend_span: true) } + let(:env) { Hash('HTTP_X_REQUEST_START' => Time.now.to_i) } + let(:frontend_span) { exporter.finished_spans[1] } + let(:request_span) { exporter.finished_spans[0] } + + it 'records span' do + _(exporter.finished_spans.size).must_equal 2 + _(frontend_span.name).must_equal 'http_server.proxy' + _(frontend_span.attributes['service']).must_be_nil + end + + it 'changes request_span kind' do + _(request_span.kind).must_equal :internal + end + + it 'frontend_span parents request_span' do + _(request_span.parent_span_id).must_equal frontend_span.span_id + end + end + end + + describe '#called with 400 level http status code' do + let(:app) do + ->(_env) { [404, { 'Foo-Bar' => 'foo bar response header' }, ['Not Found']] } + end + + it 'leaves status code unset' do + _(first_span.attributes['http.response.status_code']).must_equal 404 + _(first_span.kind).must_equal :server + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + end + + describe 'config[:quantization]' do + before do + Rack::MockRequest.new(rack_builder).get('/really_long_url', env) + end + + describe 'without quantization' do + it 'span.name defaults to low cardinality name HTTP method' do + _(first_span.name).must_equal 'GET' + _(first_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with simple quantization' do + let(:quantization_example) do + ->(url, _env) { url.to_s } + end + + let(:config) { default_config.merge(url_quantization: quantization_example) } + + it 'sets the span.name to the full path' do + _(first_span.name).must_equal '/really_long_url' + _(first_span.attributes['url.path']).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(:config) { default_config.merge(url_quantization: quantization_example) } + + it 'mutates url according to url_quantization' do + _(first_span.name).must_equal '/reall' + end + end + end + + describe 'config[:response_propagators]' do + describe 'with default options' do + it 'does not inject the traceresponse header' do + res = Rack::MockRequest.new(rack_builder).get('/ping', env) + _(res.headers).wont_include('traceresponse') + end + end + + describe 'with ResponseTextMapPropagator' do + let(:config) { default_config.merge(response_propagators: [OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator.new]) } + + it 'injects the traceresponse header' do + res = Rack::MockRequest.new(rack_builder).get('/ping', env) + _(res.headers).must_include('traceresponse') + end + end + + describe 'propagator throws' do + class MockPropagator < OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator + def inject(carrier) + raise 'Injection failed' + end + end + + let(:config) { default_config.merge(response_propagators: [MockPropagator.new]) } + + it 'leads to application errors when there are exceptions' do + assert_raises RuntimeError do + Rack::MockRequest.new(rack_builder).get('/ping', env) + end + end + end + end + + describe '#call with error' do + SimulatedError = Class.new(StandardError) + + let(:app) do + ->(_env) { raise SimulatedError } + end + + it 'records error in span and then re-raises' do + assert_raises SimulatedError do + Rack::MockRequest.new(rack_builder).get('/', env) + end + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack_test.rb index 5eccd35aa4..719be2caec 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack_test.rb @@ -10,6 +10,8 @@ let(:instrumentation) { OpenTelemetry::Instrumentation::Rack::Instrumentation.instance } let(:new_span) { OpenTelemetry::Trace.non_recording_span(OpenTelemetry::Trace::SpanContext.new) } + before { skip unless ENV['BUNDLE_GEMFILE'].include?('old') } + it 'has #name' do _(instrumentation.name).must_equal 'OpenTelemetry::Instrumentation::Rack' end diff --git a/instrumentation/sinatra/Appraisals b/instrumentation/sinatra/Appraisals index 73cb3a9d43..20cb43082e 100644 --- a/instrumentation/sinatra/Appraisals +++ b/instrumentation/sinatra/Appraisals @@ -4,12 +4,21 @@ # # SPDX-License-Identifier: Apache-2.0 -%w[4.1 3.0 2.1].each do |version| - appraise "sinatra-#{version}" do - gem 'sinatra', "~> #{version}" +# To facilitate HTTP semantic convention stability migration, we are using +# appraisal to test the different semantic convention modes/ Rack middlewares. +# When the migration is complete, we should revert testing with different stability +# modes. For more information see CHANGELOG: HTTP semantic convention stability +semconv_stability = %w[dup stable old] +sinatra_versions = %w[4.1 3.0 2.1] + +semconv_stability.each do |mode| + sinatra_versions.each do |version| + appraise "sinatra-#{version}-#{mode}" do + gem 'sinatra', "~> #{version}" + end end -end -appraise 'sinatra-latest' do - gem 'sinatra' + appraise "sinatra-latest-#{mode}" do + gem 'sinatra' + end end diff --git a/instrumentation/sinatra/README.md b/instrumentation/sinatra/README.md index 063efad0a9..248aaa1d18 100644 --- a/instrumentation/sinatra/README.md +++ b/instrumentation/sinatra/README.md @@ -72,3 +72,21 @@ The `opentelemetry-instrumentation-sinatra` gem is distributed under the Apache [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 Rack 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. + +Sinatra instrumentation installs Rack middleware, but the middleware version it installs depends on which `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable is set. + +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, Rack instrumentation code comes in three patch versions: `dup`, `old`, and `stable`. These versions are identical except for the attributes they send. Any changes to Rack 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/). diff --git a/instrumentation/sinatra/example/config.ru b/instrumentation/sinatra/example/config.ru index 0de51a9493..dfdb343f39 100755 --- a/instrumentation/sinatra/example/config.ru +++ b/instrumentation/sinatra/example/config.ru @@ -47,5 +47,9 @@ class App < Sinatra::Base end end -use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) +# Rack instrumentation is moving through the process of migrating to the new HTTP semantic +# conventions. In this example, we will use the old HTTP conventions by patching the Rack +# middleware that uses the old conventions. See README: HTTP Semantic Conventions for more +# information. +use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old) run App diff --git a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb index bd7b85ad60..313427635f 100644 --- a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb +++ b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb @@ -51,7 +51,19 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base end def install_middleware(app) - app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) if config[:install_rack] + if config[:install_rack] + stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') + values = stability_opt_in.split(',').map(&:strip) + + if values.include?('http/dup') + app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup) + elsif values.include?('http') + app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable) + else + app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old) + end + end + app.use(Middlewares::TracerMiddleware) end end diff --git a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb new file mode 100644 index 0000000000..332edc4762 --- /dev/null +++ b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::Instrumentation::Sinatra do + include Rack::Test::Methods + + let(:instrumentation) { OpenTelemetry::Instrumentation::Sinatra::Instrumentation.instance } + let(:exporter) { EXPORTER } + let(:config) { {} } + + class CustomError < StandardError; end + + let(:app_one) do + Class.new(Sinatra::Application) do + set :raise_errors, false + get '/endpoint' do + '1' + end + + get '/error' do + raise CustomError, 'custom message' + end + + template :foo_template do + 'Foo Template' + end + + get '/with_template' do + erb :foo_template + end + + get '/api/v1/foo/:myname/?' do + 'Some name' + end + end + end + + let(:app_two) do + Class.new(Sinatra::Application) do + set :raise_errors, false + get '/endpoint' do + '2' + end + end + end + + let(:apps) do + { + '/one' => app_one, + '/two' => app_two + } + end + + let(:app) do + apps_to_build = apps + + Rack::Builder.new do + apps_to_build.each do |root, app| + map root do + run app + end + end + end.to_app + end + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('dup') + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http/dup' + + Sinatra::Base.reset! + + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.instance_variable_set(:@installed, false) + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.config.clear + + instrumentation.instance_variable_set(:@installed, false) + instrumentation.config.clear + instrumentation.install(config) + exporter.reset + end + + describe 'tracing' do + it 'before request' do + _(exporter.finished_spans.size).must_equal 0 + end + + it 'after request' do + get '/one/endpoint' + + _(exporter.finished_spans.size).must_equal 1 + end + + it 'traces all apps' do + get '/two/endpoint' + + _(exporter.finished_spans.size).must_equal 1 + end + + it 'records attributes' do + get '/one/endpoint' + + _(exporter.finished_spans.first.attributes).must_equal( + 'http.host' => 'example.org', + 'http.method' => 'GET', + 'http.route' => '/endpoint', + 'http.scheme' => 'http', + 'http.status_code' => 200, + 'http.target' => '/endpoint', + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'url.scheme' => 'http', + 'http.response.status_code' => 200, + 'url.path' => '/endpoint' + ) + end + + it 'traces templates' do + get '/one/with_template' + + _(exporter.finished_spans.size).must_equal 3 + _(exporter.finished_spans.map(&:name)) + .must_equal [ + 'sinatra.render_template', + 'sinatra.render_template', + 'GET /with_template' + ] + _(exporter.finished_spans[0..1].map(&:attributes) + .map { |h| h['sinatra.template_name'] }) + .must_equal %w[layout foo_template] + end + + it 'correctly name spans' do + get '/one//api/v1/foo/janedoe/' + + _(exporter.finished_spans.size).must_equal 1 + _(exporter.finished_spans.first.attributes).must_equal( + 'http.host' => 'example.org', + 'http.method' => 'GET', + 'http.target' => '/api/v1/foo/janedoe/', + 'http.scheme' => 'http', + 'http.status_code' => 200, + 'http.route' => '/api/v1/foo/:myname/?', + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'url.path' => '/api/v1/foo/janedoe/', + 'url.scheme' => 'http', + 'http.response.status_code' => 200 + ) + _(exporter.finished_spans.map(&:name)) + .must_equal [ + 'GET /api/v1/foo/:myname/?' + ] + end + + it 'does not create unhandled exceptions for missing routes' do + get '/one/missing_example/not_present' + + _(exporter.finished_spans.first.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + _(exporter.finished_spans.first.attributes).must_equal( + 'http.host' => 'example.org', + 'http.method' => 'GET', + 'http.scheme' => 'http', + 'http.status_code' => 404, + 'http.target' => '/missing_example/not_present', + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'url.scheme' => 'http', + 'http.response.status_code' => 404, + 'url.path' => '/missing_example/not_present' + ) + _(exporter.finished_spans.flat_map(&:events)).must_equal([nil]) + end + + it 'does correctly name spans and add attributes and exception events when the app raises errors' do + get '/one/error' + + _(exporter.finished_spans.first.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + _(exporter.finished_spans.first.name).must_equal('GET /error') + _(exporter.finished_spans.first.attributes).must_equal( + 'http.host' => 'example.org', + 'http.method' => 'GET', + 'http.route' => '/error', + 'http.scheme' => 'http', + 'http.target' => '/error', + 'http.status_code' => 500, + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'url.scheme' => 'http', + 'url.path' => '/error', + 'http.response.status_code' => 500 + ) + _(exporter.finished_spans.flat_map(&:events).map(&:name)).must_equal(['exception']) + end + + it 'adds exception type to events when the app raises errors' do + get '/one/error' + + _(exporter.finished_spans.first.events[0].attributes['exception.type']).must_equal('CustomError') + _(exporter.finished_spans.first.events[0].attributes['exception.message']).must_equal('custom message') + end + end + + describe 'when install_rack is set to false' do + let(:config) { { install_rack: false } } + + describe 'missing rack installation' do + it 'disables tracing' do + get '/one/endpoint' + + _(exporter.finished_spans).must_be_empty + end + end + + describe 'when rack is manually installed' do + let(:app) do + apps_to_build = apps + Rack::Builder.new do + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup) + + apps_to_build.each do |root, app| + map root do + run app + end + end + end.to_app + end + + before do + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.install + end + + it 'creates a span' do + get '/one/endpoint' + + _(exporter.finished_spans.first.attributes).must_equal( + 'http.method' => 'GET', + 'http.host' => 'example.org', + 'http.scheme' => 'http', + 'http.target' => '/one/endpoint', + 'http.route' => '/endpoint', + 'http.status_code' => 200, + 'http.request.method' => 'GET', + 'server.address' => 'example.org', + 'url.scheme' => 'http', + 'url.path' => '/one/endpoint', + 'http.response.status_code' => 200 + ) + end + end + end +end diff --git a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_test.rb b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_old_http_test.rb similarity index 98% rename from instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_test.rb rename to instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_old_http_test.rb index 4c9d5814a9..ceb047caa7 100644 --- a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_test.rb +++ b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_old_http_test.rb @@ -69,6 +69,8 @@ class CustomError < StandardError; end end before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + Sinatra::Base.reset! OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.instance_variable_set(:@installed, false) @@ -196,7 +198,7 @@ class CustomError < StandardError; end let(:app) do apps_to_build = apps Rack::Builder.new do - use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old) apps_to_build.each do |root, app| map root do diff --git a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_stable_http_test.rb b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_stable_http_test.rb new file mode 100644 index 0000000000..0d0313e395 --- /dev/null +++ b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_stable_http_test.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::Instrumentation::Sinatra do + include Rack::Test::Methods + + let(:instrumentation) { OpenTelemetry::Instrumentation::Sinatra::Instrumentation.instance } + let(:exporter) { EXPORTER } + let(:config) { {} } + + class CustomError < StandardError; end + + let(:app_one) do + Class.new(Sinatra::Application) do + set :raise_errors, false + get '/endpoint' do + '1' + end + + get '/error' do + raise CustomError, 'custom message' + end + + template :foo_template do + 'Foo Template' + end + + get '/with_template' do + erb :foo_template + end + + get '/api/v1/foo/:myname/?' do + 'Some name' + end + end + end + + let(:app_two) do + Class.new(Sinatra::Application) do + set :raise_errors, false + get '/endpoint' do + '2' + end + end + end + + let(:apps) do + { + '/one' => app_one, + '/two' => app_two + } + end + + let(:app) do + apps_to_build = apps + + Rack::Builder.new do + apps_to_build.each do |root, app| + map root do + run app + end + end + end.to_app + end + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('stable') + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http' + + Sinatra::Base.reset! + + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.instance_variable_set(:@installed, false) + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.config.clear + + instrumentation.instance_variable_set(:@installed, false) + instrumentation.config.clear + instrumentation.install(config) + exporter.reset + end + + describe 'tracing' do + it 'before request' do + _(exporter.finished_spans.size).must_equal 0 + end + + it 'after request' do + get '/one/endpoint' + + _(exporter.finished_spans.size).must_equal 1 + end + + it 'traces all apps' do + get '/two/endpoint' + + _(exporter.finished_spans.size).must_equal 1 + end + + it 'records attributes' do + get '/one/endpoint' + + _(exporter.finished_spans.first.attributes).must_equal( + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'http.route' => '/endpoint', + 'url.scheme' => 'http', + 'http.response.status_code' => 200, + 'url.path' => '/endpoint' + ) + end + + it 'traces templates' do + get '/one/with_template' + + _(exporter.finished_spans.size).must_equal 3 + _(exporter.finished_spans.map(&:name)) + .must_equal [ + 'sinatra.render_template', + 'sinatra.render_template', + 'GET /with_template' + ] + _(exporter.finished_spans[0..1].map(&:attributes) + .map { |h| h['sinatra.template_name'] }) + .must_equal %w[layout foo_template] + end + + it 'correctly name spans' do + get '/one//api/v1/foo/janedoe/' + + _(exporter.finished_spans.size).must_equal 1 + _(exporter.finished_spans.first.attributes).must_equal( + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'url.path' => '/api/v1/foo/janedoe/', + 'url.scheme' => 'http', + 'http.response.status_code' => 200, + 'http.route' => '/api/v1/foo/:myname/?' + ) + _(exporter.finished_spans.map(&:name)) + .must_equal [ + 'GET /api/v1/foo/:myname/?' + ] + end + + it 'does not create unhandled exceptions for missing routes' do + get '/one/missing_example/not_present' + + _(exporter.finished_spans.first.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + _(exporter.finished_spans.first.attributes).must_equal( + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'url.scheme' => 'http', + 'http.response.status_code' => 404, + 'url.path' => '/missing_example/not_present' + ) + _(exporter.finished_spans.flat_map(&:events)).must_equal([nil]) + end + + it 'does correctly name spans and add attributes and exception events when the app raises errors' do + get '/one/error' + + _(exporter.finished_spans.first.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + _(exporter.finished_spans.first.name).must_equal('GET /error') + _(exporter.finished_spans.first.attributes).must_equal( + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'http.route' => '/error', + 'url.scheme' => 'http', + 'url.path' => '/error', + 'http.response.status_code' => 500 + ) + _(exporter.finished_spans.flat_map(&:events).map(&:name)).must_equal(['exception']) + end + + it 'adds exception type to events when the app raises errors' do + get '/one/error' + + _(exporter.finished_spans.first.events[0].attributes['exception.type']).must_equal('CustomError') + _(exporter.finished_spans.first.events[0].attributes['exception.message']).must_equal('custom message') + end + end + + describe 'when install_rack is set to false' do + let(:config) { { install_rack: false } } + + describe 'missing rack installation' do + it 'disables tracing' do + get '/one/endpoint' + + _(exporter.finished_spans).must_be_empty + end + end + + describe 'when rack is manually installed' do + let(:app) do + apps_to_build = apps + Rack::Builder.new do + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable) + + apps_to_build.each do |root, app| + map root do + run app + end + end + end.to_app + end + + before do + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.install + end + + it 'creates a span' do + get '/one/endpoint' + + _(exporter.finished_spans.first.attributes).must_equal( + 'http.request.method' => 'GET', + 'server.address' => 'example.org', + 'url.scheme' => 'http', + 'url.path' => '/one/endpoint', + 'http.route' => '/endpoint', + 'http.response.status_code' => 200 + ) + end + end + end +end