Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
78 changes: 55 additions & 23 deletions exporter/otlp-common/lib/opentelemetry/exporter/otlp/common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ def as_encoded_etsr(span_data)
nil
end

# As JSON etsr (ExportTraceServiceRequest)
#
# Serializes to spec-compliant OTLP/JSON: trace/span ids as hex strings and
# enum values as integers.
#
# @param [Enumerable<OpenTelemetry::SDK::Trace::SpanData>] span_data the
# list of recorded {OpenTelemetry::SDK::Trace::SpanData} structs to be
# encoded.
#
# @return [String] returns a JSON encoded ETSR of the provided span data
def as_json_etsr(span_data)
as_etsr(span_data, json: true).to_json(format_enums_as_integers: true)
rescue StandardError => e
OpenTelemetry.handle_error(exception: e, message: 'unexpected error in OTLP::Common#as_json_etsr')
nil
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we consolidate this into the existing method with a new protocol parameter to control the difference.

# As etsr (ExportTraceServiceRequest)
#
# @param [Enumerable<OpenTelemetry::SDK::Trace::SpanData>] span_data the
Expand All @@ -43,7 +60,7 @@ def as_encoded_etsr(span_data)
#
# @return [Opentelemetry::Proto::Collector::Trace::V1::ExportTraceServiceRequest]
# returns an ETSR of the provided span data
def as_etsr(span_data)
def as_etsr(span_data, json: false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to avoid a mixture of named & positional arguments so something like

Suggested change
def as_etsr(span_data, json: false)
def as_etsr(span_data, json = false)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like your idea of using a positional argument here.
What do you think about the name format? (either :json or :protobuf); that term keeps the focus squarely on serialization and avoids any confusion with other terminology.

Also, what are your thoughts on making this a named argument for public methods, but keep it positional for private ones?

Opentelemetry::Proto::Collector::Trace::V1::ExportTraceServiceRequest.new(
resource_spans: span_data
.group_by(&:resource)
Expand All @@ -60,7 +77,7 @@ def as_etsr(span_data)
name: il.name,
version: il.version
),
spans: sds.map { |sd| as_otlp_span(sd) }
spans: sds.map { |sd| as_otlp_span(sd, json: json) }
)
end
)
Expand All @@ -70,36 +87,22 @@ def as_etsr(span_data)

private

