diff --git a/Gemfile b/Gemfile index f2b27131c56..5e17a488f29 100644 --- a/Gemfile +++ b/Gemfile @@ -65,7 +65,7 @@ group :test do gem 'addressable' gem 'cucumber' gem 'webmock' - gem 'multipart-post' gem 'rspec' + gem 'opentelemetry-sdk' end diff --git a/build_tools/aws-sdk-code-generator/lib/aws-sdk-code-generator/plugin_list.rb b/build_tools/aws-sdk-code-generator/lib/aws-sdk-code-generator/plugin_list.rb index 6bf82f8220b..30ff8cba9ea 100644 --- a/build_tools/aws-sdk-code-generator/lib/aws-sdk-code-generator/plugin_list.rb +++ b/build_tools/aws-sdk-code-generator/lib/aws-sdk-code-generator/plugin_list.rb @@ -63,7 +63,8 @@ def default_plugins 'Aws::Plugins::ChecksumAlgorithm' => "#{core_plugins}/checksum_algorithm.rb", 'Aws::Plugins::RequestCompression' => "#{core_plugins}/request_compression.rb", 'Aws::Plugins::DefaultsMode' => "#{core_plugins}/defaults_mode.rb", - 'Aws::Plugins::RecursionDetection' => "#{core_plugins}/recursion_detection.rb" + 'Aws::Plugins::RecursionDetection' => "#{core_plugins}/recursion_detection.rb", + 'Aws::Plugins::Telemetry' => "#{core_plugins}/telemetry.rb" } end diff --git a/build_tools/aws-sdk-code-generator/templates/async_client_class.mustache b/build_tools/aws-sdk-code-generator/templates/async_client_class.mustache index 60f018fdf36..42567024795 100644 --- a/build_tools/aws-sdk-code-generator/templates/async_client_class.mustache +++ b/build_tools/aws-sdk-code-generator/templates/async_client_class.mustache @@ -83,13 +83,18 @@ module {{module_name}} # @api private def build_request(operation_name, params = {}) handlers = @handlers.for(operation_name) + tracer = config.telemetry_provider.tracer_provider.tracer( + Aws::Telemetry.module_to_tracer_name('{{module_name}}') + ) context = Seahorse::Client::RequestContext.new( operation_name: operation_name, operation: config.api.operation(operation_name), client: self, params: params, http_response: Seahorse::Client::Http::AsyncResponse.new, - config: config) + config: config, + tracer: tracer + ) context[:gem_name] = '{{gem_name}}' context[:gem_version] = '{{gem_version}}' Seahorse::Client::Request.new(handlers, context) diff --git a/build_tools/aws-sdk-code-generator/templates/client_class.mustache b/build_tools/aws-sdk-code-generator/templates/client_class.mustache index 4aea5de7e32..4145271b124 100644 --- a/build_tools/aws-sdk-code-generator/templates/client_class.mustache +++ b/build_tools/aws-sdk-code-generator/templates/client_class.mustache @@ -99,13 +99,18 @@ module {{module_name}} end end {{/authorizer?}} + tracer = config.telemetry_provider.tracer_provider.tracer( + Aws::Telemetry.module_to_tracer_name('{{module_name}}') + ) context = Seahorse::Client::RequestContext.new( operation_name: operation_name, operation: config.api.operation(operation_name),{{#authorizer?}} authorizer: authorizer,{{/authorizer?}} client: self, params: params, - config: config) + config: config, + tracer: tracer + ) context[:gem_name] = '{{gem_name}}' context[:gem_version] = '{{gem_version}}' Seahorse::Client::Request.new(handlers, context) diff --git a/build_tools/customizations.rb b/build_tools/customizations.rb index 11a8e5f1bff..53a23cf0f5b 100644 --- a/build_tools/customizations.rb +++ b/build_tools/customizations.rb @@ -107,6 +107,8 @@ def dynamodb_example_deep_transform(subsegment, keys) end api('ImportExport') do |api| + api['metadata']['serviceId'] ||= 'importexport' + api['operations'].each do |_, operation| operation['http']['requestUri'] = '/' end @@ -114,6 +116,7 @@ def dynamodb_example_deep_transform(subsegment, keys) %w(Lambda LambdaPreview).each do |svc_name| api(svc_name) do |api| + api['metadata']['serviceId'] ||= 'Lambda Preview' if svc_name == 'LambdaPreview' api['shapes']['Timestamp']['type'] = 'timestamp' end @@ -220,6 +223,8 @@ def dynamodb_example_deep_transform(subsegment, keys) # uses both flattened and locationName. Query protocol is supposed to # ignore location name (xmlName) when flattened (xmlFlattened) is used. api('SimpleDB') do |api| + api['metadata']['serviceId'] ||= 'SimpleDB' + api['shapes'].each do |_, shape| next unless shape['type'] == 'structure' diff --git a/build_tools/services.rb b/build_tools/services.rb index 0a89ffd6fe2..0909815bc96 100644 --- a/build_tools/services.rb +++ b/build_tools/services.rb @@ -10,10 +10,10 @@ class ServiceEnumerator MANIFEST_PATH = File.expand_path('../../services.json', __FILE__) # Minimum `aws-sdk-core` version for new gem builds - MINIMUM_CORE_VERSION = "3.201.0" + MINIMUM_CORE_VERSION = "3.203.0" # Minimum `aws-sdk-core` version for new S3 gem builds - MINIMUM_CORE_VERSION_S3 = "3.201.0" + MINIMUM_CORE_VERSION_S3 = "3.203.0" EVENTSTREAM_PLUGIN = "Aws::Plugins::EventStreamConfiguration" diff --git a/gems/aws-sdk-core/CHANGELOG.md b/gems/aws-sdk-core/CHANGELOG.md index 0fff50ca704..ef4c338f5ef 100644 --- a/gems/aws-sdk-core/CHANGELOG.md +++ b/gems/aws-sdk-core/CHANGELOG.md @@ -1,6 +1,8 @@ Unreleased Changes ------------------ +* Feature - Add support for Observability which includes a configuration, `telemetry_provider` and an OpenTelemetry-based telemetry provider. + 3.202.2 (2024-08-30) ------------------ diff --git a/gems/aws-sdk-core/lib/aws-sdk-core.rb b/gems/aws-sdk-core/lib/aws-sdk-core.rb index f9685d2373b..c1c6a45a278 100644 --- a/gems/aws-sdk-core/lib/aws-sdk-core.rb +++ b/gems/aws-sdk-core/lib/aws-sdk-core.rb @@ -7,7 +7,6 @@ require_relative 'aws-sdk-core/deprecations' # credential providers - require_relative 'aws-sdk-core/credential_provider' require_relative 'aws-sdk-core/refreshing_credentials' require_relative 'aws-sdk-core/assume_role_credentials' @@ -30,7 +29,6 @@ require_relative 'aws-sdk-core/plugins/bearer_authorization' # client modules - require_relative 'aws-sdk-core/client_stubs' require_relative 'aws-sdk-core/async_client_stubs' require_relative 'aws-sdk-core/eager_loader' @@ -45,24 +43,20 @@ require_relative 'aws-sdk-core/util' # resource classes - require_relative 'aws-sdk-core/resources/collection' # logging - require_relative 'aws-sdk-core/log/formatter' require_relative 'aws-sdk-core/log/param_filter' require_relative 'aws-sdk-core/log/param_formatter' # stubbing - require_relative 'aws-sdk-core/stubbing/empty_stub' require_relative 'aws-sdk-core/stubbing/data_applicator' require_relative 'aws-sdk-core/stubbing/stub_data' require_relative 'aws-sdk-core/stubbing/xml_error' # stubbing protocols - require_relative 'aws-sdk-core/stubbing/protocols/json' require_relative 'aws-sdk-core/stubbing/protocols/rest' require_relative 'aws-sdk-core/stubbing/protocols/rest_json' @@ -73,7 +67,6 @@ require_relative 'aws-sdk-core/stubbing/protocols/api_gateway' # protocols - require_relative 'aws-sdk-core/error_handler' require_relative 'aws-sdk-core/rest' require_relative 'aws-sdk-core/xml' @@ -82,21 +75,18 @@ require_relative 'aws-sdk-core/rpc_v2' # event stream - require_relative 'aws-sdk-core/binary' require_relative 'aws-sdk-core/event_emitter' # endpoint discovery - require_relative 'aws-sdk-core/endpoint_cache' -# client metrics - +# client metrics / telemetry require_relative 'aws-sdk-core/client_side_monitoring/request_metrics' require_relative 'aws-sdk-core/client_side_monitoring/publisher' +require_relative 'aws-sdk-core/telemetry' # utilities - require_relative 'aws-sdk-core/arn' require_relative 'aws-sdk-core/arn_parser' require_relative 'aws-sdk-core/ec2_metadata' diff --git a/gems/aws-sdk-core/lib/aws-sdk-core/plugins/stub_responses.rb b/gems/aws-sdk-core/lib/aws-sdk-core/plugins/stub_responses.rb index 9ed88e8c5aa..e0ee7e9561a 100644 --- a/gems/aws-sdk-core/lib/aws-sdk-core/plugins/stub_responses.rb +++ b/gems/aws-sdk-core/lib/aws-sdk-core/plugins/stub_responses.rb @@ -49,6 +49,14 @@ def after_initialize(client) class Handler < Seahorse::Client::Handler def call(context) + span_wrapper(context) do + stub_responses(context) + end + end + + private + + def stub_responses(context) stub = context.client.next_stub(context) resp = Seahorse::Client::Response.new(context: context) async_mode = context.client.is_a? Seahorse::Client::AsyncBase @@ -58,8 +66,15 @@ def call(context) apply_stub(stub, resp, async_mode) end - async_mode ? Seahorse::Client::AsyncResponse.new( - context: context, stream: context[:input_event_stream_handler].event_emitter.stream, sync_queue: Queue.new) : resp + if async_mode + Seahorse::Client::AsyncResponse.new( + context: context, + stream: context[:input_event_stream_handler].event_emitter.stream, + sync_queue: Queue.new + ) + else + resp + end end def apply_stub(stub, response, async_mode = false) @@ -99,6 +114,18 @@ def signal_http(stub, http_resp, async_mode = false) http_resp.signal_done end + def span_wrapper(context, &block) + context.tracer.in_span( + 'Handler.StubResponses', + attributes: Aws::Telemetry.http_request_attrs(context) + ) do |span| + block.call.tap do + span.add_attributes( + Aws::Telemetry.http_response_attrs(context) + ) + end + end + end end end end diff --git a/gems/aws-sdk-core/lib/aws-sdk-core/plugins/telemetry.rb b/gems/aws-sdk-core/lib/aws-sdk-core/plugins/telemetry.rb new file mode 100644 index 00000000000..57bfb216713 --- /dev/null +++ b/gems/aws-sdk-core/lib/aws-sdk-core/plugins/telemetry.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Aws + module Plugins + # @api private + class Telemetry < Seahorse::Client::Plugin + option( + :telemetry_provider, + default: Aws::Telemetry::NoOpTelemetryProvider, + doc_type: Aws::Telemetry::TelemetryProviderBase, + rbs_type: Aws::Telemetry::TelemetryProviderBase, + docstring: <<-DOCS) do |_cfg| +Allows you to provide a telemetry provider, which is used to +emit telemetry data. By default, uses `NoOpTelemetryProvider` which +will not record or emit any telemetry data. The SDK supports the +following telemetry providers: + +* OpenTelemetry (OTel) - To use the OTel provider, install and require the +`opentelemetry-sdk` gem and then, pass in an instance of a +`Aws::Telemetry::OTelProvider` for telemetry provider. + DOCS + Aws::Telemetry::NoOpTelemetryProvider.new + end + + def after_initialize(client) + validate_telemetry_provider(client.config) + end + + def validate_telemetry_provider(config) + unless config.telemetry_provider.is_a?(Aws::Telemetry::TelemetryProviderBase) + raise ArgumentError, + 'Must provide a telemetry provider for the '\ + '`telemetry_provider` configuration option.' + end + end + + class Handler < Seahorse::Client::Handler + def call(context) + span_wrapper(context) { @handler.call(context) } + end + + private + + def span_wrapper(context, &block) + service_id = service_id(context) + attributes = { + 'rpc.system' => 'aws-api', + 'rpc.service' => service_id, + 'rpc.method' => context.operation.name, + 'code.function' => context.operation_name.to_s, + 'code.namespace' => 'Aws::Plugins::Telemetry' + } + context.tracer.in_span( + parent_span_name(context, service_id), + attributes: attributes, + kind: Aws::Telemetry::SpanKind::CLIENT, + &block + ) + end + + def service_id(context) + context.config.api.metadata['serviceId'] || + context.config.api.metadata['serviceAbbreviation'] || + context.config.api.metadata['serviceFullName'] + end + + def parent_span_name(context, service_id) + "#{service_id}.#{context.operation.name}".delete(' ') + end + end + + handler(Handler, step: :initialize, priority: 99) + end + end +end diff --git a/gems/aws-sdk-core/lib/aws-sdk-core/telemetry.rb b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry.rb new file mode 100644 index 00000000000..2d6452cfd10 --- /dev/null +++ b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require_relative 'telemetry/base' +require_relative 'telemetry/no_op' +require_relative 'telemetry/otel' +require_relative 'telemetry/span_kind' +require_relative 'telemetry/span_status' + +module Aws + # Observability is the extent to which a system's current state can be + # inferred from the data it emits. The data emitted is commonly referred + # as Telemetry. The AWS SDK for Ruby currently supports traces as + # a telemetry signal. + # + # A telemetry provider is used to emit telemetry data. By default, the + # {NoOpTelemetryProvider} will not record or emit any telemetry data. + # The SDK currently supports OpenTelemetry (OTel) as a provider. See + # {OTelProvider} for more information. + # + # If a provider isn't supported, you can implement your own provider by + # inheriting the following base classes and implementing the interfaces + # defined: + # * {TelemetryProviderBase} + # * {ContextManagerBase} + # * {TracerProviderBase} + # * {TracerBase} + # * {SpanBase} + module Telemetry + class << self + # @api private + def module_to_tracer_name(module_name) + "#{module_name.gsub('::', '.')}.client".downcase + end + + # @api private + def http_request_attrs(context) + { + 'http.method' => context.http_request.http_method, + 'net.protocol.name' => 'http' + }.tap do |h| + h['net.protocol.version'] = + if context.client.is_a? Seahorse::Client::AsyncBase + '2' + else + Net::HTTP::HTTPVersion + end + + unless context.config.stub_responses + h['net.peer.name'] = context.http_request.endpoint.host + h['net.peer.port'] = context.http_request.endpoint.port.to_s + end + + if context.http_request.headers.key?('Content-Length') + h['http.request_content_length'] = + context.http_request.headers['Content-Length'] + end + end + end + + # @api private + def http_response_attrs(context) + { + 'http.status_code' => context.http_response.status_code.to_s + }.tap do |h| + if context.http_response.headers.key?('Content-Length') + h['http.response_content_length'] = + context.http_response.headers['Content-Length'] + end + + if context.http_response.headers.key?('x-amz-request-id') + h['aws.request_id'] = + context.http_response.headers['x-amz-request-id'] + end + end + end + end + end +end diff --git a/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/base.rb b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/base.rb new file mode 100644 index 00000000000..fbe011a4363 --- /dev/null +++ b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/base.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +module Aws + module Telemetry + # Base for `TelemetryProvider` classes. + # They are used to emit telemetry data. It needs the + # following class implementations to function: + # * {TracerProviderBase} - A provider that returns a tracer + # instance. Then, a tracer will create spans and those + # spans will contain information in that given moment. + # * {ContextManagerBase} - Manages context and used to + # return the current context within a trace. + class TelemetryProviderBase + # @param [Aws::Telemetry::TracerBase] tracer_provider A provider + # that returns a tracer instance. + # @param [Aws::Telemetry::ContextManagerBase] context_manager Manages + # context and used to return the current context. + def initialize(tracer_provider: nil, context_manager: nil) + @tracer_provider = tracer_provider + @context_manager = context_manager + end + + # @return [Aws::Telemetry::TracerProviderBase] + attr_reader :tracer_provider + + # @return [Aws::Telemetry::ContextManagerBase] + attr_reader :context_manager + end + + # Base for `TracerProvider` classes. + class TracerProviderBase + # Returns a Tracer instance. + # + # @param [String] name Tracer name + # @return [Aws::Telemetry::TracerBase] + def tracer(name = nil) + raise NotImplementedError + end + end + + # Base for `Tracer` classes. + class TracerBase + # Used when a caller wants to manage the activation/deactivation and + # lifecycle of the Span and its parent manually. + # + # @param [String] name Span name + # @param [Object] with_parent Parent Context + # @param [Hash] attributes Attributes to attach to the span + # @param [Aws::Telemetry::SpanKind] kind Type of Span + # @return [Aws::Telemetry::SpanBase] + def start_span(name, with_parent: nil, attributes: nil, kind: nil) + raise NotImplementedError + end + + # A helper for the default use-case of extending the current trace + # with a span. + # On exit, the Span that was active before calling this method will + # be reactivated. If an exception occurs during the execution of the + # provided block, it will be recorded on the span and re-raised. + # + # @param [String] name Span name + # @param [Hash] attributes Attributes to attach to the span + # @param [Aws::Telemetry::SpanKind] kind Type of Span + # @return [Aws::Telemetry::SpanBase] + def in_span(name, attributes: nil, kind: nil) + raise NotImplementedError + end + + # Returns the current active span. + # + # @return [Aws::Telemetry::SpanBase] + def current_span + raise NotImplementedError + end + end + + # Base for `Span` classes. + class SpanBase + # Set attribute. + # + # @param [String] key + # @param [String, Boolean, Numeric, Array] value + # Value must be non-nil and (array of) string, boolean or numeric type. + # Array values must not contain nil elements and all elements must be of + # the same basic type (string, numeric, boolean) + # @return [self] returns itself + def set_attribute(key, value) + raise NotImplementedError + end + alias []= set_attribute + + # Add attributes. + # + # @param [Hash{String => String, Numeric, Boolean, Array}] attributes Values must be non-nil and (array of) string, + # boolean or numeric type. Array values must not contain nil elements + # and all elements must be of the same basic type (string, numeric, + # boolean) + # @return [self] returns itself + def add_attributes(attributes) + raise NotImplementedError + end + + # Add event to a Span. + # + # @param [String] name Name of the event + # @param [Hash{String => String, Numeric, Boolean, Array}] attributes Values must be non-nil and (array of) + # string, boolean or numeric type. Array values must not contain nil + # elements and all elements must be of the same basic type (string, + # numeric, boolean) + # @return [self] returns itself + def add_event(name, attributes: nil) + raise NotImplementedError + end + + # Sets the Span status. + # + # @param [Aws::Telemetry::SpanStatus] status The new status, which + # overrides the default Span status, which is `OK` + # @return [void] + def status=(status) + raise NotImplementedError + end + + # Finishes the Span. + # + # @param [Time] end_timestamp End timestamp for the span. + # @return [self] returns itself + def finish(end_timestamp: nil) + raise NotImplementedError + end + + # Record an exception during the execution of this span. Multiple + # exceptions can be recorded on a span. + # + # @param [Exception] exception The exception to be recorded + # @param [Hash{String => String, Numeric, Boolean, Array}] attributes One or more key:value pairs, where the + # keys must be strings and the values may be (array of) string, boolean + # or numeric type. + # @return [void] + def record_exception(exception, attributes: nil) + raise NotImplementedError + end + end + + # Base for all `ContextManager` classes. + class ContextManagerBase + # Returns current context. + # + # @return [Context] + def current + raise NotImplementedError + end + + # Associates a Context with the caller’s current execution unit. + # Returns a token to be used with the matching call to detach. + # + # @param [Object] context The new context + # @return [Object] token A token to be used when detaching + def attach(context) + raise NotImplementedError + end + + # Restore the previous Context associated with the current + # execution unit to the value it had before attaching a + # specified Context. + # + # @param [Object] token The token provided by matching the call to attach + # @return [Boolean] `True` if the calls matched, `False` otherwise + def detach(token) + raise NotImplementedError + end + end + end +end diff --git a/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/no_op.rb b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/no_op.rb new file mode 100644 index 00000000000..5b7d855b249 --- /dev/null +++ b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/no_op.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Aws + module Telemetry + # No-op implementation for {TelemetryProviderBase}. + class NoOpTelemetryProvider < TelemetryProviderBase + def initialize + super( + tracer_provider: NoOpTracerProvider.new, + context_manager: NoOpContextManager.new + ) + end + end + + # No-op implementation for {TracerProviderBase}. + class NoOpTracerProvider < TracerProviderBase + def tracer(name = nil) + @tracer ||= NoOpTracer.new + end + end + + # No-op implementation for {TracerBase}. + class NoOpTracer < TracerBase + def start_span(name, with_parent: nil, attributes: nil, kind: nil) + NoOpSpan.new + end + + def in_span(name, attributes: nil, kind: nil) + yield NoOpSpan.new + end + + def current_span + NoOpSpan.new + end + end + + # No-op implementation for {SpanBase}. + class NoOpSpan < SpanBase + def set_attribute(key, value) + self + end + alias []= set_attribute + + def add_attributes(attributes) + self + end + + def add_event(name, attributes: nil) + self + end + + def status=(status); end + + def finish(end_timestamp: nil) + self + end + + def record_exception(exception, attributes: nil); end + end + + # No-op implementation for {ContextManagerBase}. + class NoOpContextManager < ContextManagerBase + def current; end + + def attach(context); end + + def detach(token); end + end + end +end diff --git a/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/otel.rb b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/otel.rb new file mode 100644 index 00000000000..72ccc6f8c27 --- /dev/null +++ b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/otel.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +module Aws + module Telemetry + # OTelProvider allows to emit telemetry data based on OpenTelemetry. + # + # To use this provider, require the `opentelemetry-sdk` gem and then, + # pass in an instance of a `Aws::Telemetry::OTelProvider` as the + # telemetry provider in the client config. + # + # @example Configuration + # require 'opentelemetry-sdk' + # + # # sets up the OpenTelemetry SDK with their config defaults + # OpenTelemetry::SDK.configure + # + # otel_provider = Aws::Telemetry::OTelProvider.new + # client = Aws::S3::Client.new(telemetry_provider: otel_provider) + # + # OpenTelemetry supports many ways to export your telemetry data. + # See {https://opentelemetry.io/docs/languages/ruby/exporters here} for + # more information. + # + # @example Exporting via console + # require 'opentelemetry-sdk' + # + # ENV['OTEL_TRACES_EXPORTER'] ||= 'console' + # + # # configures the OpenTelemetry SDK with defaults + # OpenTelemetry::SDK.configure + # + # otel_provider = Aws::Telemetry::OTelProvider.new + # client = Aws::S3::Client.new(telemetry_provider: otel_provider) + class OTelProvider < TelemetryProviderBase + def initialize + unless otel_loaded? + raise ArgumentError, + 'Requires the `opentelemetry-sdk` gem to use OTel Provider.' + end + super( + tracer_provider: OTelTracerProvider.new, + context_manager: OTelContextManager.new + ) + end + + private + + def otel_loaded? + if @use_otel.nil? + @use_otel = + begin + require 'opentelemetry-sdk' + true + rescue LoadError, NameError + false + end + end + @use_otel + end + end + + # OpenTelemetry-based {TracerProviderBase}, an entry point for + # creating Tracer instances. + class OTelTracerProvider < TracerProviderBase + def initialize + super + @tracer_provider = OpenTelemetry.tracer_provider + end + + # Returns a Tracer instance. + # + # @param [optional String] name Tracer name + # @return [Aws::Telemetry::OTelTracer] + def tracer(name = nil) + OTelTracer.new(@tracer_provider.tracer(name)) + end + end + + # OpenTelemetry-based {TracerBase}, responsible for creating spans. + class OTelTracer < TracerBase + def initialize(tracer) + super() + @tracer = tracer + end + + # Used when a caller wants to manage the activation/deactivation and + # lifecycle of the Span and its parent manually. + # + # @param [String] name Span name + # @param [Object] with_parent Parent Context + # @param [Hash] attributes Attributes to attach to the span + # @param [Aws::Telemetry::SpanKind] kind Type of Span + # @return [Aws::Telemetry::OTelSpan] + def start_span(name, with_parent: nil, attributes: nil, kind: nil) + span = @tracer.start_span( + name, + with_parent: with_parent, + attributes: attributes, + kind: kind + ) + OTelSpan.new(span) + end + + # A helper for the default use-case of extending the current trace + # with a span. + # On exit, the Span that was active before calling this method will + # be reactivated. If an exception occurs during the execution of the + # provided block, it will be recorded on the span and re-raised. + # + # @param [String] name Span name + # @param [Hash] attributes Attributes to attach to the span + # @param [Aws::Telemetry::SpanKind] kind Type of Span + # @return [Aws::Telemetry::OTelSpan] + def in_span(name, attributes: nil, kind: nil, &block) + @tracer.in_span(name, attributes: attributes, kind: kind) do |span| + block.call(OTelSpan.new(span)) + end + end + + # Returns the current active span. + # + # @return [Aws::Telemetry::OTelSpan] + def current_span + OTelSpan.new(OpenTelemetry::Trace.current_span) + end + end + + # OpenTelemetry-based {SpanBase}, represents a single operation + # within a trace. + class OTelSpan < SpanBase + def initialize(span) + super() + @span = span + end + + # Set attribute. + # + # @param [String] key + # @param [String, Boolean, Numeric, Array] value + # Value must be non-nil and (array of) string, boolean or numeric type. + # Array values must not contain nil elements and all elements must be of + # the same basic type (string, numeric, boolean) + # @return [self] returns itself + def set_attribute(key, value) + @span.set_attribute(key, value) + end + alias []= set_attribute + + # Add attributes. + # + # @param [Hash{String => String, Numeric, Boolean, Array}] attributes Values must be non-nil and (array of) string, + # boolean or numeric type. Array values must not contain nil elements + # and all elements must be of the same basic type (string, numeric, + # boolean) + # @return [self] returns itself + def add_attributes(attributes) + @span.add_attributes(attributes) + end + + # Add event to a Span. + # + # @param [String] name Name of the event + # @param [Hash{String => String, Numeric, Boolean, Array}] attributes Values must be non-nil and (array of) + # string, boolean or numeric type. Array values must not contain nil + # elements and all elements must be of the same basic type (string, + # numeric, boolean) + # @return [self] returns itself + def add_event(name, attributes: nil) + @span.add_event(name, attributes: attributes) + end + + # Sets the Span status. + # + # @param [Aws::Telemetry::Status] status The new status, which + # overrides the default Span status, which is `OK` + # @return [void] + def status=(status) + @span.status = status + end + + # Finishes the Span. + # + # @param [Time] end_timestamp End timestamp for the span + # @return [self] returns itself + def finish(end_timestamp: nil) + @span.finish(end_timestamp: end_timestamp) + end + + # Record an exception during the execution of this span. Multiple + # exceptions can be recorded on a span. + # + # @param [Exception] exception The exception to be recorded + # @param [Hash{String => String, Numeric, Boolean, Array}] attributes One or more key:value pairs, where the + # keys must be strings and the values may be (array of) string, boolean + # or numeric type + # @return [void] + def record_exception(exception, attributes: nil) + @span.record_exception(exception, attributes: attributes) + end + end + + # OpenTelemetry-based {ContextManagerBase}, manages context and + # used to return the current context within a trace. + class OTelContextManager < ContextManagerBase + # Returns current context. + # + # @return [Context] + def current + OpenTelemetry::Context.current + end + + # Associates a Context with the caller’s current execution unit. + # Returns a token to be used with the matching call to detach. + # + # @param [Context] context The new context + # @return [Object] token A token to be used when detaching + def attach(context) + OpenTelemetry::Context.attach(context) + end + + # Restore the previous Context associated with the current + # execution unit to the value it had before attaching a + # specified Context. + # + # @param [Object] token The token provided by matching the call to attach + # @return [Boolean] `True` if the calls matched, `False` otherwise + def detach(token) + OpenTelemetry::Context.detach(token) + end + end + end +end diff --git a/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/span_kind.rb b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/span_kind.rb new file mode 100644 index 00000000000..588985a086a --- /dev/null +++ b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/span_kind.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Aws + module Telemetry + module SpanKind + # Default. Represents an internal operation within an application. + INTERNAL = :internal + + # Represents handling synchronous network requests. + SERVER = :server + + # Represents a request to some remote service. + CLIENT = :client + + # Represents a child of an asynchronous `PRODUCER` request. + CONSUMER = :consumer + + # Represents an asynchronous request. + PRODUCER = :producer + end + end +end diff --git a/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/span_status.rb b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/span_status.rb new file mode 100644 index 00000000000..3c75358e0a1 --- /dev/null +++ b/gems/aws-sdk-core/lib/aws-sdk-core/telemetry/span_status.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Aws + module Telemetry + # Represents the status of a finished span. + class SpanStatus + class << self + private :new + + # Returns a newly created {SpanStatus} with code, `UNSET` + # and an optional description. + # + # @param [optional String] description + # @return [SpanStatus] + def unset(description = '') + new(UNSET, description: description) + end + + # Returns a newly created {SpanStatus} with code, `OK` + # and an optional description. + # + # @param [optional String] description + # @return [SpanStatus] + def ok(description = '') + new(OK, description: description) + end + + # Returns a newly created {SpanStatus} with code, `ERROR` + # and an optional description. + # + # @param [optional String] description + # @return [SpanStatus] + def error(description = '') + new(ERROR, description: description) + end + end + + def initialize(code, description: '') + @code = code + @description = description + end + + # @return [Integer] code + attr_reader :code + + # @return [String] description + attr_reader :description + + # The operation completed successfully. + OK = 0 + + # The default status. + UNSET = 1 + + # An error. + ERROR = 2 + end + end +end diff --git a/gems/aws-sdk-core/lib/seahorse/client/h2/handler.rb b/gems/aws-sdk-core/lib/seahorse/client/h2/handler.rb index d6001af9b0e..0a70f0970f2 100644 --- a/gems/aws-sdk-core/lib/seahorse/client/h2/handler.rb +++ b/gems/aws-sdk-core/lib/seahorse/client/h2/handler.rb @@ -27,6 +27,12 @@ module H2 class Handler < Client::Handler def call(context) + span_wrapper(context) { _call(context) } + end + + private + + def _call(context) stream = nil begin conn = context.client.connection @@ -80,8 +86,6 @@ def call(context) ) end - private - def _register_callbacks(resp, stream, stream_mutex, close_condition, sync_queue) stream.on(:headers) do |headers| resp.signal_headers(headers) @@ -146,8 +150,14 @@ def error_message(req, error) end end + def span_wrapper(context, &block) + context.tracer.in_span( + 'Handler.H2', + attributes: Aws::Telemetry.http_request_attrs(context), + &block + ) + end end - end end end diff --git a/gems/aws-sdk-core/lib/seahorse/client/net_http/handler.rb b/gems/aws-sdk-core/lib/seahorse/client/net_http/handler.rb index c1fdc838c2c..82c7b3117f6 100644 --- a/gems/aws-sdk-core/lib/seahorse/client/net_http/handler.rb +++ b/gems/aws-sdk-core/lib/seahorse/client/net_http/handler.rb @@ -42,7 +42,13 @@ class InvalidHttpVerbError < StandardError; end # @param [RequestContext] context # @return [Response] def call(context) - transmit(context.config, context.http_request, context.http_response) + span_wrapper(context) do + transmit( + context.config, + context.http_request, + context.http_response + ) + end Response.new(context: context) end @@ -192,6 +198,17 @@ def extract_headers(response) end end + def span_wrapper(context, &block) + context.tracer.in_span( + 'Handler.NetHttp', + attributes: Aws::Telemetry.http_request_attrs(context) + ) do |span| + block.call + span.add_attributes( + Aws::Telemetry.http_response_attrs(context) + ) + end + end end end end diff --git a/gems/aws-sdk-core/lib/seahorse/client/request_context.rb b/gems/aws-sdk-core/lib/seahorse/client/request_context.rb index 36f81a8ba2d..de7650b2ae9 100644 --- a/gems/aws-sdk-core/lib/seahorse/client/request_context.rb +++ b/gems/aws-sdk-core/lib/seahorse/client/request_context.rb @@ -9,11 +9,14 @@ class RequestContext # @option options [required,Symbol] :operation_name (nil) # @option options [required,Model::Operation] :operation (nil) # @option options [Model::Authorizer] :authorizer (nil) + # @option options [Client] :client (nil) # @option options [Hash] :params ({}) # @option options [Configuration] :config (nil) # @option options [Http::Request] :http_request (Http::Request.new) # @option options [Http::Response] :http_response (Http::Response.new) - # and #rewind. + # @option options [Integer] :retries (0) + # @option options [Aws::Telemetry::TracerBase] :tracer (Aws::Telemetry::NoOpTracer.new) + # @options options [Hash] :metadata ({}) def initialize(options = {}) @operation_name = options[:operation_name] @operation = options[:operation] @@ -24,6 +27,7 @@ def initialize(options = {}) @http_request = options[:http_request] || Http::Request.new @http_response = options[:http_response] || Http::Response.new @retries = 0 + @tracer = options[:tracer] || Aws::Telemetry::NoOpTracer.new @metadata = {} end @@ -54,6 +58,9 @@ def initialize(options = {}) # @return [Integer] attr_accessor :retries + # @return [Tracer] + attr_accessor :tracer + # @return [Hash] attr_reader :metadata diff --git a/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/base.rbs b/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/base.rbs new file mode 100644 index 00000000000..3050bbc5710 --- /dev/null +++ b/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/base.rbs @@ -0,0 +1,46 @@ +module Aws + module Telemetry + class TelemetryProviderBase + def initialize: (?tracer_provider: TracerProviderBase, ?context_manager: ContextManagerBase) -> void + attr_reader tracer_provider: TracerProviderBase + + attr_reader context_manager: ContextManagerBase + end + + class TracerProviderBase + def tracer: (?String name) -> TracerBase + end + + class TracerBase + def start_span: (String name, ?untyped with_parent, ?Hash[String, untyped] attributes, ?SpanKind kind) -> SpanBase + + def in_span: (String name, ?Hash[String, untyped] attributes, ?SpanKind kind) -> SpanBase + + def current_span: () -> SpanBase + end + + class SpanBase + def set_attribute: (String key, untyped value) -> self + alias []= set_attribute + + def add_attributes: (Hash[String, untyped] attributes) -> self + + def add_event: (String name, ?Hash[String, untyped] attributes) -> self + + def status=: (SpanStatus status) -> void + + def finish: (?Time end_timestamp) -> self + + def record_exception: (untyped exception, ?Hash[String, untyped] attributes) -> void + end + + class ContextManagerBase + def current: () -> untyped + + def attach: (untyped context) -> untyped + + def detach: (untyped token) -> bool + end + + end +end diff --git a/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/otel.rbs b/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/otel.rbs new file mode 100644 index 00000000000..131ad2f2a1c --- /dev/null +++ b/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/otel.rbs @@ -0,0 +1,22 @@ +module Aws + module Telemetry + class OTelProvider < TelemetryProviderBase + def initialize: () -> void + end + + class OTelTracerProvider < TracerProviderBase + def initialize: () -> void + end + + class OTelTracer < TracerBase + def initialize: (untyped tracer) -> void + end + + class OTelSpan < SpanBase + def initialize: (untyped span) -> void + end + + class OTelContextManager < ContextManagerBase + end + end +end diff --git a/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/span_kind.rbs b/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/span_kind.rbs new file mode 100644 index 00000000000..38ec29a1936 --- /dev/null +++ b/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/span_kind.rbs @@ -0,0 +1,15 @@ +module Aws + module Telemetry + module SpanKind + INTERNAL: :internal + + SERVER: :server + + CLIENT: :client + + CONSUMER: :consumer + + PRODUCER: :producer + end + end +end diff --git a/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/span_status.rbs b/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/span_status.rbs new file mode 100644 index 00000000000..ec9c42614b4 --- /dev/null +++ b/gems/aws-sdk-core/sig/aws-sdk-core/telemetry/span_status.rbs @@ -0,0 +1,24 @@ +module Aws + module Telemetry + class SpanStatus + + def self.unset: (?::String description) -> SpanStatus + + def self.ok: (?::String description) -> SpanStatus + + def self.error: (?::String description) -> SpanStatus + + def initialize: (Integer code, ?description: ::String) -> void + + attr_reader code: Integer + + attr_reader description: String + + OK: 0 + + UNSET: 1 + + ERROR: 2 + end + end +end diff --git a/gems/aws-sdk-core/spec/aws/plugins/telemetry_spec.rb b/gems/aws-sdk-core/spec/aws/plugins/telemetry_spec.rb new file mode 100644 index 00000000000..c0af563fd70 --- /dev/null +++ b/gems/aws-sdk-core/spec/aws/plugins/telemetry_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' +require 'opentelemetry-sdk' + +module Aws + module Plugins + describe Telemetry do + let(:creds) { Aws::Credentials.new('akid', 'secret') } + let(:client_config) { { credentials: creds, region: 'us-east-1' } } + + TelemetryClient = ApiHelper.sample_service( + metadata: { + 'protocol' => 'rest-xml', + 'serviceId' => 'Telemetry Service' + }, + operations: { + 'SomeOperation' => { + 'http' => { 'method' => 'POST', 'requestUri' => '/some_operation' }, + 'input' => { 'shape' => 'OperationRequest' } + } + }, + shapes: { + 'OperationRequest' => { + 'type' => 'structure', + 'members' => { + 'Body' => { 'shape' => 'Body', 'streaming' => false } + }, + 'payload' => 'Body' + }, + 'Body' => { 'type' => 'blob' } + } + ).const_get(:Client) + + context 'telemetry_provider option' do + let(:custom_class) { Class.new(Aws::Telemetry::TelemetryProviderBase) } + let(:custom_provider) { custom_class.new } + + it 'defaults to no-op provider' do + client = TelemetryClient.new(client_config) + expect(client.config.telemetry_provider) + .to be_an_instance_of(Aws::Telemetry::NoOpTelemetryProvider) + end + + it 'does not raise error when given an otel provider' do + otel_provider = Aws::Telemetry::OTelProvider.new + client_config[:telemetry_provider] = otel_provider + expect { TelemetryClient.new(client_config) } + .not_to raise_error + end + + it 'does not raise error when given a custom provider' do + client_config[:telemetry_provider] = custom_provider + expect { TelemetryClient.new(client_config) } + .not_to raise_error + end + + it 'raises an argument error when given an invalid provider' do + expect do + TelemetryClient.new(telemetry_provider: 'foo') + end.to raise_error(ArgumentError) + end + end + + describe 'telemetry providers' do + context 'no-op telemetry provider' do + it 'does not raise error when calling an operation' do + client = TelemetryClient.new(stub_responses: true) + expect { client.some_operation }.not_to raise_error + end + end + + context 'otel provider' do + let(:otel_provider) { Aws::Telemetry::OTelProvider.new } + let(:otel_export) { OpenTelemetry::SDK::Trace::Export } + let(:otel_exporter) { otel_export::InMemorySpanExporter.new } + + before do + processor = otel_export::SimpleSpanProcessor.new(otel_exporter) + OpenTelemetry::SDK.configure do |c| + c.add_span_processor(processor) + end + end + + after { SpecHelper.reset_opentelemetry_sdk } + + let(:finished_send_span) do + otel_exporter + .finished_spans + .find { |span| span.name == 'Handler.NetHttp' } + end + + let(:finished_op_span) do + otel_exporter + .finished_spans + .find { |span| span.name == 'TelemetryService.SomeOperation' } + end + + let(:client) do + client_config.tap do |c| + c[:telemetry_provider] = otel_provider + c[:endpoint] = 'https://foo.com' + end + TelemetryClient.new(client_config) + end + + it 'raises error when an otel dependency is not required' do + allow_any_instance_of(Aws::Telemetry::OTelProvider) + .to receive(:otel_loaded?).and_return(false) + expect { otel_provider } + .to raise_error( + ArgumentError, + 'Requires the `opentelemetry-sdk` gem to use OTel Provider.' + ) + end + + it 'creates spans with all the supplied parameters' do + stub_request(:post, 'https://foo.com/some_operation') + client.some_operation + + expect(finished_send_span).not_to be_nil + expect(finished_op_span).not_to be_nil + expect(finished_send_span.attributes) + .to include( + 'http.method' => 'POST', + 'net.protocol.name' => 'http', + 'net.protocol.version' => '1.1', + 'net.peer.name' => 'foo.com', + 'net.peer.port' => '443', + 'http.status_code' => '200' + ) + expect(finished_op_span.attributes) + .to include( + 'rpc.system' => 'aws-api', + 'rpc.service' => 'Telemetry Service', + 'rpc.method' => 'SomeOperation', + 'code.function' => 'some_operation', + 'code.namespace' => 'Aws::Plugins::Telemetry' + ) + expect(finished_send_span.kind).to eq(:internal) + expect(finished_op_span.kind).to eq(:client) + expect(finished_send_span.parent_span_id) + .to eq(finished_op_span.span_id) + end + + it 'applies content-length span attributes when applicable' do + body = 'AAAA' + stub_request(:post, 'https://foo.com/some_operation') + .to_return( + body: body, + headers: { 'Content-Length' => body.size } + ) + client.some_operation(body: body) + + expect(finished_send_span.attributes) + .to include( + 'http.request_content_length' => body.size.to_s, + 'http.response_content_length' => body.size.to_s + ) + end + + it 'populates span data with error when it occurs' do + stub_request(:post, 'https://foo.com/some_operation') + .to_return(status: 500) + server_err = nil + begin + client.some_operation + rescue StandardError => e + server_err = e + end + + expect(finished_op_span.status.code).to eq(2) # err code + expect(finished_op_span.events[0].name).to eq('exception') + expect(finished_op_span.events[0].attributes['exception.type']) + .to eq(server_err.to_s) + end + + context 'stub_responses' do + it 'creates a stub span with all the supplied parameters' do + client = TelemetryClient.new( + stub_responses: true, + telemetry_provider: otel_provider + ) + client.some_operation + + finished_stub_span = + otel_exporter + .finished_spans + .find { |span| span.name == 'Handler.StubResponses' } + + expect(finished_stub_span).not_to be_nil + expect(finished_stub_span.attributes) + .to include( + 'http.method' => 'POST', + 'net.protocol.name' => 'http', + 'net.protocol.version' => '1.1' + ) + expect(finished_stub_span.kind).to eq(:internal) + expect(finished_stub_span.parent_span_id) + .to eq(finished_op_span.span_id) + end + end + end + end + end + end +end diff --git a/gems/aws-sdk-core/spec/aws/telemetry/base_spec.rb b/gems/aws-sdk-core/spec/aws/telemetry/base_spec.rb new file mode 100644 index 00000000000..bb7ef0fc564 --- /dev/null +++ b/gems/aws-sdk-core/spec/aws/telemetry/base_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +module Aws + module Telemetry + describe TracerProviderBase do + it 'defines the interface' do + expect do + subject.tracer + end.to raise_error(NotImplementedError) + end + end + + describe TracerBase do + it 'defines the interface' do + expect do + subject.start_span('foo') + end.to raise_error(NotImplementedError) + + expect do + subject.in_span('foo') + end.to raise_error(NotImplementedError) + + expect do + subject.current_span + end.to raise_error(NotImplementedError) + end + end + + describe SpanBase do + it 'defines the interface' do + expect do + subject.set_attribute('foo', 'bar') + end.to raise_error(NotImplementedError) + + expect do + subject.add_attributes('foo' => 'bar') + end.to raise_error(NotImplementedError) + + expect do + subject.add_event('foo') + end.to raise_error(NotImplementedError) + + expect do + subject.status = Aws::Telemetry::SpanStatus.error + end.to raise_error(NotImplementedError) + + expect do + subject.finish + end.to raise_error(NotImplementedError) + + expect do + subject.record_exception(StandardError.new) + end.to raise_error(NotImplementedError) + end + end + + describe ContextManagerBase do + it 'defines the interface' do + expect do + subject.current + end.to raise_error(NotImplementedError) + + expect do + subject.attach('foo') + end.to raise_error(NotImplementedError) + + expect do + subject.detach('foo') + end.to raise_error(NotImplementedError) + end + end + end +end diff --git a/gems/aws-sdk-core/spec/aws/telemetry/no_op_spec.rb b/gems/aws-sdk-core/spec/aws/telemetry/no_op_spec.rb new file mode 100644 index 00000000000..dec98bf5877 --- /dev/null +++ b/gems/aws-sdk-core/spec/aws/telemetry/no_op_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +module Aws + module Telemetry + describe NoOpTelemetryProvider do + describe '#initialize' do + it 'sets up no-op provider' do + expect(subject.tracer_provider) + .to be_a(Aws::Telemetry::NoOpTracerProvider) + end + end + end + + describe NoOpTracerProvider do + describe '#tracer' do + it 'returns an instance of no-op tracer' do + expect(subject.tracer) + .to be_an_instance_of(Aws::Telemetry::NoOpTracer) + end + end + end + + describe NoOpTracer do + describe '#start_span' do + it 'yields an instance of no-op span' do + span = subject.start_span('foo') + expect(span).to be_an_instance_of(Aws::Telemetry::NoOpSpan) + end + end + + describe '#in_span' do + it 'yields an instance of no-op span' do + subject.in_span('wrapper') do |span| + expect(span) + .to be_an_instance_of(Aws::Telemetry::NoOpSpan) + end + end + end + + describe '#current_span' do + it 'returns an instance of no-op span' do + expect(subject.current_span) + .to be_an_instance_of(Aws::Telemetry::NoOpSpan) + end + end + end + + describe NoOpSpan do + describe '#set_attribute' do + it 'returns itself' do + expect(subject.set_attribute('some_attribute', 'some_value')) + .to be_an_instance_of(Aws::Telemetry::NoOpSpan) + end + end + + describe '#add_attributes' do + it 'returns itself' do + expect(subject.add_attributes({ 'foo' => 'bar' })) + .to be_an_instance_of(Aws::Telemetry::NoOpSpan) + end + end + + describe '#add_event' do + it 'returns itself' do + expect(subject.add_event('some_event', attributes: {})) + .to be_an_instance_of(Aws::Telemetry::NoOpSpan) + end + end + + describe '#finish' do + it 'returns itself' do + expect(subject.finish) + .to be_an_instance_of(Aws::Telemetry::NoOpSpan) + end + end + end + end +end diff --git a/gems/aws-sdk-core/spec/aws/telemetry/otel_spec.rb b/gems/aws-sdk-core/spec/aws/telemetry/otel_spec.rb new file mode 100644 index 00000000000..01502945322 --- /dev/null +++ b/gems/aws-sdk-core/spec/aws/telemetry/otel_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' +require 'opentelemetry-sdk' + +module Aws + module Telemetry + describe OTelProvider do + let(:otel_provider) { OTelProvider.new } + let(:context_manager) { otel_provider.context_manager } + let(:tracer_provider) { otel_provider.tracer_provider } + + describe '#initialize' do + it 'raises ArgumentError when otel dependency fails to load' do + allow_any_instance_of(Aws::Telemetry::OTelProvider) + .to receive(:require).with('opentelemetry-sdk').and_raise(LoadError) + expect { otel_provider }.to raise_error(ArgumentError) + end + + it 'sets up tracer provider and context manager' do + expect(tracer_provider).to be_a(Aws::Telemetry::OTelTracerProvider) + expect(context_manager).to be_a(Aws::Telemetry::OTelContextManager) + end + end + + describe OTelContextManager do + before { OpenTelemetry::Context.clear } + let(:root_context) { OpenTelemetry::Context::ROOT } + let(:new_context) do + OpenTelemetry::Context.empty.set_value('new', 'context') + end + + describe '#current' do + it 'returns the current context' do + expect(context_manager.current).to eq(root_context) + end + end + + describe '#attach' do + it 'sets the current context' do + context_manager.attach(new_context) + expect(context_manager.current).to eq(new_context) + end + end + + describe '#detach' do + it 'detaches the previously set context' do + token = context_manager.attach(new_context) + expect(context_manager.current).to eq(new_context) + context_manager.detach(token) + expect(context_manager.current).to eq(root_context) + end + end + end + + describe 'OTelTracerProvider' do + let(:tracer) { tracer_provider.tracer('some_tracer') } + + it 'returns a tracer instance' do + expect(tracer).to be_a(Aws::Telemetry::OTelTracer) + end + + context 'tracer' do + let(:otel_export) { OpenTelemetry::SDK::Trace::Export } + let(:otel_exporter) { otel_export::InMemorySpanExporter.new } + let(:finished_span) { otel_exporter.finished_spans[0] } + + before do + processor = otel_export::SimpleSpanProcessor.new(otel_exporter) + OpenTelemetry::SDK.configure do |c| + c.add_span_processor(processor) + end + end + after { SpecHelper.reset_opentelemetry_sdk } + + describe '#start_span' do + it 'returns a valid span with supplied parameters' do + span = tracer.start_span('some_span') + span.set_attribute('apple', 'pie') + span.add_event('pizza party') + span.status = Aws::Telemetry::SpanStatus.ok + span.finish + expect(finished_span.name).to eq('some_span') + expect(finished_span.attributes).to include('apple' => 'pie') + expect(finished_span.events[0].name).to eq('pizza party') + expect(finished_span.status) + .to be_an_instance_of(Aws::Telemetry::SpanStatus) + end + end + + describe '#in_span' do + let(:error) { StandardError.new('foo') } + + it 'returns a valid span with supplied parameters' do + tracer.in_span('foo') do |span| + span['meat'] = 'pie' + span.add_attributes('durian' => 'pie') + span.status = Aws::Telemetry::SpanStatus.error + span.record_exception(error, attributes: { 'burnt' => 'pie' }) + end + expect(finished_span.name).to eq('foo') + expect(finished_span.attributes) + .to include('meat' => 'pie', 'durian' => 'pie') + expect(finished_span.status.code).to eq(2) + expect(finished_span.events.size).to eq(1) + expect(finished_span.events[0].name).to eq('exception') + expect(finished_span.events[0].attributes['exception.type']) + .to eq(error.class.to_s) + expect(finished_span.events[0].attributes['exception.message']) + .to eq(error.message) + expect(finished_span.events[0].attributes['burnt']).to eq('pie') + end + end + + describe '#current_span' do + it 'returns the current span' do + tracer.in_span('foo') do |span| + span['blueberry'] = 'pie' + expect(tracer.current_span.instance_variable_get(:@span)) + .to eq(span.instance_variable_get(:@span)) + end + end + end + end + end + end + end +end diff --git a/gems/aws-sdk-core/spec/aws/telemetry/span_status_spec.rb b/gems/aws-sdk-core/spec/aws/telemetry/span_status_spec.rb new file mode 100644 index 00000000000..7a7689eecdc --- /dev/null +++ b/gems/aws-sdk-core/spec/aws/telemetry/span_status_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +module Aws + module Telemetry + describe SpanStatus do + describe '.unset' do + it 'returns the correct expected code' do + status = Aws::Telemetry::SpanStatus.unset + expect(status.code).to eq(Aws::Telemetry::SpanStatus::UNSET) + end + end + + describe '.ok' do + it 'returns the correct expected code' do + status = Aws::Telemetry::SpanStatus.ok + expect(status.code).to eq(Aws::Telemetry::SpanStatus::OK) + end + end + + describe '.error' do + it 'returns the correct expected code' do + status = Aws::Telemetry::SpanStatus.error + expect(status.code).to eq(Aws::Telemetry::SpanStatus::ERROR) + end + end + end + end +end diff --git a/gems/aws-sdk-core/spec/aws/telemetry_spec.rb b/gems/aws-sdk-core/spec/aws/telemetry_spec.rb new file mode 100644 index 00000000000..1b4236dfb01 --- /dev/null +++ b/gems/aws-sdk-core/spec/aws/telemetry_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' +require 'opentelemetry-sdk' + +module Aws + describe Telemetry do + describe '.module_to_tracer_name' do + it 'correctly converts module to tracer name' do + expect(Aws::Telemetry.module_to_tracer_name('Aws::Telemetry')) + .to eq('aws.telemetry.client') + end + end + end +end diff --git a/gems/aws-sdk-core/spec/spec_helper.rb b/gems/aws-sdk-core/spec/spec_helper.rb index a6ca9fcdd6e..fb750c3082b 100644 --- a/gems/aws-sdk-core/spec/spec_helper.rb +++ b/gems/aws-sdk-core/spec/spec_helper.rb @@ -70,6 +70,16 @@ def client_with_plugin(options = {}, &block) client_class.new(options) end + # clears opentelemetry-sdk configuration state between specs + # https://github.com/open-telemetry/opentelemetry-ruby/blob/main/test_helpers/lib/opentelemetry/test_helpers.rb#L18 + def reset_opentelemetry_sdk + OpenTelemetry.instance_variable_set( + :@tracer_provider, + OpenTelemetry::Internal::ProxyTracerProvider.new + ) + OpenTelemetry.error_handler = nil + OpenTelemetry.propagation = nil + end end end