diff --git a/CHANGELOG.md b/CHANGELOG.md index fd4fc6af2..cf0bef2b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## Unreleased + +### Features + +- Add support for OTLP ingestion in `sentry-opentelemetry` ([#2853](https://github.com/getsentry/sentry-ruby/pull/2853)) + + Sentry now has first class [OTLP ingestion](https://docs.sentry.io/concepts/otlp/) capabilities. + + ```ruby + Sentry.init do |config| + ## ... + config.otlp.enabled = true + end + ``` + + Under the hood, this will setup: + - An `OpenTelemetry::Exporter` that will automatically set up the OTLP ingestion endpoint from your DSN + - You can turn this off with `config.otlp.setup_otlp_traces_exporter = false` to setup your own exporter + - An `OTLPPropagator` that ensures Distributed Tracing works + - You can turn this off with `config.otlp.setup_propagator = false` + - Trace/Span linking for all other Sentry events such as Errors, Logs, Crons and Metrics + + If you were using the `SpanProcessor` before, we recommend migrating over to `config.otlp` since it's a much simpler setup. + ## 6.3.1 ### Bug Fixes diff --git a/sentry-opentelemetry/lib/sentry-opentelemetry.rb b/sentry-opentelemetry/lib/sentry-opentelemetry.rb index 9c9bb7e2f..9f7298a11 100644 --- a/sentry-opentelemetry/lib/sentry-opentelemetry.rb +++ b/sentry-opentelemetry/lib/sentry-opentelemetry.rb @@ -6,3 +6,4 @@ require "sentry/opentelemetry/version" require "sentry/opentelemetry/span_processor" require "sentry/opentelemetry/propagator" +require "sentry/opentelemetry/configuration" diff --git a/sentry-opentelemetry/lib/sentry/opentelemetry/configuration.rb b/sentry-opentelemetry/lib/sentry/opentelemetry/configuration.rb new file mode 100644 index 000000000..dd99a2e0d --- /dev/null +++ b/sentry-opentelemetry/lib/sentry/opentelemetry/configuration.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "sentry/opentelemetry/otlp_setup" + +module Sentry + class Configuration + # OTLP related configuration. + # @return [OTLP::Configuration] + attr_reader :otlp + + after(:initialize) do + @otlp = OTLP::Configuration.new + end + + after(:configured) do + Sentry::OpenTelemetry::OTLPSetup.setup(self) if otlp.enabled + end + end + + module OTLP + class Configuration + attr_accessor :enabled + attr_accessor :setup_otlp_traces_exporter + attr_accessor :setup_propagator + + def initialize + @enabled = false + @setup_otlp_traces_exporter = true + @setup_propagator = true + end + end + end +end diff --git a/sentry-opentelemetry/lib/sentry/opentelemetry/otlp_propagator.rb b/sentry-opentelemetry/lib/sentry/opentelemetry/otlp_propagator.rb new file mode 100644 index 000000000..2dba3670d --- /dev/null +++ b/sentry-opentelemetry/lib/sentry/opentelemetry/otlp_propagator.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Sentry + module OpenTelemetry + class OTLPPropagator < Propagator + def inject( + carrier, + context: ::OpenTelemetry::Context.current, + setter: ::OpenTelemetry::Context::Propagation.text_map_setter + ) + span_context = ::OpenTelemetry::Trace.current_span(context).context + return unless span_context.valid? + + setter.set(carrier, SENTRY_TRACE_HEADER_NAME, to_sentry_trace(span_context)) + + baggage = context[SENTRY_BAGGAGE_KEY] + if baggage.is_a?(Sentry::Baggage) + baggage_string = baggage.serialize + setter.set(carrier, BAGGAGE_HEADER_NAME, baggage_string) unless baggage_string&.empty? + end + end + + private + + def to_sentry_trace(span_context) + sampled = span_context.trace_flags.sampled? ? "1" : "0" + "#{span_context.hex_trace_id}-#{span_context.hex_span_id}-#{sampled}" + end + end + end +end diff --git a/sentry-opentelemetry/lib/sentry/opentelemetry/otlp_setup.rb b/sentry-opentelemetry/lib/sentry/opentelemetry/otlp_setup.rb new file mode 100644 index 000000000..843ca8a00 --- /dev/null +++ b/sentry-opentelemetry/lib/sentry/opentelemetry/otlp_setup.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# +require "sentry/opentelemetry/otlp_propagator" + +module Sentry + module OpenTelemetry + module OTLPSetup + USER_AGENT = "sentry-ruby.otlp/#{Sentry::VERSION}" + + class << self + def setup(config) + @dsn = config.dsn + @sdk_logger = config.sdk_logger + log_debug("[OTLP] Setting up OTLP integration") + + setup_external_propagation_context + setup_otlp_exporter if config.otlp.setup_otlp_traces_exporter + setup_sentry_propagator if config.otlp.setup_propagator + end + + private + + def log_debug(message) + @sdk_logger&.debug(message) + end + + def log_warn(message) + @sdk_logger&.warn(message) + end + + def setup_external_propagation_context + log_debug("[OTLP] Setting up trace linking for all events") + + Sentry.register_external_propagation_context do + span_context = ::OpenTelemetry::Trace.current_span.context + span_context.valid? ? [span_context.hex_trace_id, span_context.hex_span_id] : nil + end + end + + def setup_otlp_exporter + return unless @dsn + + log_debug("[OTLP] Setting up OTLP exporter") + + begin + require "opentelemetry/exporter/otlp" + rescue LoadError + log_warn("[OTLP] opentelemetry-exporter-otlp gem is not installed. " \ + "Please add it to your Gemfile to use the OTLP exporter.") + return + end + + endpoint = "#{@dsn.server}#{@dsn.otlp_traces_endpoint}" + auth_header = @dsn.generate_auth_header(client: USER_AGENT) + + log_debug("[OTLP] Sending traces to #{endpoint}") + + exporter = ::OpenTelemetry::Exporter::OTLP::Exporter.new( + endpoint: endpoint, + headers: { "X-Sentry-Auth" => auth_header } + ) + + span_processor = ::OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter) + ::OpenTelemetry.tracer_provider.add_span_processor(span_processor) + end + + def setup_sentry_propagator + log_debug("[OTLP] Setting up propagator for distributed tracing") + ::OpenTelemetry.propagation = OTLPPropagator.new + end + end + end + end +end diff --git a/sentry-opentelemetry/spec/sentry/opentelemetry/configuration_spec.rb b/sentry-opentelemetry/spec/sentry/opentelemetry/configuration_spec.rb new file mode 100644 index 000000000..292be5080 --- /dev/null +++ b/sentry-opentelemetry/spec/sentry/opentelemetry/configuration_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.describe Sentry::OTLP::Configuration do + subject { described_class.new } + + describe "#initialize" do + it "sets default values" do + expect(subject.enabled).to eq(false) + expect(subject.setup_otlp_traces_exporter).to eq(true) + expect(subject.setup_propagator).to eq(true) + end + end + + describe "accessors" do + it "allows setting enabled" do + subject.enabled = true + expect(subject.enabled).to eq(true) + end + + it "allows setting setup_otlp_traces_exporter" do + subject.setup_otlp_traces_exporter = false + expect(subject.setup_otlp_traces_exporter).to eq(false) + end + + it "allows setting setup_propagator" do + subject.setup_propagator = false + expect(subject.setup_propagator).to eq(false) + end + end +end diff --git a/sentry-opentelemetry/spec/sentry/opentelemetry/otlp_propagator_spec.rb b/sentry-opentelemetry/spec/sentry/opentelemetry/otlp_propagator_spec.rb new file mode 100644 index 000000000..decf01814 --- /dev/null +++ b/sentry-opentelemetry/spec/sentry/opentelemetry/otlp_propagator_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sentry::OpenTelemetry::OTLPPropagator do + let(:tracer) { ::OpenTelemetry.tracer_provider.tracer('sentry', '1.0') } + + before do + perform_basic_setup + perform_otel_setup + end + + describe '#inject' do + let(:carrier) { {} } + + it 'noops with invalid span_context' do + subject.inject(carrier) + expect(carrier).to eq({}) + end + + context 'with valid span' do + it 'sets sentry-trace header on carrier' do + span = tracer.start_root_span('test') + ctx = ::OpenTelemetry::Trace.context_with_span(span) + + subject.inject(carrier, context: ctx) + + span_context = span.context + expected_trace = "#{span_context.hex_trace_id}-#{span_context.hex_span_id}-1" + expect(carrier['sentry-trace']).to eq(expected_trace) + end + + it 'sets sampled flag to 0 when not sampled' do + span = tracer.start_root_span('test') + ctx = ::OpenTelemetry::Trace.context_with_span(span) + + allow(span.context.trace_flags).to receive(:sampled?).and_return(false) + subject.inject(carrier, context: ctx) + + span_context = span.context + expected_trace = "#{span_context.hex_trace_id}-#{span_context.hex_span_id}-0" + expect(carrier['sentry-trace']).to eq(expected_trace) + end + end + + context 'with baggage in context' do + it 'sets baggage header on carrier' do + span = tracer.start_root_span('test') + ctx = ::OpenTelemetry::Trace.context_with_span(span) + + baggage = Sentry::Baggage.new({ + 'trace_id' => 'abc123', + 'public_key' => 'key123' + }) + ctx = ctx.set_value(described_class::SENTRY_BAGGAGE_KEY, baggage) + + subject.inject(carrier, context: ctx) + + expect(carrier['baggage']).to include('sentry-trace_id=abc123') + expect(carrier['baggage']).to include('sentry-public_key=key123') + end + + it 'does not set baggage header when baggage is empty' do + span = tracer.start_root_span('test') + ctx = ::OpenTelemetry::Trace.context_with_span(span) + + baggage = Sentry::Baggage.new({}) + ctx = ctx.set_value(described_class::SENTRY_BAGGAGE_KEY, baggage) + + subject.inject(carrier, context: ctx) + + expect(carrier['baggage']).to be_nil + end + end + end + + describe '#extract' do + let(:ctx) { ::OpenTelemetry::Context.empty } + + it 'returns unchanged context without sentry-trace' do + carrier = {} + updated_ctx = subject.extract(carrier, context: ctx) + expect(updated_ctx).to eq(ctx) + end + + it 'returns unchanged context with invalid sentry-trace' do + carrier = { 'sentry-trace' => '000-000-0' } + updated_ctx = subject.extract(carrier, context: ctx) + expect(updated_ctx).to eq(ctx) + end + + context 'with valid sentry-trace header' do + let(:carrier) do + { 'sentry-trace' => 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1' } + end + + it 'returns context with sentry-trace data' do + updated_ctx = subject.extract(carrier, context: ctx) + + sentry_trace_data = updated_ctx[described_class::SENTRY_TRACE_KEY] + expect(sentry_trace_data).not_to be_nil + + trace_id, parent_span_id, parent_sampled = sentry_trace_data + expect(trace_id).to eq('d4cda95b652f4a1592b449d5929fda1b') + expect(parent_span_id).to eq('6e0c63257de34c92') + expect(parent_sampled).to eq(true) + end + + it 'returns context with correct span_context' do + updated_ctx = subject.extract(carrier, context: ctx) + + span_context = ::OpenTelemetry::Trace.current_span(updated_ctx).context + expect(span_context.valid?).to eq(true) + expect(span_context.hex_trace_id).to eq('d4cda95b652f4a1592b449d5929fda1b') + expect(span_context.hex_span_id).to eq('6e0c63257de34c92') + expect(span_context.remote?).to eq(true) + end + end + + context 'with sentry-trace and baggage headers' do + let(:carrier) do + { + 'sentry-trace' => 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1', + 'baggage' => 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b, sentry-public_key=key123' + } + end + + it 'returns context with baggage' do + updated_ctx = subject.extract(carrier, context: ctx) + + baggage = updated_ctx[described_class::SENTRY_BAGGAGE_KEY] + expect(baggage).to be_a(Sentry::Baggage) + expect(baggage.mutable).to eq(false) + expect(baggage.items['trace_id']).to eq('d4cda95b652f4a1592b449d5929fda1b') + expect(baggage.items['public_key']).to eq('key123') + end + end + end + + describe '#fields' do + it 'returns header names' do + expect(subject.fields).to eq(['sentry-trace', 'baggage']) + end + end +end diff --git a/sentry-opentelemetry/spec/sentry/opentelemetry/otlp_setup_spec.rb b/sentry-opentelemetry/spec/sentry/opentelemetry/otlp_setup_spec.rb new file mode 100644 index 000000000..06d5236e6 --- /dev/null +++ b/sentry-opentelemetry/spec/sentry/opentelemetry/otlp_setup_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sentry::OpenTelemetry::OTLPSetup do + before do + perform_otel_setup + end + + describe '.setup' do + context 'with setup_propagator enabled' do + before do + perform_basic_setup do |config| + config.otlp.enabled = true + config.otlp.setup_propagator = true + end + end + + it 'sets up the OTLP propagator' do + described_class.setup(Sentry.configuration) + + expect(::OpenTelemetry.propagation).to be_a(Sentry::OpenTelemetry::OTLPPropagator) + end + end + + context 'with setup_otlp_traces_exporter enabled' do + before do + perform_basic_setup do |config| + config.otlp.enabled = true + end + end + + it 'logs a warning when opentelemetry-exporter-otlp is not installed' do + allow_any_instance_of(Object).to receive(:require).with("opentelemetry/exporter/otlp").and_raise(LoadError) + + expect(Sentry.configuration.sdk_logger).to receive(:warn).with(/opentelemetry-exporter-otlp gem is not installed/) + described_class.setup(Sentry.configuration) + end + end + + context 'with external propagation context' do + before do + perform_basic_setup do |config| + config.otlp.enabled = true + end + end + + it 'registers external propagation context for trace linking' do + expect(Sentry).to receive(:register_external_propagation_context) + described_class.setup(Sentry.configuration) + end + + context 'when OpenTelemetry span context is valid' do + it 'returns trace_id and span_id from current span' do + described_class.setup(Sentry.configuration) + + tracer = ::OpenTelemetry.tracer_provider.tracer('test') + tracer.in_span('test_span') do + span_context = ::OpenTelemetry::Trace.current_span.context + expected_trace_id = span_context.hex_trace_id + expected_span_id = span_context.hex_span_id + + trace_id, span_id = Sentry.get_external_propagation_context + + expect(trace_id).to eq(expected_trace_id) + expect(span_id).to eq(expected_span_id) + end + end + end + + context 'when OpenTelemetry span context is invalid' do + it 'returns nil' do + described_class.setup(Sentry.configuration) + + result = Sentry.get_external_propagation_context + expect(result).to be_nil + end + end + end + end +end diff --git a/sentry-opentelemetry/spec/spec_helper.rb b/sentry-opentelemetry/spec/spec_helper.rb index 21241a270..e9d82a6ca 100644 --- a/sentry-opentelemetry/spec/spec_helper.rb +++ b/sentry-opentelemetry/spec/spec_helper.rb @@ -39,6 +39,7 @@ config.after :each do reset_sentry_globals! + Sentry::Scope.global_event_processors.clear end end @@ -47,12 +48,15 @@ def perform_basic_setup config.sdk_logger = Logger.new(nil) config.dsn = Sentry::TestHelper::DUMMY_DSN config.transport.transport_class = Sentry::DummyTransport - # so the events will be sent synchronously for testing config.background_worker_threads = 0 config.instrumenter = :otel config.traces_sample_rate = 1.0 yield(config) if block_given? end + + if Sentry.configuration.instrumenter == :otel + Sentry::OpenTelemetry::SpanProcessor.instance.send(:setup_event_processor) + end end def perform_otel_setup diff --git a/sentry-ruby/lib/sentry/dsn.rb b/sentry-ruby/lib/sentry/dsn.rb index 0be43a0e3..71ee00934 100644 --- a/sentry-ruby/lib/sentry/dsn.rb +++ b/sentry-ruby/lib/sentry/dsn.rb @@ -6,6 +6,7 @@ module Sentry class DSN + PROTOCOL_VERSION = "7" PORT_MAP = { "http" => 80, "https" => 443 }.freeze REQUIRED_ATTRIBUTES = %w[host path public_key project_id].freeze LOCALHOST_NAMES = %w[localhost 127.0.0.1 ::1 [::1]].freeze @@ -54,6 +55,10 @@ def envelope_endpoint "#{path}/api/#{project_id}/envelope/" end + def otlp_traces_endpoint + "#{path}/api/#{project_id}/integration/otlp/v1/traces/" + end + def local? @local ||= (localhost? || private_ip? || resolved_ips_private?) end @@ -81,5 +86,20 @@ def resolved_ips_private? end end end + + def generate_auth_header(client: nil) + now = Sentry.utc_now.to_i + + fields = { + "sentry_version" => PROTOCOL_VERSION, + "sentry_timestamp" => now, + "sentry_key" => @public_key + } + + fields["sentry_client"] = client if client + fields["sentry_secret"] = @secret_key if @secret_key + + "Sentry " + fields.map { |key, value| "#{key}=#{value}" }.join(", ") + end end end diff --git a/sentry-ruby/lib/sentry/transport.rb b/sentry-ruby/lib/sentry/transport.rb index 3c2a1ac43..503e6a2ac 100644 --- a/sentry-ruby/lib/sentry/transport.rb +++ b/sentry-ruby/lib/sentry/transport.rb @@ -5,7 +5,7 @@ module Sentry class Transport - PROTOCOL_VERSION = "7" + PROTOCOL_VERSION = DSN::PROTOCOL_VERSION USER_AGENT = "sentry-ruby/#{Sentry::VERSION}" CLIENT_REPORT_INTERVAL = 30 diff --git a/sentry-ruby/lib/sentry/transport/http_transport.rb b/sentry-ruby/lib/sentry/transport/http_transport.rb index ab1591997..47cf6c4e7 100644 --- a/sentry-ruby/lib/sentry/transport/http_transport.rb +++ b/sentry-ruby/lib/sentry/transport/http_transport.rb @@ -69,17 +69,7 @@ def endpoint end def generate_auth_header - return nil unless @dsn - - now = Sentry.utc_now.to_i - fields = { - "sentry_version" => PROTOCOL_VERSION, - "sentry_client" => USER_AGENT, - "sentry_timestamp" => now, - "sentry_key" => @dsn.public_key - } - fields["sentry_secret"] = @dsn.secret_key if @dsn.secret_key - "Sentry " + fields.map { |key, value| "#{key}=#{value}" }.join(", ") + @dsn&.generate_auth_header(client: USER_AGENT) end def conn diff --git a/sentry-ruby/spec/sentry/dsn_spec.rb b/sentry-ruby/spec/sentry/dsn_spec.rb index 04f2b8d11..87e48bceb 100644 --- a/sentry-ruby/spec/sentry/dsn_spec.rb +++ b/sentry-ruby/spec/sentry/dsn_spec.rb @@ -26,6 +26,12 @@ end end + describe "#otlp_traces_endpoint" do + it "assembles correct OTLP traces endpoint" do + expect(subject.otlp_traces_endpoint).to eq("/sentry/api/42/integration/otlp/v1/traces/") + end + end + describe "#server" do it "returns scheme + host" do expect(subject.server).to eq("http://sentry.localdomain:3000") diff --git a/sentry-ruby/spec/sentry/transport/http_transport_spec.rb b/sentry-ruby/spec/sentry/transport/http_transport_spec.rb index 170660b76..4070bddd1 100644 --- a/sentry-ruby/spec/sentry/transport/http_transport_spec.rb +++ b/sentry-ruby/spec/sentry/transport/http_transport_spec.rb @@ -331,8 +331,8 @@ it "generates an auth header" do expect(subject.send(:generate_auth_header)).to eq( - "Sentry sentry_version=7, sentry_client=sentry-ruby/#{Sentry::VERSION}, sentry_timestamp=#{fake_time.to_i}, " \ - "sentry_key=12345, sentry_secret=67890" + "Sentry sentry_version=7, sentry_timestamp=#{fake_time.to_i}, " \ + "sentry_key=12345, sentry_client=sentry-ruby/#{Sentry::VERSION}, sentry_secret=67890" ) end @@ -340,8 +340,8 @@ configuration.server = "https://66260460f09b5940498e24bb7ce093a0@sentry.io/42" expect(subject.send(:generate_auth_header)).to eq( - "Sentry sentry_version=7, sentry_client=sentry-ruby/#{Sentry::VERSION}, sentry_timestamp=#{fake_time.to_i}, " \ - "sentry_key=66260460f09b5940498e24bb7ce093a0" + "Sentry sentry_version=7, sentry_timestamp=#{fake_time.to_i}, " \ + "sentry_key=66260460f09b5940498e24bb7ce093a0, sentry_client=sentry-ruby/#{Sentry::VERSION}" ) end end