Skip to content
5 changes: 5 additions & 0 deletions .changeset/hungry-hedgehogs-request-context.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 40 additions & 5 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
119 changes: 119 additions & 0 deletions lib/posthog/internal/context.rb
Original file line number Diff line number Diff line change
@@ -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
21 changes: 17 additions & 4 deletions posthog-rails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
8 changes: 7 additions & 1 deletion posthog-rails/examples/posthog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion posthog-rails/lib/generators/posthog/templates/posthog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions posthog-rails/lib/posthog/rails.rb
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
32 changes: 20 additions & 12 deletions posthog-rails/lib/posthog/rails/capture_exceptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Loading
Loading