diff --git a/.changeset/hungry-hedgehogs-request-context.md b/.changeset/hungry-hedgehogs-request-context.md new file mode 100644 index 0000000..bf80c5b --- /dev/null +++ b/.changeset/hungry-hedgehogs-request-context.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +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/lib/posthog/client.rb b/lib/posthog/client.rb index 16b1f78..7d12a7f 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/internal/context' module PostHog class Client @@ -185,9 +186,13 @@ 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 + 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 @@ -260,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 @@ -270,12 +276,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, @@ -684,6 +686,39 @@ def shutdown private + 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) + context_properties = context&.properties || {} + if properties_are_hash + attrs[:properties] = Internal::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/internal/context.rb b/lib/posthog/internal/context.rb new file mode 100644 index 0000000..ef30c03 --- /dev/null +++ b/lib/posthog/internal/context.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +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 + # 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 + if defined?(ActiveSupport::IsolatedExecutionState) + ActiveSupport::IsolatedExecutionState[STORAGE_KEY] + else + Thread.current[STORAGE_KEY] + end + end + + def self.current=(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) + 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/README.md b/posthog-rails/README.md index c5b906b..6d19268 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.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) @@ -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 | +| `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) | | `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. 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 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.use_tracing_headers = 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 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 3b6fc81..ffd8318 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) + # 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.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 # 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..7a13aec 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) + # 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.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 # config.capture_user_context = true # Controller method name to get current user (default: :current_user) diff --git a/posthog-rails/lib/posthog/rails.rb b/posthog-rails/lib/posthog/rails.rb index 450b3e1..5e6d124 100644 --- a/posthog-rails/lib/posthog/rails.rb +++ b/posthog-rails/lib/posthog/rails.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true 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' 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..c8c2801 100644 --- a/posthog-rails/lib/posthog/rails/capture_exceptions.rb +++ b/posthog-rails/lib/posthog/rails/capture_exceptions.rb @@ -18,6 +18,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) @@ -51,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) @@ -60,20 +61,21 @@ def capture_exception(exception, env) PostHog::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}") end - def extract_distinct_id(env, request) - # Try to get user from controller if capture_user_context is enabled + 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 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 - # Fallback to session ID or nil - request.session&.id&.to_s + nil end def extract_user_id(user) @@ -98,10 +100,7 @@ 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' } # Add controller and action if available @@ -118,14 +117,23 @@ def build_properties(request, env) properties['$request_params'] = safe_serialize(filtered_params) unless filtered_params.empty? end - # Add user agent - properties['$user_agent'] = safe_serialize(request.user_agent) if request.user_agent + 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 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/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb index f64d3bf..169b9f8 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 use PostHog tracing headers for request-scoped identity/session context + attr_accessor :use_tracing_headers + # 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 = [] + @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/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..c951a71 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/request_context.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'posthog/internal/context' +require 'posthog/rails/tracing_headers' +require 'posthog/rails/request_metadata' + +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) + + Internal::Context.with_context(context_data(request), fresh: true) do + @app.call(env) + end + end + + private + + def context_data(request) + data = { properties: request_properties(request) } + return data unless use_tracing_headers? + + data.merge( + distinct_id: tracing_header(request, 'X-POSTHOG-DISTINCT-ID'), + session_id: tracing_header(request, 'X-POSTHOG-SESSION-ID') + ) + end + + def use_tracing_headers? + PostHog::Rails.config&.use_tracing_headers != false + end + + def request_properties(request) + RequestMetadata.extract(request) + end + + def tracing_header(request, header_name) + TracingHeaders.extract_header(request, header_name) + 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/request_metadata.rb b/posthog-rails/lib/posthog/rails/request_metadata.rb new file mode 100644 index 0000000..dcaa106 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/request_metadata.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'posthog/rails/tracing_headers' + +module PostHog + module Rails + # Internal helpers for extracting request metadata owned by RequestContext. + module RequestMetadata + module_function + + def extract(request) + properties = {} + 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)) + add_property(properties, '$user_agent', TracingHeaders.extract_header(request, 'User-Agent')) + add_property(properties, '$ip', client_ip(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) + + 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/posthog-rails/lib/posthog/rails/tracing_headers.rb b/posthog-rails/lib/posthog/rails/tracing_headers.rb new file mode 100644 index 0000000..8fec5f7 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/tracing_headers.rb @@ -0,0 +1,77 @@ +# 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 + + private_constant :TracingHeaders + end +end diff --git a/spec/posthog/client_spec.rb b/spec/posthog/client_spec.rb index 62a7c91..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 @@ -192,8 +193,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 +985,174 @@ module PostHog end 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 + context_class.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 + context_class.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 + context_class.with_context( + distinct_id: 'outer-user', + session_id: 'outer-session', + properties: { 'outer' => true, 'shared' => 'parent' } + ) do + context_class.with_context(properties: { 'inner' => true, 'shared' => 'child' }) do + client.capture(event: 'inherited_event') + end + + context_class.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 'allows a child context session_id to override the inherited session_id' 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 + + message = client.dequeue_last_message + expect(message[:properties]['$session_id']).to eq('inner-session') + end + + it 'restores context after the block exits' do + context_class.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 + context_class.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 + context_class.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) 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..fb5b9b9 --- /dev/null +++ b/spec/posthog/rails/request_context_spec.rb @@ -0,0 +1,293 @@ +# 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) } + + 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( + 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]).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') + expect(message[:properties]['$user_agent']).to eq('RSpec Agent') + 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 + + 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 eq('/api/test') + expect(message[:properties]['$user_agent']).to eq('RSpec Agent') + 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', + '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 '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 + 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') + end + + it 'disables tracing headers for exceptions while preserving request metadata' do + PostHog::Rails.config.auto_capture_exceptions = true + 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) + 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