def as_otlp_span(span_data)
def as_otlp_span(span_data, json: false)
parent_span_id = span_data.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID ? nil : span_data.parent_span_id
Opentelemetry::Proto::Trace::V1::Span.new(
trace_id: span_data.trace_id,
span_id: span_data.span_id,
trace_id: format_id(span_data.trace_id, json: json),
span_id: format_id(span_data.span_id, json: json),
trace_state: span_data.tracestate.to_s,
parent_span_id: span_data.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID ? nil : span_data.parent_span_id,
parent_span_id: format_id(parent_span_id, json: json),
name: span_data.name,
kind: as_otlp_span_kind(span_data.kind),
start_time_unix_nano: span_data.start_timestamp,
end_time_unix_nano: span_data.end_timestamp,
attributes: span_data.attributes&.map { |k, v| as_otlp_key_value(k, v) },
dropped_attributes_count: span_data.total_recorded_attributes - span_data.attributes&.size.to_i,
events: span_data.events&.map do |event|
Opentelemetry::Proto::Trace::V1::Span::Event.new(
time_unix_nano: event.timestamp,
name: event.name,
attributes: event.attributes&.map { |k, v| as_otlp_key_value(k, v) }
# TODO: track dropped_attributes_count in Span#append_event
)
end,
events: span_data.events&.map { |event| as_otlp_span_event(event) },
dropped_events_count: span_data.total_recorded_events - span_data.events&.size.to_i,
links: span_data.links&.map do |link|
Opentelemetry::Proto::Trace::V1::Span::Link.new(
trace_id: link.span_context.trace_id,
span_id: link.span_context.span_id,
trace_state: link.span_context.tracestate.to_s,
attributes: link.attributes&.map { |k, v| as_otlp_key_value(k, v) }
# TODO: track dropped_attributes_count in Span#trim_links
)
end,
links: span_data.links&.map { |link| as_otlp_span_link(link, json: json) },
dropped_links_count: span_data.total_recorded_links - span_data.links&.size.to_i,
status: span_data.status&.then do |status|
Opentelemetry::Proto::Trace::V1::Status.new(
Expand All @@ -110,6 +113,35 @@ def as_otlp_span(span_data)
)
end

def as_otlp_span_event(event)
Opentelemetry::Proto::Trace::V1::Span::Event.new(
time_unix_nano: event.timestamp,
name: event.name,
attributes: event.attributes&.map { |k, v| as_otlp_key_value(k, v) }
# TODO: track dropped_attributes_count in Span#append_event
)
end

def as_otlp_span_link(link, json: false)
Opentelemetry::Proto::Trace::V1::Span::Link.new(
trace_id: format_id(link.span_context.trace_id, json: json),
span_id: format_id(link.span_context.span_id, json: json),
trace_state: link.span_context.tracestate.to_s,
attributes: link.attributes&.map { |k, v| as_otlp_key_value(k, v) }
# TODO: track dropped_attributes_count in Span#trim_links
)
end

# OTLP/JSON requires trace/span ids as hex, but protobuf JSON base64-encodes.
# For the JSON path, applying initial base64 decoding to the hex string
# yields bytes that protobuf re-encodes back into that hex string.
def format_id(id_bytes, json:)
return id_bytes unless json
return id_bytes if id_bytes.nil? || id_bytes.empty?

id_bytes.unpack1('H*').unpack1('m0')
end

def as_otlp_status_code(code)
case code
when OpenTelemetry::Trace::Status::OK then Opentelemetry::Proto::Trace::V1::Status::StatusCode::STATUS_CODE_OK
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Gem::Specification.new do |spec|
spec.required_ruby_version = '>= 3.3'

spec.add_dependency 'googleapis-common-protos-types', '~> 1.3'
spec.add_dependency 'google-protobuf', '~> 3.19'
spec.add_dependency 'google-protobuf', '>= 3.23'
spec.add_dependency 'opentelemetry-api', '~> 1.1'

if spec.respond_to?(:metadata)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,69 @@
end
end

describe '#as_json_etsr' do
it 'encodes ids as hex strings and enums as integers' do
trace_id = OpenTelemetry::Trace.generate_trace_id
span_id = OpenTelemetry::Trace.generate_span_id
parent_span_id = OpenTelemetry::Trace.generate_span_id
span_data = OpenTelemetry::TestHelpers.create_span_data(
kind: :server,
status: OpenTelemetry::Trace::Status.error,
trace_id: trace_id,
span_id: span_id,
parent_span_id: parent_span_id
)

result = OpenTelemetry::Exporter::OTLP::Common.as_json_etsr([span_data])
_(result).must_be_kind_of(String)

span = JSON.parse(result)['resourceSpans'][0]['scopeSpans'][0]['spans'][0]

# Ids must be hex, not base64.
_(span['traceId']).must_match(/\A[0-9a-f]{32}\z/)
_(span['spanId']).must_match(/\A[0-9a-f]{16}\z/)
_(span['parentSpanId']).must_match(/\A[0-9a-f]{16}\z/)
_(span['traceId']).must_equal(trace_id.unpack1('H*'))
_(span['spanId']).must_equal(span_id.unpack1('H*'))
_(span['parentSpanId']).must_equal(parent_span_id.unpack1('H*'))

# Enums must be integers, not name strings.
_(span['kind']).must_be_kind_of(Integer)
_(span['kind']).must_equal(Opentelemetry::Proto::Trace::V1::Span::SpanKind::SPAN_KIND_SERVER)
_(span['status']['code']).must_be_kind_of(Integer)
_(span['status']['code']).must_equal(Opentelemetry::Proto::Trace::V1::Status::StatusCode::STATUS_CODE_ERROR)
end

it 'encodes nested link ids as hex strings' do
OpenTelemetry.tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new
tracer = OpenTelemetry.tracer_provider.tracer('tracer', 'v0.0.1')

trace_id = OpenTelemetry::Trace.generate_trace_id
linked_span_id = OpenTelemetry::Trace.generate_span_id
linked = OpenTelemetry::TestHelpers.with_ids(trace_id, linked_span_id) { tracer.start_root_span('linked') }
span = tracer.start_root_span('span', links: [OpenTelemetry::Trace::Link.new(linked.context)])
span.finish

result = OpenTelemetry::Exporter::OTLP::Common.as_json_etsr([span.to_span_data])
link = JSON.parse(result)['resourceSpans'][0]['scopeSpans'][0]['spans'][0]['links'][0]

_(link['traceId']).must_match(/\A[0-9a-f]{32}\z/)
_(link['spanId']).must_match(/\A[0-9a-f]{16}\z/)
_(link['spanId']).must_equal(linked_span_id.unpack1('H*'))
end

it 'handles errors gracefully' do
OpenTelemetry::TestHelpers.with_test_logger do |log_stream|
span_data = OpenTelemetry::TestHelpers.create_span_data
Opentelemetry::Proto::Collector::Trace::V1::ExportTraceServiceRequest.stub(:new, ->(*) { raise StandardError, 'boom' }) do
result = OpenTelemetry::Exporter::OTLP::Common.as_json_etsr([span_data])
_(result).must_be_nil
_(log_stream.string).must_match(/ERROR -- : OpenTelemetry error: unexpected error in OTLP::Common#as_json_etsr/)
end
end
end
end

describe '#as_etsr' do
it 'handles valid and empty span data' do
# Valid span data
Expand Down
3 changes: 3 additions & 0 deletions exporter/otlp-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,14 @@ The collector exporter can be configured explicitly in code, or via environment
| `headers:` | `OTEL_EXPORTER_OTLP_HEADERS` | |
| `compression:` | `OTEL_EXPORTER_OTLP_COMPRESSION` | `"gzip"` |
| `timeout:` | `OTEL_EXPORTER_OTLP_TIMEOUT` | `10` |
| `protocol:` | `OTEL_EXPORTER_OTLP_PROTOCOL` | `"http/protobuf"` |
| `ssl_verify_mode:` | `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_PEER` or | `OpenSSL::SSL:VERIFY_PEER` |
| | `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` | |

`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.5.1/libdoc/net/http/rdoc/Net/HTTP.html#verify_mode) for more information about these flags.

`protocol:` selects the OTLP encoding: `"http/protobuf"` (the default) sends binary Protobuf, and `"http/json"` sends spec-compliant OTLP/JSON.

## How can I get involved?

The `opentelemetry-exporter-otlp-http` gem source is [on github][repo-github], along with related gems including `opentelemetry-sdk`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ def initialize(endpoint: nil,
ssl_verify_mode: fetch_ssl_verify_mode,
headers: OpenTelemetry::Common::Utilities.config_opt('OTEL_EXPORTER_OTLP_TRACES_HEADERS', 'OTEL_EXPORTER_OTLP_HEADERS', default: {}),
compression: OpenTelemetry::Common::Utilities.config_opt('OTEL_EXPORTER_OTLP_TRACES_COMPRESSION', 'OTEL_EXPORTER_OTLP_COMPRESSION', default: 'gzip'),
timeout: OpenTelemetry::Common::Utilities.config_opt('OTEL_EXPORTER_OTLP_TRACES_TIMEOUT', 'OTEL_EXPORTER_OTLP_TIMEOUT', default: 10))
timeout: OpenTelemetry::Common::Utilities.config_opt('OTEL_EXPORTER_OTLP_TRACES_TIMEOUT', 'OTEL_EXPORTER_OTLP_TIMEOUT', default: 10),
protocol: OpenTelemetry::Common::Utilities.config_opt('OTEL_EXPORTER_OTLP_TRACES_PROTOCOL', 'OTEL_EXPORTER_OTLP_PROTOCOL', default: 'http/protobuf'))
raise ArgumentError, "unsupported compression key #{compression}" unless compression.nil? || %w[gzip none].include?(compression)
raise ArgumentError, "unsupported protocol #{protocol}" unless %w[http/json http/protobuf].include?(protocol)

@uri = prepare_endpoint(endpoint)

Expand All @@ -48,6 +50,7 @@ def initialize(endpoint: nil,
@headers = prepare_headers(headers)
@timeout = timeout.to_f
@compression = compression
@content_type = protocol == 'http/json' ? 'application/json' : 'application/x-protobuf'
@shutdown = false
end

Expand All @@ -61,7 +64,12 @@ def initialize(endpoint: nil,
def export(span_data, timeout: nil)
return FAILURE if @shutdown

send_bytes(OpenTelemetry::Exporter::OTLP::Common.as_encoded_etsr(span_data), timeout: timeout)
bytes = if @content_type == 'application/json'
OpenTelemetry::Exporter::OTLP::Common.as_json_etsr(span_data)
else
OpenTelemetry::Exporter::OTLP::Common.as_encoded_etsr(span_data)
end
send_bytes(bytes, timeout: timeout)
Comment on lines -64 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update based on above

end

# Called when {OpenTelemetry::SDK::Trace::TracerProvider#force_flush} is called, if
Expand Down Expand Up @@ -135,7 +143,7 @@ def send_bytes(bytes, timeout:) # rubocop:disable Metrics/MethodLength
bytes
end
request.body = body
request.add_field('Content-Type', 'application/x-protobuf')
request.add_field('Content-Type', @content_type)
@headers.each { |key, value| request.add_field(key, value) }

remaining_timeout = OpenTelemetry::Common::Utilities.maybe_timeout(timeout, start_time)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,36 @@
end
end

it 'only allows http/protobuf or http/json protocol' do
assert_raises ArgumentError do
OpenTelemetry::Exporter::OTLP::HTTP::TraceExporter.new(protocol: 'grpc')
end

%w[http/protobuf http/json].each do |protocol|
exp = OpenTelemetry::Exporter::OTLP::HTTP::TraceExporter.new(protocol: protocol)
expected_content_type = protocol == 'http/protobuf' ? 'application/x-protobuf' : 'application/json'
_(exp.instance_variable_get(:@content_type)).must_equal(expected_content_type)
end

[
{ envar: 'OTEL_EXPORTER_OTLP_PROTOCOL', value: 'http/protobuf' },
{ envar: 'OTEL_EXPORTER_OTLP_PROTOCOL', value: 'http/json' },
{ envar: 'OTEL_EXPORTER_OTLP_TRACES_PROTOCOL', value: 'http/protobuf' },
{ envar: 'OTEL_EXPORTER_OTLP_TRACES_PROTOCOL', value: 'http/json' }
].each do |example|
OpenTelemetry::TestHelpers.with_env(example[:envar] => example[:value]) do
exp = OpenTelemetry::Exporter::OTLP::HTTP::TraceExporter.new
expected_content_type = example[:value] == 'http/protobuf' ? 'application/x-protobuf' : 'application/json'
_(exp.instance_variable_get(:@content_type)).must_equal(expected_content_type)
end
end
end

it 'defaults to application/x-protobuf content type' do
exp = OpenTelemetry::Exporter::OTLP::HTTP::TraceExporter.new
_(exp.instance_variable_get(:@content_type)).must_equal('application/x-protobuf')
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',
Expand Down Expand Up @@ -569,6 +599,32 @@
_(result).must_equal(success)
end

it 'encodes as spec-compliant JSON when protocol is http/json' do
exp = OpenTelemetry::Exporter::OTLP::HTTP::TraceExporter.new(protocol: 'http/json', compression: 'none')
trace_id = OpenTelemetry::Trace.generate_trace_id
span_id = OpenTelemetry::Trace.generate_span_id
parsed = nil
stub_post = stub_request(:post, 'http://localhost:4318/v1/traces').to_return do |request|
_(request.headers['Content-Type']).must_equal('application/json')
parsed = JSON.parse(request.body)
{ status: 200 }
end

span_data = OpenTelemetry::TestHelpers.create_span_data(kind: :server, status: OpenTelemetry::Trace::Status.error, trace_id: trace_id, span_id: span_id)
result = exp.export([span_data])

_(result).must_equal(success)
assert_requested(stub_post)

span = parsed['resourceSpans'][0]['scopeSpans'][0]['spans'][0]
_(span['traceId']).must_equal(trace_id.unpack1('H*'))
_(span['spanId']).must_equal(span_id.unpack1('H*'))
_(span['traceId']).must_match(/\A[0-9a-f]{32}\z/)
_(span['spanId']).must_match(/\A[0-9a-f]{16}\z/)
_(span['kind']).must_be_kind_of(Integer)
_(span['status']['code']).must_be_kind_of(Integer)
end

it 'handles encoding errors with poise and grace' do
log_stream = StringIO.new
logger = OpenTelemetry.logger
Expand Down
3 changes: 3 additions & 0 deletions exporter/otlp-logs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,14 @@ The collector exporter can be configured explicitly in code, or via environment
| `headers:` | `OTEL_EXPORTER_OTLP_HEADERS` | |
| `compression:` | `OTEL_EXPORTER_OTLP_COMPRESSION` | `"gzip"` |
| `timeout:` | `OTEL_EXPORTER_OTLP_TIMEOUT` | `10` |
| `protocol:` | `OTEL_EXPORTER_OTLP_PROTOCOL` | `"http/protobuf"` |
| `ssl_verify_mode:` | `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_PEER` or | `OpenSSL::SSL:VERIFY_PEER` |
| | `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` | |

`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.

`protocol:` selects the OTLP encoding: `"http/protobuf"` (the default) sends binary Protobuf, and `"http/json"` sends spec-compliant OTLP/JSON.

## How can I get involved?

The `opentelemetry-exporter-otlp-logs` gem source is [on github][repo-github], along with related gems including `opentelemetry-logs-sdk`.
Expand Down
Loading