From 9e7ace63b34aa8b9efc6ee17ffe729c6dedff464 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 16 Oct 2026 11:54:02 +0100 Subject: [PATCH 1/2] feat: add ambient trace context to exception events Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- .changeset/ambient-exception-traces.md | 5 ++ lib/posthog/client.rb | 18 +++- spec/posthog/client_spec.rb | 113 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 .changeset/ambient-exception-traces.md diff --git a/.changeset/ambient-exception-traces.md b/.changeset/ambient-exception-traces.md new file mode 100644 index 00000000..0ca869f5 --- /dev/null +++ b/.changeset/ambient-exception-traces.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +Add the active OpenTelemetry trace and span IDs to captured exception events when the application provides OpenTelemetry. diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 17eae407..29dd574c 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -415,7 +415,7 @@ def capture_exception(exception, distinct_id = nil, additional_properties = {}, return if exception_list.nil? - properties = { '$exception_list' => exception_list } + properties = { '$exception_list' => exception_list }.merge(active_trace_properties) properties.merge!(additional_properties) if additional_properties && !additional_properties.empty? event_data = { @@ -977,6 +977,22 @@ def shutdown(timeout: nil) private + def active_trace_properties + return {} unless defined?(::OpenTelemetry::Trace) + + context = ::OpenTelemetry::Trace.current_span.context + return {} unless context.valid? + + trace_id = context.hex_trace_id + span_id = context.hex_span_id + return {} unless trace_id.match?(/\A[0-9a-f]{32}\z/) && trace_id != '0' * 32 + return {} unless span_id.match?(/\A[0-9a-f]{16}\z/) && span_id != '0' * 16 + + { '$trace_id' => trace_id, '$span_id' => span_id } + rescue StandardError, LoadError + {} + end + def enrich_capture_attrs_with_context(attrs) context = Internal::Context.current explicit_properties = attrs[:properties] diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index b992eb74..030a9a86 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -2154,6 +2154,119 @@ def release expect(message[:properties]['request_id']).to eq('req-123') end + describe 'ambient trace context' do + let(:trace_api) { Module.new } + let(:trace_id) { '0123456789abcdef0123456789abcdef' } + let(:span_id) { '0123456789abcdef' } + let(:span_context) { double('span context', valid?: true, hex_trace_id: trace_id, hex_span_id: span_id) } + + before do + stub_const('OpenTelemetry::Trace', trace_api) + allow(trace_api).to receive(:current_span).and_return(double('span', context: span_context)) + end + + [ + ['sampled', '0123456789abcdef0123456789abcdef', '0123456789abcdef', true], + ['unsampled', '0123456789abcdef0123456789abcdef', '0123456789abcdef', false], + ['with leading zeros', '00000000000000000000000000000003', '0000000000000004', true] + ].each do |name, trace_id, span_id, sampled| + it "adds IDs from a valid span #{name}" do + allow(span_context).to receive_messages(hex_trace_id: trace_id, hex_span_id: span_id) + allow(span_context).to receive(:trace_flags).and_return(double('trace flags', sampled?: sampled)) + + client.capture_exception(StandardError.new('example error'), 'example-user') + + message = client.dequeue_last_message + expect(message[:event]).to eq('$exception') + expect(message[:properties]).to include('$trace_id' => trace_id, '$span_id' => span_id) + end + end + + [ + ['no active span', false, '0' * 32, '0' * 16], + ['zero trace ID', true, '0' * 32, '0123456789abcdef'], + ['zero span ID', true, '0123456789abcdef0123456789abcdef', '0' * 16], + ['short trace ID', true, '1234', '0123456789abcdef'], + ['non-hex span ID', true, '0123456789abcdef0123456789abcdef', 'not-a-hex-string'] + ].each do |name, valid, trace_id, span_id| + it "captures without derived IDs for #{name}" do + allow(span_context).to receive_messages(valid?: valid, hex_trace_id: trace_id, hex_span_id: span_id) + + client.capture_exception(StandardError.new('example error'), 'example-user') + + message = client.dequeue_last_message + expect(message[:event]).to eq('$exception') + expect(message[:properties]).not_to have_key('$trace_id') + expect(message[:properties]).not_to have_key('$span_id') + end + end + + [StandardError, LoadError].each do |error_class| + it "still captures if reading the span raises #{error_class}" do + allow(trace_api).to receive(:current_span).and_raise(error_class, 'example lookup failure') + + client.capture_exception(StandardError.new('example error'), 'example-user') + + message = client.dequeue_last_message + expect(message[:event]).to eq('$exception') + expect(message[:properties]).not_to have_key('$trace_id') + expect(message[:properties]).not_to have_key('$span_id') + end + end + + it 'keeps caller-supplied properties ahead of derived IDs' do + properties = { '$trace_id' => 'caller-trace', '$span_id' => 'caller-span' }.freeze + + client.capture_exception(StandardError.new('example error'), 'example-user', properties) + + expect(client.dequeue_last_message[:properties]).to include(properties) + end + + it 'still captures when the OpenTelemetry API is not installed' do + hide_const('OpenTelemetry') + + client.capture_exception(StandardError.new('example error'), 'example-user') + + message = client.dequeue_last_message + expect(message[:event]).to eq('$exception') + expect(message[:properties]).not_to have_key('$trace_id') + expect(message[:properties]).not_to have_key('$span_id') + end + end + + describe 'with the real OpenTelemetry API' do + before do + require 'opentelemetry-api' + rescue LoadError + skip 'Install the gemfiles/otel.gemfile bundle to run OpenTelemetry integration' + end + + [0, 1].each do |trace_flags| + it "reads the active span with trace flags #{trace_flags} and clears context after its scope" do + trace_id = '00000000000000000000000000000003' + span_id = '0000000000000004' + context = OpenTelemetry::Trace::SpanContext.new( + trace_id: [trace_id].pack('H*'), + span_id: [span_id].pack('H*'), + trace_flags: OpenTelemetry::Trace::TraceFlags.from_byte(trace_flags) + ) + span = OpenTelemetry::Trace.non_recording_span(context) + + OpenTelemetry::Trace.with_span(span) do + client.capture_exception(StandardError.new('example error'), 'example-user') + end + + expect(client.dequeue_last_message[:properties]).to include('$trace_id' => trace_id, '$span_id' => span_id) + + client.capture_exception(StandardError.new('outside span'), 'example-user') + + properties = client.dequeue_last_message[:properties] + expect(properties).not_to have_key('$trace_id') + expect(properties).not_to have_key('$span_id') + end + end + end + it 'emits the SDK-generated exception timestamp in UTC' do allow(Time).to receive(:now).and_return(Time.new(2024, 7, 16, 13, 30, Rational(123, 1000), '+09:30')) From 5b0177d638701a796c4424fbaa1c93a7c84aeb2e Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 16 Oct 2026 14:24:41 +0100 Subject: [PATCH 2/2] fix: preserve request trace properties on exceptions Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- lib/posthog/client.rb | 5 ++++- spec/posthog/client_spec.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 29dd574c..f304d0bb 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -988,7 +988,10 @@ def active_trace_properties return {} unless trace_id.match?(/\A[0-9a-f]{32}\z/) && trace_id != '0' * 32 return {} unless span_id.match?(/\A[0-9a-f]{16}\z/) && span_id != '0' * 16 - { '$trace_id' => trace_id, '$span_id' => span_id } + context_properties = Internal::Context.current&.properties + { '$trace_id' => trace_id, '$span_id' => span_id }.reject do |key, _| + property_key?(context_properties, key) + end rescue StandardError, LoadError {} end diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index 030a9a86..ad6a38bf 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -2214,6 +2214,33 @@ def release end end + [ + ['string trace only', { '$trace_id' => 'request-trace' }, {}, + { '$trace_id' => 'request-trace', '$span_id' => '0123456789abcdef' }], + ['symbol span only', { :$span_id => 'request-span' }, {}, + { '$trace_id' => '0123456789abcdef0123456789abcdef', :$span_id => 'request-span' }], + ['both string IDs', { '$trace_id' => 'request-trace', '$span_id' => 'request-span' }, {}, + { '$trace_id' => 'request-trace', '$span_id' => 'request-span' }], + ['both symbol IDs', { :$trace_id => 'request-trace', :$span_id => 'request-span' }, {}, + { :$trace_id => 'request-trace', :$span_id => 'request-span' }], + ['nil and empty IDs', { '$trace_id' => nil, :$span_id => '' }, {}, + { '$trace_id' => nil, :$span_id => '' }], + ['explicit string trace ID', { :$trace_id => 'request-trace' }, { '$trace_id' => 'caller-trace' }, + { '$trace_id' => 'caller-trace', '$span_id' => '0123456789abcdef' }], + ['explicit symbol span ID', { '$span_id' => 'request-span' }, { :$span_id => 'caller-span' }, + { '$trace_id' => '0123456789abcdef0123456789abcdef', :$span_id => 'caller-span' }] + ].each do |name, request_properties, caller_properties, expected_ids| + it "keeps request and caller trace properties for #{name}" do + context_class.with_context(properties: request_properties.freeze) do + client.capture_exception(StandardError.new('example error'), 'example-user', caller_properties.freeze) + end + + properties = client.dequeue_last_message[:properties] + actual_ids = properties.select { |key, _| ['$trace_id', '$span_id'].include?(key.to_s) } + expect(actual_ids).to eq(expected_ids) + end + end + it 'keeps caller-supplied properties ahead of derived IDs' do properties = { '$trace_id' => 'caller-trace', '$span_id' => 'caller-span' }.freeze