From 9d897501789cd936f835a038220106b016bae87c Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Thu, 16 Nov 2023 12:11:44 -0500 Subject: [PATCH 1/5] feat: add metrics_exporter for metrics_sdk --- exporter/otlp/Gemfile | 2 + exporter/otlp/README.md | 39 ++ .../otlp/lib/opentelemetry/exporter/otlp.rb | 1 + .../exporter/otlp/metrics_exporter.rb | 288 +++++++++ .../lib/opentelemetry/exporter/otlp/util.rb | 149 +++++ .../exporter/otlp/metrics_exporter_test.rb | 600 ++++++++++++++++++ exporter/otlp/test/test_helper.rb | 21 + 7 files changed, 1100 insertions(+) create mode 100644 exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb create mode 100644 exporter/otlp/lib/opentelemetry/exporter/otlp/util.rb create mode 100644 exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb diff --git a/exporter/otlp/Gemfile b/exporter/otlp/Gemfile index 486fa4a3e9..cf3bbd7d4a 100644 --- a/exporter/otlp/Gemfile +++ b/exporter/otlp/Gemfile @@ -11,6 +11,8 @@ gemspec group :test, :development do gem 'opentelemetry-api', path: '../../api' gem 'opentelemetry-common', path: '../../common' + gem 'opentelemetry-metrics-api', path: '../../metrics_api' + gem 'opentelemetry-metrics-sdk', path: '../../metrics_sdk' gem 'opentelemetry-registry', path: '../../registry' gem 'opentelemetry-sdk', path: '../../sdk' gem 'opentelemetry-semantic_conventions', path: '../../semantic_conventions' diff --git a/exporter/otlp/README.md b/exporter/otlp/README.md index 55d09b8470..47e588594f 100644 --- a/exporter/otlp/README.md +++ b/exporter/otlp/README.md @@ -67,6 +67,31 @@ end tracer_provider.shutdown ``` +For otlp metrics exporter + +```ruby +require 'opentelemetry/sdk' +require 'opentelemetry-metrics-sdk' +require 'opentelemetry/exporter/otlp' + +OpenTelemetry::SDK.configure + +# To start a trace you need to get a Tracer from the TracerProvider + +otlp_metric_exporter = OpenTelemetry::Exporter::OTLP::MetricsExporter.new + +OpenTelemetry.meter_provider.add_metric_reader(otlp_metric_exporter) + +meter = OpenTelemetry.meter_provider.meter("SAMPLE_METER_NAME") + +histogram = meter.create_histogram('histogram', unit: 'smidgen', description: 'desscription') + +histogram.record(123, attributes: {'foo' => 'bar'}) + +OpenTelemetry.meter_provider.metric_readers.each(&:pull) +OpenTelemetry.meter_provider.shutdown +``` + For additional examples, see the [examples on github][examples-github]. ## How can I configure the OTLP exporter? @@ -85,6 +110,20 @@ The collector exporter can be configured explicitly in code, or via environment `ssl_verify_mode:` parameter values should be flags for server certificate verification: `OpenSSL::SSL:VERIFY_PEER` and `OpenSSL::SSL:VERIFY_NONE` are acceptable. These values can also be set using the appropriately named environment variables as shown where `VERIFY_PEER` will take precedence over `VERIFY_NONE`. Please see [the Net::HTTP docs](https://ruby-doc.org/stdlib-2.7.6/libdoc/net/http/rdoc/Net/HTTP.html#verify_mode) for more information about these flags. +## How can I configure the OTLP Metrics exporter? + +The collector exporter can be configured explicitly in code, or via environment variables as shown above. The configuration parameters, environment variables, and defaults are shown below. + +| Parameter | Environment variable | Default | +| ------------------- | -------------------------------------------- | ----------------------------------- | +| `endpoint:` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `"http://localhost:4318/v1/metrics"` | +| `certificate_file: `| `OTEL_EXPORTER_OTLP_CERTIFICATE` | | +| `headers:` | `OTEL_EXPORTER_OTLP_HEADERS` | | +| `compression:` | `OTEL_EXPORTER_OTLP_COMPRESSION` | `"gzip"` | +| `timeout:` | `OTEL_EXPORTER_OTLP_TIMEOUT` | `10` | +| `ssl_verify_mode:` | `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_PEER` or | `OpenSSL::SSL:VERIFY_PEER` | +| | `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` | | + ## How can I get involved? The `opentelemetry-exporter-otlp` gem source is [on github][repo-github], along with related gems including `opentelemetry-sdk`. diff --git a/exporter/otlp/lib/opentelemetry/exporter/otlp.rb b/exporter/otlp/lib/opentelemetry/exporter/otlp.rb index d40c0ab700..01a0dca366 100644 --- a/exporter/otlp/lib/opentelemetry/exporter/otlp.rb +++ b/exporter/otlp/lib/opentelemetry/exporter/otlp.rb @@ -6,6 +6,7 @@ require 'opentelemetry/exporter/otlp/version' require 'opentelemetry/exporter/otlp/exporter' +require 'opentelemetry/exporter/otlp/metrics_exporter' if defined?(::OpenTelemetry::SDK::Metrics) # OpenTelemetry is an open source observability framework, providing a # general-purpose API, SDK, and related tools required for the instrumentation diff --git a/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb b/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb new file mode 100644 index 0000000000..e51d76b3dd --- /dev/null +++ b/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry/common' +require 'opentelemetry/sdk' +require 'net/http' +require 'csv' +require 'zlib' + +require 'google/rpc/status_pb' + +require 'opentelemetry/proto/common/v1/common_pb' +require 'opentelemetry/proto/resource/v1/resource_pb' +require 'opentelemetry/proto/metrics/v1/metrics_pb' +require 'opentelemetry/proto/collector/metrics/v1/metrics_service_pb' + +require 'opentelemetry/metrics' +require 'opentelemetry/sdk/metrics' + +require_relative './util' + +module OpenTelemetry + module Exporter + module OTLP + # An OpenTelemetry metrics exporter that sends spans over HTTP as Protobuf encoded OTLP ExportMetricsServiceRequest. + class MetricsExporter < ::OpenTelemetry::SDK::Metrics::Export::MetricReader # rubocop:disable Metrics/ClassLength + attr_reader :metric_snapshots + + SUCCESS = OpenTelemetry::SDK::Metrics::Export::SUCCESS + FAILURE = OpenTelemetry::SDK::Metrics::Export::FAILURE + private_constant(:SUCCESS, :FAILURE) + + WRITE_TIMEOUT_SUPPORTED = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.6') + private_constant(:WRITE_TIMEOUT_SUPPORTED) + + def initialize(endpoint: OpenTelemetry::Common::Utilities.config_opt('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', 'OTEL_EXPORTER_OTLP_ENDPOINT', default: 'http://localhost:4318/v1/metrics'), + certificate_file: OpenTelemetry::Common::Utilities.config_opt('OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE', 'OTEL_EXPORTER_OTLP_CERTIFICATE'), + ssl_verify_mode: Util.ssl_verify_mode, + headers: OpenTelemetry::Common::Utilities.config_opt('OTEL_EXPORTER_OTLP_METRICS_HEADERS', 'OTEL_EXPORTER_OTLP_HEADERS', default: {}), + compression: OpenTelemetry::Common::Utilities.config_opt('OTEL_EXPORTER_OTLP_METRICS_COMPRESSION', 'OTEL_EXPORTER_OTLP_COMPRESSION', default: 'gzip'), + timeout: OpenTelemetry::Common::Utilities.config_opt('OTEL_EXPORTER_OTLP_METRICS_TIMEOUT', 'OTEL_EXPORTER_OTLP_TIMEOUT', default: 10)) + raise ArgumentError, "invalid url for OTLP::MetricsExporter #{endpoint}" unless OpenTelemetry::Common::Utilities.valid_url?(endpoint) + raise ArgumentError, "unsupported compression key #{compression}" unless compression.nil? || %w[gzip none].include?(compression) + + # create the MetricStore object + super() + + @uri = if endpoint == ENV['OTEL_EXPORTER_OTLP_ENDPOINT'] + URI.join(endpoint, 'v1/metrics') + else + URI(endpoint) + end + + @http = Util.http_connection(@uri, ssl_verify_mode, certificate_file) + + @path = @uri.path + @headers = Util.prepare_headers(headers) + @timeout = timeout.to_f + @compression = compression + @mutex = Mutex.new + @shutdown = false + end + + # consolidate the metrics data into the form of MetricData + # + # return MetricData + def pull + export(collect) + end + + # metrics [Metric Object] + def export(metrics, timeout: nil) + @mutex.synchronize do + send_bytes(encode(metrics), timeout: timeout) + end + end + + def send_bytes(bytes, timeout:) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + return FAILURE if bytes.nil? + + request = Net::HTTP::Post.new(@path) + + if @compression == 'gzip' + request.add_field('Content-Encoding', 'gzip') + body = Zlib.gzip(bytes) + else + body = bytes + end + + request.body = body + request.add_field('Content-Type', 'application/x-protobuf') + @headers.each { |key, value| request.add_field(key, value) } + + retry_count = 0 + timeout ||= @timeout + start_time = OpenTelemetry::Common::Utilities.timeout_timestamp + + Util.around_request do + remaining_timeout = OpenTelemetry::Common::Utilities.maybe_timeout(timeout, start_time) + return FAILURE if remaining_timeout.zero? + + @http.open_timeout = remaining_timeout + @http.read_timeout = remaining_timeout + @http.write_timeout = remaining_timeout if WRITE_TIMEOUT_SUPPORTED + @http.start unless @http.started? + response = Util.measure_request_duration { @http.request(request) } + case response + when Net::HTTPOK + response.body # Read and discard body + SUCCESS + when Net::HTTPServiceUnavailable, Net::HTTPTooManyRequests + response.body # Read and discard body + redo if backoff?(retry_after: response['Retry-After'], retry_count: retry_count += 1, reason: response.code) + OpenTelemetry.logger.warn('Net::HTTPServiceUnavailable/Net::HTTPTooManyRequests in MetricsExporter#send_bytes') + FAILURE + when Net::HTTPRequestTimeOut, Net::HTTPGatewayTimeOut, Net::HTTPBadGateway + response.body # Read and discard body + redo if backoff?(retry_count: retry_count += 1, reason: response.code) + OpenTelemetry.logger.warn('Net::HTTPRequestTimeOut/Net::HTTPGatewayTimeOut/Net::HTTPBadGateway in MetricsExporter#send_bytes') + FAILURE + when Net::HTTPNotFound + OpenTelemetry.handle_error(message: "OTLP metrics_exporter received http.code=404 for uri: '#{@path}'") + FAILURE + when Net::HTTPBadRequest, Net::HTTPClientError, Net::HTTPServerError + Util.log_status(response.body) + OpenTelemetry.logger.warn('Net::HTTPBadRequest/Net::HTTPClientError/Net::HTTPServerError in MetricsExporter#send_bytes') + FAILURE + when Net::HTTPRedirection + @http.finish + handle_redirect(response['location']) + redo if backoff?(retry_after: 0, retry_count: retry_count += 1, reason: response.code) + else + @http.finish + OpenTelemetry.logger.warn("Unexpected error in OTLP::MetricsExporter#send_bytes - #{response.message}") + FAILURE + end + rescue Net::OpenTimeout, Net::ReadTimeout + retry if backoff?(retry_count: retry_count += 1, reason: 'timeout') + OpenTelemetry.logger.warn('Net::OpenTimeout/Net::ReadTimeout in MetricsExporter#send_bytes') + return FAILURE + rescue OpenSSL::SSL::SSLError + retry if backoff?(retry_count: retry_count += 1, reason: 'openssl_error') + OpenTelemetry.logger.warn('OpenSSL::SSL::SSLError in MetricsExporter#send_bytes') + return FAILURE + rescue SocketError + retry if backoff?(retry_count: retry_count += 1, reason: 'socket_error') + OpenTelemetry.logger.warn('SocketError in MetricsExporter#send_bytes') + return FAILURE + rescue SystemCallError => e + retry if backoff?(retry_count: retry_count += 1, reason: e.class.name) + OpenTelemetry.logger.warn('SystemCallError in MetricsExporter#send_bytes') + return FAILURE + rescue EOFError + retry if backoff?(retry_count: retry_count += 1, reason: 'eof_error') + OpenTelemetry.logger.warn('EOFError in MetricsExporter#send_bytes') + return FAILURE + rescue Zlib::DataError + retry if backoff?(retry_count: retry_count += 1, reason: 'zlib_error') + OpenTelemetry.logger.warn('Zlib::DataError in MetricsExporter#send_bytes') + return FAILURE + rescue StandardError => e + OpenTelemetry.handle_error(exception: e, message: 'unexpected error in OTLP::MetricsExporter#send_bytes') + return FAILURE + end + ensure + # Reset timeouts to defaults for the next call. + @http.open_timeout = @timeout + @http.read_timeout = @timeout + @http.write_timeout = @timeout if WRITE_TIMEOUT_SUPPORTED + end + + def encode(metrics_data) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity + Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest.encode( + Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest.new( + resource_metrics: metrics_data + .group_by(&:resource) + .map do |resource, scope_metrics| + Opentelemetry::Proto::Metrics::V1::ResourceMetrics.new( + resource: Opentelemetry::Proto::Resource::V1::Resource.new( + attributes: resource.attribute_enumerator.map { |key, value| Util.as_otlp_key_value(key, value) } + ), + scope_metrics: scope_metrics + .group_by(&:instrumentation_scope) + .map do |instrumentation_scope, metrics| + Opentelemetry::Proto::Metrics::V1::ScopeMetrics.new( + scope: Opentelemetry::Proto::Common::V1::InstrumentationScope.new( + name: instrumentation_scope.name, + version: instrumentation_scope.version + ), + metrics: metrics.map { |sd| as_otlp_metrics(sd) } + ) + end + ) + end + ) + ) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e, message: 'unexpected error in OTLP::MetricsExporter#encode') + nil + end + + # metrics_pb has following type of data: :gauge, :sum, :histogram, :exponential_histogram, :summary + # current metric sdk only implements instrument: :counter -> :sum, :histogram -> :histogram + # + # metrics [MetricData] + def as_otlp_metrics(metrics) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity + case metrics.instrument_kind + when :observable_gauge + Opentelemetry::Proto::Metrics::V1::Metric.new( + name: metrics.name, + description: metrics.description, + unit: metrics.unit, + gauge: Opentelemetry::Proto::Metrics::V1::Gauage.new( + data_points: metrics.data_points.map do |ndp| + Opentelemetry::Proto::Metrics::V1::NumberDataPoint.new( + attributes: ndp.attributes.map { |k, v| Util.as_otlp_key_value(k, v) }, + as_int: ndp.value, + start_time_unix_nano: ndp.start_time_unix_nano, + time_unix_nano: ndp.time_unix_nano, + exemplars: ndp.exemplars # exemplars not implemented yet from metrics sdk + ) + end + ) + ) + + when :counter, :up_down_counter + Opentelemetry::Proto::Metrics::V1::Metric.new( + name: metrics.name, + description: metrics.description, + unit: metrics.unit, + sum: Opentelemetry::Proto::Metrics::V1::Sum.new( + data_points: metrics.data_points.map do |ndp| + Opentelemetry::Proto::Metrics::V1::NumberDataPoint.new( + attributes: ndp.attributes.map { |k, v| Util.as_otlp_key_value(k, v) }, + as_int: ndp.value, + start_time_unix_nano: ndp.start_time_unix_nano, + time_unix_nano: ndp.time_unix_nano, + exemplars: ndp.exemplars # exemplars not implemented yet from metrics sdk + ) + end + ) + ) + + when :histogram + Opentelemetry::Proto::Metrics::V1::Metric.new( + name: metrics.name, + description: metrics.description, + unit: metrics.unit, + histogram: Opentelemetry::Proto::Metrics::V1::Histogram.new( + data_points: metrics.data_points.map do |hdp| + Opentelemetry::Proto::Metrics::V1::HistogramDataPoint.new( + attributes: hdp.attributes.map { |k, v| Util.as_otlp_key_value(k, v) }, + start_time_unix_nano: hdp.start_time_unix_nano, + time_unix_nano: hdp.time_unix_nano, + count: hdp.count, + sum: hdp.sum, + bucket_counts: hdp.bucket_counts, + explicit_bounds: hdp.explicit_bounds, + exemplars: hdp.exemplars, + min: hdp.min, + max: hdp.max + ) + end + ) + ) + end + end + + def backoff?(retry_count:, reason:, retry_after: nil) + Util.backoff?(retry_count: retry_count, reason: reason, retry_after: retry_after) + end + + # may not need this + def reset + SUCCESS + end + + def shutdown(timeout: nil) + @shutdown = true + SUCCESS + end + end + end + end +end diff --git a/exporter/otlp/lib/opentelemetry/exporter/otlp/util.rb b/exporter/otlp/lib/opentelemetry/exporter/otlp/util.rb new file mode 100644 index 0000000000..dc87cd0877 --- /dev/null +++ b/exporter/otlp/lib/opentelemetry/exporter/otlp/util.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Exporter + module OTLP + # Util module provide essential functionality for exporter + module Util # rubocop:disable Metrics/ModuleLength + KEEP_ALIVE_TIMEOUT = 30 + RETRY_COUNT = 5 + ERROR_MESSAGE_INVALID_HEADERS = 'headers must be a String with comma-separated URL Encoded UTF-8 k=v pairs or a Hash' + DEFAULT_USER_AGENT = "OTel-OTLP-MetricsExporter-Ruby/#{OpenTelemetry::Exporter::OTLP::VERSION} Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM}; #{RUBY_ENGINE}/#{RUBY_ENGINE_VERSION})".freeze + + def self.ssl_verify_mode + if ENV.key?('OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_PEER') + OpenSSL::SSL::VERIFY_PEER + elsif ENV.key?('OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE') + OpenSSL::SSL::VERIFY_NONE + else + OpenSSL::SSL::VERIFY_PEER + end + end + + def self.http_connection(uri, ssl_verify_mode, certificate_file) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.verify_mode = ssl_verify_mode + http.ca_file = certificate_file unless certificate_file.nil? + http.keep_alive_timeout = KEEP_ALIVE_TIMEOUT + http + end + + def self.around_request + OpenTelemetry::Common::Utilities.untraced { yield } # rubocop:disable Style/ExplicitBlockArgument + end + + def self.as_otlp_key_value(key, value) + Opentelemetry::Proto::Common::V1::KeyValue.new(key: key, value: as_otlp_any_value(value)) + rescue Encoding::UndefinedConversionError => e + encoded_value = value.encode('UTF-8', invalid: :replace, undef: :replace, replace: '�') + OpenTelemetry.handle_error(exception: e, message: "encoding error for key #{key} and value #{encoded_value}") + Opentelemetry::Proto::Common::V1::KeyValue.new(key: key, value: as_otlp_any_value('Encoding Error')) + end + + def self.as_otlp_any_value(value) + result = Opentelemetry::Proto::Common::V1::AnyValue.new + case value + when String + result.string_value = value + when Integer + result.int_value = value + when Float + result.double_value = value + when true, false + result.bool_value = value + when Array + values = value.map { |element| as_otlp_any_value(element) } + result.array_value = Opentelemetry::Proto::Common::V1::ArrayValue.new(values: values) + end + result + end + + def self.prepare_headers(config_headers) + headers = case config_headers + when String then parse_headers(config_headers) + when Hash then config_headers.dup + else + raise ArgumentError, ERROR_MESSAGE_INVALID_HEADERS + end + + headers['User-Agent'] = "#{headers.fetch('User-Agent', '')} #{DEFAULT_USER_AGENT}".strip + + headers + end + + def self.measure_request_duration + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + begin + yield + ensure + stop = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1000.0 * (stop - start) + end + end + + def self.parse_headers(raw) + entries = raw.split(',') + raise ArgumentError, ERROR_MESSAGE_INVALID_HEADERS if entries.empty? + + entries.each_with_object({}) do |entry, headers| + k, v = entry.split('=', 2).map(&CGI.method(:unescape)) + begin + k = k.to_s.strip + v = v.to_s.strip + rescue Encoding::CompatibilityError + raise ArgumentError, ERROR_MESSAGE_INVALID_HEADERS + rescue ArgumentError => e + raise e, ERROR_MESSAGE_INVALID_HEADERS + end + raise ArgumentError, ERROR_MESSAGE_INVALID_HEADERS if k.empty? || v.empty? + + headers[k] = v + end + end + + def self.backoff?(retry_count:, reason:, retry_after: nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + return false if retry_count > RETRY_COUNT + + sleep_interval = nil + unless retry_after.nil? + sleep_interval = + begin + Integer(retry_after) + rescue ArgumentError + nil + end + sleep_interval ||= + begin + Time.httpdate(retry_after) - Time.now + rescue # rubocop:disable Style/RescueStandardError + nil + end + sleep_interval = nil unless sleep_interval&.positive? + end + sleep_interval ||= rand(2**retry_count) + + sleep(sleep_interval) + true + end + + def self.log_status(body) + status = Google::Rpc::Status.decode(body) + details = status.details.map do |detail| + klass_or_nil = ::Google::Protobuf::DescriptorPool.generated_pool.lookup(detail.type_name).msgclass + detail.unpack(klass_or_nil) if klass_or_nil + end.compact + OpenTelemetry.handle_error(message: "OTLP metrics_exporter received rpc.Status{message=#{status.message}, details=#{details}}") + rescue StandardError => e + OpenTelemetry.handle_error(exception: e, message: 'unexpected error decoding rpc.Status in OTLP::MetricsExporter#log_status') + end + + def self.handle_redirect(location); end + end + end + end +end diff --git a/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb b/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb new file mode 100644 index 0000000000..1fdd01d9fa --- /dev/null +++ b/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb @@ -0,0 +1,600 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 +require 'test_helper' +require 'google/protobuf/wrappers_pb' +require 'google/protobuf/well_known_types' + +describe OpenTelemetry::Exporter::OTLP::MetricsExporter do + METRICS_SUCCESS = OpenTelemetry::SDK::Metrics::Export::SUCCESS + METRICS_FAILURE = OpenTelemetry::SDK::Metrics::Export::FAILURE + METRICS_VERSION = OpenTelemetry::Exporter::OTLP::VERSION + METRICS_DEFAULT_USER_AGENT = OpenTelemetry::Exporter::OTLP::Util::DEFAULT_USER_AGENT + + describe '#initialize' do + it 'initializes with defaults' do + exp = OpenTelemetry::Exporter::OTLP::MetricsExporter.new + _(exp).wont_be_nil + _(exp.instance_variable_get(:@headers)).must_equal('User-Agent' => METRICS_DEFAULT_USER_AGENT) + _(exp.instance_variable_get(:@timeout)).must_equal 10.0 + _(exp.instance_variable_get(:@path)).must_equal '/v1/metrics' + _(exp.instance_variable_get(:@compression)).must_equal 'gzip' + http = exp.instance_variable_get(:@http) + _(http.ca_file).must_be_nil + _(http.use_ssl?).must_equal false + _(http.address).must_equal 'localhost' + _(http.verify_mode).must_equal OpenSSL::SSL::VERIFY_PEER + _(http.port).must_equal 4318 + end + + it 'provides a useful, spec-compliant default user agent header' do + _(METRICS_DEFAULT_USER_AGENT).must_match("OTel-OTLP-MetricsExporter-Ruby/#{VERSION}") + _(METRICS_DEFAULT_USER_AGENT).must_match("Ruby/#{RUBY_VERSION}") + _(METRICS_DEFAULT_USER_AGENT).must_match(RUBY_PLATFORM) + _(METRICS_DEFAULT_USER_AGENT).must_match("#{RUBY_ENGINE}/#{RUBY_ENGINE_VERSION}") + end + + it 'refuses invalid endpoint' do + assert_raises ArgumentError do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new(endpoint: 'not a url') + end + end + + it 'uses endpoints path if provided' do + exp = OpenTelemetry::Exporter::OTLP::MetricsExporter.new(endpoint: 'https://localhost/custom/path') + _(exp.instance_variable_get(:@path)).must_equal '/custom/path' + end + + it 'only allows gzip compression or none' do + assert_raises ArgumentError do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new(compression: 'flate') + end + exp = OpenTelemetry::Exporter::OTLP::MetricsExporter.new(compression: nil) + _(exp.instance_variable_get(:@compression)).must_be_nil + + %w[gzip none].each do |compression| + exp = OpenTelemetry::Exporter::OTLP::MetricsExporter.new(compression: compression) + _(exp.instance_variable_get(:@compression)).must_equal(compression) + end + + [ + { envar: 'OTEL_EXPORTER_OTLP_COMPRESSION', value: 'gzip' }, + { envar: 'OTEL_EXPORTER_OTLP_COMPRESSION', value: 'none' }, + { envar: 'OTEL_EXPORTER_OTLP_METRICS_COMPRESSION', value: 'gzip' }, + { envar: 'OTEL_EXPORTER_OTLP_METRICS_COMPRESSION', value: 'none' } + ].each do |example| + OpenTelemetry::TestHelpers.with_env(example[:envar] => example[:value]) do + exp = OpenTelemetry::Exporter::OTLP::MetricsExporter.new + _(exp.instance_variable_get(:@compression)).must_equal(example[:value]) + end + end + end + + it 'sets parameters from the environment' do + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_ENDPOINT' => 'https://localhost:1234', + 'OTEL_EXPORTER_OTLP_CERTIFICATE' => '/foo/bar', + 'OTEL_EXPORTER_OTLP_HEADERS' => 'a=b,c=d', + 'OTEL_EXPORTER_OTLP_COMPRESSION' => 'gzip', + 'OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE' => 'true', + 'OTEL_EXPORTER_OTLP_TIMEOUT' => '11') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('a' => 'b', 'c' => 'd', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + _(exp.instance_variable_get(:@timeout)).must_equal 11.0 + _(exp.instance_variable_get(:@path)).must_equal '/v1/metrics' + _(exp.instance_variable_get(:@compression)).must_equal 'gzip' + http = exp.instance_variable_get(:@http) + _(http.ca_file).must_equal '/foo/bar' + _(http.use_ssl?).must_equal true + _(http.address).must_equal 'localhost' + _(http.verify_mode).must_equal OpenSSL::SSL::VERIFY_NONE + _(http.port).must_equal 1234 + end + + it 'prefers explicit parameters rather than the environment' do + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_ENDPOINT' => 'https://localhost:1234', + 'OTEL_EXPORTER_OTLP_CERTIFICATE' => '/foo/bar', + 'OTEL_EXPORTER_OTLP_HEADERS' => 'a:b,c:d', + 'OTEL_EXPORTER_OTLP_COMPRESSION' => 'flate', + 'OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_PEER' => 'true', + 'OTEL_EXPORTER_OTLP_TIMEOUT' => '11') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new(endpoint: 'http://localhost:4321', + certificate_file: '/baz', + headers: { 'x' => 'y' }, + compression: 'gzip', + ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE, + timeout: 12) + end + _(exp.instance_variable_get(:@headers)).must_equal('x' => 'y', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + _(exp.instance_variable_get(:@timeout)).must_equal 12.0 + _(exp.instance_variable_get(:@path)).must_equal '' + _(exp.instance_variable_get(:@compression)).must_equal 'gzip' + http = exp.instance_variable_get(:@http) + _(http.ca_file).must_equal '/baz' + _(http.use_ssl?).must_equal false + _(http.verify_mode).must_equal OpenSSL::SSL::VERIFY_NONE + _(http.address).must_equal 'localhost' + _(http.port).must_equal 4321 + end + + it 'appends the correct path if OTEL_EXPORTER_OTLP_ENDPOINT has a trailing slash' do + exp = OpenTelemetry::TestHelpers.with_env( + 'OTEL_EXPORTER_OTLP_ENDPOINT' => 'https://localhost:1234/' + ) do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@path)).must_equal '/v1/metrics' + end + + it 'appends the correct path if OTEL_EXPORTER_OTLP_ENDPOINT does not have a trailing slash' do + exp = OpenTelemetry::TestHelpers.with_env( + 'OTEL_EXPORTER_OTLP_ENDPOINT' => 'https://localhost:1234' + ) do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@path)).must_equal '/v1/metrics' + end + + it 'restricts explicit headers to a String or Hash' do + exp = OpenTelemetry::Exporter::OTLP::MetricsExporter.new(headers: { 'token' => 'über' }) + _(exp.instance_variable_get(:@headers)).must_equal('token' => 'über', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + + exp = OpenTelemetry::Exporter::OTLP::MetricsExporter.new(headers: 'token=%C3%BCber') + _(exp.instance_variable_get(:@headers)).must_equal('token' => 'über', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + + error = _ do + exp = OpenTelemetry::Exporter::OTLP::MetricsExporter.new(headers: Object.new) + _(exp.instance_variable_get(:@headers)).must_equal('token' => 'über') + end.must_raise(ArgumentError) + _(error.message).must_match(/headers/i) + end + + it 'ignores later mutations of a headers Hash parameter' do + a_hash_to_mutate_later = { 'token' => 'über' } + exp = OpenTelemetry::Exporter::OTLP::MetricsExporter.new(headers: a_hash_to_mutate_later) + _(exp.instance_variable_get(:@headers)).must_equal('token' => 'über', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + + a_hash_to_mutate_later['token'] = 'unter' + a_hash_to_mutate_later['oops'] = 'i forgot to add this, too' + _(exp.instance_variable_get(:@headers)).must_equal('token' => 'über', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + end + + describe 'Headers Environment Variable' do + it 'allows any number of the equal sign (=) characters in the value' do + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_HEADERS' => 'a=b,c=d==,e=f') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('a' => 'b', 'c' => 'd==', 'e' => 'f', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_METRICS_HEADERS' => 'a=b,c=d==,e=f') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('a' => 'b', 'c' => 'd==', 'e' => 'f', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + end + + it 'trims any leading or trailing whitespaces in keys and values' do + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_HEADERS' => 'a = b ,c=d , e=f') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('a' => 'b', 'c' => 'd', 'e' => 'f', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_METRICS_HEADERS' => 'a = b ,c=d , e=f') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('a' => 'b', 'c' => 'd', 'e' => 'f', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + end + + it 'decodes values as URL encoded UTF-8 strings' do + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_HEADERS' => 'token=%C3%BCber') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('token' => 'über', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_HEADERS' => '%C3%BCber=token') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('über' => 'token', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_METRICS_HEADERS' => 'token=%C3%BCber') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('token' => 'über', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_METRICS_HEADERS' => '%C3%BCber=token') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('über' => 'token', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + end + + it 'appends the default user agent to one provided in config' do + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_HEADERS' => 'User-Agent=%C3%BCber/3.2.1') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('User-Agent' => "über/3.2.1 #{METRICS_DEFAULT_USER_AGENT}") + end + + it 'prefers METRICS specific variable' do + exp = OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_HEADERS' => 'a=b,c=d==,e=f', 'OTEL_EXPORTER_OTLP_METRICS_HEADERS' => 'token=%C3%BCber') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + _(exp.instance_variable_get(:@headers)).must_equal('token' => 'über', 'User-Agent' => METRICS_DEFAULT_USER_AGENT) + end + + it 'fails fast when header values are missing' do + error = _ do + OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_HEADERS' => 'a = ') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + end.must_raise(ArgumentError) + _(error.message).must_match(/headers/i) + + error = _ do + OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_METRICS_HEADERS' => 'a = ') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + end.must_raise(ArgumentError) + _(error.message).must_match(/headers/i) + end + + it 'fails fast when header or values are not found' do + error = _ do + OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_HEADERS' => ',') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + end.must_raise(ArgumentError) + _(error.message).must_match(/headers/i) + + error = _ do + OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_METRICS_HEADERS' => ',') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + end.must_raise(ArgumentError) + _(error.message).must_match(/headers/i) + end + + it 'fails fast when header values contain invalid escape characters' do + error = _ do + OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_HEADERS' => 'c=hi%F3') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + end.must_raise(ArgumentError) + _(error.message).must_match(/headers/i) + + error = _ do + OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_METRICS_HEADERS' => 'c=hi%F3') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + end.must_raise(ArgumentError) + _(error.message).must_match(/headers/i) + end + + it 'fails fast when headers are invalid' do + error = _ do + OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_HEADERS' => 'this is not a header') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + end.must_raise(ArgumentError) + _(error.message).must_match(/headers/i) + + error = _ do + OpenTelemetry::TestHelpers.with_env('OTEL_EXPORTER_OTLP_METRICS_HEADERS' => 'this is not a header') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + end.must_raise(ArgumentError) + _(error.message).must_match(/headers/i) + end + end + end + + describe 'ssl_verify_mode:' do + it 'can be set to VERIFY_NONE by an envvar' do + exp = OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE' => 'true') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + http = exp.instance_variable_get(:@http) + _(http.verify_mode).must_equal OpenSSL::SSL::VERIFY_NONE + end + + it 'can be set to VERIFY_PEER by an envvar' do + exp = OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_PEER' => 'true') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + http = exp.instance_variable_get(:@http) + _(http.verify_mode).must_equal OpenSSL::SSL::VERIFY_PEER + end + + it 'VERIFY_PEER will override VERIFY_NONE' do + exp = OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE' => 'true', + 'OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_PEER' => 'true') do + OpenTelemetry::Exporter::OTLP::MetricsExporter.new + end + http = exp.instance_variable_get(:@http) + _(http.verify_mode).must_equal OpenSSL::SSL::VERIFY_PEER + end + end + + describe '#export' do + let(:exporter) { OpenTelemetry::Exporter::OTLP::MetricsExporter.new } + let(:meter_provider) { OpenTelemetry::SDK::Metrics::MeterProvider.new(resource: OpenTelemetry::SDK::Resources::Resource.telemetry_sdk) } + + it 'integrates with collector' do + skip unless ENV['TRACING_INTEGRATION_TEST'] + WebMock.disable_net_connect!(allow: 'localhost') + metrics_data = create_metrics_data + exporter = OpenTelemetry::Exporter::OTLP::MetricsExporter.new(endpoint: 'http://localhost:4318', compression: 'gzip') + result = exporter.export([metrics_data]) + _(result).must_equal(METRICS_SUCCESS) + end + + it 'retries on timeout' do + stub_request(:post, 'http://localhost:4318/v1/metrics').to_timeout.then.to_return(status: 200) + metrics_data = create_metrics_data + result = exporter.export([metrics_data]) + _(result).must_equal(METRICS_SUCCESS) + end + + it 'returns TIMEOUT on timeout' do + stub_request(:post, 'http://localhost:4318/v1/metrics').to_return(status: 200) + metrics_data = create_metrics_data + result = exporter.export([metrics_data], timeout: 0) + _(result).must_equal(METRICS_FAILURE) + end + + it 'returns METRICS_FAILURE on unexpected exceptions' do + log_stream = StringIO.new + logger = OpenTelemetry.logger + OpenTelemetry.logger = ::Logger.new(log_stream) + + stub_request(:post, 'http://localhost:4318/v1/metrics').to_raise('something unexpected') + metrics_data = create_metrics_data + result = exporter.export([metrics_data], timeout: 1) + _(log_stream.string).must_match( + /ERROR -- : OpenTelemetry error: unexpected error in OTLP::MetricsExporter#send_bytes - something unexpected/ + ) + + _(result).must_equal(METRICS_FAILURE) + ensure + OpenTelemetry.logger = logger + end + + it 'handles encoding failures' do + log_stream = StringIO.new + logger = OpenTelemetry.logger + OpenTelemetry.logger = ::Logger.new(log_stream) + + stub_request(:post, 'http://localhost:4318/v1/metrics').to_return(status: 200) + metrics_data = create_metrics_data + + Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest.stub(:encode, ->(_) { raise 'a little hell' }) do + _(exporter.export([metrics_data], timeout: 1)).must_equal(METRICS_FAILURE) + end + + _(log_stream.string).must_match( + /ERROR -- : OpenTelemetry error: unexpected error in OTLP::MetricsExporter#encode - a little hell/ + ) + ensure + OpenTelemetry.logger = logger + end + + it 'returns TIMEOUT on timeout after retrying' do + stub_request(:post, 'http://localhost:4318/v1/metrics').to_timeout.then.to_raise('this should not be reached') + metrics_data = create_metrics_data + + @retry_count = 0 + backoff_stubbed_call = lambda do |**_args| + sleep(0.10) + @retry_count += 1 + true + end + + exporter.stub(:backoff?, backoff_stubbed_call) do + _(exporter.export([metrics_data], timeout: 0.1)).must_equal(METRICS_FAILURE) + end + ensure + @retry_count = 0 + end + + it 'returns METRICS_FAILURE when shutdown' do + exporter.shutdown + result = exporter.export(nil) + _(result).must_equal(METRICS_FAILURE) + end + + it 'returns METRICS_FAILURE when encryption to receiver endpoint fails' do + exporter = OpenTelemetry::Exporter::OTLP::MetricsExporter.new(endpoint: 'https://localhost:4318/v1/metrics') + stub_request(:post, 'https://localhost:4318/v1/metrics').to_raise(OpenSSL::SSL::SSLError.new('enigma wedged')) + metrics_data = create_metrics_data + exporter.stub(:backoff?, ->(**_) { false }) do + _(exporter.export([metrics_data])).must_equal(METRICS_FAILURE) + end + end + + it 'exports a metrics_data' do + stub_request(:post, 'http://localhost:4318/v1/metrics').to_return(status: 200) + metrics_data = create_metrics_data + result = exporter.export([metrics_data]) + _(result).must_equal(METRICS_SUCCESS) + end + + it 'handles encoding errors with poise and grace' do + log_stream = StringIO.new + logger = OpenTelemetry.logger + OpenTelemetry.logger = ::Logger.new(log_stream) + + stub_request(:post, 'http://localhost:4318/v1/metrics').to_return(status: 200) + + ndp = OpenTelemetry::SDK::Metrics::Aggregation::NumberDataPoint.new + ndp.attributes = { 'a' => "\xC2".dup.force_encoding(::Encoding::ASCII_8BIT) } + ndp.start_time_unix_nano = 0 + ndp.time_unix_nano = 0 + ndp.value = 1 + + metrics_data = create_metrics_data(data_points: [ndp]) + + result = exporter.export([metrics_data]) + + _(log_stream.string).must_match( + /ERROR -- : OpenTelemetry error: encoding error for key a and value �/ + ) + + _(result).must_equal(METRICS_SUCCESS) + ensure + OpenTelemetry.logger = logger + end + + it 'logs rpc.Status on bad request' do + log_stream = StringIO.new + logger = OpenTelemetry.logger + OpenTelemetry.logger = ::Logger.new(log_stream) + + details = [::Google::Protobuf::Any.pack(::Google::Protobuf::StringValue.new(value: 'you are a bad request'))] + status = ::Google::Rpc::Status.encode(::Google::Rpc::Status.new(code: 1, message: 'bad request', details: details)) + stub_request(:post, 'http://localhost:4318/v1/metrics').to_return(status: 400, body: status, headers: { 'Content-Type' => 'application/x-protobuf' }) + metrics_data = create_metrics_data + + result = exporter.export([metrics_data]) + + _(log_stream.string).must_match( + /ERROR -- : OpenTelemetry error: OTLP metrics_exporter received rpc.Status{message=bad request, details=\[.*you are a bad request.*\]}/ + ) + + _(result).must_equal(METRICS_FAILURE) + ensure + OpenTelemetry.logger = logger + end + + it 'logs a specific message when there is a 404' do + log_stream = StringIO.new + logger = OpenTelemetry.logger + OpenTelemetry.logger = ::Logger.new(log_stream) + + stub_request(:post, 'http://localhost:4318/v1/metrics').to_return(status: 404, body: "Not Found\n") + metrics_data = create_metrics_data + + result = exporter.export([metrics_data]) + + _(log_stream.string).must_match( + %r{ERROR -- : OpenTelemetry error: OTLP metrics_exporter received http\.code=404 for uri: '/v1/metrics'} + ) + + _(result).must_equal(METRICS_FAILURE) + ensure + OpenTelemetry.logger = logger + end + + it 'handles Zlib gzip compression errors' do + stub_request(:post, 'http://localhost:4318/v1/metrics').to_raise(Zlib::DataError.new('data error')) + metrics_data = create_metrics_data + exporter.stub(:backoff?, ->(**_) { false }) do + _(exporter.export([metrics_data])).must_equal(METRICS_FAILURE) + end + end + + it 'exports a metrics' do + stub_post = stub_request(:post, 'http://localhost:4318/v1/metrics').to_return(status: 200) + meter_provider.add_metric_reader(exporter) + meter = meter_provider.meter('test') + counter = meter.create_counter('test_counter', unit: 'smidgen', description: 'a small amount of something') + counter.add(5, attributes: { 'foo' => 'bar' }) + exporter.pull + meter_provider.shutdown + + assert_requested(stub_post) + end + + it 'compresses with gzip if enabled' do + exporter = OpenTelemetry::Exporter::OTLP::MetricsExporter.new(compression: 'gzip') + stub_post = stub_request(:post, 'http://localhost:4318/v1/metrics').to_return do |request| + Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest.decode(Zlib.gunzip(request.body)) + { status: 200 } + end + + metrics_data = create_metrics_data + result = exporter.export([metrics_data]) + + _(result).must_equal(METRICS_SUCCESS) + assert_requested(stub_post) + end + + it 'batches per resource' do + etsr = nil + stub_post = stub_request(:post, 'http://localhost:4318/v1/metrics').to_return do |request| + proto = Zlib.gunzip(request.body) + etsr = Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest.decode(proto) + { status: 200 } + end + + metrics_data1 = create_metrics_data(resource: OpenTelemetry::SDK::Resources::Resource.create('k1' => 'v1')) + metrics_data2 = create_metrics_data(resource: OpenTelemetry::SDK::Resources::Resource.create('k2' => 'v2')) + + result = exporter.export([metrics_data1, metrics_data2]) + + _(result).must_equal(METRICS_SUCCESS) + assert_requested(stub_post) + _(etsr.resource_metrics.length).must_equal(2) + end + + it 'translates all the things' do + skip 'Intermittently fails' if RUBY_ENGINE == 'truffleruby' + + stub_request(:post, 'http://localhost:4318/v1/metrics').to_return(status: 200) + meter_provider.add_metric_reader(exporter) + meter = meter_provider.meter('test') + counter = meter.create_counter('test_counter', unit: 'smidgen', description: 'a small amount of something') + + counter.add(5, attributes: { 'foo' => 'bar' }) + exporter.pull + meter_provider.shutdown + + encoded_etsr = Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest.encode( + Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest.new( + resource_metrics: [ + Opentelemetry::Proto::Metrics::V1::ResourceMetrics.new( + resource: Opentelemetry::Proto::Resource::V1::Resource.new( + attributes: [ + Opentelemetry::Proto::Common::V1::KeyValue.new(key: 'telemetry.sdk.name', value: Opentelemetry::Proto::Common::V1::AnyValue.new(string_value: 'opentelemetry')), + Opentelemetry::Proto::Common::V1::KeyValue.new(key: 'telemetry.sdk.language', value: Opentelemetry::Proto::Common::V1::AnyValue.new(string_value: 'ruby')), + Opentelemetry::Proto::Common::V1::KeyValue.new(key: 'telemetry.sdk.version', value: Opentelemetry::Proto::Common::V1::AnyValue.new(string_value: '1.3.1')) + ] + ), + scope_metrics: [ + Opentelemetry::Proto::Metrics::V1::ScopeMetrics.new( + scope: Opentelemetry::Proto::Common::V1::InstrumentationScope.new( + name: 'test', + version: '' + ), + metrics: [ + Opentelemetry::Proto::Metrics::V1::Metric.new( + name: 'test_counter', + description: 'a small amount of something', + unit: 'smidgen', + sum: Opentelemetry::Proto::Metrics::V1::Sum.new( + data_points: [ + Opentelemetry::Proto::Metrics::V1::NumberDataPoint.new( + attributes: [ + Opentelemetry::Proto::Common::V1::KeyValue.new(key: 'foo', value: Opentelemetry::Proto::Common::V1::AnyValue.new(string_value: 'bar')) + ], + as_int: 5, + start_time_unix_nano: 1_699_593_427_329_946_585, + time_unix_nano: 1_699_593_427_329_946_586, + exemplars: nil + ) + ] + ) + ) + ] + ) + ] + ) + ] + ) + ) + + assert_requested(:post, 'http://localhost:4318/v1/metrics') do |req| + req.body == Zlib.gzip(encoded_etsr) # is asserting that the body of the HTTP request is equal to the result of gzipping the encoded_etsr. + end + end + end +end diff --git a/exporter/otlp/test/test_helper.rb b/exporter/otlp/test/test_helper.rb index 13f3a55dc7..ebf5346fa0 100644 --- a/exporter/otlp/test/test_helper.rb +++ b/exporter/otlp/test/test_helper.rb @@ -15,3 +15,24 @@ require 'webmock/minitest' OpenTelemetry.logger = Logger.new(File::NULL) + +module MockSum + def collect(start_time, end_time) + start_time = 1_699_593_427_329_946_585 # rubocop:disable Lint/ShadowedArgument + end_time = 1_699_593_427_329_946_586 # rubocop:disable Lint/ShadowedArgument + super + end +end + +OpenTelemetry::SDK::Metrics::Aggregation::Sum.prepend(MockSum) + +def create_metrics_data(name: '', description: '', unit: '', instrument_kind: :counter, resource: nil, + instrumentation_scope: OpenTelemetry::SDK::InstrumentationScope.new('', 'v0.0.1'), + data_points: nil, start_time_unix_nano: 0, time_unix_nano: 0) + data_points ||= [OpenTelemetry::SDK::Metrics::Aggregation::NumberDataPoint.new(attributes: {}, start_time_unix_nano: 0, time_unix_nano: 0, value: 1, exemplars: nil)] + resource ||= OpenTelemetry::SDK::Resources::Resource.telemetry_sdk + + OpenTelemetry::SDK::Metrics::State::MetricData.new(name, description, unit, instrument_kind, + resource, instrumentation_scope, data_points, + start_time_unix_nano, time_unix_nano) +end From 10bf5e41422c812f6671486f0ad794c2e9ffd555 Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Fri, 17 Nov 2023 16:42:39 -0500 Subject: [PATCH 2/5] feat: add histogram as part of test case --- .../exporter/otlp/metrics_exporter_test.rb | 28 ++++++++++++++++++- exporter/otlp/test/test_helper.rb | 1 + 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb b/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb index 1fdd01d9fa..2a11f4e876 100644 --- a/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb +++ b/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb @@ -3,6 +3,7 @@ # Copyright The OpenTelemetry Authors # # SPDX-License-Identifier: Apache-2.0 + require 'test_helper' require 'google/protobuf/wrappers_pb' require 'google/protobuf/well_known_types' @@ -543,8 +544,10 @@ meter_provider.add_metric_reader(exporter) meter = meter_provider.meter('test') counter = meter.create_counter('test_counter', unit: 'smidgen', description: 'a small amount of something') - counter.add(5, attributes: { 'foo' => 'bar' }) + + histogram = meter.create_histogram('test_histogram', unit: 'smidgen', description: 'a small amount of something') + histogram.record(10, attributes: {'oof' => 'rab'}) exporter.pull meter_provider.shutdown @@ -583,6 +586,29 @@ ) ] ) + ), + Opentelemetry::Proto::Metrics::V1::Metric.new( + name: 'test_histogram', + description: 'a small amount of something', + unit: 'smidgen', + histogram: Opentelemetry::Proto::Metrics::V1::Histogram.new( + data_points: [ + Opentelemetry::Proto::Metrics::V1::HistogramDataPoint.new( + attributes: [ + Opentelemetry::Proto::Common::V1::KeyValue.new(key: 'oof', value: Opentelemetry::Proto::Common::V1::AnyValue.new(string_value: 'rab')) + ], + start_time_unix_nano: 1_699_593_427_329_946_585, + time_unix_nano: 1_699_593_427_329_946_586, + count: 1, + sum: 10, + bucket_counts: [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0], + explicit_bounds: [0, 5, 10, 25, 50, 75, 100, 250, 500, 1000], + exemplars: nil, + min: 10, + max: 10 + ) + ] + ) ) ] ) diff --git a/exporter/otlp/test/test_helper.rb b/exporter/otlp/test/test_helper.rb index ebf5346fa0..c6263d91ae 100644 --- a/exporter/otlp/test/test_helper.rb +++ b/exporter/otlp/test/test_helper.rb @@ -25,6 +25,7 @@ def collect(start_time, end_time) end OpenTelemetry::SDK::Metrics::Aggregation::Sum.prepend(MockSum) +OpenTelemetry::SDK::Metrics::Aggregation::ExplicitBucketHistogram.prepend(MockSum) def create_metrics_data(name: '', description: '', unit: '', instrument_kind: :counter, resource: nil, instrumentation_scope: OpenTelemetry::SDK::InstrumentationScope.new('', 'v0.0.1'), From 1f1256f01ed7be009d4d564d3db9d9fe93bc6246 Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Tue, 21 Nov 2023 12:08:25 -0500 Subject: [PATCH 3/5] feat: refactor --- .../exporter/otlp/metrics_exporter.rb | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb b/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb index e51d76b3dd..d0753bf2e0 100644 --- a/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb +++ b/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb @@ -215,13 +215,7 @@ def as_otlp_metrics(metrics) # rubocop:disable Metrics/MethodLength, Metrics/Cyc unit: metrics.unit, gauge: Opentelemetry::Proto::Metrics::V1::Gauage.new( data_points: metrics.data_points.map do |ndp| - Opentelemetry::Proto::Metrics::V1::NumberDataPoint.new( - attributes: ndp.attributes.map { |k, v| Util.as_otlp_key_value(k, v) }, - as_int: ndp.value, - start_time_unix_nano: ndp.start_time_unix_nano, - time_unix_nano: ndp.time_unix_nano, - exemplars: ndp.exemplars # exemplars not implemented yet from metrics sdk - ) + number_data_point(ndp) end ) ) @@ -233,13 +227,7 @@ def as_otlp_metrics(metrics) # rubocop:disable Metrics/MethodLength, Metrics/Cyc unit: metrics.unit, sum: Opentelemetry::Proto::Metrics::V1::Sum.new( data_points: metrics.data_points.map do |ndp| - Opentelemetry::Proto::Metrics::V1::NumberDataPoint.new( - attributes: ndp.attributes.map { |k, v| Util.as_otlp_key_value(k, v) }, - as_int: ndp.value, - start_time_unix_nano: ndp.start_time_unix_nano, - time_unix_nano: ndp.time_unix_nano, - exemplars: ndp.exemplars # exemplars not implemented yet from metrics sdk - ) + number_data_point(ndp) end ) ) @@ -251,24 +239,38 @@ def as_otlp_metrics(metrics) # rubocop:disable Metrics/MethodLength, Metrics/Cyc unit: metrics.unit, histogram: Opentelemetry::Proto::Metrics::V1::Histogram.new( data_points: metrics.data_points.map do |hdp| - Opentelemetry::Proto::Metrics::V1::HistogramDataPoint.new( - attributes: hdp.attributes.map { |k, v| Util.as_otlp_key_value(k, v) }, - start_time_unix_nano: hdp.start_time_unix_nano, - time_unix_nano: hdp.time_unix_nano, - count: hdp.count, - sum: hdp.sum, - bucket_counts: hdp.bucket_counts, - explicit_bounds: hdp.explicit_bounds, - exemplars: hdp.exemplars, - min: hdp.min, - max: hdp.max - ) + histogram_data_point(hdp) end ) ) end end + def histogram_data_point(hdp) + Opentelemetry::Proto::Metrics::V1::HistogramDataPoint.new( + attributes: hdp.attributes.map { |k, v| Util.as_otlp_key_value(k, v) }, + start_time_unix_nano: hdp.start_time_unix_nano, + time_unix_nano: hdp.time_unix_nano, + count: hdp.count, + sum: hdp.sum, + bucket_counts: hdp.bucket_counts, + explicit_bounds: hdp.explicit_bounds, + exemplars: hdp.exemplars, + min: hdp.min, + max: hdp.max + ) + end + + def number_data_point(ndp) + Opentelemetry::Proto::Metrics::V1::NumberDataPoint.new( + attributes: ndp.attributes.map { |k, v| Util.as_otlp_key_value(k, v) }, + as_int: ndp.value, + start_time_unix_nano: ndp.start_time_unix_nano, + time_unix_nano: ndp.time_unix_nano, + exemplars: ndp.exemplars # exemplars not implemented yet from metrics sdk + ) + end + def backoff?(retry_count:, reason:, retry_after: nil) Util.backoff?(retry_count: retry_count, reason: reason, retry_after: retry_after) end From cc0b1df90a667d76adbb1a0bdbc2f169fcbfae7d Mon Sep 17 00:00:00 2001 From: xuan-cao-swi Date: Tue, 21 Nov 2023 12:10:37 -0500 Subject: [PATCH 4/5] feat: lint --- .../otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb | 2 +- .../test/opentelemetry/exporter/otlp/metrics_exporter_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb b/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb index d0753bf2e0..2625686d00 100644 --- a/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb +++ b/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb @@ -206,7 +206,7 @@ def encode(metrics_data) # rubocop:disable Metrics/MethodLength, Metrics/Cycloma # current metric sdk only implements instrument: :counter -> :sum, :histogram -> :histogram # # metrics [MetricData] - def as_otlp_metrics(metrics) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity + def as_otlp_metrics(metrics) # rubocop:disable Metrics/MethodLength case metrics.instrument_kind when :observable_gauge Opentelemetry::Proto::Metrics::V1::Metric.new( diff --git a/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb b/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb index 2a11f4e876..d05690ab72 100644 --- a/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb +++ b/exporter/otlp/test/opentelemetry/exporter/otlp/metrics_exporter_test.rb @@ -547,7 +547,7 @@ counter.add(5, attributes: { 'foo' => 'bar' }) histogram = meter.create_histogram('test_histogram', unit: 'smidgen', description: 'a small amount of something') - histogram.record(10, attributes: {'oof' => 'rab'}) + histogram.record(10, attributes: { 'oof' => 'rab' }) exporter.pull meter_provider.shutdown From d255fc988cf2f0063b98985659be5517d1ec026d Mon Sep 17 00:00:00 2001 From: Xuan <112967240+xuan-cao-swi@users.noreply.github.com> Date: Tue, 5 Dec 2023 12:55:44 -0500 Subject: [PATCH 5/5] Update exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb Co-authored-by: Kayla Reopelle (she/her) <87386821+kaylareopelle@users.noreply.github.com> --- .../otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb b/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb index 2625686d00..d5f320f381 100644 --- a/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb +++ b/exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb @@ -71,7 +71,7 @@ def pull export(collect) end - # metrics [Metric Object] + # metrics Array[MetricData] def export(metrics, timeout: nil) @mutex.synchronize do send_bytes(encode(metrics), timeout: timeout)