From 131de594a4e38e11a056bbb28a75c8611c3a16ee Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Mon, 14 Jul 2025 16:35:47 -0700 Subject: [PATCH 01/17] feat: Rack semantic stability opt in --- instrumentation/CONTRIBUTING.md | 5 +- .../instrumentation/action_pack/railtie.rb | 23 +- .../grape/example/trace_demonstration.rb | 5 +- instrumentation/grape/test/test_helper.rb | 12 +- instrumentation/rack/Appraisals | 37 +- .../instrumentation/rack/instrumentation.rb | 58 +- .../rack/middlewares/dup/event_handler.rb | 277 ++++++++++ .../rack/middlewares/dup/tracer_middleware.rb | 209 +++++++ .../rack/middlewares/event_handler.rb | 268 --------- .../rack/middlewares/old/event_handler.rb | 270 ++++++++++ .../rack/middlewares/old/tracer_middleware.rb | 205 +++++++ .../rack/middlewares/stable/event_handler.rb | 271 ++++++++++ .../middlewares/stable/tracer_middleware.rb | 206 +++++++ .../rack/middlewares/tracer_middleware.rb | 203 ------- .../rack/instrumentation_test.rb | 12 +- .../middlewares/dup/event_handler_test.rb | 508 ++++++++++++++++++ .../middlewares/dup/tracer_middleware_test.rb | 417 ++++++++++++++ .../event_handler_resiliency_test.rb | 6 +- .../{ => old}/event_handler_test.rb | 12 +- .../{ => old}/tracer_middleware_test.rb | 12 +- .../middlewares/stable/event_handler_test.rb | 481 +++++++++++++++++ .../stable/tracer_middleware_test.rb | 390 ++++++++++++++ .../instrumentation/rack_test.rb | 2 + instrumentation/sinatra/example/config.ru | 2 +- .../sinatra/instrumentation.rb | 12 +- .../instrumentation/sinatra_test.rb | 2 +- 26 files changed, 3384 insertions(+), 521 deletions(-) create mode 100644 instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb create mode 100644 instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb delete mode 100644 instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/event_handler.rb create mode 100644 instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler.rb create mode 100644 instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware.rb create mode 100644 instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb create mode 100644 instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb delete mode 100644 instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware.rb create mode 100644 instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/event_handler_test.rb create mode 100644 instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware_test.rb rename instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/{ => old}/event_handler_test.rb (96%) rename instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/{ => old}/tracer_middleware_test.rb (96%) create mode 100644 instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/event_handler_test.rb create mode 100644 instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware_test.rb 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..5825fa5c08 100644 --- a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb +++ b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb @@ -12,10 +12,25 @@ class Railtie < ::Rails::Railtie config.before_initialize do |app| OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.install({}) - app.middleware.insert_before( - 0, - *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args - ) + stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') + values = stability_opt_in.split(',').map(&:strip) + + if values.include?('http/dup') + app.middleware.insert_before( + 0, + *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup + ) + elsif values.include?('http') + app.middleware.insert_before( + 0, + *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable + ) + else + app.middleware.insert_before( + 0, + *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old + ) + end end end end diff --git a/instrumentation/grape/example/trace_demonstration.rb b/instrumentation/grape/example/trace_demonstration.rb index 7f67a93d19..a890349e6e 100644 --- a/instrumentation/grape/example/trace_demonstration.rb +++ b/instrumentation/grape/example/trace_demonstration.rb @@ -43,8 +43,11 @@ class ExampleAPI < Grape::API # 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/grape/test/test_helper.rb b/instrumentation/grape/test/test_helper.rb index 759aa83a7b..2c7537594d 100644 --- a/instrumentation/grape/test/test_helper.rb +++ b/instrumentation/grape/test/test_helper.rb @@ -41,7 +41,17 @@ def unsubscribe def build_rack_app(api_class) builder = Rack::Builder.app do - use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) + stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') + values = stability_opt_in.split(',').map(&:strip) + + if values.include?('http/dup') + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup) + elsif values.include?('http') + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable) + else + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old) + end + run api_class end Rack::MockRequest.new(builder) 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/lib/opentelemetry/instrumentation/rack/instrumentation.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb index 3bfb68a3c9..18046c9e7c 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,62 @@ 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 + + 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..89a6f7c8da --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb @@ -0,0 +1,277 @@ +# 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', + '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? + 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/dup/tracer_middleware.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb new file mode 100644 index 0000000000..ecb4b66cf8 --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb @@ -0,0 +1,209 @@ +# 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', + '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? + 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'].to_s + 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..046ad39c94 --- /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..a575cca32c --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../../util' +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + 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://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.request.method' => env['REQUEST_METHOD'], + 'http.host' => 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['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/stable/tracer_middleware.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb new file mode 100644 index 0000000000..df2787c1ae --- /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'], + 'http.host' => 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['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 + env['REQUEST_METHOD'].to_s + 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_test.rb index 2a0cf5d6dd..85bcbc8d91 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb @@ -12,6 +12,8 @@ let(:config) { {} } before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + # 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_old _(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::Old::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_old + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware] 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..4cae2d3199 --- /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/example/config.ru b/instrumentation/sinatra/example/config.ru index 0de51a9493..61d8b10ba4 100755 --- a/instrumentation/sinatra/example/config.ru +++ b/instrumentation/sinatra/example/config.ru @@ -47,5 +47,5 @@ class App < Sinatra::Base end end -use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) +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..cea1355dea 100644 --- a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb +++ b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb @@ -51,7 +51,17 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base end def install_middleware(app) - app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) 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) if config[:install_rack] + elsif values.include?('http') + app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable) if config[:install_rack] + else + app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old) if config[:install_rack] + end + app.use(Middlewares::TracerMiddleware) end end diff --git a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_test.rb b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_test.rb index 4c9d5814a9..4eb372ac85 100644 --- a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_test.rb +++ b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_test.rb @@ -196,7 +196,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 From b41a13cbc9459573c2225614f6b2773bbdcc6c99 Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Mon, 14 Jul 2025 16:43:19 -0700 Subject: [PATCH 02/17] fix: add readme and rubocop fix --- instrumentation/rack/README.md | 16 +++++ .../rack/middlewares/dup/event_handler.rb | 2 +- .../rack/middlewares/old/event_handler.rb | 70 +++++++++---------- .../middlewares/dup/event_handler_test.rb | 2 +- 4 files changed, 53 insertions(+), 37 deletions(-) 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/middlewares/dup/event_handler.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb index 89a6f7c8da..7ed3fad657 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb @@ -128,7 +128,7 @@ def extract_request_headers(env) end def extract_response_attributes(response) - attributes = { + attributes = { 'http.status_code' => response.status.to_i, 'http.response.status_code' => response.status.to_i } 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 index 046ad39c94..56dd6ddbe6 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler.rb @@ -42,10 +42,10 @@ module Old # @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} # @@ -58,7 +58,7 @@ def on_start(request, _) 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) @@ -66,7 +66,7 @@ def on_start(request, _) 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 @@ -75,7 +75,7 @@ def on_start(request, _) 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 @@ -84,7 +84,7 @@ def on_commit(request, response) 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 @@ -94,13 +94,13 @@ def on_commit(request, response) 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 @@ -109,33 +109,33 @@ def on_error(request, _, error) 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] @@ -150,14 +150,14 @@ def extract_response_headers(headers) 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., @@ -167,7 +167,7 @@ def untraced_request?(env) 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) @@ -175,7 +175,7 @@ def create_request_span_name(request) "HTTP #{request.request_method}" end end - + def extract_remote_context(request, context = Context.current) OpenTelemetry.propagation.extract( request.env, @@ -183,7 +183,7 @@ def extract_remote_context(request, context = Context.current) getter: OpenTelemetry::Common::Propagation.rack_env_getter ) end - + def request_span_attributes(env) attributes = { 'http.method' => env['REQUEST_METHOD'], @@ -191,22 +191,22 @@ def request_span_attributes(env) '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) @@ -214,43 +214,43 @@ def add_response_attributes(span, response) 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), 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 index 4cae2d3199..9b5dc929c2 100644 --- 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 @@ -234,7 +234,7 @@ 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 From 08895b14fe6f6eb99026954b1a6de223ee97f304 Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Mon, 14 Jul 2025 16:52:44 -0700 Subject: [PATCH 03/17] fix: rubocp edits --- instrumentation/grape/test/test_helper.rb | 4 ++-- .../instrumentation/sinatra/instrumentation.rb | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/instrumentation/grape/test/test_helper.rb b/instrumentation/grape/test/test_helper.rb index 2c7537594d..b42f4e9648 100644 --- a/instrumentation/grape/test/test_helper.rb +++ b/instrumentation/grape/test/test_helper.rb @@ -49,9 +49,9 @@ def build_rack_app(api_class) elsif values.include?('http') use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable) else - use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old) + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old) end - + run api_class end Rack::MockRequest.new(builder) diff --git a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb index cea1355dea..7a2af1ed7d 100644 --- a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb +++ b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb @@ -54,12 +54,14 @@ def install_middleware(app) 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) if config[:install_rack] - elsif values.include?('http') - app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable) if config[:install_rack] - else - app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old) if config[:install_rack] + if config[:install_rack] + 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) From 0d73d7b924a222d56e0f8a8eb4f268d991f1c5e6 Mon Sep 17 00:00:00 2001 From: Hannah Ramadan <76922290+hannahramadan@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:03:49 -0700 Subject: [PATCH 04/17] Apply suggestions from code review Co-authored-by: Kayla Reopelle <87386821+kaylareopelle@users.noreply.github.com> --- .../instrumentation/action_pack/railtie.rb | 22 ++++++++----------- .../rack/middlewares/stable/event_handler.rb | 6 ++--- .../middlewares/stable/tracer_middleware.rb | 2 +- 3 files changed, 12 insertions(+), 18 deletions(-) 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 5825fa5c08..e54e57eba6 100644 --- a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb +++ b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb @@ -15,22 +15,18 @@ class Railtie < ::Rails::Railtie stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') values = stability_opt_in.split(',').map(&:strip) - if values.include?('http/dup') - app.middleware.insert_before( - 0, - *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup - ) + middleware_args = if values.include?('http/dup') + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup elsif values.include?('http') - app.middleware.insert_before( - 0, - *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable - ) + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable else - app.middleware.insert_before( - 0, - *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old - ) + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old end + + app.middleware.insert_before( + 0, + *middleware_args + ) 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 index a575cca32c..bc58a33228 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb @@ -158,12 +158,10 @@ def untraced_request?(env) false end - # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name + # 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) - # - # 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 @@ -193,7 +191,7 @@ def request_span_attributes(env) } attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? - attributes['http.user_agent'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] + attributes['user_agent.original'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] attributes.merge!(extract_request_headers(env)) attributes 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 index df2787c1ae..90faa3ba9b 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb @@ -132,7 +132,7 @@ def request_span_attributes(env:) } attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? - attributes['http.user_agent'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] + attributes['user_agent.original'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] attributes.merge!(allowed_request_headers(env)) end From fe46f3d4407518b6f6f0f8f4d6bf8b182066f51a Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Tue, 29 Jul 2025 18:15:53 -0700 Subject: [PATCH 05/17] Fix: Add Sinatra documentation & small refactor --- instrumentation/sinatra/README.md | 18 ++++++++++++++++++ instrumentation/sinatra/example/config.ru | 4 ++++ .../instrumentation/sinatra/instrumentation.rb | 6 +++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/instrumentation/sinatra/README.md b/instrumentation/sinatra/README.md index 063efad0a9..77bf1fa662 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 instrumentaion 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 61d8b10ba4..dfdb343f39 100755 --- a/instrumentation/sinatra/example/config.ru +++ b/instrumentation/sinatra/example/config.ru @@ -47,5 +47,9 @@ class App < Sinatra::Base end end +# 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 7a2af1ed7d..313427635f 100644 --- a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb +++ b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb @@ -51,10 +51,10 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base end def install_middleware(app) - stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') - values = stability_opt_in.split(',').map(&:strip) - 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') From 69239d1cfd40839202e6a4922705520421e4b6dd Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Tue, 29 Jul 2025 18:17:02 -0700 Subject: [PATCH 06/17] Fix: spelling --- instrumentation/sinatra/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentation/sinatra/README.md b/instrumentation/sinatra/README.md index 77bf1fa662..248aaa1d18 100644 --- a/instrumentation/sinatra/README.md +++ b/instrumentation/sinatra/README.md @@ -79,7 +79,7 @@ In the OpenTelemetry ecosystem, HTTP semantic conventions have now reached a sta 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 instrumentaion installs Rack middleware, but the middleware version it installs depends on which `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable is set. +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: From 516883111a79f5cfb74649babd79590f578352c8 Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Tue, 29 Jul 2025 18:28:28 -0700 Subject: [PATCH 07/17] Fix: refactors --- .../instrumentation/rack/middlewares/dup/event_handler.rb | 5 ++++- .../rack/middlewares/dup/tracer_middleware.rb | 7 ++++++- .../rack/middlewares/stable/tracer_middleware.rb | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) 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 index 7ed3fad657..a0e0e5515f 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb @@ -199,7 +199,10 @@ def request_span_attributes(env) } attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? - attributes['http.user_agent'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] + 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 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 index ecb4b66cf8..ff1bbbd1ab 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb @@ -127,6 +127,7 @@ 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'], @@ -135,6 +136,10 @@ def request_span_attributes(env:) } 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 @@ -151,7 +156,7 @@ def create_request_span_name(request_uri_or_path_info, env) if (implementation = config[:url_quantization]) implementation.call(request_uri_or_path_info, env) else - env['REQUEST_METHOD'].to_s + env['REQUEST_METHOD'] 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 index 90faa3ba9b..f14e819fcc 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb @@ -126,7 +126,7 @@ def tracer def request_span_attributes(env:) attributes = { 'http.request.method' => env['REQUEST_METHOD'], - 'http.host' => env['HTTP_HOST'] || 'unknown', + 'server.address' => env['HTTP_HOST'] || 'unknown', 'url.scheme' => env['rack.url_scheme'], 'url.path' => env['PATH_INFO'] } @@ -149,7 +149,7 @@ def create_request_span_name(request_uri_or_path_info, env) if (implementation = config[:url_quantization]) implementation.call(request_uri_or_path_info, env) else - env['REQUEST_METHOD'].to_s + env['REQUEST_METHOD'] end end From 74aa619581785d3913335115d3c8db2f781af0fc Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Wed, 30 Jul 2025 13:28:24 -0700 Subject: [PATCH 08/17] Test middleware_args_* --- .../rack/instrumentation_dup_test.rb | 76 +++++++++++++++++++ ...on_test.rb => instrumentation_old_test.rb} | 0 .../rack/instrumentation_stable_test.rb | 76 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb rename instrumentation/rack/test/opentelemetry/instrumentation/rack/{instrumentation_test.rb => instrumentation_old_test.rb} (100%) create mode 100644 instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_stable_test.rb diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb new file mode 100644 index 0000000000..1614b1475d --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_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?('dup') + + # 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_dup + _(args[0]).must_equal Rack::Events + _(args[1][0]).must_be_instance_of OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::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_dup + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::TracerMiddleware] + end + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb similarity index 100% rename from instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb rename to instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb 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 From 5ad41e3353d983d9badd9f411a26dd8124800f0f Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Wed, 30 Jul 2025 13:41:21 -0700 Subject: [PATCH 09/17] Fix: rubocop --- .../instrumentation/rack/instrumentation_dup_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb index 1614b1475d..baca6f689b 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb @@ -66,7 +66,7 @@ describe 'when rack events are disabled' do let(:config) { Hash(use_rack_events: false) } - + it 'instantiates a custom middleware' do args = instrumentation.middleware_args_dup _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::TracerMiddleware] From 6dca891bf0b820b58a493d966e9fefb3c80fedce Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Wed, 30 Jul 2025 14:22:52 -0700 Subject: [PATCH 10/17] Sinatra testing --- .../rack/middlewares/dup/event_handler.rb | 1 + .../rack/middlewares/stable/event_handler.rb | 2 +- instrumentation/sinatra/Appraisals | 21 +- .../instrumentation/sinatra_dup_http_test.rb | 255 ++++++++++++++++++ ...natra_test.rb => sinatra_old_http_test.rb} | 2 + .../sinatra_stable_http_test.rb | 230 ++++++++++++++++ 6 files changed, 504 insertions(+), 7 deletions(-) create mode 100644 instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb rename instrumentation/sinatra/test/opentelemetry/instrumentation/{sinatra_test.rb => sinatra_old_http_test.rb} (99%) create mode 100644 instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_stable_http_test.rb 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 index a0e0e5515f..f97e721b3b 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb @@ -191,6 +191,7 @@ 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'], 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 index bc58a33228..3998aa504a 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb @@ -185,7 +185,7 @@ def extract_remote_context(request, context = Context.current) def request_span_attributes(env) attributes = { 'http.request.method' => env['REQUEST_METHOD'], - 'http.host' => env['HTTP_HOST'] || 'unknown', + 'server.address' => env['HTTP_HOST'] || 'unknown', 'url.scheme' => env['rack.url_scheme'], 'url.path' => env['PATH_INFO'] } 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/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..38148b0d33 --- /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 99% rename from instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_test.rb rename to instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_old_http_test.rb index 4eb372ac85..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) 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 From 47e65dc2075cbc7317b3aae5b252a972722f8e64 Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Wed, 30 Jul 2025 15:20:52 -0700 Subject: [PATCH 11/17] rubocop --- .../test/opentelemetry/instrumentation/sinatra_dup_http_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb index 38148b0d33..332edc4762 100644 --- a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb +++ b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb @@ -148,7 +148,7 @@ class CustomError < StandardError; end 'http.request.method' => 'GET', 'url.path' => '/api/v1/foo/janedoe/', 'url.scheme' => 'http', - 'http.response.status_code' => 200, + 'http.response.status_code' => 200 ) _(exporter.finished_spans.map(&:name)) .must_equal [ From 72908226f8a8a71135f98e9017900409309ad0d5 Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Mon, 11 Aug 2025 09:40:03 -0700 Subject: [PATCH 12/17] alias middleware_args middleware_args_old --- .../instrumentation/rack/instrumentation.rb | 2 ++ .../instrumentation/rack/instrumentation_old_test.rb | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb index 18046c9e7c..9c2e395726 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb @@ -48,6 +48,8 @@ def middleware_args_old 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]] diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb index 85bcbc8d91..d3f8bcca38 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb @@ -72,5 +72,14 @@ _(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 From 07529050d5e1819ffa7d12de893c242c7c58d93f Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Mon, 11 Aug 2025 09:52:45 -0700 Subject: [PATCH 13/17] appease rubocop --- .../instrumentation/action_pack/railtie.rb | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 e54e57eba6..20df14fbf4 100644 --- a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb +++ b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb @@ -16,17 +16,17 @@ class Railtie < ::Rails::Railtie 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 + 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, - *middleware_args - ) + app.middleware.insert_before( + 0, + *middleware_args + ) end end end From b8e0c736f9581d226165c4f318fb8f7731e2318f Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Tue, 12 Aug 2025 11:27:21 -0700 Subject: [PATCH 14/17] revert grape changes --- instrumentation/grape/example/trace_demonstration.rb | 5 +---- instrumentation/grape/test/test_helper.rb | 12 +----------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/instrumentation/grape/example/trace_demonstration.rb b/instrumentation/grape/example/trace_demonstration.rb index a890349e6e..7f67a93d19 100644 --- a/instrumentation/grape/example/trace_demonstration.rb +++ b/instrumentation/grape/example/trace_demonstration.rb @@ -43,11 +43,8 @@ class ExampleAPI < Grape::API # 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_old + use *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args run ExampleAPI end app = Rack::MockRequest.new(builder) diff --git a/instrumentation/grape/test/test_helper.rb b/instrumentation/grape/test/test_helper.rb index b42f4e9648..759aa83a7b 100644 --- a/instrumentation/grape/test/test_helper.rb +++ b/instrumentation/grape/test/test_helper.rb @@ -41,17 +41,7 @@ def unsubscribe def build_rack_app(api_class) builder = Rack::Builder.app do - stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') - values = stability_opt_in.split(',').map(&:strip) - - if values.include?('http/dup') - use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup) - elsif values.include?('http') - use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable) - else - use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_old) - end - + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) run api_class end Rack::MockRequest.new(builder) From 62ce1cade30b6221bac9e54ae68f86c14eb1cebe Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Thu, 14 Aug 2025 16:38:33 -0700 Subject: [PATCH 15/17] Update action_pack to use middleware alias --- .../instrumentation/action_pack/railtie.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 20df14fbf4..54c47658a5 100644 --- a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb +++ b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb @@ -15,17 +15,17 @@ class Railtie < ::Rails::Railtie 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 + rack_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 + end app.middleware.insert_before( 0, - *middleware_args + *rack_middleware_args ) end end From 2f7a79dd643126855efb99389ba2ef929cc9296c Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Thu, 14 Aug 2025 16:45:05 -0700 Subject: [PATCH 16/17] Rely on Rack semconv middlware alias for Sinatra instrumentation --- instrumentation/CONTRIBUTING.md | 5 +---- .../opentelemetry/instrumentation/rack/instrumentation.rb | 6 +++--- instrumentation/sinatra/example/config.ru | 6 +----- .../instrumentation/sinatra/instrumentation.rb | 2 +- .../opentelemetry/instrumentation/sinatra_old_http_test.rb | 2 +- 5 files changed, 7 insertions(+), 14 deletions(-) diff --git a/instrumentation/CONTRIBUTING.md b/instrumentation/CONTRIBUTING.md index 35ddea2c72..9a77d6d7ad 100644 --- a/instrumentation/CONTRIBUTING.md +++ b/instrumentation/CONTRIBUTING.md @@ -458,11 +458,8 @@ 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_old + use *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args run ExampleAPI end app = Rack::MockRequest.new(builder) diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb index 9c2e395726..f1bce7f921 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb @@ -36,11 +36,11 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base # # @example Default usage # Rack::Builder.new do - # use *OpenTelemetry::Instrumentation::Rack::Instrumenation.instance.middleware_args_old + # use *OpenTelemetry::Instrumentation::Rack::Instrumenation.instance.middleware_args # run lambda { |_arg| [200, { 'Content-Type' => 'text/plain' }, body] } # end # @return [Array] consisting of a middleware and arguments used in rack builders - def middleware_args_old + def middleware_args 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 @@ -48,7 +48,7 @@ def middleware_args_old end end - alias middleware_args middleware_args_old + alias middleware_args_old middleware_args def middleware_args_dup if config.fetch(:use_rack_events, false) == true && defined?(OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler) diff --git a/instrumentation/sinatra/example/config.ru b/instrumentation/sinatra/example/config.ru index dfdb343f39..0de51a9493 100755 --- a/instrumentation/sinatra/example/config.ru +++ b/instrumentation/sinatra/example/config.ru @@ -47,9 +47,5 @@ class App < Sinatra::Base end end -# 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) +use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) run App diff --git a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb index 313427635f..b1ed53e7a7 100644 --- a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb +++ b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb @@ -60,7 +60,7 @@ def install_middleware(app) 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) + app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) end end diff --git a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_old_http_test.rb b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_old_http_test.rb index ceb047caa7..d4bd5225f3 100644 --- a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_old_http_test.rb +++ b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_old_http_test.rb @@ -198,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_old) + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) apps_to_build.each do |root, app| map root do From a0dd8ba1a138c3bb2a8b83f293afdd9bde4a6bbf Mon Sep 17 00:00:00 2001 From: Hannah Ramadan Date: Thu, 14 Aug 2025 16:49:01 -0700 Subject: [PATCH 17/17] Update action_pack changelog --- instrumentation/action_pack/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/instrumentation/action_pack/README.md b/instrumentation/action_pack/README.md index 8eb0fd76eb..890426ff1a 100644 --- a/instrumentation/action_pack/README.md +++ b/instrumentation/action_pack/README.md @@ -89,3 +89,21 @@ The `opentelemetry-instrumentation-action_pack` gem is distributed under the Apa [slack-channel]: https://cloud-native.slack.com/archives/C01NWKKMKMY [discussions-url]: https://github.com/open-telemetry/opentelemetry-ruby/discussions [rails-home]: https://rubyonrails.org/ + +## HTTP semantic convention stability + +In the OpenTelemetry ecosystem, HTTP semantic conventions have now reached a stable state. However, the initial Rack instrumentation, which Action Pack relies on, 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/).