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/). 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..54c47658a5 100644 --- a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb +++ b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb @@ -12,9 +12,20 @@ class Railtie < ::Rails::Railtie config.before_initialize do |app| OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.install({}) + stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') + values = stability_opt_in.split(',').map(&:strip) + + 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, - *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args + *rack_middleware_args ) end end diff --git a/instrumentation/rack/Appraisals b/instrumentation/rack/Appraisals index 6f96b35843..7ec5ed4dfc 100644 --- a/instrumentation/rack/Appraisals +++ b/instrumentation/rack/Appraisals @@ -4,22 +4,31 @@ # # SPDX-License-Identifier: Apache-2.0 -appraise 'rack-latest' do - gem 'rack' -end +# To faclitate HTTP semantic convention stability migration, we are using +# appraisal to test the different semantic convention modes along with different +# HTTP gem versions. For more information on the semantic convention modes, see: +# https://opentelemetry.io/docs/specs/semconv/non-normative/http-migration/ -appraise 'rack-3.0' do - gem 'rack', '~> 3.0.0' -end +semconv_stability = %w[stable old dup] -appraise 'rack-2.2.x' do - gem 'rack', '~> 2.2.0' -end +semconv_stability.each do |mode| + appraise "rack-latest-#{mode}" do + gem 'rack' + end -appraise 'rack-2.1' do - gem 'rack', '~> 2.1.2' -end + appraise "rack-3.0-#{mode}" do + gem 'rack', '~> 3.0.0' + end + + appraise "rack-2.2.x-#{mode}" do + gem 'rack', '~> 2.2.0' + end + + appraise "rack-2.1-#{mode}" do + gem 'rack', '~> 2.1.2' + end -appraise 'rack-2.0' do - gem 'rack', '~> 2.0.8' + appraise "rack-2.0-#{mode}" do + gem 'rack', '~> 2.0.8' + end end diff --git a/instrumentation/rack/README.md b/instrumentation/rack/README.md index 6373900448..72654474be 100644 --- a/instrumentation/rack/README.md +++ b/instrumentation/rack/README.md @@ -101,3 +101,19 @@ The `opentelemetry-instrumentation-rack` gem is distributed under the Apache 2.0 [community-meetings]: https://github.com/open-telemetry/community#community-meetings [slack-channel]: https://cloud-native.slack.com/archives/C01NWKKMKMY [discussions-url]: https://github.com/open-telemetry/opentelemetry-ruby/discussions + +## HTTP semantic convention stability + +In the OpenTelemetry ecosystem, HTTP semantic conventions have now reached a stable state. However, the initial Rack instrumentation was introduced before this stability was achieved, which resulted in HTTP attributes being based on an older version of the semantic conventions. + +To facilitate the migration to stable semantic conventions, you can use the `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable. This variable allows you to opt-in to the new stable conventions, ensuring compatibility and future-proofing your instrumentation. + +When setting the value for `OTEL_SEMCONV_STABILITY_OPT_IN`, you can specify which conventions you wish to adopt: + +- `http` - Emits the stable HTTP and networking conventions and ceases emitting the old conventions previously emitted by the instrumentation. +- `http/dup` - Emits both the old and stable HTTP and networking conventions, enabling a phased rollout of the stable semantic conventions. +- Default behavior (in the absence of either value) is to continue emitting the old HTTP and networking conventions the instrumentation previously emitted. + +During the transition from old to stable conventions, Rack instrumentation code comes in three patch versions: `dup`, `old`, and `stable`. These versions are identical except for the attributes they send. Any changes to Rack instrumentation should consider all three patches. + +For additional information on migration, please refer to our [documentation](https://opentelemetry.io/docs/specs/semconv/non-normative/http-migration/). diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb index 3bfb68a3c9..f1bce7f921 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 @@ -40,18 +41,59 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base # 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]] + if config.fetch(:use_rack_events, false) == true && defined?(OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler) + [::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler.new]] else - [OpenTelemetry::Instrumentation::Rack::Middlewares::TracerMiddleware] + [OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware] + end + end + + alias middleware_args_old middleware_args + + def middleware_args_dup + if config.fetch(:use_rack_events, false) == true && defined?(OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler) + [::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler.new]] + else + [OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::TracerMiddleware] + end + end + + def middleware_args_stable + if config.fetch(:use_rack_events, false) == true && defined?(OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::EventHandler) + [::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::EventHandler.new]] + else + [OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::TracerMiddleware] end end private - def require_dependencies - require_relative 'middlewares/event_handler' if defined?(::Rack::Events) - require_relative 'middlewares/tracer_middleware' + def determine_semconv + stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') + values = stability_opt_in.split(',').map(&:strip) + + if values.include?('http/dup') + 'dup' + elsif values.include?('http') + 'stable' + else + 'old' + end + end + + def require_dependencies_old + require_relative 'middlewares/old/event_handler' if defined?(::Rack::Events) + require_relative 'middlewares/old/tracer_middleware' + end + + def require_dependencies_stable + require_relative 'middlewares/stable/event_handler' if defined?(::Rack::Events) + require_relative 'middlewares/stable/tracer_middleware' + end + + def require_dependencies_dup + require_relative 'middlewares/dup/event_handler' if defined?(::Rack::Events) + require_relative 'middlewares/dup/tracer_middleware' end def config_options(user_config) diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb new file mode 100644 index 0000000000..f97e721b3b --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../../util' +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + module Dup + # OTel Rack Event Handler + # + # This seeds the root context for this service with the server span as the `current_span` + # allowing for callers later in the stack to reference it using {OpenTelemetry::Trace.current_span} + # + # It also registers the server span in a context dedicated to this instrumentation that users may look up + # using {OpenTelemetry::Instrumentation::Rack.current_span}, which makes it possible for users to mutate the span, + # e.g. add events or update the span name like in the {ActionPack} instrumentation. + # + # @example Rack App Using BodyProxy + # GLOBAL_LOGGER = Logger.new($stderr) + # APP_TRACER = OpenTelemetry.tracer_provider.tracer('my-app', '1.0.0') + # + # Rack::Builder.new do + # use Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new] + # run lambda { |_arg| + # APP_TRACER.in_span('hello-world') do |_span| + # body = Rack::BodyProxy.new(['hello world!']) do + # rack_span = OpenTelemetry::Instrumentation::Rack.current_span + # GLOBAL_LOGGER.info("otel.trace_id=#{rack_span.context.hex_trace_id} otel.span_id=#{rack_span.context.hex_span_id}") + # end + # [200, { 'Content-Type' => 'text/plain' }, body] + # end + # } + # end + # + # @see Rack::Events + # @see OpenTelemetry::Instrumentation::Rack.current_span + class EventHandler + include ::Rack::Events::Abstract + + OTEL_TOKEN_AND_SPAN = 'otel.rack.token_and_span' + EMPTY_HASH = {}.freeze + + # Creates a server span for this current request using the incoming parent context + # and registers them as the {current_span} + # + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] This is nil in practice + # @return [void] + def on_start(request, _) + parent_context = if untraced_request?(request.env) + extract_remote_context(request, OpenTelemetry::Common::Utilities.untraced) + else + extract_remote_context(request) + end + + span = create_span(parent_context, request) + span_ctx = OpenTelemetry::Trace.context_with_span(span, parent_context: parent_context) + rack_ctx = OpenTelemetry::Instrumentation::Rack.context_with_span(span, parent_context: span_ctx) + request.env[OTEL_TOKEN_AND_SPAN] = [OpenTelemetry::Context.attach(rack_ctx), span] + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Optionally adds debugging response headers injected from {response_propagators} + # + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] This current HTTP response + # @return [void] + def on_commit(request, response) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + response_propagators&.each do |propagator| + propagator.inject(response.headers) + rescue StandardError => e + OpenTelemetry.handle_error(message: 'Unable to inject response propagation headers', exception: e) + end + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Records Unexpected Exceptions on the Rack span and set the Span Status to Error + # + # @note does nothing if the span is a non-recording span + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] The current HTTP response + # @param [Exception] An unxpected error raised by the application + def on_error(request, _, error) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + span.record_exception(error) + span.status = OpenTelemetry::Trace::Status.error(error.class.name) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Finishes the span making it eligible to be exported and cleans up existing contexts + # + # @note does nothing if the span is a non-recording span + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] The current HTTP response + def on_finish(request, response) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + add_response_attributes(span, response) if response + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + ensure + detach_context(request) + end + + private + + def extract_request_headers(env) + return EMPTY_HASH if allowed_request_headers.empty? + + allowed_request_headers.each_with_object({}) do |(key, value), result| + result[value] = env[key] if env.key?(key) + end + end + + def extract_response_attributes(response) + attributes = { + 'http.status_code' => response.status.to_i, + 'http.response.status_code' => response.status.to_i + } + attributes.merge!(extract_response_headers(response.headers)) + attributes + end + + def extract_response_headers(headers) + return EMPTY_HASH if allowed_response_headers.empty? + + allowed_response_headers.each_with_object({}) do |(key, value), result| + if headers.key?(key) + result[value] = headers[key] + else + # do case-insensitive match: + headers.each do |k, v| + if k.upcase == key + result[value] = v + break + end + end + end + end + end + + def untraced_request?(env) + return true if untraced_endpoints.include?(env['PATH_INFO']) + return true if untraced_requests&.call(env) + + false + end + + # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name + # + # recommendation: span.name(s) should be low-cardinality (e.g., + # strip off query param value, keep param name) + # + # see http://github.com/open-telemetry/opentelemetry-specification/pull/416/files + def create_request_span_name(request) + # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) + # see Datadog::Quantization::HTTP.url + + if (implementation = url_quantization) + request_uri_or_path_info = request.env['REQUEST_URI'] || request.path_info + implementation.call(request_uri_or_path_info, request.env) + else + request.request_method.to_s + end + end + + def extract_remote_context(request, context = Context.current) + OpenTelemetry.propagation.extract( + request.env, + context: context, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + end + + def request_span_attributes(env) + attributes = { + 'http.method' => env['REQUEST_METHOD'], + 'http.host' => env['HTTP_HOST'] || 'unknown', + 'server.address' => env['HTTP_HOST'] || 'unknown', + 'http.scheme' => env['rack.url_scheme'], + 'http.target' => env['QUERY_STRING'].empty? ? env['PATH_INFO'] : "#{env['PATH_INFO']}?#{env['QUERY_STRING']}", + 'http.request.method' => env['REQUEST_METHOD'], + 'url.scheme' => env['rack.url_scheme'], + 'url.path' => env['PATH_INFO'] + } + + attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? + if env['HTTP_USER_AGENT'] + attributes['http.user_agent'] = env['HTTP_USER_AGENT'] + attributes['user_agent.original'] = env['HTTP_USER_AGENT'] + end + attributes.merge!(extract_request_headers(env)) + attributes + end + + def detach_context(request) + return nil unless request.env[OTEL_TOKEN_AND_SPAN] + + token, span = request.env[OTEL_TOKEN_AND_SPAN] + span.finish + OpenTelemetry::Context.detach(token) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def add_response_attributes(span, response) + span.status = OpenTelemetry::Trace::Status.error if response.server_error? + attributes = extract_response_attributes(response) + span.add_attributes(attributes) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def record_frontend_span? + config[:record_frontend_span] == true + end + + def untraced_endpoints + config[:untraced_endpoints] + end + + def untraced_requests + config[:untraced_requests] + end + + def url_quantization + config[:url_quantization] + end + + def response_propagators + config[:response_propagators] + end + + def allowed_request_headers + config[:allowed_rack_request_headers] + end + + def allowed_response_headers + config[:allowed_rack_response_headers] + end + + def tracer + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer + end + + def config + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.config + end + + def create_span(parent_context, request) + span = tracer.start_span( + create_request_span_name(request), + with_parent: parent_context, + kind: :server, + attributes: request_span_attributes(request.env) + ) + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(request.env) + span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? + span + end + end + end + end + end + end +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb new file mode 100644 index 0000000000..ff1bbbd1ab --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + module Dup + # TracerMiddleware propagates context and instruments Rack requests + # by way of its middleware system + class TracerMiddleware + class << self + def allowed_rack_request_headers + @allowed_rack_request_headers ||= Array(config[:allowed_request_headers]).each_with_object({}) do |header, memo| + key = header.to_s.upcase.gsub(/[-\s]/, '_') + case key + when 'CONTENT_TYPE', 'CONTENT_LENGTH' + memo[key] = build_attribute_name('http.request.header.', header) + else + memo["HTTP_#{key}"] = build_attribute_name('http.request.header.', header) + end + end + end + + def allowed_response_headers + @allowed_response_headers ||= Array(config[:allowed_response_headers]).each_with_object({}) do |header, memo| + memo[header] = build_attribute_name('http.response.header.', header) + memo[header.to_s.upcase] = build_attribute_name('http.response.header.', header) + end + end + + def build_attribute_name(prefix, suffix) + prefix + suffix.to_s.downcase.gsub(/[-\s]/, '_') + end + + def config + Rack::Instrumentation.instance.config + end + + private + + def clear_cached_config + @allowed_rack_request_headers = nil + @allowed_response_headers = nil + end + end + + EMPTY_HASH = {}.freeze + + def initialize(app) + @app = app + @untraced_endpoints = config[:untraced_endpoints] + end + + def call(env) + if untraced_request?(env) + OpenTelemetry::Common::Utilities.untraced do + return @app.call(env) + end + end + + original_env = env.dup + extracted_context = OpenTelemetry.propagation.extract( + env, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + frontend_context = create_frontend_span(env, extracted_context) + + # restore extracted context in this process: + OpenTelemetry::Context.with_current(frontend_context || extracted_context) do + request_span_name = create_request_span_name(env['REQUEST_URI'] || original_env['PATH_INFO'], env) + request_span_kind = frontend_context.nil? ? :server : :internal + tracer.in_span(request_span_name, + attributes: request_span_attributes(env: env), + kind: request_span_kind) do |request_span| + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + request_span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? + OpenTelemetry::Instrumentation::Rack.with_span(request_span) do + @app.call(env).tap do |status, headers, response| + set_attributes_after_request(request_span, status, headers, response) + config[:response_propagators].each { |propagator| propagator.inject(headers) } + end + end + end + end + ensure + finish_span(frontend_context) + end + + private + + def untraced_request?(env) + return true if @untraced_endpoints.include?(env['PATH_INFO']) + return true if config[:untraced_requests]&.call(env) + + false + end + + # return Context with the frontend span as the current span + def create_frontend_span(env, extracted_context) + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + + return unless config[:record_frontend_span] && !request_start_time.nil? + + span = tracer.start_span('http_server.proxy', + with_parent: extracted_context, + start_timestamp: request_start_time, + kind: :server) + + OpenTelemetry::Trace.context_with_span(span, parent_context: extracted_context) + end + + def finish_span(context) + OpenTelemetry::Trace.current_span(context).finish if context + end + + def tracer + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer + end + + def request_span_attributes(env:) + attributes = { + 'http.method' => env['REQUEST_METHOD'], + 'http.host' => env['HTTP_HOST'] || 'unknown', + 'server.address' => env['HTTP_HOST'] || 'unknown', + 'http.scheme' => env['rack.url_scheme'], + 'http.target' => env['QUERY_STRING'].empty? ? env['PATH_INFO'] : "#{env['PATH_INFO']}?#{env['QUERY_STRING']}", + 'http.request.method' => env['REQUEST_METHOD'], + 'url.scheme' => env['rack.url_scheme'], + 'url.path' => env['PATH_INFO'] + } + + attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? + if env['HTTP_USER_AGENT'] + attributes['http.user_agent'] = env['HTTP_USER_AGENT'] + attributes['user_agent.original'] = env['HTTP_USER_AGENT'] + end + attributes.merge!(allowed_request_headers(env)) + end + + # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name + # + # recommendation: span.name(s) should be low-cardinality (e.g., + # strip off query param value, keep param name) + # + # see http://github.com/open-telemetry/opentelemetry-specification/pull/416/files + def create_request_span_name(request_uri_or_path_info, env) + # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) + # see Datadog::Quantization::HTTP.url + + if (implementation = config[:url_quantization]) + implementation.call(request_uri_or_path_info, env) + else + env['REQUEST_METHOD'] + end + end + + def set_attributes_after_request(span, status, headers, _response) + span.status = OpenTelemetry::Trace::Status.error if (500..599).cover?(status.to_i) + span.set_attribute('http.status_code', status) + span.set_attribute('http.response.status_code', status) + + # NOTE: if data is available, it would be good to do this: + # set_attribute('http.route', ... + # e.g., "/users/:userID? + + allowed_response_headers(headers).each { |k, v| span.set_attribute(k, v) } + end + + def allowed_request_headers(env) + return EMPTY_HASH if self.class.allowed_rack_request_headers.empty? + + {}.tap do |result| + self.class.allowed_rack_request_headers.each do |key, value| + result[value] = env[key] if env.key?(key) + end + end + end + + def allowed_response_headers(headers) + return EMPTY_HASH if headers.nil? + return EMPTY_HASH if self.class.allowed_response_headers.empty? + + {}.tap do |result| + self.class.allowed_response_headers.each do |key, value| + if headers.key?(key) + result[value] = headers[key] + else + # do case-insensitive match: + headers.each do |k, v| + if k.upcase == key + result[value] = v + break + end + end + end + end + end + end + + def config + Rack::Instrumentation.instance.config + end + end + end + end + end + end +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/event_handler.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/event_handler.rb deleted file mode 100644 index bd6899a72c..0000000000 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/event_handler.rb +++ /dev/null @@ -1,268 +0,0 @@ -# frozen_string_literal: true - -# Copyright The OpenTelemetry Authors -# -# SPDX-License-Identifier: Apache-2.0 - -require_relative '../util' -require 'opentelemetry/trace/status' - -module OpenTelemetry - module Instrumentation - module Rack - module Middlewares - # OTel Rack Event Handler - # - # This seeds the root context for this service with the server span as the `current_span` - # allowing for callers later in the stack to reference it using {OpenTelemetry::Trace.current_span} - # - # It also registers the server span in a context dedicated to this instrumentation that users may look up - # using {OpenTelemetry::Instrumentation::Rack.current_span}, which makes it possible for users to mutate the span, - # e.g. add events or update the span name like in the {ActionPack} instrumentation. - # - # @example Rack App Using BodyProxy - # GLOBAL_LOGGER = Logger.new($stderr) - # APP_TRACER = OpenTelemetry.tracer_provider.tracer('my-app', '1.0.0') - # - # Rack::Builder.new do - # use Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new] - # run lambda { |_arg| - # APP_TRACER.in_span('hello-world') do |_span| - # body = Rack::BodyProxy.new(['hello world!']) do - # rack_span = OpenTelemetry::Instrumentation::Rack.current_span - # GLOBAL_LOGGER.info("otel.trace_id=#{rack_span.context.hex_trace_id} otel.span_id=#{rack_span.context.hex_span_id}") - # end - # [200, { 'Content-Type' => 'text/plain' }, body] - # end - # } - # end - # - # @see Rack::Events - # @see OpenTelemetry::Instrumentation::Rack.current_span - class EventHandler - include ::Rack::Events::Abstract - - OTEL_TOKEN_AND_SPAN = 'otel.rack.token_and_span' - EMPTY_HASH = {}.freeze - - # Creates a server span for this current request using the incoming parent context - # and registers them as the {current_span} - # - # @param [Rack::Request] The current HTTP request - # @param [Rack::Response] This is nil in practice - # @return [void] - def on_start(request, _) - parent_context = if untraced_request?(request.env) - extract_remote_context(request, OpenTelemetry::Common::Utilities.untraced) - else - extract_remote_context(request) - end - - span = create_span(parent_context, request) - span_ctx = OpenTelemetry::Trace.context_with_span(span, parent_context: parent_context) - rack_ctx = OpenTelemetry::Instrumentation::Rack.context_with_span(span, parent_context: span_ctx) - request.env[OTEL_TOKEN_AND_SPAN] = [OpenTelemetry::Context.attach(rack_ctx), span] - rescue StandardError => e - OpenTelemetry.handle_error(exception: e) - end - - # Optionally adds debugging response headers injected from {response_propagators} - # - # @param [Rack::Request] The current HTTP request - # @param [Rack::Response] This current HTTP response - # @return [void] - def on_commit(request, response) - span = OpenTelemetry::Instrumentation::Rack.current_span - return unless span.recording? - - response_propagators&.each do |propagator| - propagator.inject(response.headers) - rescue StandardError => e - OpenTelemetry.handle_error(message: 'Unable to inject response propagation headers', exception: e) - end - rescue StandardError => e - OpenTelemetry.handle_error(exception: e) - end - - # Records Unexpected Exceptions on the Rack span and set the Span Status to Error - # - # @note does nothing if the span is a non-recording span - # @param [Rack::Request] The current HTTP request - # @param [Rack::Response] The current HTTP response - # @param [Exception] An unxpected error raised by the application - def on_error(request, _, error) - span = OpenTelemetry::Instrumentation::Rack.current_span - return unless span.recording? - - span.record_exception(error) - span.status = OpenTelemetry::Trace::Status.error(error.class.name) - rescue StandardError => e - OpenTelemetry.handle_error(exception: e) - end - - # Finishes the span making it eligible to be exported and cleans up existing contexts - # - # @note does nothing if the span is a non-recording span - # @param [Rack::Request] The current HTTP request - # @param [Rack::Response] The current HTTP response - def on_finish(request, response) - span = OpenTelemetry::Instrumentation::Rack.current_span - return unless span.recording? - - add_response_attributes(span, response) if response - rescue StandardError => e - OpenTelemetry.handle_error(exception: e) - ensure - detach_context(request) - end - - private - - def extract_request_headers(env) - return EMPTY_HASH if allowed_request_headers.empty? - - allowed_request_headers.each_with_object({}) do |(key, value), result| - result[value] = env[key] if env.key?(key) - end - end - - def extract_response_attributes(response) - attributes = { 'http.status_code' => response.status.to_i } - attributes.merge!(extract_response_headers(response.headers)) - attributes - end - - def extract_response_headers(headers) - return EMPTY_HASH if allowed_response_headers.empty? - - allowed_response_headers.each_with_object({}) do |(key, value), result| - if headers.key?(key) - result[value] = headers[key] - else - # do case-insensitive match: - headers.each do |k, v| - if k.upcase == key - result[value] = v - break - end - end - end - end - end - - def untraced_request?(env) - return true if untraced_endpoints.include?(env['PATH_INFO']) - return true if untraced_requests&.call(env) - - false - end - - # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name - # - # recommendation: span.name(s) should be low-cardinality (e.g., - # strip off query param value, keep param name) - # - # see http://github.com/open-telemetry/opentelemetry-specification/pull/416/files - def create_request_span_name(request) - # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) - # see Datadog::Quantization::HTTP.url - - if (implementation = url_quantization) - request_uri_or_path_info = request.env['REQUEST_URI'] || request.path_info - implementation.call(request_uri_or_path_info, request.env) - else - "HTTP #{request.request_method}" - end - end - - def extract_remote_context(request, context = Context.current) - OpenTelemetry.propagation.extract( - request.env, - context: context, - getter: OpenTelemetry::Common::Propagation.rack_env_getter - ) - end - - def request_span_attributes(env) - attributes = { - 'http.method' => env['REQUEST_METHOD'], - 'http.host' => env['HTTP_HOST'] || 'unknown', - 'http.scheme' => env['rack.url_scheme'], - 'http.target' => env['QUERY_STRING'].empty? ? env['PATH_INFO'] : "#{env['PATH_INFO']}?#{env['QUERY_STRING']}" - } - - attributes['http.user_agent'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] - attributes.merge!(extract_request_headers(env)) - attributes - end - - def detach_context(request) - return nil unless request.env[OTEL_TOKEN_AND_SPAN] - - token, span = request.env[OTEL_TOKEN_AND_SPAN] - span.finish - OpenTelemetry::Context.detach(token) - rescue StandardError => e - OpenTelemetry.handle_error(exception: e) - end - - def add_response_attributes(span, response) - span.status = OpenTelemetry::Trace::Status.error if response.server_error? - attributes = extract_response_attributes(response) - span.add_attributes(attributes) - rescue StandardError => e - OpenTelemetry.handle_error(exception: e) - end - - def record_frontend_span? - config[:record_frontend_span] == true - end - - def untraced_endpoints - config[:untraced_endpoints] - end - - def untraced_requests - config[:untraced_requests] - end - - def url_quantization - config[:url_quantization] - end - - def response_propagators - config[:response_propagators] - end - - def allowed_request_headers - config[:allowed_rack_request_headers] - end - - def allowed_response_headers - config[:allowed_rack_response_headers] - end - - def tracer - OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer - end - - def config - OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.config - end - - def create_span(parent_context, request) - span = tracer.start_span( - create_request_span_name(request), - with_parent: parent_context, - kind: :server, - attributes: request_span_attributes(request.env) - ) - request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(request.env) - span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? - span - end - end - end - end - end -end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler.rb new file mode 100644 index 0000000000..56dd6ddbe6 --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler.rb @@ -0,0 +1,270 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../../util' +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + module Old + # OTel Rack Event Handler + # + # This seeds the root context for this service with the server span as the `current_span` + # allowing for callers later in the stack to reference it using {OpenTelemetry::Trace.current_span} + # + # It also registers the server span in a context dedicated to this instrumentation that users may look up + # using {OpenTelemetry::Instrumentation::Rack.current_span}, which makes it possible for users to mutate the span, + # e.g. add events or update the span name like in the {ActionPack} instrumentation. + # + # @example Rack App Using BodyProxy + # GLOBAL_LOGGER = Logger.new($stderr) + # APP_TRACER = OpenTelemetry.tracer_provider.tracer('my-app', '1.0.0') + # + # Rack::Builder.new do + # use Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new] + # run lambda { |_arg| + # APP_TRACER.in_span('hello-world') do |_span| + # body = Rack::BodyProxy.new(['hello world!']) do + # rack_span = OpenTelemetry::Instrumentation::Rack.current_span + # GLOBAL_LOGGER.info("otel.trace_id=#{rack_span.context.hex_trace_id} otel.span_id=#{rack_span.context.hex_span_id}") + # end + # [200, { 'Content-Type' => 'text/plain' }, body] + # end + # } + # end + # + # @see Rack::Events + # @see OpenTelemetry::Instrumentation::Rack.current_span + class EventHandler + include ::Rack::Events::Abstract + + OTEL_TOKEN_AND_SPAN = 'otel.rack.token_and_span' + EMPTY_HASH = {}.freeze + + # Creates a server span for this current request using the incoming parent context + # and registers them as the {current_span} + # + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] This is nil in practice + # @return [void] + def on_start(request, _) + parent_context = if untraced_request?(request.env) + extract_remote_context(request, OpenTelemetry::Common::Utilities.untraced) + else + extract_remote_context(request) + end + + span = create_span(parent_context, request) + span_ctx = OpenTelemetry::Trace.context_with_span(span, parent_context: parent_context) + rack_ctx = OpenTelemetry::Instrumentation::Rack.context_with_span(span, parent_context: span_ctx) + request.env[OTEL_TOKEN_AND_SPAN] = [OpenTelemetry::Context.attach(rack_ctx), span] + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Optionally adds debugging response headers injected from {response_propagators} + # + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] This current HTTP response + # @return [void] + def on_commit(request, response) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + response_propagators&.each do |propagator| + propagator.inject(response.headers) + rescue StandardError => e + OpenTelemetry.handle_error(message: 'Unable to inject response propagation headers', exception: e) + end + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Records Unexpected Exceptions on the Rack span and set the Span Status to Error + # + # @note does nothing if the span is a non-recording span + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] The current HTTP response + # @param [Exception] An unxpected error raised by the application + def on_error(request, _, error) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + span.record_exception(error) + span.status = OpenTelemetry::Trace::Status.error(error.class.name) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Finishes the span making it eligible to be exported and cleans up existing contexts + # + # @note does nothing if the span is a non-recording span + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] The current HTTP response + def on_finish(request, response) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + add_response_attributes(span, response) if response + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + ensure + detach_context(request) + end + + private + + def extract_request_headers(env) + return EMPTY_HASH if allowed_request_headers.empty? + + allowed_request_headers.each_with_object({}) do |(key, value), result| + result[value] = env[key] if env.key?(key) + end + end + + def extract_response_attributes(response) + attributes = { 'http.status_code' => response.status.to_i } + attributes.merge!(extract_response_headers(response.headers)) + attributes + end + + def extract_response_headers(headers) + return EMPTY_HASH if allowed_response_headers.empty? + + allowed_response_headers.each_with_object({}) do |(key, value), result| + if headers.key?(key) + result[value] = headers[key] + else + # do case-insensitive match: + headers.each do |k, v| + if k.upcase == key + result[value] = v + break + end + end + end + end + end + + def untraced_request?(env) + return true if untraced_endpoints.include?(env['PATH_INFO']) + return true if untraced_requests&.call(env) + + false + end + + # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name + # + # recommendation: span.name(s) should be low-cardinality (e.g., + # strip off query param value, keep param name) + # + # see http://github.com/open-telemetry/opentelemetry-specification/pull/416/files + def create_request_span_name(request) + # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) + # see Datadog::Quantization::HTTP.url + + if (implementation = url_quantization) + request_uri_or_path_info = request.env['REQUEST_URI'] || request.path_info + implementation.call(request_uri_or_path_info, request.env) + else + "HTTP #{request.request_method}" + end + end + + def extract_remote_context(request, context = Context.current) + OpenTelemetry.propagation.extract( + request.env, + context: context, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + end + + def request_span_attributes(env) + attributes = { + 'http.method' => env['REQUEST_METHOD'], + 'http.host' => env['HTTP_HOST'] || 'unknown', + 'http.scheme' => env['rack.url_scheme'], + 'http.target' => env['QUERY_STRING'].empty? ? env['PATH_INFO'] : "#{env['PATH_INFO']}?#{env['QUERY_STRING']}" + } + + attributes['http.user_agent'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] + attributes.merge!(extract_request_headers(env)) + attributes + end + + def detach_context(request) + return nil unless request.env[OTEL_TOKEN_AND_SPAN] + + token, span = request.env[OTEL_TOKEN_AND_SPAN] + span.finish + OpenTelemetry::Context.detach(token) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def add_response_attributes(span, response) + span.status = OpenTelemetry::Trace::Status.error if response.server_error? + attributes = extract_response_attributes(response) + span.add_attributes(attributes) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def record_frontend_span? + config[:record_frontend_span] == true + end + + def untraced_endpoints + config[:untraced_endpoints] + end + + def untraced_requests + config[:untraced_requests] + end + + def url_quantization + config[:url_quantization] + end + + def response_propagators + config[:response_propagators] + end + + def allowed_request_headers + config[:allowed_rack_request_headers] + end + + def allowed_response_headers + config[:allowed_rack_response_headers] + end + + def tracer + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer + end + + def config + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.config + end + + def create_span(parent_context, request) + span = tracer.start_span( + create_request_span_name(request), + with_parent: parent_context, + kind: :server, + attributes: request_span_attributes(request.env) + ) + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(request.env) + span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? + span + end + end + end + end + end + end +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware.rb new file mode 100644 index 0000000000..2b515c8697 --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + module Old + # TracerMiddleware propagates context and instruments Rack requests + # by way of its middleware system + class TracerMiddleware + class << self + def allowed_rack_request_headers + @allowed_rack_request_headers ||= Array(config[:allowed_request_headers]).each_with_object({}) do |header, memo| + key = header.to_s.upcase.gsub(/[-\s]/, '_') + case key + when 'CONTENT_TYPE', 'CONTENT_LENGTH' + memo[key] = build_attribute_name('http.request.header.', header) + else + memo["HTTP_#{key}"] = build_attribute_name('http.request.header.', header) + end + end + end + + def allowed_response_headers + @allowed_response_headers ||= Array(config[:allowed_response_headers]).each_with_object({}) do |header, memo| + memo[header] = build_attribute_name('http.response.header.', header) + memo[header.to_s.upcase] = build_attribute_name('http.response.header.', header) + end + end + + def build_attribute_name(prefix, suffix) + prefix + suffix.to_s.downcase.gsub(/[-\s]/, '_') + end + + def config + Rack::Instrumentation.instance.config + end + + private + + def clear_cached_config + @allowed_rack_request_headers = nil + @allowed_response_headers = nil + end + end + + EMPTY_HASH = {}.freeze + + def initialize(app) + @app = app + @untraced_endpoints = config[:untraced_endpoints] + end + + def call(env) + if untraced_request?(env) + OpenTelemetry::Common::Utilities.untraced do + return @app.call(env) + end + end + + original_env = env.dup + extracted_context = OpenTelemetry.propagation.extract( + env, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + frontend_context = create_frontend_span(env, extracted_context) + + # restore extracted context in this process: + OpenTelemetry::Context.with_current(frontend_context || extracted_context) do + request_span_name = create_request_span_name(env['REQUEST_URI'] || original_env['PATH_INFO'], env) + request_span_kind = frontend_context.nil? ? :server : :internal + tracer.in_span(request_span_name, + attributes: request_span_attributes(env: env), + kind: request_span_kind) do |request_span| + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + request_span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? + OpenTelemetry::Instrumentation::Rack.with_span(request_span) do + @app.call(env).tap do |status, headers, response| + set_attributes_after_request(request_span, status, headers, response) + config[:response_propagators].each { |propagator| propagator.inject(headers) } + end + end + end + end + ensure + finish_span(frontend_context) + end + + private + + def untraced_request?(env) + return true if @untraced_endpoints.include?(env['PATH_INFO']) + return true if config[:untraced_requests]&.call(env) + + false + end + + # return Context with the frontend span as the current span + def create_frontend_span(env, extracted_context) + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + + return unless config[:record_frontend_span] && !request_start_time.nil? + + span = tracer.start_span('http_server.proxy', + with_parent: extracted_context, + start_timestamp: request_start_time, + kind: :server) + + OpenTelemetry::Trace.context_with_span(span, parent_context: extracted_context) + end + + def finish_span(context) + OpenTelemetry::Trace.current_span(context).finish if context + end + + def tracer + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer + end + + def request_span_attributes(env:) + attributes = { + 'http.method' => env['REQUEST_METHOD'], + 'http.host' => env['HTTP_HOST'] || 'unknown', + 'http.scheme' => env['rack.url_scheme'], + 'http.target' => env['QUERY_STRING'].empty? ? env['PATH_INFO'] : "#{env['PATH_INFO']}?#{env['QUERY_STRING']}" + } + + attributes['http.user_agent'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] + attributes.merge!(allowed_request_headers(env)) + end + + # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name + # + # recommendation: span.name(s) should be low-cardinality (e.g., + # strip off query param value, keep param name) + # + # see http://github.com/open-telemetry/opentelemetry-specification/pull/416/files + def create_request_span_name(request_uri_or_path_info, env) + # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) + # see Datadog::Quantization::HTTP.url + + if (implementation = config[:url_quantization]) + implementation.call(request_uri_or_path_info, env) + else + "HTTP #{env['REQUEST_METHOD']}" + end + end + + def set_attributes_after_request(span, status, headers, _response) + span.status = OpenTelemetry::Trace::Status.error if (500..599).cover?(status.to_i) + span.set_attribute('http.status_code', status) + + # NOTE: if data is available, it would be good to do this: + # set_attribute('http.route', ... + # e.g., "/users/:userID? + + allowed_response_headers(headers).each { |k, v| span.set_attribute(k, v) } + end + + def allowed_request_headers(env) + return EMPTY_HASH if self.class.allowed_rack_request_headers.empty? + + {}.tap do |result| + self.class.allowed_rack_request_headers.each do |key, value| + result[value] = env[key] if env.key?(key) + end + end + end + + def allowed_response_headers(headers) + return EMPTY_HASH if headers.nil? + return EMPTY_HASH if self.class.allowed_response_headers.empty? + + {}.tap do |result| + self.class.allowed_response_headers.each do |key, value| + if headers.key?(key) + result[value] = headers[key] + else + # do case-insensitive match: + headers.each do |k, v| + if k.upcase == key + result[value] = v + break + end + end + end + end + end + end + + def config + Rack::Instrumentation.instance.config + end + end + end + end + end + end +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb new file mode 100644 index 0000000000..3998aa504a --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative '../../util' +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + module Stable + # OTel Rack Event Handler + # + # This seeds the root context for this service with the server span as the `current_span` + # allowing for callers later in the stack to reference it using {OpenTelemetry::Trace.current_span} + # + # It also registers the server span in a context dedicated to this instrumentation that users may look up + # using {OpenTelemetry::Instrumentation::Rack.current_span}, which makes it possible for users to mutate the span, + # e.g. add events or update the span name like in the {ActionPack} instrumentation. + # + # @example Rack App Using BodyProxy + # GLOBAL_LOGGER = Logger.new($stderr) + # APP_TRACER = OpenTelemetry.tracer_provider.tracer('my-app', '1.0.0') + # + # Rack::Builder.new do + # use Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new] + # run lambda { |_arg| + # APP_TRACER.in_span('hello-world') do |_span| + # body = Rack::BodyProxy.new(['hello world!']) do + # rack_span = OpenTelemetry::Instrumentation::Rack.current_span + # GLOBAL_LOGGER.info("otel.trace_id=#{rack_span.context.hex_trace_id} otel.span_id=#{rack_span.context.hex_span_id}") + # end + # [200, { 'Content-Type' => 'text/plain' }, body] + # end + # } + # end + # + # @see Rack::Events + # @see OpenTelemetry::Instrumentation::Rack.current_span + class EventHandler + include ::Rack::Events::Abstract + + OTEL_TOKEN_AND_SPAN = 'otel.rack.token_and_span' + EMPTY_HASH = {}.freeze + + # Creates a server span for this current request using the incoming parent context + # and registers them as the {current_span} + # + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] This is nil in practice + # @return [void] + def on_start(request, _) + parent_context = if untraced_request?(request.env) + extract_remote_context(request, OpenTelemetry::Common::Utilities.untraced) + else + extract_remote_context(request) + end + + span = create_span(parent_context, request) + span_ctx = OpenTelemetry::Trace.context_with_span(span, parent_context: parent_context) + rack_ctx = OpenTelemetry::Instrumentation::Rack.context_with_span(span, parent_context: span_ctx) + request.env[OTEL_TOKEN_AND_SPAN] = [OpenTelemetry::Context.attach(rack_ctx), span] + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Optionally adds debugging response headers injected from {response_propagators} + # + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] This current HTTP response + # @return [void] + def on_commit(request, response) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + response_propagators&.each do |propagator| + propagator.inject(response.headers) + rescue StandardError => e + OpenTelemetry.handle_error(message: 'Unable to inject response propagation headers', exception: e) + end + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Records Unexpected Exceptions on the Rack span and set the Span Status to Error + # + # @note does nothing if the span is a non-recording span + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] The current HTTP response + # @param [Exception] An unxpected error raised by the application + def on_error(request, _, error) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + span.record_exception(error) + span.status = OpenTelemetry::Trace::Status.error(error.class.name) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + # Finishes the span making it eligible to be exported and cleans up existing contexts + # + # @note does nothing if the span is a non-recording span + # @param [Rack::Request] The current HTTP request + # @param [Rack::Response] The current HTTP response + def on_finish(request, response) + span = OpenTelemetry::Instrumentation::Rack.current_span + return unless span.recording? + + add_response_attributes(span, response) if response + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + ensure + detach_context(request) + end + + private + + def extract_request_headers(env) + return EMPTY_HASH if allowed_request_headers.empty? + + allowed_request_headers.each_with_object({}) do |(key, value), result| + result[value] = env[key] if env.key?(key) + end + end + + def extract_response_attributes(response) + attributes = { 'http.response.status_code' => response.status.to_i } + attributes.merge!(extract_response_headers(response.headers)) + attributes + end + + def extract_response_headers(headers) + return EMPTY_HASH if allowed_response_headers.empty? + + allowed_response_headers.each_with_object({}) do |(key, value), result| + if headers.key?(key) + result[value] = headers[key] + else + # do case-insensitive match: + headers.each do |k, v| + if k.upcase == key + result[value] = v + break + end + end + end + end + end + + def untraced_request?(env) + return true if untraced_endpoints.include?(env['PATH_INFO']) + return true if untraced_requests&.call(env) + + false + end + + # https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name + # + # recommendation: span.name(s) should be low-cardinality (e.g., + # strip off query param value, keep param name) + def create_request_span_name(request) + # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) + # see Datadog::Quantization::HTTP.url + + if (implementation = url_quantization) + request_uri_or_path_info = request.env['REQUEST_URI'] || request.path_info + implementation.call(request_uri_or_path_info, request.env) + else + request.request_method.to_s + end + end + + def extract_remote_context(request, context = Context.current) + OpenTelemetry.propagation.extract( + request.env, + context: context, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + end + + def request_span_attributes(env) + attributes = { + 'http.request.method' => env['REQUEST_METHOD'], + 'server.address' => env['HTTP_HOST'] || 'unknown', + 'url.scheme' => env['rack.url_scheme'], + 'url.path' => env['PATH_INFO'] + } + + attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? + attributes['user_agent.original'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] + attributes.merge!(extract_request_headers(env)) + attributes + end + + def detach_context(request) + return nil unless request.env[OTEL_TOKEN_AND_SPAN] + + token, span = request.env[OTEL_TOKEN_AND_SPAN] + span.finish + OpenTelemetry::Context.detach(token) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def add_response_attributes(span, response) + span.status = OpenTelemetry::Trace::Status.error if response.server_error? + attributes = extract_response_attributes(response) + span.add_attributes(attributes) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e) + end + + def record_frontend_span? + config[:record_frontend_span] == true + end + + def untraced_endpoints + config[:untraced_endpoints] + end + + def untraced_requests + config[:untraced_requests] + end + + def url_quantization + config[:url_quantization] + end + + def response_propagators + config[:response_propagators] + end + + def allowed_request_headers + config[:allowed_rack_request_headers] + end + + def allowed_response_headers + config[:allowed_rack_response_headers] + end + + def tracer + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer + end + + def config + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.config + end + + def create_span(parent_context, request) + span = tracer.start_span( + create_request_span_name(request), + with_parent: parent_context, + kind: :server, + attributes: request_span_attributes(request.env) + ) + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(request.env) + span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? + span + end + end + end + end + end + end +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb new file mode 100644 index 0000000000..f14e819fcc --- /dev/null +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry/trace/status' + +module OpenTelemetry + module Instrumentation + module Rack + module Middlewares + module Stable + # TracerMiddleware propagates context and instruments Rack requests + # by way of its middleware system + class TracerMiddleware + class << self + def allowed_rack_request_headers + @allowed_rack_request_headers ||= Array(config[:allowed_request_headers]).each_with_object({}) do |header, memo| + key = header.to_s.upcase.gsub(/[-\s]/, '_') + case key + when 'CONTENT_TYPE', 'CONTENT_LENGTH' + memo[key] = build_attribute_name('http.request.header.', header) + else + memo["HTTP_#{key}"] = build_attribute_name('http.request.header.', header) + end + end + end + + def allowed_response_headers + @allowed_response_headers ||= Array(config[:allowed_response_headers]).each_with_object({}) do |header, memo| + memo[header] = build_attribute_name('http.response.header.', header) + memo[header.to_s.upcase] = build_attribute_name('http.response.header.', header) + end + end + + def build_attribute_name(prefix, suffix) + prefix + suffix.to_s.downcase.gsub(/[-\s]/, '_') + end + + def config + Rack::Instrumentation.instance.config + end + + private + + def clear_cached_config + @allowed_rack_request_headers = nil + @allowed_response_headers = nil + end + end + + EMPTY_HASH = {}.freeze + + def initialize(app) + @app = app + @untraced_endpoints = config[:untraced_endpoints] + end + + def call(env) + if untraced_request?(env) + OpenTelemetry::Common::Utilities.untraced do + return @app.call(env) + end + end + + original_env = env.dup + extracted_context = OpenTelemetry.propagation.extract( + env, + getter: OpenTelemetry::Common::Propagation.rack_env_getter + ) + frontend_context = create_frontend_span(env, extracted_context) + + # restore extracted context in this process: + OpenTelemetry::Context.with_current(frontend_context || extracted_context) do + request_span_name = create_request_span_name(env['REQUEST_URI'] || original_env['PATH_INFO'], env) + request_span_kind = frontend_context.nil? ? :server : :internal + tracer.in_span(request_span_name, + attributes: request_span_attributes(env: env), + kind: request_span_kind) do |request_span| + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + request_span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? + OpenTelemetry::Instrumentation::Rack.with_span(request_span) do + @app.call(env).tap do |status, headers, response| + set_attributes_after_request(request_span, status, headers, response) + config[:response_propagators].each { |propagator| propagator.inject(headers) } + end + end + end + end + ensure + finish_span(frontend_context) + end + + private + + def untraced_request?(env) + return true if @untraced_endpoints.include?(env['PATH_INFO']) + return true if config[:untraced_requests]&.call(env) + + false + end + + # return Context with the frontend span as the current span + def create_frontend_span(env, extracted_context) + request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) + + return unless config[:record_frontend_span] && !request_start_time.nil? + + span = tracer.start_span('http_server.proxy', + with_parent: extracted_context, + start_timestamp: request_start_time, + kind: :server) + + OpenTelemetry::Trace.context_with_span(span, parent_context: extracted_context) + end + + def finish_span(context) + OpenTelemetry::Trace.current_span(context).finish if context + end + + def tracer + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer + end + + def request_span_attributes(env:) + attributes = { + 'http.request.method' => env['REQUEST_METHOD'], + 'server.address' => env['HTTP_HOST'] || 'unknown', + 'url.scheme' => env['rack.url_scheme'], + 'url.path' => env['PATH_INFO'] + } + + attributes['url.query'] = env['QUERY_STRING'] unless env['QUERY_STRING'].empty? + attributes['user_agent.original'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] + attributes.merge!(allowed_request_headers(env)) + end + + # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name + # + # recommendation: span.name(s) should be low-cardinality (e.g., + # strip off query param value, keep param name) + # + # see http://github.com/open-telemetry/opentelemetry-specification/pull/416/files + def create_request_span_name(request_uri_or_path_info, env) + # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) + # see Datadog::Quantization::HTTP.url + + if (implementation = config[:url_quantization]) + implementation.call(request_uri_or_path_info, env) + else + env['REQUEST_METHOD'] + end + end + + def set_attributes_after_request(span, status, headers, _response) + span.status = OpenTelemetry::Trace::Status.error if (500..599).cover?(status.to_i) + span.set_attribute('http.response.status_code', status) + + # NOTE: if data is available, it would be good to do this: + # set_attribute('http.route', ... + # e.g., "/users/:userID? + + allowed_response_headers(headers).each { |k, v| span.set_attribute(k, v) } + end + + def allowed_request_headers(env) + return EMPTY_HASH if self.class.allowed_rack_request_headers.empty? + + {}.tap do |result| + self.class.allowed_rack_request_headers.each do |key, value| + result[value] = env[key] if env.key?(key) + end + end + end + + def allowed_response_headers(headers) + return EMPTY_HASH if headers.nil? + return EMPTY_HASH if self.class.allowed_response_headers.empty? + + {}.tap do |result| + self.class.allowed_response_headers.each do |key, value| + if headers.key?(key) + result[value] = headers[key] + else + # do case-insensitive match: + headers.each do |k, v| + if k.upcase == key + result[value] = v + break + end + end + end + end + end + end + + def config + Rack::Instrumentation.instance.config + end + end + end + end + end + end +end diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware.rb deleted file mode 100644 index e6aceba7da..0000000000 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware.rb +++ /dev/null @@ -1,203 +0,0 @@ -# frozen_string_literal: true - -# Copyright The OpenTelemetry Authors -# -# SPDX-License-Identifier: Apache-2.0 - -require 'opentelemetry/trace/status' - -module OpenTelemetry - module Instrumentation - module Rack - module Middlewares - # TracerMiddleware propagates context and instruments Rack requests - # by way of its middleware system - class TracerMiddleware - class << self - def allowed_rack_request_headers - @allowed_rack_request_headers ||= Array(config[:allowed_request_headers]).each_with_object({}) do |header, memo| - key = header.to_s.upcase.gsub(/[-\s]/, '_') - case key - when 'CONTENT_TYPE', 'CONTENT_LENGTH' - memo[key] = build_attribute_name('http.request.header.', header) - else - memo["HTTP_#{key}"] = build_attribute_name('http.request.header.', header) - end - end - end - - def allowed_response_headers - @allowed_response_headers ||= Array(config[:allowed_response_headers]).each_with_object({}) do |header, memo| - memo[header] = build_attribute_name('http.response.header.', header) - memo[header.to_s.upcase] = build_attribute_name('http.response.header.', header) - end - end - - def build_attribute_name(prefix, suffix) - prefix + suffix.to_s.downcase.gsub(/[-\s]/, '_') - end - - def config - Rack::Instrumentation.instance.config - end - - private - - def clear_cached_config - @allowed_rack_request_headers = nil - @allowed_response_headers = nil - end - end - - EMPTY_HASH = {}.freeze - - def initialize(app) - @app = app - @untraced_endpoints = config[:untraced_endpoints] - end - - def call(env) - if untraced_request?(env) - OpenTelemetry::Common::Utilities.untraced do - return @app.call(env) - end - end - - original_env = env.dup - extracted_context = OpenTelemetry.propagation.extract( - env, - getter: OpenTelemetry::Common::Propagation.rack_env_getter - ) - frontend_context = create_frontend_span(env, extracted_context) - - # restore extracted context in this process: - OpenTelemetry::Context.with_current(frontend_context || extracted_context) do - request_span_name = create_request_span_name(env['REQUEST_URI'] || original_env['PATH_INFO'], env) - request_span_kind = frontend_context.nil? ? :server : :internal - tracer.in_span(request_span_name, - attributes: request_span_attributes(env: env), - kind: request_span_kind) do |request_span| - request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) - request_span.add_event('http.proxy.request.started', timestamp: request_start_time) unless request_start_time.nil? - OpenTelemetry::Instrumentation::Rack.with_span(request_span) do - @app.call(env).tap do |status, headers, response| - set_attributes_after_request(request_span, status, headers, response) - config[:response_propagators].each { |propagator| propagator.inject(headers) } - end - end - end - end - ensure - finish_span(frontend_context) - end - - private - - def untraced_request?(env) - return true if @untraced_endpoints.include?(env['PATH_INFO']) - return true if config[:untraced_requests]&.call(env) - - false - end - - # return Context with the frontend span as the current span - def create_frontend_span(env, extracted_context) - request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env) - - return unless config[:record_frontend_span] && !request_start_time.nil? - - span = tracer.start_span('http_server.proxy', - with_parent: extracted_context, - start_timestamp: request_start_time, - kind: :server) - - OpenTelemetry::Trace.context_with_span(span, parent_context: extracted_context) - end - - def finish_span(context) - OpenTelemetry::Trace.current_span(context).finish if context - end - - def tracer - OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer - end - - def request_span_attributes(env:) - attributes = { - 'http.method' => env['REQUEST_METHOD'], - 'http.host' => env['HTTP_HOST'] || 'unknown', - 'http.scheme' => env['rack.url_scheme'], - 'http.target' => env['QUERY_STRING'].empty? ? env['PATH_INFO'] : "#{env['PATH_INFO']}?#{env['QUERY_STRING']}" - } - - attributes['http.user_agent'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT'] - attributes.merge!(allowed_request_headers(env)) - end - - # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name - # - # recommendation: span.name(s) should be low-cardinality (e.g., - # strip off query param value, keep param name) - # - # see http://github.com/open-telemetry/opentelemetry-specification/pull/416/files - def create_request_span_name(request_uri_or_path_info, env) - # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality) - # see Datadog::Quantization::HTTP.url - - if (implementation = config[:url_quantization]) - implementation.call(request_uri_or_path_info, env) - else - "HTTP #{env['REQUEST_METHOD']}" - end - end - - def set_attributes_after_request(span, status, headers, _response) - span.status = OpenTelemetry::Trace::Status.error if (500..599).cover?(status.to_i) - span.set_attribute('http.status_code', status) - - # NOTE: if data is available, it would be good to do this: - # set_attribute('http.route', ... - # e.g., "/users/:userID? - - allowed_response_headers(headers).each { |k, v| span.set_attribute(k, v) } - end - - def allowed_request_headers(env) - return EMPTY_HASH if self.class.allowed_rack_request_headers.empty? - - {}.tap do |result| - self.class.allowed_rack_request_headers.each do |key, value| - result[value] = env[key] if env.key?(key) - end - end - end - - def allowed_response_headers(headers) - return EMPTY_HASH if headers.nil? - return EMPTY_HASH if self.class.allowed_response_headers.empty? - - {}.tap do |result| - self.class.allowed_response_headers.each do |key, value| - if headers.key?(key) - result[value] = headers[key] - else - # do case-insensitive match: - headers.each do |k, v| - if k.upcase == key - result[value] = v - break - end - end - end - end - end - end - - def config - Rack::Instrumentation.instance.config - end - end - end - end - end -end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb similarity index 88% rename from instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb rename to instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb index 2a0cf5d6dd..baca6f689b 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_dup_test.rb @@ -12,6 +12,8 @@ let(:config) { {} } before do + skip unless ENV['BUNDLE_GEMFILE'].include?('dup') + # simulate a fresh install: instrumentation.instance_variable_set(:@installed, false) instrumentation.config.clear @@ -47,7 +49,7 @@ end end - describe '#middleware_args' do + describe '#middleware_args_old' do before do instrumentation.install(config) end @@ -56,9 +58,9 @@ let(:config) { Hash(use_rack_events: true) } it 'instantiates a custom event handler' do - args = instrumentation.middleware_args + args = instrumentation.middleware_args_dup _(args[0]).must_equal Rack::Events - _(args[1][0]).must_be_instance_of OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler + _(args[1][0]).must_be_instance_of OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler end end @@ -66,8 +68,8 @@ let(:config) { Hash(use_rack_events: false) } it 'instantiates a custom middleware' do - args = instrumentation.middleware_args - _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::TracerMiddleware] + args = instrumentation.middleware_args_dup + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::TracerMiddleware] end end end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb new file mode 100644 index 0000000000..d3f8bcca38 --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_old_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::Instrumentation::Rack::Instrumentation do + let(:instrumentation_class) { OpenTelemetry::Instrumentation::Rack::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + let(:config) { {} } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.config.clear + end + + describe 'given default config options' do + before do + instrumentation.install(config) + end + + it 'is installed with default settings' do + _(instrumentation).must_be :installed? + _(instrumentation.config[:allowed_request_headers]).must_be_empty + _(instrumentation.config[:allowed_response_headers]).must_be_empty + _(instrumentation.config[:application]).must_be_nil + _(instrumentation.config[:record_frontend_span]).must_equal false + _(instrumentation.config[:untraced_endpoints]).must_be_empty + _(instrumentation.config[:url_quantization]).must_be_nil + _(instrumentation.config[:untraced_requests]).must_be_nil + _(instrumentation.config[:response_propagators]).must_be_empty + _(instrumentation.config[:use_rack_events]).must_equal true + end + end + + describe 'when rack gem does not exist' do + before do + hide_const('Rack') + instrumentation.install(config) + end + + it 'skips installation' do + _(instrumentation).wont_be :installed? + end + end + + describe '#middleware_args_old' do + before do + instrumentation.install(config) + end + + describe 'when rack events are configured' do + let(:config) { Hash(use_rack_events: true) } + + it 'instantiates a custom event handler' do + args = instrumentation.middleware_args_old + _(args[0]).must_equal Rack::Events + _(args[1][0]).must_be_instance_of OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler + end + end + + describe 'when rack events are disabled' do + let(:config) { Hash(use_rack_events: false) } + + it 'instantiates a custom middleware' do + args = instrumentation.middleware_args_old + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware] + end + end + + describe 'when previously defined middleware_args is called' do + let(:config) { Hash(use_rack_events: false) } + + it 'alias to middleware_args_old' do + args = instrumentation.middleware_args + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware] + end + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_stable_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_stable_test.rb new file mode 100644 index 0000000000..be80413912 --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/instrumentation_stable_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::Instrumentation::Rack::Instrumentation do + let(:instrumentation_class) { OpenTelemetry::Instrumentation::Rack::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + let(:config) { {} } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('http') + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.config.clear + end + + describe 'given default config options' do + before do + instrumentation.install(config) + end + + it 'is installed with default settings' do + _(instrumentation).must_be :installed? + _(instrumentation.config[:allowed_request_headers]).must_be_empty + _(instrumentation.config[:allowed_response_headers]).must_be_empty + _(instrumentation.config[:application]).must_be_nil + _(instrumentation.config[:record_frontend_span]).must_equal false + _(instrumentation.config[:untraced_endpoints]).must_be_empty + _(instrumentation.config[:url_quantization]).must_be_nil + _(instrumentation.config[:untraced_requests]).must_be_nil + _(instrumentation.config[:response_propagators]).must_be_empty + _(instrumentation.config[:use_rack_events]).must_equal true + end + end + + describe 'when rack gem does not exist' do + before do + hide_const('Rack') + instrumentation.install(config) + end + + it 'skips installation' do + _(instrumentation).wont_be :installed? + end + end + + describe '#middleware_args_old' do + before do + instrumentation.install(config) + end + + describe 'when rack events are configured' do + let(:config) { Hash(use_rack_events: true) } + + it 'instantiates a custom event handler' do + args = instrumentation.middleware_args_stable + _(args[0]).must_equal Rack::Events + _(args[1][0]).must_be_instance_of OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::EventHandler + end + end + + describe 'when rack events are disabled' do + let(:config) { Hash(use_rack_events: false) } + + it 'instantiates a custom middleware' do + args = instrumentation.middleware_args_stable + _(args).must_equal [OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::TracerMiddleware] + end + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/event_handler_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/event_handler_test.rb new file mode 100644 index 0000000000..9b5dc929c2 --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/event_handler_test.rb @@ -0,0 +1,508 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/middlewares/dup/event_handler' + +describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler' do + include Rack::Test::Methods + + let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } + let(:instrumentation_class) { instrumentation_module::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + let(:instrumentation_enabled) { true } + + let(:config) do + { + untraced_endpoints: untraced_endpoints, + untraced_requests: untraced_requests, + allowed_request_headers: allowed_request_headers, + allowed_response_headers: allowed_response_headers, + url_quantization: url_quantization, + response_propagators: response_propagators, + enabled: instrumentation_enabled, + use_rack_events: true + } + end + + let(:exporter) { EXPORTER } + let(:finished_spans) { exporter.finished_spans } + let(:rack_span) { exporter.finished_spans.first } + let(:proxy_event) { rack_span.events&.first } + let(:uri) { '/' } + let(:handler) do + OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler.new + end + + let(:after_close) { nil } + let(:response_body) { Rack::BodyProxy.new(['Hello World']) { after_close&.call } } + + let(:service) do + ->(_arg) { [200, { 'Content-Type' => 'text/plain' }, response_body] } + end + let(:untraced_endpoints) { [] } + let(:untraced_requests) { nil } + let(:allowed_request_headers) { nil } + let(:allowed_response_headers) { nil } + let(:response_propagators) { nil } + let(:url_quantization) { nil } + let(:headers) { {} } + let(:app) do + Rack::Builder.new.tap do |builder| + builder.use Rack::Events, [handler] + builder.run service + end + end + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('dup') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http/dup' + exporter.reset + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(config) + end + + # Simulating buggy instrumentation that starts a span, sets the ctx + # but fails to detach or close the span + describe 'broken instrumentation' do + let(:service) do + lambda do |_env| + span = OpenTelemetry.tracer_provider.tracer('buggy').start_span('I never close') + OpenTelemetry::Context.attach(OpenTelemetry::Trace.context_with_span(span)) + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + it 'still closes the rack span' do + assert_raises OpenTelemetry::Context::DetachError do + get uri, {}, headers + end + _(finished_spans.size).must_equal 1 + _(rack_span.name).must_equal 'GET' + OpenTelemetry::Context.clear + end + end + + describe '#call' do + before do + get uri, {}, headers + end + + it 'record a span' do + _(rack_span.attributes['http.method']).must_equal 'GET' + _(rack_span.attributes['http.status_code']).must_equal 200 + _(rack_span.attributes['http.target']).must_equal '/' + _(rack_span.attributes['http.url']).must_be_nil + _(rack_span.attributes['http.request.method']).must_equal 'GET' + _(rack_span.attributes['http.response.status_code']).must_equal 200 + _(rack_span.attributes['url.path']).must_equal '/' + _(rack_span.attributes['url.full']).must_be_nil + _(rack_span.name).must_equal 'GET' + _(rack_span.kind).must_equal :server + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + _(rack_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + _(proxy_event).must_be_nil + end + + describe 'with a hijacked response' do + let(:service) do + lambda do |env| + env['rack.hijack?'] = true + [-1, {}, []] + end + end + + it 'sets the span status to "unset"' do + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + + describe 'when baggage is set' do + let(:headers) do + Hash( + 'baggage' => 'foo=123' + ) + end + + let(:service) do + lambda do |_env| + _(OpenTelemetry::Baggage.raw_entries['foo'].value).must_equal('123') + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + it 'sets baggage in the request context' do + _(rack_span.name).must_equal 'GET' + end + end + + describe 'when a query is passed in' do + let(:uri) { '/endpoint?query=true' } + + it 'records the query path' do + _(rack_span.attributes['http.target']).must_equal '/endpoint?query=true' + _(rack_span.attributes['url.path']).must_equal '/endpoint' + _(rack_span.attributes['url.query']).must_equal 'query=true' + _(rack_span.name).must_equal 'GET' + end + end + + describe 'config[:untraced_endpoints]' do + let(:service) do + lambda do |_env| + OpenTelemetry.tracer_provider.tracer('req').in_span('in_req_span') {} + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + describe 'when an array is passed in' do + let(:uri) { '/ping' } + let(:untraced_endpoints) { ['/ping'] } + + it 'does not trace paths listed in the array' do + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + + _(ping_span).must_be_nil + _(ping_span_stable).must_be_nil + + _(finished_spans.size).must_equal 0 + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_endpoints: nil } } + + it 'traces everything' do + get '/ping' + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + _(ping_span_stable).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + end + + describe 'config[:untraced_requests]' do + let(:service) do + lambda do |_env| + OpenTelemetry.tracer_provider.tracer('req').in_span('in_req_span') {} + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + describe 'when a callable is passed in' do + let(:uri) { '/assets' } + let(:untraced_requests) do + ->(env) { env['PATH_INFO'] =~ %r{^\/assets} } + end + + it 'does not trace requests in which the callable returns true' do + assets_span = finished_spans.find { |s| s.attributes['http.target'] == '/assets' } + assets_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(assets_span).must_be_nil + _(assets_span_stable).must_be_nil + + _(finished_spans.size).must_equal 0 + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_requests: nil } } + + it 'traces everything' do + get '/assets' + + asset_span = finished_spans.find { |s| s.attributes['http.target'] == '/assets' } + asset_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(asset_span).wont_be_nil + _(asset_span_stable).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + end + + describe 'config[:allowed_request_headers]' do + let(:headers) do + Hash( + 'CONTENT_LENGTH' => '123', + 'CONTENT_TYPE' => 'application/json', + 'HTTP_FOO_BAR' => 'http foo bar value' + ) + end + + it 'defaults to nil' do + _(rack_span.attributes['http.request.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:allowed_request_headers) do + ['foo_BAR'] + end + + it 'returns attribute' do + _(rack_span.attributes['http.request.header.foo_bar']).must_equal 'http foo bar value' + end + end + + describe 'when content-type' do + let(:allowed_request_headers) { ['CONTENT_TYPE'] } + + it 'returns attribute' do + _(rack_span.attributes['http.request.header.content_type']).must_equal 'application/json' + end + end + + describe 'when content-length' do + let(:allowed_request_headers) { ['CONTENT_LENGTH'] } + + it 'returns attribute' do + _(rack_span.attributes['http.request.header.content_length']).must_equal '123' + end + end + end + + describe 'config[:allowed_response_headers]' do + let(:service) do + ->(_env) { [200, { 'Foo-Bar' => 'foo bar response header' }, ['OK']] } + end + + it 'defaults to nil' do + _(rack_span.attributes['http.response.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:allowed_response_headers) { ['Foo-Bar'] } + + it 'returns attribute' do + _(rack_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + + describe 'case-sensitively' do + let(:allowed_response_headers) { ['fOO-bAR'] } + + it 'returns attribute' do + _(rack_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + end + end + end + + describe 'given request proxy headers' do + let(:headers) { Hash('HTTP_X_REQUEST_START' => '1677723466') } + + it 'records an event' do + _(proxy_event.name).must_equal 'http.proxy.request.started' + _(proxy_event.timestamp).must_equal 1_677_723_466_000_000_000 + end + end + + describe '#called with 400 level http status code' do + let(:service) do + ->(_env) { [404, { 'Foo-Bar' => 'foo bar response header' }, ['Not Found']] } + end + + it 'leaves status code unset' do + _(rack_span.attributes['http.status_code']).must_equal 404 + _(rack_span.attributes['http.response.status_code']).must_equal 404 + _(rack_span.kind).must_equal :server + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + end + + describe 'url quantization' do + describe 'when using standard Rack environment variables' do + describe 'without quantization' do + it 'span.name defaults to low cardinality name HTTP method' do + get '/really_long_url' + + _(rack_span.name).must_equal 'GET' + _(rack_span.attributes['http.target']).must_equal '/really_long_url' + _(rack_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with simple quantization' do + let(:quantization_example) do + ->(url, _env) { url.to_s } + end + + let(:url_quantization) { quantization_example } + + it 'sets the span.name to the full path' do + get '/really_long_url' + + _(rack_span.name).must_equal '/really_long_url' + _(rack_span.attributes['http.target']).must_equal '/really_long_url' + _(rack_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with quantization' do + let(:quantization_example) do + # demonstrate simple shortening of URL: + ->(url, _env) { url.to_s[0..5] } + end + let(:url_quantization) { quantization_example } + + it 'mutates url according to url_quantization' do + get '/really_long_url' + + _(rack_span.name).must_equal '/reall' + end + end + end + + describe 'when using Action Dispatch custom environment variables' do + describe 'without quantization' do + it 'span.name defaults to low cardinality name HTTP method' do + get '/really_long_url', {}, { 'REQUEST_URI' => '/action-dispatch-uri' } + + _(rack_span.name).must_equal 'GET' + _(rack_span.attributes['http.target']).must_equal '/really_long_url' + _(rack_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with simple quantization' do + let(:quantization_example) do + ->(url, _env) { url.to_s } + end + + let(:url_quantization) { quantization_example } + + it 'sets the span.name to the full path' do + get '/really_long_url', {}, { 'REQUEST_URI' => '/action-dispatch-uri' } + + _(rack_span.name).must_equal '/action-dispatch-uri' + _(rack_span.attributes['http.target']).must_equal '/really_long_url' + _(rack_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with quantization' do + let(:quantization_example) do + # demonstrate simple shortening of URL: + ->(url, _env) { url.to_s[0..5] } + end + let(:url_quantization) { quantization_example } + + it 'mutates url according to url_quantization' do + get '/really_long_url', {}, { 'REQUEST_URI' => '/action-dispatch-uri' } + + _(rack_span.name).must_equal '/actio' + end + end + end + end + + describe 'response_propagators' do + describe 'with default options' do + it 'does not inject the traceresponse header' do + get '/ping' + _(last_response.headers).wont_include('traceresponse') + end + end + + describe 'with ResponseTextMapPropagator' do + let(:response_propagators) { [OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator.new] } + + it 'injects the traceresponse header' do + get '/ping' + _(last_response.headers).must_include('traceresponse') + end + end + + describe 'response propagators that raise errors' do + class EventMockPropagator < OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator + CustomError = Class.new(StandardError) + def inject(carrier) + raise CustomError, 'Injection failed' + end + end + + let(:response_propagators) { [EventMockPropagator.new, OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator.new] } + + it 'is fault tolerant' do + expect(OpenTelemetry).to receive(:handle_error).with(exception: instance_of(EventMockPropagator::CustomError), message: /Unable/) + + get '/ping' + _(last_response.headers).must_include('traceresponse') + end + end + end + + describe '#call with error' do + EventHandlerError = Class.new(StandardError) + + let(:service) do + ->(_env) { raise EventHandlerError } + end + + it 'records error in span and then re-raises' do + assert_raises EventHandlerError do + get '/' + end + + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + end + end + + describe 'when the instrumentation is disabled' do + let(:instrumenation_enabled) { false } + + it 'does nothing' do + _(rack_span).must_be_nil + end + end + + describe 'when response body is called' do + let(:after_close) { -> { OpenTelemetry::Instrumentation::Rack.current_span.add_event('after-response-called') } } + + it 'has access to a Rack read/write span' do + get '/' + _(rack_span.events.map(&:name)).must_include('after-response-called') + end + end + + describe 'when response body is called' do + let(:response_body) { ['Simple, Hello World!'] } + + it 'has access to a Rack read/write span' do + get '/' + _(rack_span.attributes['http.method']).must_equal 'GET' + _(rack_span.attributes['http.status_code']).must_equal 200 + _(rack_span.attributes['http.target']).must_equal '/' + _(rack_span.attributes['http.url']).must_be_nil + _(rack_span.attributes['http.request.method']).must_equal 'GET' + _(rack_span.attributes['http.response.status_code']).must_equal 200 + _(rack_span.attributes['url.path']).must_equal '/' + _(rack_span.attributes['url.full']).must_be_nil + _(rack_span.name).must_equal 'GET' + _(rack_span.kind).must_equal :server + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + _(rack_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + _(proxy_event).must_be_nil + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware_test.rb new file mode 100644 index 0000000000..aece2a3470 --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware_test.rb @@ -0,0 +1,417 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +# require Instrumentation so .install method is found: +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/middlewares/dup/tracer_middleware' + +describe OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::TracerMiddleware do + let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } + let(:instrumentation_class) { instrumentation_module::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + + let(:described_class) { OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::TracerMiddleware } + + let(:app) { ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['OK']] } } + let(:middleware) { described_class.new(app) } + let(:rack_builder) { Rack::Builder.new } + + let(:exporter) { EXPORTER } + let(:finished_spans) { exporter.finished_spans } + let(:first_span) { exporter.finished_spans.first } + let(:proxy_event) { first_span.events&.first } + + let(:default_config) { {} } + let(:config) { default_config } + let(:env) { {} } + let(:uri) { '/' } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('dup') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http/dup' + # clear captured spans: + exporter.reset + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(config) + + # clear out cached config: + described_class.send(:clear_cached_config) + + # integrate tracer middleware: + rack_builder.run app + rack_builder.use described_class + end + + after do + # installation is 'global', so it should be reset: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(default_config) + end + + describe '#call' do + before do + Rack::MockRequest.new(rack_builder).get(uri, env) + end + + it 'records attributes' do + _(first_span.attributes['http.method']).must_equal 'GET' + _(first_span.attributes['http.status_code']).must_equal 200 + _(first_span.attributes['http.target']).must_equal '/' + _(first_span.attributes['http.url']).must_be_nil + _(first_span.attributes['http.request.method']).must_equal 'GET' + _(first_span.attributes['http.response.status_code']).must_equal 200 + _(first_span.attributes['url.path']).must_equal '/' + _(first_span.attributes['url.full']).must_be_nil + _(first_span.name).must_equal 'GET' + _(first_span.kind).must_equal :server + end + + it 'does not explicitly set status OK' do + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + + describe 'with a hijacked response' do + let(:app) do + lambda do |env| + env['rack.hijack?'] = true + [-1, {}, []] + end + end + + it 'sets the span status to "unset"' do + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + + it 'has no parent' do + _(first_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + end + + describe 'when a query is passed in' do + let(:uri) { '/endpoint?query=true' } + + it 'records the query path' do + _(first_span.attributes['http.target']).must_equal '/endpoint?query=true' + _(first_span.attributes['url.path']).must_equal '/endpoint' + _(first_span.attributes['url.query']).must_equal 'query=true' + _(first_span.name).must_equal 'GET' + end + end + + describe 'given request proxy headers' do + let(:env) { Hash('HTTP_X_REQUEST_START' => '1677723466') } + + it 'records an event' do + _(proxy_event.name).must_equal 'http.proxy.request.started' + _(proxy_event.timestamp).must_equal 1_677_723_466_000_000_000 + end + end + + describe 'config[:untraced_endpoints]' do + describe 'when an array is passed in' do + let(:config) { { untraced_endpoints: ['/ping'] } } + + it 'does not trace paths listed in the array' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).must_be_nil + _(ping_span_stable).must_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + + describe 'when a string is passed in' do + let(:config) { { untraced_endpoints: '/ping' } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + _(ping_span_stable).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_endpoints: nil } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/ping' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + _(ping_span_stable).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + end + + describe 'config[:untraced_requests]' do + describe 'when a callable is passed in' do + let(:untraced_callable) do + ->(env) { env['PATH_INFO'] =~ %r{^\/assets} } + end + let(:config) { default_config.merge(untraced_requests: untraced_callable) } + + it 'does not trace requests in which the callable returns true' do + Rack::MockRequest.new(rack_builder).get('/assets', env) + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/assets' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(ping_span).must_be_nil + _(ping_span_stable).must_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_requests: nil } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/assets', env) + + ping_span = finished_spans.find { |s| s.attributes['http.target'] == '/assets' } + ping_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(ping_span).wont_be_nil + _(ping_span_stable).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['http.target'] == '/' } + root_span_stable = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + _(root_span_stable).wont_be_nil + end + end + end + + describe 'config[:allowed_request_headers]' do + let(:env) do + Hash( + 'CONTENT_LENGTH' => '123', + 'CONTENT_TYPE' => 'application/json', + 'HTTP_FOO_BAR' => 'http foo bar value' + ) + end + + it 'defaults to nil' do + _(first_span.attributes['http.request.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:config) { default_config.merge(allowed_request_headers: ['foo_BAR']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.foo_bar']).must_equal 'http foo bar value' + end + end + + describe 'when content-type' do + let(:config) { default_config.merge(allowed_request_headers: ['CONTENT_TYPE']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.content_type']).must_equal 'application/json' + end + end + + describe 'when content-length' do + let(:config) { default_config.merge(allowed_request_headers: ['CONTENT_LENGTH']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.content_length']).must_equal '123' + end + end + end + + describe 'config[:allowed_response_headers]' do + let(:app) do + ->(_env) { [200, { 'Foo-Bar' => 'foo bar response header' }, ['OK']] } + end + + it 'defaults to nil' do + _(first_span.attributes['http.response.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:config) { default_config.merge(allowed_response_headers: ['Foo-Bar']) } + + it 'returns attribute' do + _(first_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + + describe 'case-sensitively' do + let(:config) { default_config.merge(allowed_response_headers: ['fOO-bAR']) } + + it 'returns attribute' do + _(first_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + end + end + end + + describe 'config[:record_frontend_span]' do + let(:request_span) { exporter.finished_spans.first } + + describe 'default' do + it 'does not record span' do + _(exporter.finished_spans.size).must_equal 1 + end + + it 'does not parent the request_span' do + _(request_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + end + end + + describe 'when recordable' do + let(:config) { default_config.merge(record_frontend_span: true) } + let(:env) { Hash('HTTP_X_REQUEST_START' => Time.now.to_i) } + let(:frontend_span) { exporter.finished_spans[1] } + let(:request_span) { exporter.finished_spans[0] } + + it 'records span' do + _(exporter.finished_spans.size).must_equal 2 + _(frontend_span.name).must_equal 'http_server.proxy' + _(frontend_span.attributes['service']).must_be_nil + end + + it 'changes request_span kind' do + _(request_span.kind).must_equal :internal + end + + it 'frontend_span parents request_span' do + _(request_span.parent_span_id).must_equal frontend_span.span_id + end + end + end + + describe '#called with 400 level http status code' do + let(:app) do + ->(_env) { [404, { 'Foo-Bar' => 'foo bar response header' }, ['Not Found']] } + end + + it 'leaves status code unset' do + _(first_span.attributes['http.status_code']).must_equal 404 + _(first_span.attributes['http.response.status_code']).must_equal 404 + _(first_span.kind).must_equal :server + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + end + + describe 'config[:quantization]' do + before do + Rack::MockRequest.new(rack_builder).get('/really_long_url', env) + end + + describe 'without quantization' do + it 'span.name defaults to low cardinality name HTTP method' do + _(first_span.name).must_equal 'GET' + _(first_span.attributes['http.target']).must_equal '/really_long_url' + _(first_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with simple quantization' do + let(:quantization_example) do + ->(url, _env) { url.to_s } + end + + let(:config) { default_config.merge(url_quantization: quantization_example) } + + it 'sets the span.name to the full path' do + _(first_span.name).must_equal '/really_long_url' + _(first_span.attributes['http.target']).must_equal '/really_long_url' + _(first_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with quantization' do + let(:quantization_example) do + # demonstrate simple shortening of URL: + ->(url, _env) { url.to_s[0..5] } + end + let(:config) { default_config.merge(url_quantization: quantization_example) } + + it 'mutates url according to url_quantization' do + _(first_span.name).must_equal '/reall' + end + end + end + + describe 'config[:response_propagators]' do + describe 'with default options' do + it 'does not inject the traceresponse header' do + res = Rack::MockRequest.new(rack_builder).get('/ping', env) + _(res.headers).wont_include('traceresponse') + end + end + + describe 'with ResponseTextMapPropagator' do + let(:config) { default_config.merge(response_propagators: [OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator.new]) } + + it 'injects the traceresponse header' do + res = Rack::MockRequest.new(rack_builder).get('/ping', env) + _(res.headers).must_include('traceresponse') + end + end + + describe 'propagator throws' do + class MockPropagator < OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator + def inject(carrier) + raise 'Injection failed' + end + end + + let(:config) { default_config.merge(response_propagators: [MockPropagator.new]) } + + it 'leads to application errors when there are exceptions' do + assert_raises RuntimeError do + Rack::MockRequest.new(rack_builder).get('/ping', env) + end + end + end + end + + describe '#call with error' do + SimulatedError = Class.new(StandardError) + + let(:app) do + ->(_env) { raise SimulatedError } + end + + it 'records error in span and then re-raises' do + assert_raises SimulatedError do + Rack::MockRequest.new(rack_builder).get('/', env) + end + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_resiliency_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_resiliency_test.rb index 863f77b478..e37370953e 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_resiliency_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_resiliency_test.rb @@ -7,13 +7,15 @@ require 'test_helper' require_relative '../../../../../lib/opentelemetry/instrumentation/rack' require_relative '../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' -require_relative '../../../../../lib/opentelemetry/instrumentation/rack/middlewares/event_handler' +require_relative '../../../../../lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler' describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler::ResiliencyTest' do let(:handler) do - OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new + OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler.new end + before { skip unless ENV['BUNDLE_GEMFILE'].include?('old') } + it 'reports unexpected errors without causing request errors' do allow(OpenTelemetry::Instrumentation::Rack).to receive(:current_span).and_raise('Bad news!') expect(OpenTelemetry).to receive(:handle_error).exactly(5).times diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/event_handler_test.rb similarity index 96% rename from instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_test.rb rename to instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/event_handler_test.rb index afb254bca9..3e8fdf2a8c 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/event_handler_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/event_handler_test.rb @@ -5,11 +5,11 @@ # SPDX-License-Identifier: Apache-2.0 require 'test_helper' -require_relative '../../../../../lib/opentelemetry/instrumentation/rack' -require_relative '../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' -require_relative '../../../../../lib/opentelemetry/instrumentation/rack/middlewares/event_handler' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/middlewares/old/event_handler' -describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler' do +describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler' do include Rack::Test::Methods let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } @@ -36,7 +36,7 @@ let(:proxy_event) { rack_span.events&.first } let(:uri) { '/' } let(:handler) do - OpenTelemetry::Instrumentation::Rack::Middlewares::EventHandler.new + OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler.new end let(:after_close) { nil } @@ -60,6 +60,8 @@ end before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + exporter.reset # simulate a fresh install: diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/tracer_middleware_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware_test.rb similarity index 96% rename from instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/tracer_middleware_test.rb rename to instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware_test.rb index b17f60ed93..4c332cba2c 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/tracer_middleware_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware_test.rb @@ -7,16 +7,16 @@ require 'test_helper' # require Instrumentation so .install method is found: -require_relative '../../../../../lib/opentelemetry/instrumentation/rack' -require_relative '../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' -require_relative '../../../../../lib/opentelemetry/instrumentation/rack/middlewares/tracer_middleware' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/middlewares/old/tracer_middleware' -describe OpenTelemetry::Instrumentation::Rack::Middlewares::TracerMiddleware do +describe OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware do let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } let(:instrumentation_class) { instrumentation_module::Instrumentation } let(:instrumentation) { instrumentation_class.instance } - let(:described_class) { OpenTelemetry::Instrumentation::Rack::Middlewares::TracerMiddleware } + let(:described_class) { OpenTelemetry::Instrumentation::Rack::Middlewares::Old::TracerMiddleware } let(:app) { ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['OK']] } } let(:middleware) { described_class.new(app) } @@ -33,6 +33,8 @@ let(:uri) { '/' } before do + skip unless ENV['BUNDLE_GEMFILE'].include?('old') + # clear captured spans: exporter.reset diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/event_handler_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/event_handler_test.rb new file mode 100644 index 0000000000..0a458fe6df --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/event_handler_test.rb @@ -0,0 +1,481 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/middlewares/stable/event_handler' + +describe 'OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::EventHandler' do + include Rack::Test::Methods + + let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } + let(:instrumentation_class) { instrumentation_module::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + let(:instrumentation_enabled) { true } + + let(:config) do + { + untraced_endpoints: untraced_endpoints, + untraced_requests: untraced_requests, + allowed_request_headers: allowed_request_headers, + allowed_response_headers: allowed_response_headers, + url_quantization: url_quantization, + response_propagators: response_propagators, + enabled: instrumentation_enabled, + use_rack_events: true + } + end + + let(:exporter) { EXPORTER } + let(:finished_spans) { exporter.finished_spans } + let(:rack_span) { exporter.finished_spans.first } + let(:proxy_event) { rack_span.events&.first } + let(:uri) { '/' } + let(:handler) do + OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::EventHandler.new + end + + let(:after_close) { nil } + let(:response_body) { Rack::BodyProxy.new(['Hello World']) { after_close&.call } } + + let(:service) do + ->(_arg) { [200, { 'Content-Type' => 'text/plain' }, response_body] } + end + let(:untraced_endpoints) { [] } + let(:untraced_requests) { nil } + let(:allowed_request_headers) { nil } + let(:allowed_response_headers) { nil } + let(:response_propagators) { nil } + let(:url_quantization) { nil } + let(:headers) { {} } + let(:app) do + Rack::Builder.new.tap do |builder| + builder.use Rack::Events, [handler] + builder.run service + end + end + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('stable') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http' + + exporter.reset + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(config) + end + + # Simulating buggy instrumentation that starts a span, sets the ctx + # but fails to detach or close the span + describe 'broken instrumentation' do + let(:service) do + lambda do |_env| + span = OpenTelemetry.tracer_provider.tracer('buggy').start_span('I never close') + OpenTelemetry::Context.attach(OpenTelemetry::Trace.context_with_span(span)) + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + it 'still closes the rack span' do + assert_raises OpenTelemetry::Context::DetachError do + get uri, {}, headers + end + _(finished_spans.size).must_equal 1 + _(rack_span.name).must_equal 'GET' + OpenTelemetry::Context.clear + end + end + + describe '#call' do + before do + get uri, {}, headers + end + + it 'record a span' do + _(rack_span.attributes['http.request.method']).must_equal 'GET' + _(rack_span.attributes['http.response.status_code']).must_equal 200 + _(rack_span.attributes['url.path']).must_equal '/' + _(rack_span.attributes['url.full']).must_be_nil + _(rack_span.name).must_equal 'GET' + _(rack_span.kind).must_equal :server + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + _(rack_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + _(proxy_event).must_be_nil + end + + describe 'with a hijacked response' do + let(:service) do + lambda do |env| + env['rack.hijack?'] = true + [-1, {}, []] + end + end + + it 'sets the span status to "unset"' do + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + + describe 'when baggage is set' do + let(:headers) do + Hash( + 'baggage' => 'foo=123' + ) + end + + let(:service) do + lambda do |_env| + _(OpenTelemetry::Baggage.raw_entries['foo'].value).must_equal('123') + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + it 'sets baggage in the request context' do + _(rack_span.name).must_equal 'GET' + end + end + + describe 'when a query is passed in' do + let(:uri) { '/endpoint?query=true' } + + it 'records the query path' do + _(rack_span.attributes['url.path']).must_equal '/endpoint' + _(rack_span.attributes['url.query']).must_equal 'query=true' + _(rack_span.name).must_equal 'GET' + end + end + + describe 'config[:untraced_endpoints]' do + let(:service) do + lambda do |_env| + OpenTelemetry.tracer_provider.tracer('req').in_span('in_req_span') {} + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + describe 'when an array is passed in' do + let(:uri) { '/ping' } + let(:untraced_endpoints) { ['/ping'] } + + it 'does not trace paths listed in the array' do + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).must_be_nil + + _(finished_spans.size).must_equal 0 + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_endpoints: nil } } + + it 'traces everything' do + get '/ping' + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + end + + describe 'config[:untraced_requests]' do + let(:service) do + lambda do |_env| + OpenTelemetry.tracer_provider.tracer('req').in_span('in_req_span') {} + [200, { 'Content-Type' => 'text/plain' }, response_body] + end + end + + describe 'when a callable is passed in' do + let(:uri) { '/assets' } + let(:untraced_requests) do + ->(env) { env['PATH_INFO'] =~ %r{^\/assets} } + end + + it 'does not trace requests in which the callable returns true' do + assets_span = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(assets_span).must_be_nil + + _(finished_spans.size).must_equal 0 + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_requests: nil } } + + it 'traces everything' do + get '/assets' + + asset_span = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(asset_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + end + + describe 'config[:allowed_request_headers]' do + let(:headers) do + Hash( + 'CONTENT_LENGTH' => '123', + 'CONTENT_TYPE' => 'application/json', + 'HTTP_FOO_BAR' => 'http foo bar value' + ) + end + + it 'defaults to nil' do + _(rack_span.attributes['http.request.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:allowed_request_headers) do + ['foo_BAR'] + end + + it 'returns attribute' do + _(rack_span.attributes['http.request.header.foo_bar']).must_equal 'http foo bar value' + end + end + + describe 'when content-type' do + let(:allowed_request_headers) { ['CONTENT_TYPE'] } + + it 'returns attribute' do + _(rack_span.attributes['http.request.header.content_type']).must_equal 'application/json' + end + end + + describe 'when content-length' do + let(:allowed_request_headers) { ['CONTENT_LENGTH'] } + + it 'returns attribute' do + _(rack_span.attributes['http.request.header.content_length']).must_equal '123' + end + end + end + + describe 'config[:allowed_response_headers]' do + let(:service) do + ->(_env) { [200, { 'Foo-Bar' => 'foo bar response header' }, ['OK']] } + end + + it 'defaults to nil' do + _(rack_span.attributes['http.response.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:allowed_response_headers) { ['Foo-Bar'] } + + it 'returns attribute' do + _(rack_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + + describe 'case-sensitively' do + let(:allowed_response_headers) { ['fOO-bAR'] } + + it 'returns attribute' do + _(rack_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + end + end + end + + describe 'given request proxy headers' do + let(:headers) { Hash('HTTP_X_REQUEST_START' => '1677723466') } + + it 'records an event' do + _(proxy_event.name).must_equal 'http.proxy.request.started' + _(proxy_event.timestamp).must_equal 1_677_723_466_000_000_000 + end + end + + describe '#called with 400 level http status code' do + let(:service) do + ->(_env) { [404, { 'Foo-Bar' => 'foo bar response header' }, ['Not Found']] } + end + + it 'leaves status code unset' do + _(rack_span.attributes['http.response.status_code']).must_equal 404 + _(rack_span.kind).must_equal :server + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + end + + describe 'url quantization' do + describe 'when using standard Rack environment variables' do + describe 'without quantization' do + it 'span.name defaults to low cardinality name HTTP method' do + get '/really_long_url' + + _(rack_span.name).must_equal 'GET' + _(rack_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with simple quantization' do + let(:quantization_example) do + ->(url, _env) { url.to_s } + end + + let(:url_quantization) { quantization_example } + + it 'sets the span.name to the full path' do + get '/really_long_url' + + _(rack_span.name).must_equal '/really_long_url' + _(rack_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with quantization' do + let(:quantization_example) do + # demonstrate simple shortening of URL: + ->(url, _env) { url.to_s[0..5] } + end + let(:url_quantization) { quantization_example } + + it 'mutates url according to url_quantization' do + get '/really_long_url' + + _(rack_span.name).must_equal '/reall' + end + end + end + + describe 'when using Action Dispatch custom environment variables' do + describe 'without quantization' do + it 'span.name defaults to low cardinality name HTTP method' do + get '/really_long_url', {}, { 'REQUEST_URI' => '/action-dispatch-uri' } + + _(rack_span.name).must_equal 'GET' + _(rack_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with simple quantization' do + let(:quantization_example) do + ->(url, _env) { url.to_s } + end + + let(:url_quantization) { quantization_example } + + it 'sets the span.name to the full path' do + get '/really_long_url', {}, { 'REQUEST_URI' => '/action-dispatch-uri' } + + _(rack_span.name).must_equal '/action-dispatch-uri' + _(rack_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with quantization' do + let(:quantization_example) do + # demonstrate simple shortening of URL: + ->(url, _env) { url.to_s[0..5] } + end + let(:url_quantization) { quantization_example } + + it 'mutates url according to url_quantization' do + get '/really_long_url', {}, { 'REQUEST_URI' => '/action-dispatch-uri' } + + _(rack_span.name).must_equal '/actio' + end + end + end + end + + describe 'response_propagators' do + describe 'with default options' do + it 'does not inject the traceresponse header' do + get '/ping' + _(last_response.headers).wont_include('traceresponse') + end + end + + describe 'with ResponseTextMapPropagator' do + let(:response_propagators) { [OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator.new] } + + it 'injects the traceresponse header' do + get '/ping' + _(last_response.headers).must_include('traceresponse') + end + end + + describe 'response propagators that raise errors' do + class EventMockPropagator < OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator + CustomError = Class.new(StandardError) + def inject(carrier) + raise CustomError, 'Injection failed' + end + end + + let(:response_propagators) { [EventMockPropagator.new, OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator.new] } + + it 'is fault tolerant' do + expect(OpenTelemetry).to receive(:handle_error).with(exception: instance_of(EventMockPropagator::CustomError), message: /Unable/) + + get '/ping' + _(last_response.headers).must_include('traceresponse') + end + end + end + + describe '#call with error' do + EventHandlerError = Class.new(StandardError) + + let(:service) do + ->(_env) { raise EventHandlerError } + end + + it 'records error in span and then re-raises' do + assert_raises EventHandlerError do + get '/' + end + + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + end + end + + describe 'when the instrumentation is disabled' do + let(:instrumenation_enabled) { false } + + it 'does nothing' do + _(rack_span).must_be_nil + end + end + + describe 'when response body is called' do + let(:after_close) { -> { OpenTelemetry::Instrumentation::Rack.current_span.add_event('after-response-called') } } + + it 'has access to a Rack read/write span' do + get '/' + _(rack_span.events.map(&:name)).must_include('after-response-called') + end + end + + describe 'when response body is called' do + let(:response_body) { ['Simple, Hello World!'] } + + it 'has access to a Rack read/write span' do + get '/' + _(rack_span.attributes['http.request.method']).must_equal 'GET' + _(rack_span.attributes['http.response.status_code']).must_equal 200 + _(rack_span.attributes['url.path']).must_equal '/' + _(rack_span.attributes['url.full']).must_be_nil + _(rack_span.name).must_equal 'GET' + _(rack_span.kind).must_equal :server + _(rack_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + _(rack_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + _(proxy_event).must_be_nil + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware_test.rb new file mode 100644 index 0000000000..f3c9f7e12a --- /dev/null +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware_test.rb @@ -0,0 +1,390 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +# require Instrumentation so .install method is found: +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/instrumentation' +require_relative '../../../../../../lib/opentelemetry/instrumentation/rack/middlewares/stable/tracer_middleware' + +describe OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::TracerMiddleware do + let(:instrumentation_module) { OpenTelemetry::Instrumentation::Rack } + let(:instrumentation_class) { instrumentation_module::Instrumentation } + let(:instrumentation) { instrumentation_class.instance } + + let(:described_class) { OpenTelemetry::Instrumentation::Rack::Middlewares::Stable::TracerMiddleware } + + let(:app) { ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['OK']] } } + let(:middleware) { described_class.new(app) } + let(:rack_builder) { Rack::Builder.new } + + let(:exporter) { EXPORTER } + let(:finished_spans) { exporter.finished_spans } + let(:first_span) { exporter.finished_spans.first } + let(:proxy_event) { first_span.events&.first } + + let(:default_config) { {} } + let(:config) { default_config } + let(:env) { {} } + let(:uri) { '/' } + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('stable') + + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http' + + # clear captured spans: + exporter.reset + + # simulate a fresh install: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(config) + + # clear out cached config: + described_class.send(:clear_cached_config) + + # integrate tracer middleware: + rack_builder.run app + rack_builder.use described_class + end + + after do + # installation is 'global', so it should be reset: + instrumentation.instance_variable_set(:@installed, false) + instrumentation.install(default_config) + end + + describe '#call' do + before do + Rack::MockRequest.new(rack_builder).get(uri, env) + end + + it 'records attributes' do + _(first_span.attributes['http.request.method']).must_equal 'GET' + _(first_span.attributes['http.response.status_code']).must_equal 200 + _(first_span.attributes['url.path']).must_equal '/' + _(first_span.attributes['url.full']).must_be_nil + _(first_span.name).must_equal 'GET' + _(first_span.kind).must_equal :server + end + + it 'does not explicitly set status OK' do + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + + describe 'with a hijacked response' do + let(:app) do + lambda do |env| + env['rack.hijack?'] = true + [-1, {}, []] + end + end + + it 'sets the span status to "unset"' do + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + + it 'has no parent' do + _(first_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + end + + describe 'when a query is passed in' do + let(:uri) { '/endpoint?query=true' } + + it 'records the query path' do + _(first_span.attributes['url.path']).must_equal '/endpoint' + _(first_span.attributes['url.query']).must_equal 'query=true' + _(first_span.name).must_equal 'GET' + end + end + + describe 'given request proxy headers' do + let(:env) { Hash('HTTP_X_REQUEST_START' => '1677723466') } + + it 'records an event' do + _(proxy_event.name).must_equal 'http.proxy.request.started' + _(proxy_event.timestamp).must_equal 1_677_723_466_000_000_000 + end + end + + describe 'config[:untraced_endpoints]' do + describe 'when an array is passed in' do + let(:config) { { untraced_endpoints: ['/ping'] } } + + it 'does not trace paths listed in the array' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).must_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + + describe 'when a string is passed in' do + let(:config) { { untraced_endpoints: '/ping' } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_endpoints: nil } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/ping', env) + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/ping' } + _(ping_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + end + + describe 'config[:untraced_requests]' do + describe 'when a callable is passed in' do + let(:untraced_callable) do + ->(env) { env['PATH_INFO'] =~ %r{^\/assets} } + end + let(:config) { default_config.merge(untraced_requests: untraced_callable) } + + it 'does not trace requests in which the callable returns true' do + Rack::MockRequest.new(rack_builder).get('/assets', env) + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(ping_span).must_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + + describe 'when nil is passed in' do + let(:config) { { untraced_requests: nil } } + + it 'traces everything' do + Rack::MockRequest.new(rack_builder).get('/assets', env) + + ping_span = finished_spans.find { |s| s.attributes['url.path'] == '/assets' } + _(ping_span).wont_be_nil + + root_span = finished_spans.find { |s| s.attributes['url.path'] == '/' } + _(root_span).wont_be_nil + end + end + end + + describe 'config[:allowed_request_headers]' do + let(:env) do + Hash( + 'CONTENT_LENGTH' => '123', + 'CONTENT_TYPE' => 'application/json', + 'HTTP_FOO_BAR' => 'http foo bar value' + ) + end + + it 'defaults to nil' do + _(first_span.attributes['http.request.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:config) { default_config.merge(allowed_request_headers: ['foo_BAR']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.foo_bar']).must_equal 'http foo bar value' + end + end + + describe 'when content-type' do + let(:config) { default_config.merge(allowed_request_headers: ['CONTENT_TYPE']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.content_type']).must_equal 'application/json' + end + end + + describe 'when content-length' do + let(:config) { default_config.merge(allowed_request_headers: ['CONTENT_LENGTH']) } + + it 'returns attribute' do + _(first_span.attributes['http.request.header.content_length']).must_equal '123' + end + end + end + + describe 'config[:allowed_response_headers]' do + let(:app) do + ->(_env) { [200, { 'Foo-Bar' => 'foo bar response header' }, ['OK']] } + end + + it 'defaults to nil' do + _(first_span.attributes['http.response.header.foo_bar']).must_be_nil + end + + describe 'when configured' do + let(:config) { default_config.merge(allowed_response_headers: ['Foo-Bar']) } + + it 'returns attribute' do + _(first_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + + describe 'case-sensitively' do + let(:config) { default_config.merge(allowed_response_headers: ['fOO-bAR']) } + + it 'returns attribute' do + _(first_span.attributes['http.response.header.foo_bar']).must_equal 'foo bar response header' + end + end + end + end + + describe 'config[:record_frontend_span]' do + let(:request_span) { exporter.finished_spans.first } + + describe 'default' do + it 'does not record span' do + _(exporter.finished_spans.size).must_equal 1 + end + + it 'does not parent the request_span' do + _(request_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID + end + end + + describe 'when recordable' do + let(:config) { default_config.merge(record_frontend_span: true) } + let(:env) { Hash('HTTP_X_REQUEST_START' => Time.now.to_i) } + let(:frontend_span) { exporter.finished_spans[1] } + let(:request_span) { exporter.finished_spans[0] } + + it 'records span' do + _(exporter.finished_spans.size).must_equal 2 + _(frontend_span.name).must_equal 'http_server.proxy' + _(frontend_span.attributes['service']).must_be_nil + end + + it 'changes request_span kind' do + _(request_span.kind).must_equal :internal + end + + it 'frontend_span parents request_span' do + _(request_span.parent_span_id).must_equal frontend_span.span_id + end + end + end + + describe '#called with 400 level http status code' do + let(:app) do + ->(_env) { [404, { 'Foo-Bar' => 'foo bar response header' }, ['Not Found']] } + end + + it 'leaves status code unset' do + _(first_span.attributes['http.response.status_code']).must_equal 404 + _(first_span.kind).must_equal :server + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + end + end + end + + describe 'config[:quantization]' do + before do + Rack::MockRequest.new(rack_builder).get('/really_long_url', env) + end + + describe 'without quantization' do + it 'span.name defaults to low cardinality name HTTP method' do + _(first_span.name).must_equal 'GET' + _(first_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with simple quantization' do + let(:quantization_example) do + ->(url, _env) { url.to_s } + end + + let(:config) { default_config.merge(url_quantization: quantization_example) } + + it 'sets the span.name to the full path' do + _(first_span.name).must_equal '/really_long_url' + _(first_span.attributes['url.path']).must_equal '/really_long_url' + end + end + + describe 'with quantization' do + let(:quantization_example) do + # demonstrate simple shortening of URL: + ->(url, _env) { url.to_s[0..5] } + end + let(:config) { default_config.merge(url_quantization: quantization_example) } + + it 'mutates url according to url_quantization' do + _(first_span.name).must_equal '/reall' + end + end + end + + describe 'config[:response_propagators]' do + describe 'with default options' do + it 'does not inject the traceresponse header' do + res = Rack::MockRequest.new(rack_builder).get('/ping', env) + _(res.headers).wont_include('traceresponse') + end + end + + describe 'with ResponseTextMapPropagator' do + let(:config) { default_config.merge(response_propagators: [OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator.new]) } + + it 'injects the traceresponse header' do + res = Rack::MockRequest.new(rack_builder).get('/ping', env) + _(res.headers).must_include('traceresponse') + end + end + + describe 'propagator throws' do + class MockPropagator < OpenTelemetry::Trace::Propagation::TraceContext::ResponseTextMapPropagator + def inject(carrier) + raise 'Injection failed' + end + end + + let(:config) { default_config.merge(response_propagators: [MockPropagator.new]) } + + it 'leads to application errors when there are exceptions' do + assert_raises RuntimeError do + Rack::MockRequest.new(rack_builder).get('/ping', env) + end + end + end + end + + describe '#call with error' do + SimulatedError = Class.new(StandardError) + + let(:app) do + ->(_env) { raise SimulatedError } + end + + it 'records error in span and then re-raises' do + assert_raises SimulatedError do + Rack::MockRequest.new(rack_builder).get('/', env) + end + _(first_span.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + end + end +end diff --git a/instrumentation/rack/test/opentelemetry/instrumentation/rack_test.rb b/instrumentation/rack/test/opentelemetry/instrumentation/rack_test.rb index 5eccd35aa4..719be2caec 100644 --- a/instrumentation/rack/test/opentelemetry/instrumentation/rack_test.rb +++ b/instrumentation/rack/test/opentelemetry/instrumentation/rack_test.rb @@ -10,6 +10,8 @@ let(:instrumentation) { OpenTelemetry::Instrumentation::Rack::Instrumentation.instance } let(:new_span) { OpenTelemetry::Trace.non_recording_span(OpenTelemetry::Trace::SpanContext.new) } + before { skip unless ENV['BUNDLE_GEMFILE'].include?('old') } + it 'has #name' do _(instrumentation.name).must_equal 'OpenTelemetry::Instrumentation::Rack' end diff --git a/instrumentation/sinatra/Appraisals b/instrumentation/sinatra/Appraisals index 73cb3a9d43..20cb43082e 100644 --- a/instrumentation/sinatra/Appraisals +++ b/instrumentation/sinatra/Appraisals @@ -4,12 +4,21 @@ # # SPDX-License-Identifier: Apache-2.0 -%w[4.1 3.0 2.1].each do |version| - appraise "sinatra-#{version}" do - gem 'sinatra', "~> #{version}" +# To facilitate HTTP semantic convention stability migration, we are using +# appraisal to test the different semantic convention modes/ Rack middlewares. +# When the migration is complete, we should revert testing with different stability +# modes. For more information see CHANGELOG: HTTP semantic convention stability +semconv_stability = %w[dup stable old] +sinatra_versions = %w[4.1 3.0 2.1] + +semconv_stability.each do |mode| + sinatra_versions.each do |version| + appraise "sinatra-#{version}-#{mode}" do + gem 'sinatra', "~> #{version}" + end end -end -appraise 'sinatra-latest' do - gem 'sinatra' + appraise "sinatra-latest-#{mode}" do + gem 'sinatra' + end end diff --git a/instrumentation/sinatra/README.md b/instrumentation/sinatra/README.md index 063efad0a9..248aaa1d18 100644 --- a/instrumentation/sinatra/README.md +++ b/instrumentation/sinatra/README.md @@ -72,3 +72,21 @@ The `opentelemetry-instrumentation-sinatra` gem is distributed under the Apache [community-meetings]: https://github.com/open-telemetry/community#community-meetings [slack-channel]: https://cloud-native.slack.com/archives/C01NWKKMKMY [discussions-url]: https://github.com/open-telemetry/opentelemetry-ruby/discussions + +## HTTP semantic convention stability + +In the OpenTelemetry ecosystem, HTTP semantic conventions have now reached a stable state. However, the initial Rack instrumentation was introduced before this stability was achieved, which resulted in HTTP attributes being based on an older version of the semantic conventions. + +To facilitate the migration to stable semantic conventions, you can use the `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable. This variable allows you to opt-in to the new stable conventions, ensuring compatibility and future-proofing your instrumentation. + +Sinatra instrumentation installs Rack middleware, but the middleware version it installs depends on which `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable is set. + +When setting the value for `OTEL_SEMCONV_STABILITY_OPT_IN`, you can specify which conventions you wish to adopt: + +- `http` - Emits the stable HTTP and networking conventions and ceases emitting the old conventions previously emitted by the instrumentation. +- `http/dup` - Emits both the old and stable HTTP and networking conventions, enabling a phased rollout of the stable semantic conventions. +- Default behavior (in the absence of either value) is to continue emitting the old HTTP and networking conventions the instrumentation previously emitted. + +During the transition from old to stable conventions, Rack instrumentation code comes in three patch versions: `dup`, `old`, and `stable`. These versions are identical except for the attributes they send. Any changes to Rack instrumentation should consider all three patches. + +For additional information on migration, please refer to our [documentation](https://opentelemetry.io/docs/specs/semconv/non-normative/http-migration/). diff --git a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb index bd7b85ad60..b1ed53e7a7 100644 --- a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb +++ b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb @@ -51,7 +51,19 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base end def install_middleware(app) - app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) if config[:install_rack] + if config[:install_rack] + stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') + values = stability_opt_in.split(',').map(&:strip) + + if values.include?('http/dup') + app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup) + elsif values.include?('http') + app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable) + else + app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) + end + end + app.use(Middlewares::TracerMiddleware) end end diff --git a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb new file mode 100644 index 0000000000..332edc4762 --- /dev/null +++ b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::Instrumentation::Sinatra do + include Rack::Test::Methods + + let(:instrumentation) { OpenTelemetry::Instrumentation::Sinatra::Instrumentation.instance } + let(:exporter) { EXPORTER } + let(:config) { {} } + + class CustomError < StandardError; end + + let(:app_one) do + Class.new(Sinatra::Application) do + set :raise_errors, false + get '/endpoint' do + '1' + end + + get '/error' do + raise CustomError, 'custom message' + end + + template :foo_template do + 'Foo Template' + end + + get '/with_template' do + erb :foo_template + end + + get '/api/v1/foo/:myname/?' do + 'Some name' + end + end + end + + let(:app_two) do + Class.new(Sinatra::Application) do + set :raise_errors, false + get '/endpoint' do + '2' + end + end + end + + let(:apps) do + { + '/one' => app_one, + '/two' => app_two + } + end + + let(:app) do + apps_to_build = apps + + Rack::Builder.new do + apps_to_build.each do |root, app| + map root do + run app + end + end + end.to_app + end + + before do + skip unless ENV['BUNDLE_GEMFILE'].include?('dup') + ENV['OTEL_SEMCONV_STABILITY_OPT_IN'] = 'http/dup' + + Sinatra::Base.reset! + + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.instance_variable_set(:@installed, false) + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.config.clear + + instrumentation.instance_variable_set(:@installed, false) + instrumentation.config.clear + instrumentation.install(config) + exporter.reset + end + + describe 'tracing' do + it 'before request' do + _(exporter.finished_spans.size).must_equal 0 + end + + it 'after request' do + get '/one/endpoint' + + _(exporter.finished_spans.size).must_equal 1 + end + + it 'traces all apps' do + get '/two/endpoint' + + _(exporter.finished_spans.size).must_equal 1 + end + + it 'records attributes' do + get '/one/endpoint' + + _(exporter.finished_spans.first.attributes).must_equal( + 'http.host' => 'example.org', + 'http.method' => 'GET', + 'http.route' => '/endpoint', + 'http.scheme' => 'http', + 'http.status_code' => 200, + 'http.target' => '/endpoint', + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'url.scheme' => 'http', + 'http.response.status_code' => 200, + 'url.path' => '/endpoint' + ) + end + + it 'traces templates' do + get '/one/with_template' + + _(exporter.finished_spans.size).must_equal 3 + _(exporter.finished_spans.map(&:name)) + .must_equal [ + 'sinatra.render_template', + 'sinatra.render_template', + 'GET /with_template' + ] + _(exporter.finished_spans[0..1].map(&:attributes) + .map { |h| h['sinatra.template_name'] }) + .must_equal %w[layout foo_template] + end + + it 'correctly name spans' do + get '/one//api/v1/foo/janedoe/' + + _(exporter.finished_spans.size).must_equal 1 + _(exporter.finished_spans.first.attributes).must_equal( + 'http.host' => 'example.org', + 'http.method' => 'GET', + 'http.target' => '/api/v1/foo/janedoe/', + 'http.scheme' => 'http', + 'http.status_code' => 200, + 'http.route' => '/api/v1/foo/:myname/?', + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'url.path' => '/api/v1/foo/janedoe/', + 'url.scheme' => 'http', + 'http.response.status_code' => 200 + ) + _(exporter.finished_spans.map(&:name)) + .must_equal [ + 'GET /api/v1/foo/:myname/?' + ] + end + + it 'does not create unhandled exceptions for missing routes' do + get '/one/missing_example/not_present' + + _(exporter.finished_spans.first.status.code).must_equal OpenTelemetry::Trace::Status::UNSET + _(exporter.finished_spans.first.attributes).must_equal( + 'http.host' => 'example.org', + 'http.method' => 'GET', + 'http.scheme' => 'http', + 'http.status_code' => 404, + 'http.target' => '/missing_example/not_present', + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'url.scheme' => 'http', + 'http.response.status_code' => 404, + 'url.path' => '/missing_example/not_present' + ) + _(exporter.finished_spans.flat_map(&:events)).must_equal([nil]) + end + + it 'does correctly name spans and add attributes and exception events when the app raises errors' do + get '/one/error' + + _(exporter.finished_spans.first.status.code).must_equal OpenTelemetry::Trace::Status::ERROR + _(exporter.finished_spans.first.name).must_equal('GET /error') + _(exporter.finished_spans.first.attributes).must_equal( + 'http.host' => 'example.org', + 'http.method' => 'GET', + 'http.route' => '/error', + 'http.scheme' => 'http', + 'http.target' => '/error', + 'http.status_code' => 500, + 'server.address' => 'example.org', + 'http.request.method' => 'GET', + 'url.scheme' => 'http', + 'url.path' => '/error', + 'http.response.status_code' => 500 + ) + _(exporter.finished_spans.flat_map(&:events).map(&:name)).must_equal(['exception']) + end + + it 'adds exception type to events when the app raises errors' do + get '/one/error' + + _(exporter.finished_spans.first.events[0].attributes['exception.type']).must_equal('CustomError') + _(exporter.finished_spans.first.events[0].attributes['exception.message']).must_equal('custom message') + end + end + + describe 'when install_rack is set to false' do + let(:config) { { install_rack: false } } + + describe 'missing rack installation' do + it 'disables tracing' do + get '/one/endpoint' + + _(exporter.finished_spans).must_be_empty + end + end + + describe 'when rack is manually installed' do + let(:app) do + apps_to_build = apps + Rack::Builder.new do + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup) + + apps_to_build.each do |root, app| + map root do + run app + end + end + end.to_app + end + + before do + OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.install + end + + it 'creates a span' do + get '/one/endpoint' + + _(exporter.finished_spans.first.attributes).must_equal( + 'http.method' => 'GET', + 'http.host' => 'example.org', + 'http.scheme' => 'http', + 'http.target' => '/one/endpoint', + 'http.route' => '/endpoint', + 'http.status_code' => 200, + 'http.request.method' => 'GET', + 'server.address' => 'example.org', + 'url.scheme' => 'http', + 'url.path' => '/one/endpoint', + 'http.response.status_code' => 200 + ) + end + end + end +end diff --git a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_test.rb b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_old_http_test.rb similarity index 99% rename from instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_test.rb rename to instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_old_http_test.rb index 4c9d5814a9..d4bd5225f3 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