Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add metrics_exporter for metrics_sdk #1

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions exporter/otlp/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
39 changes: 39 additions & 0 deletions exporter/otlp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions exporter/otlp/lib/opentelemetry/exporter/otlp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
290 changes: 290 additions & 0 deletions exporter/otlp/lib/opentelemetry/exporter/otlp/metrics_exporter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
# 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 Array[MetricData]
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
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|
number_data_point(ndp)
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|
number_data_point(ndp)
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|
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

# may not need this
def reset
SUCCESS
end

def shutdown(timeout: nil)
@shutdown = true
SUCCESS
end
end
end
end
end
Loading
Loading