diff --git a/.changeset/quiet-rails-spans.md b/.changeset/quiet-rails-spans.md new file mode 100644 index 00000000..f9813583 --- /dev/null +++ b/.changeset/quiet-rails-spans.md @@ -0,0 +1,6 @@ +--- +"posthog-ruby": minor +"posthog-rails": patch +--- + +Add an optional context span processor for application OpenTelemetry spans. Rails request context adds session and person IDs to the active recording request span and preserves explicit span attributes. diff --git a/lib/posthog/internal/context.rb b/lib/posthog/internal/context.rb index ef30c03e..c3ed4472 100644 --- a/lib/posthog/internal/context.rb +++ b/lib/posthog/internal/context.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'posthog/internal/span_context' + module PostHog module Internal # Internal request/fiber-local context applied to capture calls. @@ -12,10 +14,12 @@ module Internal # server-side SDK globally stateful per user. class Context STORAGE_KEY = :posthog_context + UNBOUND_OWNER = Object.new.freeze - attr_reader :distinct_id, :session_id, :properties + attr_reader :distinct_id, :session_id, :properties, :owner - def initialize(distinct_id: nil, session_id: nil, properties: {}) + def initialize(distinct_id: nil, session_id: nil, properties: {}, owner: UNBOUND_OWNER) + @owner = owner @distinct_id = distinct_id @session_id = session_id @properties = properties ? properties.dup : {} @@ -38,17 +42,18 @@ def self.current=(context) end end - def self.with_context(data = nil, fresh: false, **kwargs) + def self.with_context(data = nil, fresh: false, owner: UNBOUND_OWNER, **kwargs) previous_context = current raise ArgumentError, 'with_context requires a block' unless block_given? - self.current = resolve(merge_data_and_kwargs(data, kwargs), previous_context, fresh: fresh) + self.current = resolve(merge_data_and_kwargs(data, kwargs), previous_context, fresh: fresh, owner: owner) + SpanContext.apply_current yield ensure self.current = previous_context end - def self.resolve(data, parent, fresh: false) + def self.resolve(data, parent, fresh: false, owner: UNBOUND_OWNER) data = normalize_data(data) parent_properties = fresh || parent.nil? ? {} : parent.properties @@ -61,7 +66,8 @@ def self.resolve(data, parent, fresh: false) new( distinct_id: data[:distinct_id] || (fresh || parent.nil? ? nil : parent.distinct_id), session_id: data[:session_id] || (fresh || parent.nil? ? nil : parent.session_id), - properties: properties + properties: properties, + owner: owner.equal?(UNBOUND_OWNER) && parent ? parent.owner : owner ) end private_class_method :resolve diff --git a/lib/posthog/internal/span_context.rb b/lib/posthog/internal/span_context.rb new file mode 100644 index 00000000..9536146f --- /dev/null +++ b/lib/posthog/internal/span_context.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PostHog + module Internal + class SpanContext + def self.apply(span) + context = Context.current + return unless context && span&.recording? && span.respond_to?(:attributes) + + return unless context.owner.equal?(Context::UNBOUND_OWNER) || context.owner&.enabled? + + attributes = span.attributes || {} + { + 'posthogDistinctId' => context.distinct_id, + 'sessionId' => context.session_id + }.each do |key, value| + next if value.nil? || value == '' || attributes.key?(key) + + span.set_attribute(key, value.is_a?(String) ? value.dup.freeze : value) + end + rescue StandardError + nil + end + + def self.apply_current + return unless defined?(::OpenTelemetry::Trace) + + apply(::OpenTelemetry::Trace.current_span) + rescue StandardError + nil + end + end + end +end diff --git a/lib/posthog/otel/context_span_processor.rb b/lib/posthog/otel/context_span_processor.rb new file mode 100644 index 00000000..37d0906b --- /dev/null +++ b/lib/posthog/otel/context_span_processor.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'posthog/internal/context' + +module PostHog + class ContextSpanProcessor + def on_start(span, _parent_context) + Internal::SpanContext.apply(span) + end + + def on_finish(_span); end + + def force_flush(**_options) + 0 + end + + def shutdown(**_options) + 0 + end + end +end diff --git a/posthog-rails/README.md b/posthog-rails/README.md index ade0cfdd..2e655920 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -9,6 +9,16 @@ SDK usage examples and code snippets live in the official documentation so they - [Ruby on Rails framework docs](https://posthog.com/docs/libraries/ruby-on-rails) - [Ruby library docs](https://posthog.com/docs/libraries/ruby) +## OpenTelemetry span context (optional) + +Request context adds missing `sessionId` and `posthogDistinctId` attributes to an active recording OpenTelemetry SDK span. Register the optional `PostHog::ContextSpanProcessor` on the application's existing provider for child spans. OpenTelemetry SDK 1.0.0 or later is supported and remains optional. + +The request keeps the Rails facade client that owned it at entry. A missing or disabled owner adds no PostHog span attributes. A valid-key test-mode client remains enabled. + +Existing canonical attributes take precedence. Missing IDs stay absent. Spans keep their captured IDs after the request ends. Separate threads, fibers, and jobs need explicit context transfer; concurrent Rails fibers require fiber-isolated execution state. + +See the [Rails trace context guide](https://posthog.com/docs/libraries/ruby-on-rails#trace-context) for setup, middleware order, and trust limits. The bridge does not replace the application's provider, exporter, or sampler. Optional Rails log forwarding uses the same request context. + ## PostHog Logs (optional) `posthog-rails` can forward `Rails.logger` output to [PostHog Logs](https://posthog.com/docs/logs) diff --git a/posthog-rails/lib/posthog/rails/request_context.rb b/posthog-rails/lib/posthog/rails/request_context.rb index d94fec23..5aa8b391 100644 --- a/posthog-rails/lib/posthog/rails/request_context.rb +++ b/posthog-rails/lib/posthog/rails/request_context.rb @@ -20,7 +20,7 @@ def initialize(app) def call(env) request = build_request(env) - Internal::Context.with_context(context_data(request), fresh: true) do + Internal::Context.with_context(context_data(request), fresh: true, owner: PostHog.client) do @app.call(env) end end diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 16f70bd6..277f876a 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -28,6 +28,11 @@ instance_method PostHog::Client#reload_feature_flags() instance_method PostHog::Client#shutdown(timeout: ...) class_method PostHog::Client.logger() class_method PostHog::Client.reset_instance_tracking!() +class PostHog::ContextSpanProcessor +instance_method PostHog::ContextSpanProcessor#force_flush(**_options) +instance_method PostHog::ContextSpanProcessor#on_finish(_span) +instance_method PostHog::ContextSpanProcessor#on_start(span, _parent_context) +instance_method PostHog::ContextSpanProcessor#shutdown(**_options) module PostHog::Defaults module PostHog::Defaults::BackoffPolicy constant PostHog::Defaults::BackoffPolicy::MAX_TIMEOUT_MS: Integer diff --git a/scripts/public_api_snapshot.rb b/scripts/public_api_snapshot.rb index cdb9bf89..5a531795 100644 --- a/scripts/public_api_snapshot.rb +++ b/scripts/public_api_snapshot.rb @@ -62,6 +62,7 @@ def load_core_sdk require 'posthog' # Opt-in integration shipped in the core gem; loaded here so its public surface is tracked too. require 'posthog/mcp' + require 'posthog/otel/context_span_processor' end def captured_core_api diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index 7dc278ec..9d734444 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -2185,10 +2185,11 @@ def release 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) } + let(:span) { double('span', context: span_context, recording?: false) } before do stub_const('OpenTelemetry::Trace', trace_api) - allow(trace_api).to receive(:current_span).and_return(double('span', context: span_context)) + allow(trace_api).to receive(:current_span).and_return(span) end [ diff --git a/spec/posthog/context_span_processor_spec.rb b/spec/posthog/context_span_processor_spec.rb new file mode 100644 index 00000000..5a074818 --- /dev/null +++ b/spec/posthog/context_span_processor_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' +require 'posthog/otel/context_span_processor' + +RSpec.describe PostHog::ContextSpanProcessor do + it 'loads and runs without loading optional OpenTelemetry gems' do + script = <<~RUBY + require 'posthog' + require 'posthog/otel/context_span_processor' + context = PostHog.const_get(:Internal).const_get(:Context) + context.with_context(distinct_id: 'local-person', session_id: 'local-session') do + raise 'context missing' unless context.current.distinct_id == 'local-person' + end + raise 'context leaked' unless context.current.nil? + raise 'OpenTelemetry loaded' if defined?(OpenTelemetry) + processor = PostHog::ContextSpanProcessor.new + raise 'flush failed' unless processor.force_flush(timeout: 1) == 0 + raise 'shutdown failed' unless processor.shutdown(timeout: 1) == 0 + RUBY + output, status = Open3.capture2e(RbConfig.ruby, '-Ilib', '-e', script) + + expect(status.success?).to be(true), output + end + + it 'does not let a failed context lookup break application tracing' do + context = PostHog.const_get(:Internal).const_get(:Context) + allow(context).to receive(:current).and_raise('context unavailable') + + expect { described_class.new.on_start(nil, nil) }.not_to raise_error + end +end diff --git a/spec/posthog/rails/span_context_spec.rb b/spec/posthog/rails/span_context_spec.rb new file mode 100644 index 00000000..3d24f653 --- /dev/null +++ b/spec/posthog/rails/span_context_spec.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rails' +require 'rails/railtie' +require 'action_dispatch' +require 'rack/mock' + +$LOAD_PATH.unshift File.expand_path('../../../posthog-rails/lib', __dir__) +require 'posthog/rails' +require 'posthog/otel/context_span_processor' + +otel_available = + begin + require 'opentelemetry-sdk' + true + rescue LoadError + false + end + +logs_available = + begin + require 'opentelemetry-logs-sdk' + true + rescue LoadError + false + end + +RSpec.describe 'Rails span context' do + before { skip 'Install the gemfiles/otel.gemfile bundle to run span integration' unless otel_available } + + let(:context_class) { PostHog.const_get(:Internal).const_get(:Context) } + let(:exporter) { OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new } + let(:provider) do + OpenTelemetry::SDK::Trace::TracerProvider.new.tap do |instance| + instance.add_span_processor(PostHog::ContextSpanProcessor.new) + instance.add_span_processor(OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)) + end + end + let(:tracer) { provider.tracer('posthog-rails-test') } + let(:headers) do + { + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'browser-person', + 'HTTP_X_POSTHOG_SESSION_ID' => 'browser-session' + } + end + + around do |example| + previous_config = PostHog::Rails.config + previous_client = PostHog.client + PostHog::Rails.config = PostHog::Rails::Configuration.new + example.run + ensure + PostHog::Rails.config = previous_config + PostHog.client&.shutdown + PostHog.client = previous_client + end + + before { PostHog.client = PostHog::Client.new(api_key: API_KEY, test_mode: true) } + + after { provider.shutdown if otel_available && defined?(OpenTelemetry::SDK) } + + def request(headers = {}, &block) + app = lambda do |_env| + block.call + [200, { 'content-type' => 'text/plain' }, ['ok']] + end + PostHog::Rails::RequestContext.new(app).call(Rack::MockRequest.env_for('/request', headers)) + end + + it 'does not enrich Rails requests before the facade has a client' do + PostHog.client.shutdown + PostHog.client = nil + tracer.in_span('request') { request(headers) { tracer.in_span('child') { nil } } } + exporter.finished_spans.each { |span| expect(span.attributes || {}).to be_empty } + expect(context_class.current).to be_nil + end + + [nil, '', API_KEY].each do |key| + [false, true].each do |raises| + it "respects the request owner with key #{key.inspect}, failure=#{raises}" do + PostHog.client.shutdown + owner = PostHog::Client.new(api_key: key, test_mode: true, silence_disabled_client_error: true) + replacement = PostHog::Client.new(api_key: key == API_KEY ? nil : API_KEY, + test_mode: true, silence_disabled_client_error: true) + PostHog.client = owner + tracer.in_span('request', attributes: { 'app.key' => 'keep' }) do + invoke = lambda do + request(headers) do + PostHog.client = replacement + context_class.with_context(distinct_id: 'nested-person', fresh: true) do + tracer.in_span('child', attributes: { 'sessionId' => 'application-session' }) { nil } + end + raise 'request failed' if raises + end + end + raises ? expect(&invoke).to(raise_error('request failed')) : invoke.call + expect(context_class.current).to be_nil + end + spans = exporter.finished_spans.to_h { |span| [span.name, span.attributes] } + if owner.enabled? + expect(spans['request']).to include('posthogDistinctId' => 'browser-person', 'sessionId' => 'browser-session') + expect(spans['child']).to include('posthogDistinctId' => 'nested-person', + 'sessionId' => 'application-session') + else + expect(spans['request']).to eq('app.key' => 'keep') + expect(spans['child']).to eq('sessionId' => 'application-session') + end + context_class.with_context(distinct_id: 'independent-person') { tracer.in_span('independent') { nil } } + expect(exporter.finished_spans.last.attributes).to include('posthogDistinctId' => 'independent-person') + ensure + owner&.shutdown + replacement&.shutdown + end + end + end + + [false, true].each do |raises| + it "stamps the existing request span and clears context after #{raises ? 'failure' : 'success'}" do + tracer.in_span('request') do + invoke = lambda do + request(headers) { raise 'request failed' if raises } + end + raises ? expect(&invoke).to(raise_error('request failed')) : invoke.call + expect(context_class.current).to be_nil + end + + expect(exporter.finished_spans.first.attributes).to include( + 'posthogDistinctId' => 'browser-person', + 'sessionId' => 'browser-session' + ) + end + end + + [ + { 'posthogDistinctId' => 'application-person' }, + { 'sessionId' => 'application-session' }, + { 'posthogDistinctId' => 'application-person', 'sessionId' => 'application-session' } + ].each do |attributes| + it "preserves explicit attributes #{attributes.keys.inspect}" do + tracer.in_span('request', attributes: attributes) do + request(headers) { tracer.in_span('child', attributes: attributes) { nil } } + end + + exporter.finished_spans.each do |span| + expect(span.attributes).to include( + { 'posthogDistinctId' => 'browser-person', 'sessionId' => 'browser-session' }.merge(attributes) + ) + end + end + end + + [ + [{}, true], + [{ 'HTTP_X_POSTHOG_SESSION_ID' => " \u0000\t ", 'HTTP_X_POSTHOG_DISTINCT_ID' => '' }, true], + [{ 'HTTP_X_POSTHOG_SESSION_ID' => 'disabled', 'HTTP_X_POSTHOG_DISTINCT_ID' => 'disabled' }, false] + ].each do |input, enabled| + it "omits missing, invalid, or disabled IDs #{[input, enabled].inspect}" do + PostHog::Rails.config.use_tracing_headers = enabled + tracer.in_span('request') { request(input) { tracer.in_span('child') { nil } } } + + exporter.finished_spans.each do |span| + expect(span.attributes || {}).not_to have_key('posthogDistinctId') + expect(span.attributes || {}).not_to have_key('sessionId') + end + end + end + + it 'omits empty explicit context IDs' do + context_class.with_context(distinct_id: '', session_id: '') do + tracer.in_span('child') { nil } + end + + expect(exporter.finished_spans.first.attributes || {}).to be_empty + end + + it 'captures child context at start and keeps it after the request ends' do + child = nil + request(headers) do + child = tracer.start_span('child') + context_class.current.distinct_id.replace('changed-person') + context_class.current.session_id.replace('changed-session') + end + request('HTTP_X_POSTHOG_DISTINCT_ID' => 'other-person') { child.finish } + tracer.in_span('outside') { nil } + + spans = exporter.finished_spans.to_h { |span| [span.name, span] } + expect(spans['child'].attributes).to include( + 'posthogDistinctId' => 'browser-person', 'sessionId' => 'browser-session' + ) + expect(spans['outside'].attributes || {}).to be_empty + end + + it 'keeps two concurrent request contexts separate' do + started = Queue.new + proceed = Queue.new + threads = %w[first second].map do |name| + Thread.new do + tracer.in_span(name) do + request('HTTP_X_POSTHOG_DISTINCT_ID' => name, 'HTTP_X_POSTHOG_SESSION_ID' => "session-#{name}") do + started << true + proceed.pop + tracer.in_span("child-#{name}") { nil } + end + expect(context_class.current).to be_nil + end + end + end + 2.times { started.pop } + 2.times { proceed << true } + threads.each(&:value) + + exporter.finished_spans.each do |span| + name = span.name.delete_prefix('child-') + expect(span.attributes).to include('posthogDistinctId' => name, 'sessionId' => "session-#{name}") + end + expect(context_class.current).to be_nil + end + + it 'keeps interleaved requests separate with Rails fiber isolation' do + previous_isolation = ActiveSupport::IsolatedExecutionState.isolation_level + ActiveSupport::IsolatedExecutionState.isolation_level = :fiber + fibers = %w[first second].map do |name| + Fiber.new do + tracer.in_span(name) do + request('HTTP_X_POSTHOG_DISTINCT_ID' => name) do + Fiber.yield + tracer.in_span("child-#{name}") { nil } + end + expect(context_class.current).to be_nil + end + end + end + fibers.each(&:resume) + fibers.each(&:resume) + + exporter.finished_spans.each do |span| + expect(span.attributes).to include('posthogDistinctId' => span.name.delete_prefix('child-')) + end + ensure + ActiveSupport::IsolatedExecutionState.isolation_level = previous_isolation + end + + [false, true].each do |lookup_raises| + it "preserves normal requests when span lookup #{lookup_raises ? 'fails' : 'is unavailable'}" do + if lookup_raises + allow(OpenTelemetry::Trace).to receive(:current_span).and_raise('tracing unavailable') + else + hide_const('OpenTelemetry') + end + expect(request(headers) { nil }.first).to eq(200) + expect(context_class.current).to be_nil + end + end + + it 'leaves sampled-out request spans unchanged' do + provider.sampler = OpenTelemetry::SDK::Trace::Samplers::ALWAYS_OFF + tracer.in_span('request') { |span| request(headers) { expect(span).not_to be_recording } } + + expect(exporter.finished_spans).to be_empty + end + + it 'forwards logs with the same session, person, and active trace as the request span' do + skip 'Install the gemfiles/otel.gemfile bundle to run log integration' unless logs_available + + logs_exporter = OpenTelemetry::SDK::Logs::Export::InMemoryLogRecordExporter.new + logs_provider = OpenTelemetry::SDK::Logs::LoggerProvider.new + logs_provider.add_log_record_processor( + OpenTelemetry::SDK::Logs::Export::SimpleLogRecordProcessor.new(logs_exporter) + ) + appender = PostHog::Rails::Logs::Appender.new(logs_provider.logger(name: 'posthog-rails-test')) + tracer.in_span('request') { request(headers) { appender.info('request processed') } } + appender.info('outside request') + + span = exporter.finished_spans.first + record, outside = logs_exporter.emitted_log_records + expect(record.attributes).to include(span.attributes) + expect(record.trace_id).to eq(span.trace_id) + expect(record.span_id).to eq(span.span_id) + expect(outside.attributes).not_to have_key('posthogDistinctId') + expect(outside.attributes).not_to have_key('sessionId') + ensure + logs_provider&.shutdown + end +end