From c6b5d8ab933a7bfc16259ab5d51206f44113e55d Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 5 May 2026 15:42:32 -0400 Subject: [PATCH 01/10] fix: align request context behavior --- lib/posthog.rb | 1 + lib/posthog/client.rb | 63 ++++++++++++++- lib/posthog/context.rb | 154 +++++++++++++++++++++++++++++++++++ spec/posthog/client_spec.rb | 157 +++++++++++++++++++++++++++++++++++- 4 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 lib/posthog/context.rb diff --git a/lib/posthog.rb b/lib/posthog.rb index d4227e6..0ced8f3 100644 --- a/lib/posthog.rb +++ b/lib/posthog.rb @@ -10,6 +10,7 @@ require 'posthog/response' require 'posthog/logging' require 'posthog/exception_capture' +require 'posthog/context' require 'posthog/feature_flag_error' require 'posthog/feature_flag_result' require 'posthog/feature_flag_evaluations' diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 16b1f78..8abc5e4 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -14,6 +14,7 @@ require 'posthog/feature_flag_evaluations' require 'posthog/send_feature_flags_options' require 'posthog/exception_capture' +require 'posthog/context' module PostHog class Client @@ -188,6 +189,7 @@ def clear # @macro common_attrs def capture(attrs) symbolize_keys! attrs + apply_context_to_capture_attrs!(attrs) # Precedence: an explicit `flags` snapshot always wins, regardless of # `send_feature_flags`. The snapshot guarantees the event carries the same @@ -270,12 +272,8 @@ def capture_exception(exception, distinct_id = nil, additional_properties = {}, return if exception_info.nil? - no_distinct_id_was_provided = distinct_id.nil? - distinct_id ||= SecureRandom.uuid - properties = { '$exception_list' => [exception_info] } properties.merge!(additional_properties) if additional_properties && !additional_properties.empty? - properties['$process_person_profile'] = false if no_distinct_id_was_provided event_data = { distinct_id: distinct_id, @@ -294,6 +292,30 @@ def capture_exception(exception, distinct_id = nil, additional_properties = {}, # # @option attrs [Hash] :properties User properties (optional) # @macro common_attrs + def with_context(data = nil, fresh: false, **kwargs, &block) + PostHog::Context.with_context(data, fresh: fresh, **kwargs, &block) + end + + def enter_context(data = nil, fresh: false, **kwargs) + PostHog::Context.enter_context(data, fresh: fresh, **kwargs) + end + + def get_context + PostHog::Context.get_context + end + + def identify_context(distinct_id) + PostHog::Context.identify_context(distinct_id) + end + + def set_context_session(session_id) + PostHog::Context.set_context_session(session_id) + end + + def tag_context(key_or_properties, value = nil) + PostHog::Context.tag_context(key_or_properties, value) + end + def identify(attrs) symbolize_keys! attrs enqueue(FieldParser.parse_for_identify(attrs)) @@ -684,6 +706,39 @@ def shutdown private + def apply_context_to_capture_attrs!(attrs) + context = PostHog::Context.current + explicit_properties = attrs[:properties] + properties_are_hash = explicit_properties.nil? || explicit_properties.is_a?(Hash) + context_properties = context&.properties || {} + if properties_are_hash + attrs[:properties] = PostHog::Context.merge_properties(context_properties, explicit_properties || {}) + end + + return if present_id?(attrs[:distinct_id]) + + if present_id?(context&.distinct_id) + attrs[:distinct_id] = context.distinct_id + return + end + + attrs[:distinct_id] = SecureRandom.uuid + return unless properties_are_hash + return if property_key?(explicit_properties, '$process_person_profile') + + attrs[:properties]['$process_person_profile'] = false + end + + def present_id?(value) + !(value.nil? || (value.is_a?(String) && value.empty?)) + end + + def property_key?(properties, key) + return false unless properties.is_a?(Hash) + + properties.key?(key) || properties.key?(key.to_sym) + end + # Shared by the legacy single-flag path ({#get_feature_flag_result}) and the # snapshot's access-recording. Owns dedup-key construction, the # per-distinct_id sent-flags cache, and the `$feature_flag_called` capture call. diff --git a/lib/posthog/context.rb b/lib/posthog/context.rb new file mode 100644 index 0000000..abc977f --- /dev/null +++ b/lib/posthog/context.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +module PostHog + # Request/fiber-local context applied to capture calls. + # + # The context is stored using Thread.current[], which is fiber-local on + # supported Ruby versions. This keeps request-scoped data isolated across + # concurrent threads/fibers when callers wrap work in {with_context}. + class Context + STORAGE_KEY = :posthog_context + + attr_accessor :distinct_id, :session_id, :properties + + def initialize(distinct_id: nil, session_id: nil, properties: {}) + @distinct_id = distinct_id + @session_id = session_id + @properties = properties ? properties.dup : {} + apply_session_property! + end + + def self.current + Thread.current[STORAGE_KEY] + end + + def self.current=(context) + Thread.current[STORAGE_KEY] = context + end + + def self.with_context(data = nil, fresh: false, **kwargs) + raise ArgumentError, 'with_context requires a block' unless block_given? + + previous_context = current + self.current = resolve(merge_data_and_kwargs(data, kwargs), previous_context, fresh: fresh) + yield + ensure + self.current = previous_context + end + + def self.enter_context(data = nil, fresh: false, **kwargs) + self.current = resolve(merge_data_and_kwargs(data, kwargs), current, fresh: fresh) + end + + def self.get_context + current&.to_h + end + + def self.identify_context(distinct_id) + return unless current + + current.distinct_id = distinct_id + end + + def self.set_context_session(session_id) + return unless current + + current.session_id = session_id + current.apply_session_property! + end + + def self.tag_context(key_or_properties, value = nil) + return unless current + + if key_or_properties.is_a?(Hash) + current.properties = merge_properties(current.properties, key_or_properties) + else + current.properties[key_or_properties] = value + end + end + + def self.resolve(data, parent, fresh: false) + data = normalize_data(data) + + parent_properties = fresh || parent.nil? ? {} : parent.properties + properties = merge_properties(parent_properties, data[:properties] || {}) + + 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 + ) + end + + def self.merge_data_and_kwargs(data, kwargs) + data ||= {} + raise ArgumentError, 'context data must be a Hash' unless data.is_a?(Hash) + + data.merge(kwargs) + end + + def self.merge_properties(base, overrides) + merged = (base || {}).dup + (overrides || {}).each do |key, value| + merged.delete(key.to_s) if key.is_a?(Symbol) + merged.delete(key.to_sym) if key.is_a?(String) + merged[key] = value + end + merged + end + + def self.normalize_data(data) + data ||= {} + raise ArgumentError, 'context data must be a Hash' unless data.is_a?(Hash) + + properties = data[:properties] || data['properties'] || {} + raise ArgumentError, 'context properties must be a Hash' unless properties.is_a?(Hash) + + { + distinct_id: data[:distinct_id] || data['distinct_id'] || data[:distinctId] || data['distinctId'], + session_id: data[:session_id] || data['session_id'] || data[:sessionId] || data['sessionId'], + properties: properties + } + end + + def to_h + { + distinct_id: distinct_id, + session_id: session_id, + properties: properties.dup + } + end + + def apply_session_property! + return if session_id.nil? || properties.key?('$session_id') || properties.key?(:'$session_id') + + properties['$session_id'] = session_id + end + end + + class << self + def with_context(data = nil, fresh: false, **kwargs, &block) + Context.with_context(data, fresh: fresh, **kwargs, &block) + end + + def enter_context(data = nil, fresh: false, **kwargs) + Context.enter_context(data, fresh: fresh, **kwargs) + end + + def get_context + Context.get_context + end + + def identify_context(distinct_id) + Context.identify_context(distinct_id) + end + + def set_context_session(session_id) + Context.set_context_session(session_id) + end + + def tag_context(key_or_properties, value = nil) + Context.tag_context(key_or_properties, value) + end + end +end diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index 62a7c91..6c0aa71 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -192,8 +192,20 @@ module PostHog ) end - it 'errors without a distinct_id' do - expect { client.capture(event: 'Event') }.to raise_error(ArgumentError) + it 'generates a personless distinct_id without an explicit or context distinct_id' do + client.capture(event: 'Event') + + message = client.dequeue_last_message + expect(message[:distinct_id]).to be_a(String) + expect(message[:distinct_id].length).to eq(36) + expect(message[:properties]['$process_person_profile']).to be false + end + + it 'does not override an explicit $process_person_profile value for personless capture' do + client.capture(event: 'Event', properties: { '$process_person_profile' => true }) + + message = client.dequeue_last_message + expect(message[:properties]['$process_person_profile']).to be true end it 'errors if properties is not a hash' do @@ -972,6 +984,147 @@ module PostHog end end + describe 'request context' do + it 'applies context distinct_id, session_id, and properties to capture' do + client.with_context( + distinct_id: 'context-user', + session_id: 'context-session', + properties: { 'plan' => 'pro' } + ) do + client.capture(event: 'context_event') + end + + message = client.dequeue_last_message + expect(message[:distinct_id]).to eq('context-user') + expect(message[:properties]['$session_id']).to eq('context-session') + expect(message[:properties]['plan']).to eq('pro') + expect(message[:properties]).not_to have_key('$process_person_profile') + end + + it 'allows explicit distinct_id and properties to override context' do + client.with_context( + distinct_id: 'context-user', + session_id: 'context-session', + properties: { 'plan' => 'free' } + ) do + client.capture( + event: 'override_event', + distinct_id: 'explicit-user', + properties: { 'plan' => 'paid', '$session_id' => 'explicit-session' } + ) + end + + message = client.dequeue_last_message + expect(message[:distinct_id]).to eq('explicit-user') + expect(message[:properties]['plan']).to eq('paid') + expect(message[:properties]['$session_id']).to eq('explicit-session') + end + + it 'inherits nested context by default and isolates fresh context' do + client.with_context( + distinct_id: 'outer-user', + session_id: 'outer-session', + properties: { 'outer' => true, 'shared' => 'parent' } + ) do + client.with_context(properties: { 'inner' => true, 'shared' => 'child' }) do + client.capture(event: 'inherited_event') + end + + client.with_context({ properties: { 'fresh' => true } }, fresh: true) do + client.capture(event: 'fresh_event') + end + end + + inherited = client.dequeue_last_message + fresh = client.dequeue_last_message + + expect(inherited[:distinct_id]).to eq('outer-user') + expect(inherited[:properties]['$session_id']).to eq('outer-session') + expect(inherited[:properties]['outer']).to be true + expect(inherited[:properties]['inner']).to be true + expect(inherited[:properties]['shared']).to eq('child') + + expect(fresh[:distinct_id]).to be_a(String) + expect(fresh[:distinct_id]).not_to eq('outer-user') + expect(fresh[:properties]['$process_person_profile']).to be false + expect(fresh[:properties]).not_to have_key('outer') + expect(fresh[:properties]).not_to have_key('$session_id') + expect(fresh[:properties]['fresh']).to be true + end + + it 'restores context after the block exits' do + client.with_context(distinct_id: 'context-user') do + client.capture(event: 'inside_context') + end + client.capture(event: 'outside_context') + + inside = client.dequeue_last_message + outside = client.dequeue_last_message + expect(inside[:distinct_id]).to eq('context-user') + expect(outside[:distinct_id]).not_to eq('context-user') + expect(outside[:properties]['$process_person_profile']).to be false + end + + it 'isolates context across concurrent threads' do + threads = 5.times.map do |index| + Thread.new do + client.with_context( + distinct_id: "user-#{index}", + session_id: "session-#{index}", + properties: { 'index' => index } + ) do + client.capture(event: 'thread_event') + end + end + end + threads.each(&:join) + + messages = 5.times.map { client.dequeue_last_message } + messages.each do |message| + index = message[:properties]['index'] + expect(message[:distinct_id]).to eq("user-#{index}") + expect(message[:properties]['$session_id']).to eq("session-#{index}") + end + end + + it 'applies context to exception capture and allows explicit overrides' do + client.with_context( + distinct_id: 'context-user', + session_id: 'context-session', + properties: { 'request_id' => 'ctx-req' } + ) do + begin + raise StandardError, 'context error' + rescue StandardError => e + client.capture_exception(e) + end + + begin + raise StandardError, 'explicit error' + rescue StandardError => e + client.capture_exception( + e, + 'explicit-user', + { '$session_id' => 'explicit-session', 'request_id' => 'explicit-req' } + ) + end + end + + context_message = client.dequeue_last_message + explicit_message = client.dequeue_last_message + + expect(context_message[:event]).to eq('$exception') + expect(context_message[:distinct_id]).to eq('context-user') + expect(context_message[:properties]['$session_id']).to eq('context-session') + expect(context_message[:properties]['request_id']).to eq('ctx-req') + expect(context_message[:properties]).not_to have_key('$process_person_profile') + + expect(explicit_message[:distinct_id]).to eq('explicit-user') + expect(explicit_message[:properties]['$session_id']).to eq('explicit-session') + expect(explicit_message[:properties]['request_id']).to eq('explicit-req') + end + end + describe '#identify' do it 'errors without any user id' do expect { client.identify({}) }.to raise_error(ArgumentError) From 77834f228cf7d9741d57cfb07c7d2008f64d21fc Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 5 May 2026 15:52:37 -0400 Subject: [PATCH 02/10] fix: align X-PostHog tracing headers --- lib/posthog/context.rb | 14 +- posthog-rails/lib/posthog/rails.rb | 2 + .../lib/posthog/rails/capture_exceptions.rb | 39 ++++- posthog-rails/lib/posthog/rails/railtie.rb | 16 +- .../lib/posthog/rails/request_context.rb | 96 +++++++++++ .../lib/posthog/rails/tracing_headers.rb | 75 ++++++++ spec/posthog/client_spec.rb | 11 ++ spec/posthog/rails/railtie_spec.rb | 4 +- spec/posthog/rails/request_context_spec.rb | 162 ++++++++++++++++++ 9 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 posthog-rails/lib/posthog/rails/request_context.rb create mode 100644 posthog-rails/lib/posthog/rails/tracing_headers.rb create mode 100644 spec/posthog/rails/request_context_spec.rb diff --git a/lib/posthog/context.rb b/lib/posthog/context.rb index abc977f..f3c8c5a 100644 --- a/lib/posthog/context.rb +++ b/lib/posthog/context.rb @@ -54,6 +54,8 @@ def self.set_context_session(session_id) return unless current current.session_id = session_id + current.properties.delete('$session_id') + current.properties.delete(:$session_id) current.apply_session_property! end @@ -72,6 +74,10 @@ def self.resolve(data, parent, fresh: false) parent_properties = fresh || parent.nil? ? {} : parent.properties properties = merge_properties(parent_properties, data[:properties] || {}) + if data[:session_id] && !session_property_key?(data[:properties]) + properties.delete('$session_id') + properties.delete(:$session_id) + end new( distinct_id: data[:distinct_id] || (fresh || parent.nil? ? nil : parent.distinct_id), @@ -111,6 +117,12 @@ def self.normalize_data(data) } end + def self.session_property_key?(properties) + return false unless properties.is_a?(Hash) + + properties.key?('$session_id') || properties.key?(:$session_id) + end + def to_h { distinct_id: distinct_id, @@ -120,7 +132,7 @@ def to_h end def apply_session_property! - return if session_id.nil? || properties.key?('$session_id') || properties.key?(:'$session_id') + return if session_id.nil? || properties.key?('$session_id') || properties.key?(:$session_id) properties['$session_id'] = session_id end diff --git a/posthog-rails/lib/posthog/rails.rb b/posthog-rails/lib/posthog/rails.rb index 450b3e1..65fa3a9 100644 --- a/posthog-rails/lib/posthog/rails.rb +++ b/posthog-rails/lib/posthog/rails.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require 'posthog/rails/configuration' +require 'posthog/rails/tracing_headers' +require 'posthog/rails/request_context' require 'posthog/rails/capture_exceptions' require 'posthog/rails/rescued_exception_interceptor' require 'posthog/rails/active_job' diff --git a/posthog-rails/lib/posthog/rails/capture_exceptions.rb b/posthog-rails/lib/posthog/rails/capture_exceptions.rb index 657ab0e..68bc590 100644 --- a/posthog-rails/lib/posthog/rails/capture_exceptions.rb +++ b/posthog-rails/lib/posthog/rails/capture_exceptions.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true +require 'posthog/context' require 'posthog/rails/parameter_filter' +require 'posthog/rails/tracing_headers' module PostHog module Rails @@ -18,6 +20,7 @@ def call(env) PostHog::Rails.enter_web_request response = @app.call(env) + env['posthog.response_status_code'] = response_status(response) # Check if there was an exception that Rails handled exception = collect_exception(env) @@ -60,7 +63,10 @@ def capture_exception(exception, env) PostHog::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}") end - def extract_distinct_id(env, request) + def extract_distinct_id(env, _request) + context_distinct_id = PostHog::Context.current&.distinct_id + return context_distinct_id if present?(context_distinct_id) + # Try to get user from controller if capture_user_context is enabled if PostHog::Rails.config&.capture_user_context && env['action_controller.instance'] controller = env['action_controller.instance'] @@ -72,8 +78,7 @@ def extract_distinct_id(env, request) end end - # Fallback to session ID or nil - request.session&.id&.to_s + nil end def extract_user_id(user) @@ -119,13 +124,39 @@ def build_properties(request, env) end # Add user agent - properties['$user_agent'] = safe_serialize(request.user_agent) if request.user_agent + user_agent = TracingHeaders.sanitize_header_value(request.user_agent) + properties['$user_agent'] = safe_serialize(user_agent) if user_agent + + ip_address = client_ip(request) + properties['$ip'] = safe_serialize(ip_address) if ip_address + + response_status_code = env['posthog.response_status_code'] + properties['$response_status_code'] = response_status_code if response_status_code # Add referrer properties['$referrer'] = safe_serialize(request.referrer) if request.referrer properties end + + def client_ip(request) + forwarded_for = TracingHeaders.extract_header(request, 'X-Forwarded-For') + forwarded_ip = forwarded_for.split(',').first&.strip if forwarded_for + return forwarded_ip if present?(forwarded_ip) + + request.remote_ip + rescue StandardError + nil + end + + def response_status(response) + status = response.respond_to?(:[]) ? response[0] : nil + status if status.is_a?(Integer) + end + + def present?(value) + !(value.nil? || (value.respond_to?(:empty?) && value.empty?)) + end end end end diff --git a/posthog-rails/lib/posthog/rails/railtie.rb b/posthog-rails/lib/posthog/rails/railtie.rb index 519e55b..63c5e18 100644 --- a/posthog-rails/lib/posthog/rails/railtie.rb +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -73,8 +73,15 @@ def ensure_initialized! end end - # Insert middleware for exception capturing + # Insert middleware for request context and exception capturing initializer 'posthog.insert_middlewares' do |app| + # Wrap the Rails exception middleware so request context is active for + # downstream handlers and exception capture. + insert_middleware_before( + app, ActionDispatch::ShowExceptions, + PostHog::Rails::RequestContext + ) + # Insert after DebugExceptions to catch rescued exceptions insert_middleware_after( app, ActionDispatch::DebugExceptions, @@ -118,6 +125,13 @@ def insert_middleware_after(app, target, middleware) app.config.middleware.insert_after(target, middleware) end + def insert_middleware_before(app, target, middleware) + # During initialization, app.config.middleware is a MiddlewareStackProxy + # which only supports recording operations (insert_before, use, etc.) + # and does NOT support query methods like include?. + app.config.middleware.insert_before(target, middleware) + end + def self.register_error_subscriber return unless PostHog::Rails.config&.auto_capture_exceptions diff --git a/posthog-rails/lib/posthog/rails/request_context.rb b/posthog-rails/lib/posthog/rails/request_context.rb new file mode 100644 index 0000000..58cc977 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/request_context.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'posthog/context' +require 'posthog/rails/tracing_headers' + +module PostHog + module Rails + # Rack middleware that creates a request-local PostHog context from tracing headers. + class RequestContext + def initialize(app) + @app = app + end + + def call(env) + request = build_request(env) + + PostHog::Context.with_context(context_data(request), fresh: true) do + @app.call(env) + end + end + + private + + def context_data(request) + session_id = tracing_header(request, 'X-POSTHOG-SESSION-ID') + distinct_id = tracing_header(request, 'X-POSTHOG-DISTINCT-ID') + window_id = tracing_header(request, 'X-POSTHOG-WINDOW-ID') + + properties = request_properties(request) + properties['$window_id'] = window_id if window_id + + { + distinct_id: distinct_id, + session_id: session_id, + properties: properties + } + end + + def request_properties(request) + properties = {} + add_property(properties, '$current_url', request_value(request, :url)) + request_method = request_value(request, :request_method) || request_value(request, :method) + add_property(properties, '$request_method', request_method) + add_property(properties, '$request_path', request_value(request, :path) || request_value(request, :path_info)) + add_property(properties, '$user_agent', tracing_header(request, 'User-Agent')) + add_property(properties, '$ip', client_ip(request)) + properties + end + + def client_ip(request) + forwarded_for = tracing_header(request, 'X-Forwarded-For') + forwarded_ip = forwarded_for.split(',').first&.strip if forwarded_for + return forwarded_ip unless forwarded_ip.nil? || forwarded_ip.empty? + + request_value(request, :remote_ip) || request_value(request, :ip) || env_value(request, 'REMOTE_ADDR') + end + + def add_property(properties, key, value) + return if value.nil? + + serialized = value.to_s + return if serialized.empty? + + properties[key] = serialized + end + + def tracing_header(request, header_name) + TracingHeaders.extract_header(request, header_name) + end + + def request_value(request, method_name) + return unless request.respond_to?(method_name) + + request.public_send(method_name) + rescue StandardError + nil + end + + def env_value(request, key) + request.respond_to?(:get_header) ? request.get_header(key) : request.env[key] + rescue StandardError + nil + end + + def build_request(env) + if defined?(ActionDispatch::Request) + ActionDispatch::Request.new(env) + elsif defined?(Rack::Request) + Rack::Request.new(env) + else + env + end + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/tracing_headers.rb b/posthog-rails/lib/posthog/rails/tracing_headers.rb new file mode 100644 index 0000000..32f89b8 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/tracing_headers.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module PostHog + module Rails + # Helpers for extracting and sanitizing PostHog tracing headers from Rack/Rails requests. + module TracingHeaders + MAX_HEADER_VALUE_LENGTH = 1000 + CONTROL_CHARACTERS = /[[:cntrl:]]/ + + module_function + + def sanitize_header_value(value) + return nil unless value.is_a?(String) + + sanitized = value.strip.gsub(CONTROL_CHARACTERS, '').strip + return nil if sanitized.empty? + + sanitized[0, MAX_HEADER_VALUE_LENGTH] + end + + def extract_header(request_or_env, header_name) + candidates = header_candidates(header_name) + + candidates.each do |candidate| + value = header_value(request_or_env, candidate) + sanitized = sanitize_header_value(value) + return sanitized if sanitized + end + + env = request_env(request_or_env) + return nil unless env.respond_to?(:each) + + target_names = candidates.map { |candidate| normalize_header_name(candidate) } + env.each do |key, value| + next unless target_names.include?(normalize_header_name(key)) + + sanitized = sanitize_header_value(value) + return sanitized if sanitized + end + + nil + end + + def header_candidates(header_name) + canonical = header_name.to_s + rack = "HTTP_#{canonical.upcase.tr('-', '_')}" + [canonical, canonical.downcase, rack] + end + private_class_method :header_candidates + + def header_value(request_or_env, header_name) + if request_or_env.respond_to?(:headers) + value = request_or_env.headers[header_name] + return value unless value.nil? + end + + env = request_env(request_or_env) + return nil unless env.respond_to?(:[]) + + env[header_name] + end + private_class_method :header_value + + def request_env(request_or_env) + request_or_env.respond_to?(:env) ? request_or_env.env : request_or_env + end + private_class_method :request_env + + def normalize_header_name(header_name) + header_name.to_s.upcase.tr('-', '_') + end + private_class_method :normalize_header_name + end + end +end diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index 6c0aa71..cf2d1e7 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -1052,6 +1052,17 @@ module PostHog expect(fresh[:properties]['fresh']).to be true end + it 'allows a child context session_id to override the inherited session_id' do + client.with_context(session_id: 'outer-session') do + client.with_context(session_id: 'inner-session') do + client.capture(event: 'session_override_event', distinct_id: 'user') + end + end + + message = client.dequeue_last_message + expect(message[:properties]['$session_id']).to eq('inner-session') + end + it 'restores context after the block exits' do client.with_context(distinct_id: 'context-user') do client.capture(event: 'inside_context') diff --git a/spec/posthog/rails/railtie_spec.rb b/spec/posthog/rails/railtie_spec.rb index 340a344..85edd54 100644 --- a/spec/posthog/rails/railtie_spec.rb +++ b/spec/posthog/rails/railtie_spec.rb @@ -25,12 +25,14 @@ # defined as an instance method (or delegated to one). railtie = PostHog::Rails::Railtie.instance expect(railtie).to respond_to(:insert_middleware_after) + expect(railtie).to respond_to(:insert_middleware_before) end it 'successfully calls insert_middleware_after when the initializer runs' do # Stub the middleware constants referenced in the initializer block stub_const('ActionDispatch::DebugExceptions', Class.new) stub_const('ActionDispatch::ShowExceptions', Class.new) + stub_const('PostHog::Rails::RequestContext', Class.new) stub_const('PostHog::Rails::RescuedExceptionInterceptor', Class.new) stub_const('PostHog::Rails::CaptureExceptions', Class.new) @@ -41,7 +43,7 @@ # During initialization, app.config.middleware is a MiddlewareStackProxy # which only supports recording operations — NOT query methods like include?. # The mock must reflect this accurately. - middleware_proxy = double('MiddlewareStackProxy', insert_after: true) + middleware_proxy = double('MiddlewareStackProxy', insert_after: true, insert_before: true) app = double('app', config: double('config', middleware: middleware_proxy)) # Reproduce the exact execution context: the block is run via instance_exec diff --git a/spec/posthog/rails/request_context_spec.rb b/spec/posthog/rails/request_context_spec.rb new file mode 100644 index 0000000..5b38055 --- /dev/null +++ b/spec/posthog/rails/request_context_spec.rb @@ -0,0 +1,162 @@ +# 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' + +RSpec.describe PostHog::Rails::RequestContext do + let(:client) { PostHog::Client.new(api_key: API_KEY, test_mode: true) } + + def env_for(path = '/api/test', headers = nil, **header_keywords) + headers = (headers || {}).merge(header_keywords) + Rack::MockRequest.env_for( + path, + headers.merge( + 'REQUEST_METHOD' => 'POST', + 'REMOTE_ADDR' => '10.0.0.1' + ) + ) + end + + def call_with(headers = nil, path: '/api/test', **header_keywords, &block) + headers = (headers || {}).merge(header_keywords) + app = lambda do |env| + block.call(env) + [200, { 'content-type' => 'text/plain' }, ['ok']] + end + + described_class.new(app).call(env_for(path, headers)) + end + + it 'applies sanitized tracing headers and request metadata to downstream captures' do + call_with( + 'HTTP_X_POSTHOG_DISTINCT_ID' => " frontend-user\n", + 'HTTP_X_POSTHOG_SESSION_ID' => " frontend-session\t", + 'HTTP_X_POSTHOG_WINDOW_ID' => 'window-123', + 'HTTP_USER_AGENT' => 'RSpec Agent', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.10, 10.0.0.2' + ) do + client.capture(event: 'request_event') + end + + message = client.dequeue_last_message + expect(message[:distinct_id]).to eq('frontend-user') + expect(message[:properties]['$session_id']).to eq('frontend-session') + expect(message[:properties]['$window_id']).to eq('window-123') + expect(message[:properties]['$current_url']).to include('/api/test') + expect(message[:properties]['$request_method']).to eq('POST') + expect(message[:properties]['$request_path']).to eq('/api/test') + expect(message[:properties]['$user_agent']).to eq('RSpec Agent') + expect(message[:properties]['$ip']).to eq('203.0.113.10') + end + + it 'lets explicit capture distinct_id and $session_id override tracing context' do + call_with( + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', + 'HTTP_X_POSTHOG_SESSION_ID' => 'header-session' + ) do + client.capture( + event: 'override_event', + distinct_id: 'explicit-user', + properties: { '$session_id' => 'explicit-session' } + ) + end + + message = client.dequeue_last_message + expect(message[:distinct_id]).to eq('explicit-user') + expect(message[:properties]['$session_id']).to eq('explicit-session') + end + + it 'handles missing tracing headers without leaking identity or session' do + call_with( + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'first-user', + 'HTTP_X_POSTHOG_SESSION_ID' => 'first-session' + ) do + client.capture(event: 'first_request') + end + + call_with do + client.capture(event: 'second_request') + end + + first = client.dequeue_last_message + second = client.dequeue_last_message + + expect(first[:distinct_id]).to eq('first-user') + expect(first[:properties]['$session_id']).to eq('first-session') + expect(second[:distinct_id]).not_to eq('first-user') + expect(second[:properties]['$session_id']).to be_nil + expect(second[:properties]['$process_person_profile']).to be false + end + + it 'supports case-insensitive and framework-normalized header names' do + call_with( + 'x-posthog-distinct-id' => 'lower-user', + 'X-Posthog-Session-Id' => 'mixed-session' + ) do + client.capture(event: 'case_event') + end + + message = client.dequeue_last_message + expect(message[:distinct_id]).to eq('lower-user') + expect(message[:properties]['$session_id']).to eq('mixed-session') + end + + it 'ignores empty/control-only values and caps long values' do + long_session_id = 's' * 1100 + + call_with( + 'HTTP_X_POSTHOG_DISTINCT_ID' => " \u0000\n\t ", + 'HTTP_X_POSTHOG_SESSION_ID' => " #{long_session_id}\n" + ) do + client.capture(event: 'sanitized_event') + end + + message = client.dequeue_last_message + expect(message[:distinct_id]).to be_a(String) + expect(message[:distinct_id]).not_to eq("\u0000") + expect(message[:properties]['$process_person_profile']).to be false + expect(message[:properties]['$session_id']).to eq('s' * 1000) + end + + it 'captures exceptions with tracing context and re-raises' do + previous_config = PostHog::Rails.config + PostHog::Rails.config = PostHog::Rails::Configuration.new + PostHog::Rails.config.auto_capture_exceptions = true + + allow(PostHog).to receive(:capture_exception) do |exception, distinct_id, properties| + client.capture_exception(exception, distinct_id, properties) + end + + app = lambda do |_env| + raise StandardError, 'boom' + end + middleware = described_class.new(PostHog::Rails::CaptureExceptions.new(app)) + + expect do + middleware.call( + env_for( + '/boom', + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'exception-user', + 'HTTP_X_POSTHOG_SESSION_ID' => 'exception-session', + 'HTTP_USER_AGENT' => 'Exception Agent' + ) + ) + end.to raise_error(StandardError, 'boom') + + message = client.dequeue_last_message + expect(message[:event]).to eq('$exception') + expect(message[:distinct_id]).to eq('exception-user') + expect(message[:properties]['$session_id']).to eq('exception-session') + expect(message[:properties]['$request_path']).to eq('/boom') + expect(message[:properties]['$user_agent']).to eq('Exception Agent') + ensure + PostHog::Rails.config = previous_config + end +end From 04e6231584ad44dcc33b2f21bd042f8bb6d683ed Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 5 May 2026 16:38:29 -0400 Subject: [PATCH 03/10] refactor: keep request context internal --- lib/posthog.rb | 1 - lib/posthog/client.rb | 30 +--- lib/posthog/context.rb | 166 ------------------ lib/posthog/internal/context.rb | 109 ++++++++++++ .../lib/posthog/rails/capture_exceptions.rb | 4 +- .../lib/posthog/rails/request_context.rb | 4 +- .../lib/posthog/rails/tracing_headers.rb | 2 + spec/posthog/client_spec.rb | 37 ++-- 8 files changed, 145 insertions(+), 208 deletions(-) delete mode 100644 lib/posthog/context.rb create mode 100644 lib/posthog/internal/context.rb diff --git a/lib/posthog.rb b/lib/posthog.rb index 0ced8f3..d4227e6 100644 --- a/lib/posthog.rb +++ b/lib/posthog.rb @@ -10,7 +10,6 @@ require 'posthog/response' require 'posthog/logging' require 'posthog/exception_capture' -require 'posthog/context' require 'posthog/feature_flag_error' require 'posthog/feature_flag_result' require 'posthog/feature_flag_evaluations' diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 8abc5e4..4738f95 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -14,7 +14,7 @@ require 'posthog/feature_flag_evaluations' require 'posthog/send_feature_flags_options' require 'posthog/exception_capture' -require 'posthog/context' +require 'posthog/internal/context' module PostHog class Client @@ -292,30 +292,6 @@ def capture_exception(exception, distinct_id = nil, additional_properties = {}, # # @option attrs [Hash] :properties User properties (optional) # @macro common_attrs - def with_context(data = nil, fresh: false, **kwargs, &block) - PostHog::Context.with_context(data, fresh: fresh, **kwargs, &block) - end - - def enter_context(data = nil, fresh: false, **kwargs) - PostHog::Context.enter_context(data, fresh: fresh, **kwargs) - end - - def get_context - PostHog::Context.get_context - end - - def identify_context(distinct_id) - PostHog::Context.identify_context(distinct_id) - end - - def set_context_session(session_id) - PostHog::Context.set_context_session(session_id) - end - - def tag_context(key_or_properties, value = nil) - PostHog::Context.tag_context(key_or_properties, value) - end - def identify(attrs) symbolize_keys! attrs enqueue(FieldParser.parse_for_identify(attrs)) @@ -707,12 +683,12 @@ def shutdown private def apply_context_to_capture_attrs!(attrs) - context = PostHog::Context.current + context = Internal::Context.current explicit_properties = attrs[:properties] properties_are_hash = explicit_properties.nil? || explicit_properties.is_a?(Hash) context_properties = context&.properties || {} if properties_are_hash - attrs[:properties] = PostHog::Context.merge_properties(context_properties, explicit_properties || {}) + attrs[:properties] = Internal::Context.merge_properties(context_properties, explicit_properties || {}) end return if present_id?(attrs[:distinct_id]) diff --git a/lib/posthog/context.rb b/lib/posthog/context.rb deleted file mode 100644 index f3c8c5a..0000000 --- a/lib/posthog/context.rb +++ /dev/null @@ -1,166 +0,0 @@ -# frozen_string_literal: true - -module PostHog - # Request/fiber-local context applied to capture calls. - # - # The context is stored using Thread.current[], which is fiber-local on - # supported Ruby versions. This keeps request-scoped data isolated across - # concurrent threads/fibers when callers wrap work in {with_context}. - class Context - STORAGE_KEY = :posthog_context - - attr_accessor :distinct_id, :session_id, :properties - - def initialize(distinct_id: nil, session_id: nil, properties: {}) - @distinct_id = distinct_id - @session_id = session_id - @properties = properties ? properties.dup : {} - apply_session_property! - end - - def self.current - Thread.current[STORAGE_KEY] - end - - def self.current=(context) - Thread.current[STORAGE_KEY] = context - end - - def self.with_context(data = nil, fresh: false, **kwargs) - raise ArgumentError, 'with_context requires a block' unless block_given? - - previous_context = current - self.current = resolve(merge_data_and_kwargs(data, kwargs), previous_context, fresh: fresh) - yield - ensure - self.current = previous_context - end - - def self.enter_context(data = nil, fresh: false, **kwargs) - self.current = resolve(merge_data_and_kwargs(data, kwargs), current, fresh: fresh) - end - - def self.get_context - current&.to_h - end - - def self.identify_context(distinct_id) - return unless current - - current.distinct_id = distinct_id - end - - def self.set_context_session(session_id) - return unless current - - current.session_id = session_id - current.properties.delete('$session_id') - current.properties.delete(:$session_id) - current.apply_session_property! - end - - def self.tag_context(key_or_properties, value = nil) - return unless current - - if key_or_properties.is_a?(Hash) - current.properties = merge_properties(current.properties, key_or_properties) - else - current.properties[key_or_properties] = value - end - end - - def self.resolve(data, parent, fresh: false) - data = normalize_data(data) - - parent_properties = fresh || parent.nil? ? {} : parent.properties - properties = merge_properties(parent_properties, data[:properties] || {}) - if data[:session_id] && !session_property_key?(data[:properties]) - properties.delete('$session_id') - properties.delete(:$session_id) - end - - 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 - ) - end - - def self.merge_data_and_kwargs(data, kwargs) - data ||= {} - raise ArgumentError, 'context data must be a Hash' unless data.is_a?(Hash) - - data.merge(kwargs) - end - - def self.merge_properties(base, overrides) - merged = (base || {}).dup - (overrides || {}).each do |key, value| - merged.delete(key.to_s) if key.is_a?(Symbol) - merged.delete(key.to_sym) if key.is_a?(String) - merged[key] = value - end - merged - end - - def self.normalize_data(data) - data ||= {} - raise ArgumentError, 'context data must be a Hash' unless data.is_a?(Hash) - - properties = data[:properties] || data['properties'] || {} - raise ArgumentError, 'context properties must be a Hash' unless properties.is_a?(Hash) - - { - distinct_id: data[:distinct_id] || data['distinct_id'] || data[:distinctId] || data['distinctId'], - session_id: data[:session_id] || data['session_id'] || data[:sessionId] || data['sessionId'], - properties: properties - } - end - - def self.session_property_key?(properties) - return false unless properties.is_a?(Hash) - - properties.key?('$session_id') || properties.key?(:$session_id) - end - - def to_h - { - distinct_id: distinct_id, - session_id: session_id, - properties: properties.dup - } - end - - def apply_session_property! - return if session_id.nil? || properties.key?('$session_id') || properties.key?(:$session_id) - - properties['$session_id'] = session_id - end - end - - class << self - def with_context(data = nil, fresh: false, **kwargs, &block) - Context.with_context(data, fresh: fresh, **kwargs, &block) - end - - def enter_context(data = nil, fresh: false, **kwargs) - Context.enter_context(data, fresh: fresh, **kwargs) - end - - def get_context - Context.get_context - end - - def identify_context(distinct_id) - Context.identify_context(distinct_id) - end - - def set_context_session(session_id) - Context.set_context_session(session_id) - end - - def tag_context(key_or_properties, value = nil) - Context.tag_context(key_or_properties, value) - end - end -end diff --git a/lib/posthog/internal/context.rb b/lib/posthog/internal/context.rb new file mode 100644 index 0000000..ecb4899 --- /dev/null +++ b/lib/posthog/internal/context.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module PostHog + module Internal + # Internal request/fiber-local context applied to capture calls. + # + # This is intentionally not exposed as a public SDK API in Ruby yet. It exists + # to let framework integrations such as posthog-rails propagate request-scoped + # tracing headers to regular capture and exception events without making the + # server-side SDK globally stateful per user. + class Context + STORAGE_KEY = :posthog_context + + attr_reader :distinct_id, :session_id, :properties + + def initialize(distinct_id: nil, session_id: nil, properties: {}) + @distinct_id = distinct_id + @session_id = session_id + @properties = properties ? properties.dup : {} + apply_session_property! + end + + def self.current + Thread.current[STORAGE_KEY] + end + + def self.current=(context) + Thread.current[STORAGE_KEY] = context + end + + def self.with_context(data = nil, fresh: false, **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) + yield + ensure + self.current = previous_context + end + + def self.resolve(data, parent, fresh: false) + data = normalize_data(data) + + parent_properties = fresh || parent.nil? ? {} : parent.properties + properties = merge_properties(parent_properties, data[:properties] || {}) + if data[:session_id] && !session_property_key?(data[:properties]) + properties.delete('$session_id') + properties.delete(:$session_id) + end + + 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 + ) + end + private_class_method :resolve + + def self.merge_data_and_kwargs(data, kwargs) + data ||= {} + raise ArgumentError, 'context data must be a Hash' unless data.is_a?(Hash) + + data.merge(kwargs) + end + private_class_method :merge_data_and_kwargs + + def self.merge_properties(base, overrides) + merged = (base || {}).dup + (overrides || {}).each do |key, value| + merged.delete(key.to_s) if key.is_a?(Symbol) + merged.delete(key.to_sym) if key.is_a?(String) + merged[key] = value + end + merged + end + + def self.normalize_data(data) + data ||= {} + raise ArgumentError, 'context data must be a Hash' unless data.is_a?(Hash) + + properties = data[:properties] || data['properties'] || {} + raise ArgumentError, 'context properties must be a Hash' unless properties.is_a?(Hash) + + { + distinct_id: data[:distinct_id] || data['distinct_id'] || data[:distinctId] || data['distinctId'], + session_id: data[:session_id] || data['session_id'] || data[:sessionId] || data['sessionId'], + properties: properties + } + end + private_class_method :normalize_data + + def self.session_property_key?(properties) + return false unless properties.is_a?(Hash) + + properties.key?('$session_id') || properties.key?(:$session_id) + end + private_class_method :session_property_key? + + def apply_session_property! + return if session_id.nil? || properties.key?('$session_id') || properties.key?(:$session_id) + + properties['$session_id'] = session_id + end + private :apply_session_property! + end + end + + private_constant :Internal +end diff --git a/posthog-rails/lib/posthog/rails/capture_exceptions.rb b/posthog-rails/lib/posthog/rails/capture_exceptions.rb index 68bc590..05e69a3 100644 --- a/posthog-rails/lib/posthog/rails/capture_exceptions.rb +++ b/posthog-rails/lib/posthog/rails/capture_exceptions.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'posthog/context' +require 'posthog/internal/context' require 'posthog/rails/parameter_filter' require 'posthog/rails/tracing_headers' @@ -64,7 +64,7 @@ def capture_exception(exception, env) end def extract_distinct_id(env, _request) - context_distinct_id = PostHog::Context.current&.distinct_id + context_distinct_id = Internal::Context.current&.distinct_id return context_distinct_id if present?(context_distinct_id) # Try to get user from controller if capture_user_context is enabled diff --git a/posthog-rails/lib/posthog/rails/request_context.rb b/posthog-rails/lib/posthog/rails/request_context.rb index 58cc977..6023fd7 100644 --- a/posthog-rails/lib/posthog/rails/request_context.rb +++ b/posthog-rails/lib/posthog/rails/request_context.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'posthog/context' +require 'posthog/internal/context' require 'posthog/rails/tracing_headers' module PostHog @@ -14,7 +14,7 @@ def initialize(app) def call(env) request = build_request(env) - PostHog::Context.with_context(context_data(request), fresh: true) do + Internal::Context.with_context(context_data(request), fresh: true) do @app.call(env) end end diff --git a/posthog-rails/lib/posthog/rails/tracing_headers.rb b/posthog-rails/lib/posthog/rails/tracing_headers.rb index 32f89b8..8fec5f7 100644 --- a/posthog-rails/lib/posthog/rails/tracing_headers.rb +++ b/posthog-rails/lib/posthog/rails/tracing_headers.rb @@ -71,5 +71,7 @@ def normalize_header_name(header_name) end private_class_method :normalize_header_name end + + private_constant :TracingHeaders end end diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index cf2d1e7..ee4895a 100644 --- a/spec/posthog/client_spec.rb +++ b/spec/posthog/client_spec.rb @@ -9,6 +9,7 @@ module PostHog describe Client do let(:client) { Client.new(api_key: API_KEY, test_mode: true) } + let(:context_class) { PostHog.const_get(:Internal).const_get(:Context) } let(:logger) { instance_double(Logger) } before do @@ -985,8 +986,24 @@ module PostHog end describe 'request context' do + it 'keeps context helpers internal rather than exposing new public client methods' do + expect { PostHog::Internal }.to raise_error(NameError) + + %i[ + with_context + enter_context + get_context + identify_context + set_context_session + tag_context + ].each do |method_name| + expect(client).not_to respond_to(method_name) + expect(PostHog).not_to respond_to(method_name) + end + end + it 'applies context distinct_id, session_id, and properties to capture' do - client.with_context( + context_class.with_context( distinct_id: 'context-user', session_id: 'context-session', properties: { 'plan' => 'pro' } @@ -1002,7 +1019,7 @@ module PostHog end it 'allows explicit distinct_id and properties to override context' do - client.with_context( + context_class.with_context( distinct_id: 'context-user', session_id: 'context-session', properties: { 'plan' => 'free' } @@ -1021,16 +1038,16 @@ module PostHog end it 'inherits nested context by default and isolates fresh context' do - client.with_context( + context_class.with_context( distinct_id: 'outer-user', session_id: 'outer-session', properties: { 'outer' => true, 'shared' => 'parent' } ) do - client.with_context(properties: { 'inner' => true, 'shared' => 'child' }) do + context_class.with_context(properties: { 'inner' => true, 'shared' => 'child' }) do client.capture(event: 'inherited_event') end - client.with_context({ properties: { 'fresh' => true } }, fresh: true) do + context_class.with_context({ properties: { 'fresh' => true } }, fresh: true) do client.capture(event: 'fresh_event') end end @@ -1053,8 +1070,8 @@ module PostHog end it 'allows a child context session_id to override the inherited session_id' do - client.with_context(session_id: 'outer-session') do - client.with_context(session_id: 'inner-session') do + context_class.with_context(session_id: 'outer-session') do + context_class.with_context(session_id: 'inner-session') do client.capture(event: 'session_override_event', distinct_id: 'user') end end @@ -1064,7 +1081,7 @@ module PostHog end it 'restores context after the block exits' do - client.with_context(distinct_id: 'context-user') do + context_class.with_context(distinct_id: 'context-user') do client.capture(event: 'inside_context') end client.capture(event: 'outside_context') @@ -1079,7 +1096,7 @@ module PostHog it 'isolates context across concurrent threads' do threads = 5.times.map do |index| Thread.new do - client.with_context( + context_class.with_context( distinct_id: "user-#{index}", session_id: "session-#{index}", properties: { 'index' => index } @@ -1099,7 +1116,7 @@ module PostHog end it 'applies context to exception capture and allows explicit overrides' do - client.with_context( + context_class.with_context( distinct_id: 'context-user', session_id: 'context-session', properties: { 'request_id' => 'ctx-req' } From 2bc60a8b390c4e2cb12facab6bd07183e6094631 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Tue, 5 May 2026 17:19:51 -0400 Subject: [PATCH 04/10] fix: tighten rails request context behavior --- .../hungry-hedgehogs-request-context.md | 5 + lib/posthog/client.rb | 6 +- lib/posthog/internal/context.rb | 14 ++- posthog-rails/README.md | 21 +++- posthog-rails/examples/posthog.rb | 8 +- .../generators/posthog/templates/posthog.rb | 8 +- .../lib/posthog/rails/capture_exceptions.rb | 16 ++-- .../lib/posthog/rails/configuration.rb | 4 + .../lib/posthog/rails/request_context.rb | 19 ++-- spec/posthog/rails/request_context_spec.rb | 96 ++++++++++++++++++- 10 files changed, 170 insertions(+), 27 deletions(-) create mode 100644 .changeset/hungry-hedgehogs-request-context.md diff --git a/.changeset/hungry-hedgehogs-request-context.md b/.changeset/hungry-hedgehogs-request-context.md new file mode 100644 index 0000000..4274e8f --- /dev/null +++ b/.changeset/hungry-hedgehogs-request-context.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +Add internal request context support for Rails so PostHog tracing headers and request metadata can be applied to captures and exception events during a request. Captures without an explicit distinct_id now use request context when available, otherwise they are sent as personless events with a generated UUID. diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 4738f95..731943f 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -186,6 +186,9 @@ def clear # events in PostHog are deduplicated by the # combination of teamId, timestamp date, # event name, distinct id, and UUID + # @note If `:distinct_id` is omitted, request/context distinct_id is used when + # available; otherwise a UUID is generated and the event is marked personless + # with `$process_person_profile: false`. # @macro common_attrs def capture(attrs) symbolize_keys! attrs @@ -262,7 +265,8 @@ def capture(attrs) # Captures an exception as an event # # @param [Exception, String, Object] exception The exception to capture, a string message, or exception-like object - # @param [String] distinct_id The ID for the user (optional, defaults to a generated UUID) + # @param [String] distinct_id The ID for the user (optional, defaults to request/context distinct_id + # or a generated UUID) # @param [Hash] additional_properties Additional properties to include with the exception event (optional) # @param [PostHog::FeatureFlagEvaluations] flags A snapshot returned by {#evaluate_flags}. # Forwarded to the inner {#capture} call so the captured `$exception` event carries the diff --git a/lib/posthog/internal/context.rb b/lib/posthog/internal/context.rb index ecb4899..ef30c03 100644 --- a/lib/posthog/internal/context.rb +++ b/lib/posthog/internal/context.rb @@ -3,6 +3,8 @@ module PostHog module Internal # Internal request/fiber-local context applied to capture calls. + # Uses Rails' isolated execution state when available, otherwise falls back + # to thread-local storage in the core SDK. # # This is intentionally not exposed as a public SDK API in Ruby yet. It exists # to let framework integrations such as posthog-rails propagate request-scoped @@ -21,11 +23,19 @@ def initialize(distinct_id: nil, session_id: nil, properties: {}) end def self.current - Thread.current[STORAGE_KEY] + if defined?(ActiveSupport::IsolatedExecutionState) + ActiveSupport::IsolatedExecutionState[STORAGE_KEY] + else + Thread.current[STORAGE_KEY] + end end def self.current=(context) - Thread.current[STORAGE_KEY] = context + if defined?(ActiveSupport::IsolatedExecutionState) + ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = context + else + Thread.current[STORAGE_KEY] = context + end end def self.with_context(data = nil, fresh: false, **kwargs) diff --git a/posthog-rails/README.md b/posthog-rails/README.md index c5b906b..f8ce4a2 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -50,7 +50,8 @@ PostHog::Rails.configure do |config| config.auto_capture_exceptions = true # Enable automatic exception capture (default: false) config.report_rescued_exceptions = true # Report exceptions Rails rescues (default: false) config.auto_instrument_active_job = true # Instrument background jobs (default: false) - config.capture_user_context = true # Include user info in exceptions + config.capture_request_context = true # Apply tracing headers/request metadata to captures (default: true) + config.capture_user_context = true # Include authenticated user info in exceptions config.current_user_method = :current_user # Method to get current user config.user_id_method = nil # Method to get ID from user (auto-detect) @@ -252,7 +253,8 @@ Configure these via `PostHog::Rails.configure` or `PostHog::Rails.config`: | `auto_capture_exceptions` | Boolean | `false` | Automatically capture exceptions | | `report_rescued_exceptions` | Boolean | `false` | Report exceptions Rails rescues | | `auto_instrument_active_job` | Boolean | `false` | Instrument ActiveJob | -| `capture_user_context` | Boolean | `true` | Include user info | +| `capture_request_context` | Boolean | `true` | Apply PostHog tracing headers and request metadata to captures during Rails requests | +| `capture_user_context` | Boolean | `true` | Include authenticated user info in exceptions | | `current_user_method` | Symbol | `:current_user` | Controller method for user | | `user_id_method` | Symbol | `nil` | Method to extract ID from user object (auto-detect if nil) | | `excluded_exceptions` | Array | `[]` | Additional exceptions to ignore | @@ -306,9 +308,19 @@ The following exceptions are not reported by default (common 4xx errors): You can add more with `PostHog::Rails.config.excluded_exceptions = ['MyException']`. +## Request Context + +PostHog Rails automatically applies request-scoped context to events captured during web requests. When present, PostHog tracing headers (`X-PostHog-Distinct-Id` and `X-PostHog-Session-Id`) are used as default `distinct_id` and `$session_id` values, and request metadata such as `$current_url`, `$request_method`, `$request_path`, `$user_agent`, and `$ip` is added to event properties. Explicit `distinct_id` and properties passed to `PostHog.capture` always take precedence. + +Disable this automatic request context if you do not want these headers or request metadata captured: + +```ruby +PostHog::Rails.config.capture_request_context = false +``` + ## User Context -PostHog Rails automatically captures user information from your controllers: +PostHog Rails automatically captures authenticated user information from your controllers for exceptions. Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity: ```ruby class ApplicationController < ActionController::Base @@ -402,7 +414,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for package-specific development instruct PostHog Rails uses the following components: - **Railtie** - Hooks into Rails initialization -- **Middleware** - Two middleware components capture exceptions: +- **Middleware** - Three middleware components provide request context and capture exceptions: + - `RequestContext` - Applies PostHog tracing headers and request metadata during Rails requests - `RescuedExceptionInterceptor` - Catches rescued exceptions - `CaptureExceptions` - Reports all exceptions to PostHog - **ActiveJob** - Prepends exception handling to `perform_now` diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index 3b6fc81..c2615a7 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -22,7 +22,13 @@ # Set to true to enable automatic ActiveJob exception tracking # config.auto_instrument_active_job = true - # Capture user context with exceptions (default: true) + # Apply PostHog tracing headers and request metadata to captures during Rails requests (default: true) + # Captures: X-PostHog-Distinct-Id, X-PostHog-Session-Id, current URL, method, path, user agent, and IP + # Set to false to disable automatic request context capture + # config.capture_request_context = true + + # Capture authenticated user context with exceptions (default: true) + # Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity # config.capture_user_context = true # Controller method name to get current user (default: :current_user) diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index 2f0ca2d..af81c8f 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -22,7 +22,13 @@ # Set to true to enable automatic ActiveJob exception tracking # config.auto_instrument_active_job = true - # Capture user context with exceptions (default: true) + # Apply PostHog tracing headers and request metadata to captures during Rails requests (default: true) + # Captures: X-PostHog-Distinct-Id, X-PostHog-Session-Id, current URL, method, path, user agent, and IP + # Set to false to disable automatic request context capture + # config.capture_request_context = true + + # Capture authenticated user context with exceptions (default: true) + # Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity # config.capture_user_context = true # Controller method name to get current user (default: :current_user) diff --git a/posthog-rails/lib/posthog/rails/capture_exceptions.rb b/posthog-rails/lib/posthog/rails/capture_exceptions.rb index 05e69a3..694d549 100644 --- a/posthog-rails/lib/posthog/rails/capture_exceptions.rb +++ b/posthog-rails/lib/posthog/rails/capture_exceptions.rb @@ -64,20 +64,21 @@ def capture_exception(exception, env) end def extract_distinct_id(env, _request) - context_distinct_id = Internal::Context.current&.distinct_id - return context_distinct_id if present?(context_distinct_id) - - # Try to get user from controller if capture_user_context is enabled + # Prefer authenticated Rails user context over client-supplied tracing headers. if PostHog::Rails.config&.capture_user_context && env['action_controller.instance'] controller = env['action_controller.instance'] method_name = PostHog::Rails.config&.current_user_method || :current_user if controller.respond_to?(method_name, true) user = controller.send(method_name) - return extract_user_id(user) if user + user_id = extract_user_id(user) if user + return user_id if present?(user_id) end end + context_distinct_id = Internal::Context.current&.distinct_id + return context_distinct_id if present?(context_distinct_id) + nil end @@ -140,11 +141,14 @@ def build_properties(request, env) end def client_ip(request) + trusted_ip = request.remote_ip + return trusted_ip if present?(trusted_ip) + forwarded_for = TracingHeaders.extract_header(request, 'X-Forwarded-For') forwarded_ip = forwarded_for.split(',').first&.strip if forwarded_for return forwarded_ip if present?(forwarded_ip) - request.remote_ip + nil rescue StandardError nil end diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index f64d3bf..ac6d46c 100644 --- a/posthog-rails/lib/posthog/rails/configuration.rb +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -15,6 +15,9 @@ class Configuration # List of exception classes to ignore (in addition to default) attr_accessor :excluded_exceptions + # Whether to apply request-scoped tracing headers and metadata to captures + attr_accessor :capture_request_context + # Whether to capture the current user context in exceptions attr_accessor :capture_user_context @@ -30,6 +33,7 @@ def initialize @report_rescued_exceptions = false @auto_instrument_active_job = false @excluded_exceptions = [] + @capture_request_context = true @capture_user_context = true @current_user_method = :current_user @user_id_method = nil diff --git a/posthog-rails/lib/posthog/rails/request_context.rb b/posthog-rails/lib/posthog/rails/request_context.rb index 6023fd7..5388140 100644 --- a/posthog-rails/lib/posthog/rails/request_context.rb +++ b/posthog-rails/lib/posthog/rails/request_context.rb @@ -12,6 +12,8 @@ def initialize(app) end def call(env) + return @app.call(env) if PostHog::Rails.config&.capture_request_context == false + request = build_request(env) Internal::Context.with_context(context_data(request), fresh: true) do @@ -24,15 +26,11 @@ def call(env) def context_data(request) session_id = tracing_header(request, 'X-POSTHOG-SESSION-ID') distinct_id = tracing_header(request, 'X-POSTHOG-DISTINCT-ID') - window_id = tracing_header(request, 'X-POSTHOG-WINDOW-ID') - - properties = request_properties(request) - properties['$window_id'] = window_id if window_id { distinct_id: distinct_id, session_id: session_id, - properties: properties + properties: request_properties(request) } end @@ -48,11 +46,18 @@ def request_properties(request) end def client_ip(request) + trusted_ip = request_value(request, :remote_ip) || request_value(request, :ip) + return trusted_ip if present?(trusted_ip) + forwarded_for = tracing_header(request, 'X-Forwarded-For') forwarded_ip = forwarded_for.split(',').first&.strip if forwarded_for - return forwarded_ip unless forwarded_ip.nil? || forwarded_ip.empty? + return forwarded_ip if present?(forwarded_ip) + + env_value(request, 'REMOTE_ADDR') + end - request_value(request, :remote_ip) || request_value(request, :ip) || env_value(request, 'REMOTE_ADDR') + def present?(value) + !(value.nil? || (value.respond_to?(:empty?) && value.empty?)) end def add_property(properties, key, value) diff --git a/spec/posthog/rails/request_context_spec.rb b/spec/posthog/rails/request_context_spec.rb index 5b38055..89ca8f5 100644 --- a/spec/posthog/rails/request_context_spec.rb +++ b/spec/posthog/rails/request_context_spec.rb @@ -13,6 +13,14 @@ RSpec.describe PostHog::Rails::RequestContext do let(:client) { PostHog::Client.new(api_key: API_KEY, test_mode: true) } + around do |example| + previous_config = PostHog::Rails.config + PostHog::Rails.config = PostHog::Rails::Configuration.new + example.run + ensure + PostHog::Rails.config = previous_config + end + def env_for(path = '/api/test', headers = nil, **header_keywords) headers = (headers || {}).merge(header_keywords) Rack::MockRequest.env_for( @@ -48,7 +56,7 @@ def call_with(headers = nil, path: '/api/test', **header_keywords, &block) message = client.dequeue_last_message expect(message[:distinct_id]).to eq('frontend-user') expect(message[:properties]['$session_id']).to eq('frontend-session') - expect(message[:properties]['$window_id']).to eq('window-123') + expect(message[:properties]).not_to have_key('$window_id') expect(message[:properties]['$current_url']).to include('/api/test') expect(message[:properties]['$request_method']).to eq('POST') expect(message[:properties]['$request_path']).to eq('/api/test') @@ -56,6 +64,38 @@ def call_with(headers = nil, path: '/api/test', **header_keywords, &block) expect(message[:properties]['$ip']).to eq('203.0.113.10') end + it 'can disable automatic Rails request context capture' do + PostHog::Rails.config.capture_request_context = false + + call_with( + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', + 'HTTP_X_POSTHOG_SESSION_ID' => 'header-session', + 'HTTP_USER_AGENT' => 'RSpec Agent' + ) do + client.capture(event: 'opt_out_event') + end + + message = client.dequeue_last_message + expect(message[:distinct_id]).not_to eq('header-user') + expect(message[:properties]['$session_id']).to be_nil + expect(message[:properties]['$request_path']).to be_nil + expect(message[:properties]['$user_agent']).to be_nil + expect(message[:properties]['$process_person_profile']).to be false + end + + it 'prefers Rails trusted remote_ip over raw forwarded headers' do + call_with( + 'action_dispatch.remote_ip' => '198.51.100.7', + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.10, 10.0.0.2' + ) do + client.capture(event: 'ip_event') + end + + message = client.dequeue_last_message + expect(message[:properties]['$ip']).to eq('198.51.100.7') + end + it 'lets explicit capture distinct_id and $session_id override tracing context' do call_with( 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', @@ -125,9 +165,57 @@ def call_with(headers = nil, path: '/api/test', **header_keywords, &block) expect(message[:properties]['$session_id']).to eq('s' * 1000) end + it 'prefers authenticated Rails user context over tracing headers for exceptions' do + PostHog::Rails.config.auto_capture_exceptions = true + + allow(PostHog).to receive(:capture_exception) do |exception, distinct_id, properties| + client.capture_exception(exception, distinct_id, properties) + end + + user = Struct.new(:id).new('rails-user') + controller_class = Class.new do + def initialize(user) + @user = user + end + + def controller_name + 'posts' + end + + def action_name + 'show' + end + + private + + def current_user + @user + end + end + + app = lambda do |env| + env['action_controller.instance'] = controller_class.new(user) + raise StandardError, 'boom' + end + middleware = described_class.new(PostHog::Rails::CaptureExceptions.new(app)) + + expect do + middleware.call( + env_for( + '/boom', + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', + 'HTTP_X_POSTHOG_SESSION_ID' => 'exception-session' + ) + ) + end.to raise_error(StandardError, 'boom') + + message = client.dequeue_last_message + expect(message[:event]).to eq('$exception') + expect(message[:distinct_id]).to eq('rails-user') + expect(message[:properties]['$session_id']).to eq('exception-session') + end + it 'captures exceptions with tracing context and re-raises' do - previous_config = PostHog::Rails.config - PostHog::Rails.config = PostHog::Rails::Configuration.new PostHog::Rails.config.auto_capture_exceptions = true allow(PostHog).to receive(:capture_exception) do |exception, distinct_id, properties| @@ -156,7 +244,5 @@ def call_with(headers = nil, path: '/api/test', **header_keywords, &block) expect(message[:properties]['$session_id']).to eq('exception-session') expect(message[:properties]['$request_path']).to eq('/boom') expect(message[:properties]['$user_agent']).to eq('Exception Agent') - ensure - PostHog::Rails.config = previous_config end end From 081c04fcb588f0099f561e3cfbb67c33dac2090d Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Wed, 6 May 2026 12:30:07 -0400 Subject: [PATCH 05/10] refactor: clarify request context helper naming --- lib/posthog/client.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 731943f..7d12a7f 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -192,7 +192,7 @@ def clear # @macro common_attrs def capture(attrs) symbolize_keys! attrs - apply_context_to_capture_attrs!(attrs) + enrich_capture_attrs_with_context(attrs) # Precedence: an explicit `flags` snapshot always wins, regardless of # `send_feature_flags`. The snapshot guarantees the event carries the same @@ -686,7 +686,7 @@ def shutdown private - def apply_context_to_capture_attrs!(attrs) + def enrich_capture_attrs_with_context(attrs) context = Internal::Context.current explicit_properties = attrs[:properties] properties_are_hash = explicit_properties.nil? || explicit_properties.is_a?(Hash) From 145129610a25985cc0b2d206f61939703611cdae Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Wed, 6 May 2026 12:42:42 -0400 Subject: [PATCH 06/10] refactor: share Rails request metadata extraction --- posthog-rails/lib/posthog/rails.rb | 1 + .../lib/posthog/rails/capture_exceptions.rb | 46 ++++++------ .../lib/posthog/rails/request_context.rb | 48 +------------ .../lib/posthog/rails/request_metadata.rb | 71 +++++++++++++++++++ spec/posthog/rails/request_context_spec.rb | 33 +++++++++ 5 files changed, 131 insertions(+), 68 deletions(-) create mode 100644 posthog-rails/lib/posthog/rails/request_metadata.rb diff --git a/posthog-rails/lib/posthog/rails.rb b/posthog-rails/lib/posthog/rails.rb index 65fa3a9..5e6d124 100644 --- a/posthog-rails/lib/posthog/rails.rb +++ b/posthog-rails/lib/posthog/rails.rb @@ -2,6 +2,7 @@ require 'posthog/rails/configuration' require 'posthog/rails/tracing_headers' +require 'posthog/rails/request_metadata' require 'posthog/rails/request_context' require 'posthog/rails/capture_exceptions' require 'posthog/rails/rescued_exception_interceptor' diff --git a/posthog-rails/lib/posthog/rails/capture_exceptions.rb b/posthog-rails/lib/posthog/rails/capture_exceptions.rb index 694d549..7fadef3 100644 --- a/posthog-rails/lib/posthog/rails/capture_exceptions.rb +++ b/posthog-rails/lib/posthog/rails/capture_exceptions.rb @@ -2,7 +2,7 @@ require 'posthog/internal/context' require 'posthog/rails/parameter_filter' -require 'posthog/rails/tracing_headers' +require 'posthog/rails/request_metadata' module PostHog module Rails @@ -104,11 +104,8 @@ def extract_user_id(user) def build_properties(request, env) properties = { - '$exception_source' => 'rails', - '$current_url' => safe_serialize(request.url), - '$request_method' => safe_serialize(request.method), - '$request_path' => safe_serialize(request.path) - } + '$exception_source' => 'rails' + }.merge(request_metadata_properties(request)) # Add controller and action if available if env['action_controller.instance'] @@ -124,13 +121,6 @@ def build_properties(request, env) properties['$request_params'] = safe_serialize(filtered_params) unless filtered_params.empty? end - # Add user agent - user_agent = TracingHeaders.sanitize_header_value(request.user_agent) - properties['$user_agent'] = safe_serialize(user_agent) if user_agent - - ip_address = client_ip(request) - properties['$ip'] = safe_serialize(ip_address) if ip_address - response_status_code = env['posthog.response_status_code'] properties['$response_status_code'] = response_status_code if response_status_code @@ -140,17 +130,29 @@ def build_properties(request, env) properties end - def client_ip(request) - trusted_ip = request.remote_ip - return trusted_ip if present?(trusted_ip) + REQUEST_METADATA_KEYS = %w[ + $current_url + $request_method + $request_path + $user_agent + $ip + ].freeze + private_constant :REQUEST_METADATA_KEYS + + def request_metadata_properties(request) + # When RequestContext is active, regular capture context already owns and + # applies these request properties. Fall back to direct extraction only + # when that context is unavailable, e.g. if capture_request_context is disabled. + return {} if request_metadata_in_context? + + RequestMetadata.extract(request) + end - forwarded_for = TracingHeaders.extract_header(request, 'X-Forwarded-For') - forwarded_ip = forwarded_for.split(',').first&.strip if forwarded_for - return forwarded_ip if present?(forwarded_ip) + def request_metadata_in_context? + properties = Internal::Context.current&.properties + return false unless properties.is_a?(Hash) - nil - rescue StandardError - nil + REQUEST_METADATA_KEYS.any? { |key| properties.key?(key) || properties.key?(key.to_sym) } end def response_status(response) diff --git a/posthog-rails/lib/posthog/rails/request_context.rb b/posthog-rails/lib/posthog/rails/request_context.rb index 5388140..170999b 100644 --- a/posthog-rails/lib/posthog/rails/request_context.rb +++ b/posthog-rails/lib/posthog/rails/request_context.rb @@ -2,6 +2,7 @@ require 'posthog/internal/context' require 'posthog/rails/tracing_headers' +require 'posthog/rails/request_metadata' module PostHog module Rails @@ -35,58 +36,13 @@ def context_data(request) end def request_properties(request) - properties = {} - add_property(properties, '$current_url', request_value(request, :url)) - request_method = request_value(request, :request_method) || request_value(request, :method) - add_property(properties, '$request_method', request_method) - add_property(properties, '$request_path', request_value(request, :path) || request_value(request, :path_info)) - add_property(properties, '$user_agent', tracing_header(request, 'User-Agent')) - add_property(properties, '$ip', client_ip(request)) - properties - end - - def client_ip(request) - trusted_ip = request_value(request, :remote_ip) || request_value(request, :ip) - return trusted_ip if present?(trusted_ip) - - forwarded_for = tracing_header(request, 'X-Forwarded-For') - forwarded_ip = forwarded_for.split(',').first&.strip if forwarded_for - return forwarded_ip if present?(forwarded_ip) - - env_value(request, 'REMOTE_ADDR') - end - - def present?(value) - !(value.nil? || (value.respond_to?(:empty?) && value.empty?)) - end - - def add_property(properties, key, value) - return if value.nil? - - serialized = value.to_s - return if serialized.empty? - - properties[key] = serialized + RequestMetadata.extract(request) end def tracing_header(request, header_name) TracingHeaders.extract_header(request, header_name) end - def request_value(request, method_name) - return unless request.respond_to?(method_name) - - request.public_send(method_name) - rescue StandardError - nil - end - - def env_value(request, key) - request.respond_to?(:get_header) ? request.get_header(key) : request.env[key] - rescue StandardError - nil - end - def build_request(env) if defined?(ActionDispatch::Request) ActionDispatch::Request.new(env) diff --git a/posthog-rails/lib/posthog/rails/request_metadata.rb b/posthog-rails/lib/posthog/rails/request_metadata.rb new file mode 100644 index 0000000..6895038 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/request_metadata.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'posthog/rails/tracing_headers' + +module PostHog + module Rails + # Internal helpers for extracting request metadata shared by request context + # and exception capture. Kept separate so exception capture can reuse the + # already-extracted request context when available and fall back to direct + # extraction when request context capture is disabled. + module RequestMetadata + module_function + + def extract(request) + properties = {} + add_property(properties, '$current_url', request_value(request, :url)) + request_method = request_value(request, :request_method) || request_value(request, :method) + add_property(properties, '$request_method', request_method) + add_property(properties, '$request_path', request_value(request, :path) || request_value(request, :path_info)) + add_property(properties, '$user_agent', TracingHeaders.extract_header(request, 'User-Agent')) + add_property(properties, '$ip', client_ip(request)) + properties + end + + def client_ip(request) + trusted_ip = request_value(request, :remote_ip) || request_value(request, :ip) + return trusted_ip if present?(trusted_ip) + + forwarded_for = TracingHeaders.extract_header(request, 'X-Forwarded-For') + forwarded_ip = forwarded_for.split(',').first&.strip if forwarded_for + return forwarded_ip if present?(forwarded_ip) + + env_value(request, 'REMOTE_ADDR') + end + private_class_method :client_ip + + def present?(value) + !(value.nil? || (value.respond_to?(:empty?) && value.empty?)) + end + private_class_method :present? + + def add_property(properties, key, value) + return if value.nil? + + serialized = value.to_s + return if serialized.empty? + + properties[key] = serialized + end + private_class_method :add_property + + def request_value(request, method_name) + return unless request.respond_to?(method_name) + + request.public_send(method_name) + rescue StandardError + nil + end + private_class_method :request_value + + def env_value(request, key) + request.respond_to?(:get_header) ? request.get_header(key) : request.env[key] + rescue StandardError + nil + end + private_class_method :env_value + end + + private_constant :RequestMetadata + end +end diff --git a/spec/posthog/rails/request_context_spec.rb b/spec/posthog/rails/request_context_spec.rb index 89ca8f5..8a0207b 100644 --- a/spec/posthog/rails/request_context_spec.rb +++ b/spec/posthog/rails/request_context_spec.rb @@ -245,4 +245,37 @@ def current_user expect(message[:properties]['$request_path']).to eq('/boom') expect(message[:properties]['$user_agent']).to eq('Exception Agent') end + + it 'preserves exception request metadata when request context capture is disabled' do + PostHog::Rails.config.auto_capture_exceptions = true + PostHog::Rails.config.capture_request_context = false + + allow(PostHog).to receive(:capture_exception) do |exception, distinct_id, properties| + client.capture_exception(exception, distinct_id, properties) + end + + app = lambda do |_env| + raise StandardError, 'boom' + end + middleware = described_class.new(PostHog::Rails::CaptureExceptions.new(app)) + + expect do + middleware.call( + env_for( + '/boom', + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'disabled-header-user', + 'HTTP_USER_AGENT' => 'Disabled Context Agent', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.11, 10.0.0.2' + ) + ) + end.to raise_error(StandardError, 'boom') + + message = client.dequeue_last_message + expect(message[:event]).to eq('$exception') + expect(message[:distinct_id]).not_to eq('disabled-header-user') + expect(message[:properties]['$process_person_profile']).to be false + expect(message[:properties]['$request_path']).to eq('/boom') + expect(message[:properties]['$user_agent']).to eq('Disabled Context Agent') + expect(message[:properties]['$ip']).to eq('203.0.113.11') + end end From 51de15ab3dd6a88f801d290359f4c14e3b290f54 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Wed, 6 May 2026 13:38:27 -0400 Subject: [PATCH 07/10] refactor: always apply Rails request context --- .../lib/posthog/rails/capture_exceptions.rb | 39 +++---------------- .../lib/posthog/rails/configuration.rb | 6 +-- .../lib/posthog/rails/request_context.rb | 21 +++++----- .../lib/posthog/rails/request_metadata.rb | 5 +-- spec/posthog/rails/request_context_spec.rb | 12 +++--- 5 files changed, 26 insertions(+), 57 deletions(-) diff --git a/posthog-rails/lib/posthog/rails/capture_exceptions.rb b/posthog-rails/lib/posthog/rails/capture_exceptions.rb index 7fadef3..c8c2801 100644 --- a/posthog-rails/lib/posthog/rails/capture_exceptions.rb +++ b/posthog-rails/lib/posthog/rails/capture_exceptions.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require 'posthog/internal/context' require 'posthog/rails/parameter_filter' -require 'posthog/rails/request_metadata' module PostHog module Rails @@ -54,7 +52,7 @@ def should_capture?(exception) def capture_exception(exception, env) request = ActionDispatch::Request.new(env) - distinct_id = extract_distinct_id(env, request) + distinct_id = extract_distinct_id(env) additional_properties = build_properties(request, env) PostHog.capture_exception(exception, distinct_id, additional_properties) @@ -63,8 +61,9 @@ def capture_exception(exception, env) PostHog::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}") end - def extract_distinct_id(env, _request) - # Prefer authenticated Rails user context over client-supplied tracing headers. + def extract_distinct_id(env) + # Prefer authenticated Rails user context. Request/tracing context is + # applied later by the core capture path if this returns nil. if PostHog::Rails.config&.capture_user_context && env['action_controller.instance'] controller = env['action_controller.instance'] method_name = PostHog::Rails.config&.current_user_method || :current_user @@ -76,9 +75,6 @@ def extract_distinct_id(env, _request) end end - context_distinct_id = Internal::Context.current&.distinct_id - return context_distinct_id if present?(context_distinct_id) - nil end @@ -105,7 +101,7 @@ def extract_user_id(user) def build_properties(request, env) properties = { '$exception_source' => 'rails' - }.merge(request_metadata_properties(request)) + } # Add controller and action if available if env['action_controller.instance'] @@ -130,31 +126,6 @@ def build_properties(request, env) properties end - REQUEST_METADATA_KEYS = %w[ - $current_url - $request_method - $request_path - $user_agent - $ip - ].freeze - private_constant :REQUEST_METADATA_KEYS - - def request_metadata_properties(request) - # When RequestContext is active, regular capture context already owns and - # applies these request properties. Fall back to direct extraction only - # when that context is unavailable, e.g. if capture_request_context is disabled. - return {} if request_metadata_in_context? - - RequestMetadata.extract(request) - end - - def request_metadata_in_context? - properties = Internal::Context.current&.properties - return false unless properties.is_a?(Hash) - - REQUEST_METADATA_KEYS.any? { |key| properties.key?(key) || properties.key?(key.to_sym) } - end - def response_status(response) status = response.respond_to?(:[]) ? response[0] : nil status if status.is_a?(Integer) diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index ac6d46c..0884ef2 100644 --- a/posthog-rails/lib/posthog/rails/configuration.rb +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -15,8 +15,8 @@ class Configuration # List of exception classes to ignore (in addition to default) attr_accessor :excluded_exceptions - # Whether to apply request-scoped tracing headers and metadata to captures - attr_accessor :capture_request_context + # Whether to use PostHog tracing headers for request-scoped identity/session context + attr_accessor :capture_tracing_headers # Whether to capture the current user context in exceptions attr_accessor :capture_user_context @@ -33,7 +33,7 @@ def initialize @report_rescued_exceptions = false @auto_instrument_active_job = false @excluded_exceptions = [] - @capture_request_context = true + @capture_tracing_headers = true @capture_user_context = true @current_user_method = :current_user @user_id_method = nil diff --git a/posthog-rails/lib/posthog/rails/request_context.rb b/posthog-rails/lib/posthog/rails/request_context.rb index 170999b..c96e9cc 100644 --- a/posthog-rails/lib/posthog/rails/request_context.rb +++ b/posthog-rails/lib/posthog/rails/request_context.rb @@ -13,8 +13,6 @@ def initialize(app) end def call(env) - return @app.call(env) if PostHog::Rails.config&.capture_request_context == false - request = build_request(env) Internal::Context.with_context(context_data(request), fresh: true) do @@ -25,14 +23,17 @@ def call(env) private def context_data(request) - session_id = tracing_header(request, 'X-POSTHOG-SESSION-ID') - distinct_id = tracing_header(request, 'X-POSTHOG-DISTINCT-ID') - - { - distinct_id: distinct_id, - session_id: session_id, - properties: request_properties(request) - } + data = { properties: request_properties(request) } + return data unless capture_tracing_headers? + + data.merge( + distinct_id: tracing_header(request, 'X-POSTHOG-DISTINCT-ID'), + session_id: tracing_header(request, 'X-POSTHOG-SESSION-ID') + ) + end + + def capture_tracing_headers? + PostHog::Rails.config&.capture_tracing_headers != false end def request_properties(request) diff --git a/posthog-rails/lib/posthog/rails/request_metadata.rb b/posthog-rails/lib/posthog/rails/request_metadata.rb index 6895038..58f789c 100644 --- a/posthog-rails/lib/posthog/rails/request_metadata.rb +++ b/posthog-rails/lib/posthog/rails/request_metadata.rb @@ -4,10 +4,7 @@ module PostHog module Rails - # Internal helpers for extracting request metadata shared by request context - # and exception capture. Kept separate so exception capture can reuse the - # already-extracted request context when available and fall back to direct - # extraction when request context capture is disabled. + # Internal helpers for extracting request metadata owned by RequestContext. module RequestMetadata module_function diff --git a/spec/posthog/rails/request_context_spec.rb b/spec/posthog/rails/request_context_spec.rb index 8a0207b..275f6f3 100644 --- a/spec/posthog/rails/request_context_spec.rb +++ b/spec/posthog/rails/request_context_spec.rb @@ -64,8 +64,8 @@ def call_with(headers = nil, path: '/api/test', **header_keywords, &block) expect(message[:properties]['$ip']).to eq('203.0.113.10') end - it 'can disable automatic Rails request context capture' do - PostHog::Rails.config.capture_request_context = false + it 'can disable tracing header capture while preserving request metadata' do + PostHog::Rails.config.capture_tracing_headers = false call_with( 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', @@ -78,8 +78,8 @@ def call_with(headers = nil, path: '/api/test', **header_keywords, &block) message = client.dequeue_last_message expect(message[:distinct_id]).not_to eq('header-user') expect(message[:properties]['$session_id']).to be_nil - expect(message[:properties]['$request_path']).to be_nil - expect(message[:properties]['$user_agent']).to be_nil + expect(message[:properties]['$request_path']).to eq('/api/test') + expect(message[:properties]['$user_agent']).to eq('RSpec Agent') expect(message[:properties]['$process_person_profile']).to be false end @@ -246,9 +246,9 @@ def current_user expect(message[:properties]['$user_agent']).to eq('Exception Agent') end - it 'preserves exception request metadata when request context capture is disabled' do + it 'disables tracing headers for exceptions while preserving request metadata' do PostHog::Rails.config.auto_capture_exceptions = true - PostHog::Rails.config.capture_request_context = false + PostHog::Rails.config.capture_tracing_headers = false allow(PostHog).to receive(:capture_exception) do |exception, distinct_id, properties| client.capture_exception(exception, distinct_id, properties) From 45c1f92c6e911a307558b3180a1a29f97628f3cf Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Wed, 6 May 2026 13:38:43 -0400 Subject: [PATCH 08/10] docs: clarify Rails tracing header context --- .changeset/hungry-hedgehogs-request-context.md | 2 +- posthog-rails/README.md | 12 ++++++------ posthog-rails/examples/posthog.rb | 8 ++++---- .../lib/generators/posthog/templates/posthog.rb | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.changeset/hungry-hedgehogs-request-context.md b/.changeset/hungry-hedgehogs-request-context.md index 4274e8f..bf80c5b 100644 --- a/.changeset/hungry-hedgehogs-request-context.md +++ b/.changeset/hungry-hedgehogs-request-context.md @@ -2,4 +2,4 @@ "posthog-ruby": minor --- -Add internal request context support for Rails so PostHog tracing headers and request metadata can be applied to captures and exception events during a request. Captures without an explicit distinct_id now use request context when available, otherwise they are sent as personless events with a generated UUID. +Add internal request context support for Rails so request metadata is applied to captures and exception events during a request, with optional PostHog tracing header support for request-scoped identity/session context. Captures without an explicit distinct_id now use request context when available, otherwise they are sent as personless events with a generated UUID. diff --git a/posthog-rails/README.md b/posthog-rails/README.md index f8ce4a2..8db6e9e 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -50,7 +50,7 @@ PostHog::Rails.configure do |config| config.auto_capture_exceptions = true # Enable automatic exception capture (default: false) config.report_rescued_exceptions = true # Report exceptions Rails rescues (default: false) config.auto_instrument_active_job = true # Instrument background jobs (default: false) - config.capture_request_context = true # Apply tracing headers/request metadata to captures (default: true) + config.capture_tracing_headers = true # Use PostHog tracing headers for identity/session context (default: true) config.capture_user_context = true # Include authenticated user info in exceptions config.current_user_method = :current_user # Method to get current user config.user_id_method = nil # Method to get ID from user (auto-detect) @@ -253,7 +253,7 @@ Configure these via `PostHog::Rails.configure` or `PostHog::Rails.config`: | `auto_capture_exceptions` | Boolean | `false` | Automatically capture exceptions | | `report_rescued_exceptions` | Boolean | `false` | Report exceptions Rails rescues | | `auto_instrument_active_job` | Boolean | `false` | Instrument ActiveJob | -| `capture_request_context` | Boolean | `true` | Apply PostHog tracing headers and request metadata to captures during Rails requests | +| `capture_tracing_headers` | Boolean | `true` | Use PostHog tracing headers as request-scoped default `distinct_id` and `$session_id` values | | `capture_user_context` | Boolean | `true` | Include authenticated user info in exceptions | | `current_user_method` | Symbol | `:current_user` | Controller method for user | | `user_id_method` | Symbol | `nil` | Method to extract ID from user object (auto-detect if nil) | @@ -310,12 +310,12 @@ You can add more with `PostHog::Rails.config.excluded_exceptions = ['MyException ## Request Context -PostHog Rails automatically applies request-scoped context to events captured during web requests. When present, PostHog tracing headers (`X-PostHog-Distinct-Id` and `X-PostHog-Session-Id`) are used as default `distinct_id` and `$session_id` values, and request metadata such as `$current_url`, `$request_method`, `$request_path`, `$user_agent`, and `$ip` is added to event properties. Explicit `distinct_id` and properties passed to `PostHog.capture` always take precedence. +PostHog Rails automatically applies request-scoped context to events captured during web requests. Request metadata such as `$current_url`, `$request_method`, `$request_path`, `$user_agent`, and `$ip` is added to event properties. When present, PostHog tracing headers (`X-PostHog-Distinct-Id` and `X-PostHog-Session-Id`) are also used as default `distinct_id` and `$session_id` values. Explicit `distinct_id` and properties passed to `PostHog.capture` always take precedence. -Disable this automatic request context if you do not want these headers or request metadata captured: +Disable tracing header identity/session capture if you do not want client-supplied PostHog tracing headers used for server-side events. Request metadata is still captured: ```ruby -PostHog::Rails.config.capture_request_context = false +PostHog::Rails.config.capture_tracing_headers = false ``` ## User Context @@ -415,7 +415,7 @@ PostHog Rails uses the following components: - **Railtie** - Hooks into Rails initialization - **Middleware** - Three middleware components provide request context and capture exceptions: - - `RequestContext` - Applies PostHog tracing headers and request metadata during Rails requests + - `RequestContext` - Applies request metadata and optional PostHog tracing header identity/session context during Rails requests - `RescuedExceptionInterceptor` - Catches rescued exceptions - `CaptureExceptions` - Reports all exceptions to PostHog - **ActiveJob** - Prepends exception handling to `perform_now` diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index c2615a7..c7a99be 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -22,10 +22,10 @@ # Set to true to enable automatic ActiveJob exception tracking # config.auto_instrument_active_job = true - # Apply PostHog tracing headers and request metadata to captures during Rails requests (default: true) - # Captures: X-PostHog-Distinct-Id, X-PostHog-Session-Id, current URL, method, path, user agent, and IP - # Set to false to disable automatic request context capture - # config.capture_request_context = true + # Use PostHog tracing headers for request-scoped identity/session context (default: true) + # Request metadata (current URL, method, path, user agent, and IP) is always captured during Rails requests + # Set to false to ignore client-supplied X-PostHog-Distinct-Id and X-PostHog-Session-Id headers + # config.capture_tracing_headers = true # Capture authenticated user context with exceptions (default: true) # Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index af81c8f..c864e05 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -22,10 +22,10 @@ # Set to true to enable automatic ActiveJob exception tracking # config.auto_instrument_active_job = true - # Apply PostHog tracing headers and request metadata to captures during Rails requests (default: true) - # Captures: X-PostHog-Distinct-Id, X-PostHog-Session-Id, current URL, method, path, user agent, and IP - # Set to false to disable automatic request context capture - # config.capture_request_context = true + # Use PostHog tracing headers for request-scoped identity/session context (default: true) + # Request metadata (current URL, method, path, user agent, and IP) is always captured during Rails requests + # Set to false to ignore client-supplied X-PostHog-Distinct-Id and X-PostHog-Session-Id headers + # config.capture_tracing_headers = true # Capture authenticated user context with exceptions (default: true) # Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity From bce69b4429284937388ee8268fe4e4cbff6f3012 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Wed, 6 May 2026 14:12:18 -0400 Subject: [PATCH 09/10] refactor: rename Rails tracing header config --- posthog-rails/README.md | 6 +++--- posthog-rails/examples/posthog.rb | 2 +- posthog-rails/lib/generators/posthog/templates/posthog.rb | 2 +- posthog-rails/lib/posthog/rails/configuration.rb | 4 ++-- posthog-rails/lib/posthog/rails/request_context.rb | 6 +++--- spec/posthog/rails/request_context_spec.rb | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/posthog-rails/README.md b/posthog-rails/README.md index 8db6e9e..6d19268 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -50,7 +50,7 @@ PostHog::Rails.configure do |config| config.auto_capture_exceptions = true # Enable automatic exception capture (default: false) config.report_rescued_exceptions = true # Report exceptions Rails rescues (default: false) config.auto_instrument_active_job = true # Instrument background jobs (default: false) - config.capture_tracing_headers = true # Use PostHog tracing headers for identity/session context (default: true) + config.use_tracing_headers = true # Use PostHog tracing headers for identity/session context (default: true) config.capture_user_context = true # Include authenticated user info in exceptions config.current_user_method = :current_user # Method to get current user config.user_id_method = nil # Method to get ID from user (auto-detect) @@ -253,7 +253,7 @@ Configure these via `PostHog::Rails.configure` or `PostHog::Rails.config`: | `auto_capture_exceptions` | Boolean | `false` | Automatically capture exceptions | | `report_rescued_exceptions` | Boolean | `false` | Report exceptions Rails rescues | | `auto_instrument_active_job` | Boolean | `false` | Instrument ActiveJob | -| `capture_tracing_headers` | Boolean | `true` | Use PostHog tracing headers as request-scoped default `distinct_id` and `$session_id` values | +| `use_tracing_headers` | Boolean | `true` | Use PostHog tracing headers as request-scoped default `distinct_id` and `$session_id` values | | `capture_user_context` | Boolean | `true` | Include authenticated user info in exceptions | | `current_user_method` | Symbol | `:current_user` | Controller method for user | | `user_id_method` | Symbol | `nil` | Method to extract ID from user object (auto-detect if nil) | @@ -315,7 +315,7 @@ PostHog Rails automatically applies request-scoped context to events captured du Disable tracing header identity/session capture if you do not want client-supplied PostHog tracing headers used for server-side events. Request metadata is still captured: ```ruby -PostHog::Rails.config.capture_tracing_headers = false +PostHog::Rails.config.use_tracing_headers = false ``` ## User Context diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb index c7a99be..ffd8318 100644 --- a/posthog-rails/examples/posthog.rb +++ b/posthog-rails/examples/posthog.rb @@ -25,7 +25,7 @@ # Use PostHog tracing headers for request-scoped identity/session context (default: true) # Request metadata (current URL, method, path, user agent, and IP) is always captured during Rails requests # Set to false to ignore client-supplied X-PostHog-Distinct-Id and X-PostHog-Session-Id headers - # config.capture_tracing_headers = true + # config.use_tracing_headers = true # Capture authenticated user context with exceptions (default: true) # Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity diff --git a/posthog-rails/lib/generators/posthog/templates/posthog.rb b/posthog-rails/lib/generators/posthog/templates/posthog.rb index c864e05..7a13aec 100644 --- a/posthog-rails/lib/generators/posthog/templates/posthog.rb +++ b/posthog-rails/lib/generators/posthog/templates/posthog.rb @@ -25,7 +25,7 @@ # Use PostHog tracing headers for request-scoped identity/session context (default: true) # Request metadata (current URL, method, path, user agent, and IP) is always captured during Rails requests # Set to false to ignore client-supplied X-PostHog-Distinct-Id and X-PostHog-Session-Id headers - # config.capture_tracing_headers = true + # config.use_tracing_headers = true # Capture authenticated user context with exceptions (default: true) # Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index 0884ef2..169b9f8 100644 --- a/posthog-rails/lib/posthog/rails/configuration.rb +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -16,7 +16,7 @@ class Configuration attr_accessor :excluded_exceptions # Whether to use PostHog tracing headers for request-scoped identity/session context - attr_accessor :capture_tracing_headers + attr_accessor :use_tracing_headers # Whether to capture the current user context in exceptions attr_accessor :capture_user_context @@ -33,7 +33,7 @@ def initialize @report_rescued_exceptions = false @auto_instrument_active_job = false @excluded_exceptions = [] - @capture_tracing_headers = true + @use_tracing_headers = true @capture_user_context = true @current_user_method = :current_user @user_id_method = nil diff --git a/posthog-rails/lib/posthog/rails/request_context.rb b/posthog-rails/lib/posthog/rails/request_context.rb index c96e9cc..c951a71 100644 --- a/posthog-rails/lib/posthog/rails/request_context.rb +++ b/posthog-rails/lib/posthog/rails/request_context.rb @@ -24,7 +24,7 @@ def call(env) def context_data(request) data = { properties: request_properties(request) } - return data unless capture_tracing_headers? + return data unless use_tracing_headers? data.merge( distinct_id: tracing_header(request, 'X-POSTHOG-DISTINCT-ID'), @@ -32,8 +32,8 @@ def context_data(request) ) end - def capture_tracing_headers? - PostHog::Rails.config&.capture_tracing_headers != false + def use_tracing_headers? + PostHog::Rails.config&.use_tracing_headers != false end def request_properties(request) diff --git a/spec/posthog/rails/request_context_spec.rb b/spec/posthog/rails/request_context_spec.rb index 275f6f3..e37943e 100644 --- a/spec/posthog/rails/request_context_spec.rb +++ b/spec/posthog/rails/request_context_spec.rb @@ -65,7 +65,7 @@ def call_with(headers = nil, path: '/api/test', **header_keywords, &block) end it 'can disable tracing header capture while preserving request metadata' do - PostHog::Rails.config.capture_tracing_headers = false + PostHog::Rails.config.use_tracing_headers = false call_with( 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', @@ -248,7 +248,7 @@ def current_user it 'disables tracing headers for exceptions while preserving request metadata' do PostHog::Rails.config.auto_capture_exceptions = true - PostHog::Rails.config.capture_tracing_headers = false + PostHog::Rails.config.use_tracing_headers = false allow(PostHog).to receive(:capture_exception) do |exception, distinct_id, properties| client.capture_exception(exception, distinct_id, properties) From 8bcb14331dbae5bc81f85100bb8a711ed2c889ed Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Thu, 7 May 2026 16:01:24 -0400 Subject: [PATCH 10/10] fix: omit query params from Rails current_url --- posthog-rails/lib/posthog/rails/request_metadata.rb | 10 +++++++++- spec/posthog/rails/request_context_spec.rb | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/posthog-rails/lib/posthog/rails/request_metadata.rb b/posthog-rails/lib/posthog/rails/request_metadata.rb index 58f789c..dcaa106 100644 --- a/posthog-rails/lib/posthog/rails/request_metadata.rb +++ b/posthog-rails/lib/posthog/rails/request_metadata.rb @@ -10,7 +10,7 @@ module RequestMetadata def extract(request) properties = {} - add_property(properties, '$current_url', request_value(request, :url)) + add_property(properties, '$current_url', current_url(request)) request_method = request_value(request, :request_method) || request_value(request, :method) add_property(properties, '$request_method', request_method) add_property(properties, '$request_path', request_value(request, :path) || request_value(request, :path_info)) @@ -19,6 +19,14 @@ def extract(request) properties end + def current_url(request) + url = request_value(request, :url) + return if url.nil? + + url.to_s.split('?', 2).first + end + private_class_method :current_url + def client_ip(request) trusted_ip = request_value(request, :remote_ip) || request_value(request, :ip) return trusted_ip if present?(trusted_ip) diff --git a/spec/posthog/rails/request_context_spec.rb b/spec/posthog/rails/request_context_spec.rb index e37943e..fb5b9b9 100644 --- a/spec/posthog/rails/request_context_spec.rb +++ b/spec/posthog/rails/request_context_spec.rb @@ -64,6 +64,18 @@ def call_with(headers = nil, path: '/api/test', **header_keywords, &block) expect(message[:properties]['$ip']).to eq('203.0.113.10') end + it 'does not include query parameters in $current_url' do + call_with(path: '/api/test?token=secret&email=user@example.com') do + client.capture(event: 'query_event') + end + + message = client.dequeue_last_message + expect(message[:properties]['$current_url']).to include('/api/test') + expect(message[:properties]['$current_url']).not_to include('?') + expect(message[:properties]['$current_url']).not_to include('token=secret') + expect(message[:properties]['$current_url']).not_to include('user@example.com') + end + it 'can disable tracing header capture while preserving request metadata' do PostHog::Rails.config.use_tracing_headers = false