Skip to content

Commit e3ae2b4

Browse files
authored
feat: add Rails request context support (#144)
* fix: align request context behavior * fix: align X-PostHog tracing headers * refactor: keep request context internal * fix: tighten rails request context behavior * refactor: clarify request context helper naming * refactor: share Rails request metadata extraction * refactor: always apply Rails request context * docs: clarify Rails tracing header context * refactor: rename Rails tracing header config * fix: omit query params from Rails current_url
1 parent ae72db3 commit e3ae2b4

16 files changed

Lines changed: 927 additions & 27 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"posthog-ruby": minor
3+
---
4+
5+
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.

lib/posthog/client.rb

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
require 'posthog/feature_flag_evaluations'
1515
require 'posthog/send_feature_flags_options'
1616
require 'posthog/exception_capture'
17+
require 'posthog/internal/context'
1718

1819
module PostHog
1920
class Client
@@ -185,9 +186,13 @@ def clear
185186
# events in PostHog are deduplicated by the
186187
# combination of teamId, timestamp date,
187188
# event name, distinct id, and UUID
189+
# @note If `:distinct_id` is omitted, request/context distinct_id is used when
190+
# available; otherwise a UUID is generated and the event is marked personless
191+
# with `$process_person_profile: false`.
188192
# @macro common_attrs
189193
def capture(attrs)
190194
symbolize_keys! attrs
195+
enrich_capture_attrs_with_context(attrs)
191196

192197
# Precedence: an explicit `flags` snapshot always wins, regardless of
193198
# `send_feature_flags`. The snapshot guarantees the event carries the same
@@ -260,7 +265,8 @@ def capture(attrs)
260265
# Captures an exception as an event
261266
#
262267
# @param [Exception, String, Object] exception The exception to capture, a string message, or exception-like object
263-
# @param [String] distinct_id The ID for the user (optional, defaults to a generated UUID)
268+
# @param [String] distinct_id The ID for the user (optional, defaults to request/context distinct_id
269+
# or a generated UUID)
264270
# @param [Hash] additional_properties Additional properties to include with the exception event (optional)
265271
# @param [PostHog::FeatureFlagEvaluations] flags A snapshot returned by {#evaluate_flags}.
266272
# 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 = {},
270276

271277
return if exception_info.nil?
272278

273-
no_distinct_id_was_provided = distinct_id.nil?
274-
distinct_id ||= SecureRandom.uuid
275-
276279
properties = { '$exception_list' => [exception_info] }
277280
properties.merge!(additional_properties) if additional_properties && !additional_properties.empty?
278-
properties['$process_person_profile'] = false if no_distinct_id_was_provided
279281

280282
event_data = {
281283
distinct_id: distinct_id,
@@ -685,6 +687,39 @@ def shutdown
685687

686688
private
687689

690+
def enrich_capture_attrs_with_context(attrs)
691+
context = Internal::Context.current
692+
explicit_properties = attrs[:properties]
693+
properties_are_hash = explicit_properties.nil? || explicit_properties.is_a?(Hash)
694+
context_properties = context&.properties || {}
695+
if properties_are_hash
696+
attrs[:properties] = Internal::Context.merge_properties(context_properties, explicit_properties || {})
697+
end
698+
699+
return if present_id?(attrs[:distinct_id])
700+
701+
if present_id?(context&.distinct_id)
702+
attrs[:distinct_id] = context.distinct_id
703+
return
704+
end
705+
706+
attrs[:distinct_id] = SecureRandom.uuid
707+
return unless properties_are_hash
708+
return if property_key?(explicit_properties, '$process_person_profile')
709+
710+
attrs[:properties]['$process_person_profile'] = false
711+
end
712+
713+
def present_id?(value)
714+
!(value.nil? || (value.is_a?(String) && value.empty?))
715+
end
716+
717+
def property_key?(properties, key)
718+
return false unless properties.is_a?(Hash)
719+
720+
properties.key?(key) || properties.key?(key.to_sym)
721+
end
722+
688723
# Shared by the legacy single-flag path ({#get_feature_flag_result}) and the
689724
# snapshot's access-recording. Owns dedup-key construction, the
690725
# per-distinct_id sent-flags cache, and the `$feature_flag_called` capture call.

lib/posthog/internal/context.rb

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# frozen_string_literal: true
2+
3+
module PostHog
4+
module Internal
5+
# Internal request/fiber-local context applied to capture calls.
6+
# Uses Rails' isolated execution state when available, otherwise falls back
7+
# to thread-local storage in the core SDK.
8+
#
9+
# This is intentionally not exposed as a public SDK API in Ruby yet. It exists
10+
# to let framework integrations such as posthog-rails propagate request-scoped
11+
# tracing headers to regular capture and exception events without making the
12+
# server-side SDK globally stateful per user.
13+
class Context
14+
STORAGE_KEY = :posthog_context
15+
16+
attr_reader :distinct_id, :session_id, :properties
17+
18+
def initialize(distinct_id: nil, session_id: nil, properties: {})
19+
@distinct_id = distinct_id
20+
@session_id = session_id
21+
@properties = properties ? properties.dup : {}
22+
apply_session_property!
23+
end
24+
25+
def self.current
26+
if defined?(ActiveSupport::IsolatedExecutionState)
27+
ActiveSupport::IsolatedExecutionState[STORAGE_KEY]
28+
else
29+
Thread.current[STORAGE_KEY]
30+
end
31+
end
32+
33+
def self.current=(context)
34+
if defined?(ActiveSupport::IsolatedExecutionState)
35+
ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = context
36+
else
37+
Thread.current[STORAGE_KEY] = context
38+
end
39+
end
40+
41+
def self.with_context(data = nil, fresh: false, **kwargs)
42+
previous_context = current
43+
raise ArgumentError, 'with_context requires a block' unless block_given?
44+
45+
self.current = resolve(merge_data_and_kwargs(data, kwargs), previous_context, fresh: fresh)
46+
yield
47+
ensure
48+
self.current = previous_context
49+
end
50+
51+
def self.resolve(data, parent, fresh: false)
52+
data = normalize_data(data)
53+
54+
parent_properties = fresh || parent.nil? ? {} : parent.properties
55+
properties = merge_properties(parent_properties, data[:properties] || {})
56+
if data[:session_id] && !session_property_key?(data[:properties])
57+
properties.delete('$session_id')
58+
properties.delete(:$session_id)
59+
end
60+
61+
new(
62+
distinct_id: data[:distinct_id] || (fresh || parent.nil? ? nil : parent.distinct_id),
63+
session_id: data[:session_id] || (fresh || parent.nil? ? nil : parent.session_id),
64+
properties: properties
65+
)
66+
end
67+
private_class_method :resolve
68+
69+
def self.merge_data_and_kwargs(data, kwargs)
70+
data ||= {}
71+
raise ArgumentError, 'context data must be a Hash' unless data.is_a?(Hash)
72+
73+
data.merge(kwargs)
74+
end
75+
private_class_method :merge_data_and_kwargs
76+
77+
def self.merge_properties(base, overrides)
78+
merged = (base || {}).dup
79+
(overrides || {}).each do |key, value|
80+
merged.delete(key.to_s) if key.is_a?(Symbol)
81+
merged.delete(key.to_sym) if key.is_a?(String)
82+
merged[key] = value
83+
end
84+
merged
85+
end
86+
87+
def self.normalize_data(data)
88+
data ||= {}
89+
raise ArgumentError, 'context data must be a Hash' unless data.is_a?(Hash)
90+
91+
properties = data[:properties] || data['properties'] || {}
92+
raise ArgumentError, 'context properties must be a Hash' unless properties.is_a?(Hash)
93+
94+
{
95+
distinct_id: data[:distinct_id] || data['distinct_id'] || data[:distinctId] || data['distinctId'],
96+
session_id: data[:session_id] || data['session_id'] || data[:sessionId] || data['sessionId'],
97+
properties: properties
98+
}
99+
end
100+
private_class_method :normalize_data
101+
102+
def self.session_property_key?(properties)
103+
return false unless properties.is_a?(Hash)
104+
105+
properties.key?('$session_id') || properties.key?(:$session_id)
106+
end
107+
private_class_method :session_property_key?
108+
109+
def apply_session_property!
110+
return if session_id.nil? || properties.key?('$session_id') || properties.key?(:$session_id)
111+
112+
properties['$session_id'] = session_id
113+
end
114+
private :apply_session_property!
115+
end
116+
end
117+
118+
private_constant :Internal
119+
end

posthog-rails/README.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ PostHog::Rails.configure do |config|
5050
config.auto_capture_exceptions = true # Enable automatic exception capture (default: false)
5151
config.report_rescued_exceptions = true # Report exceptions Rails rescues (default: false)
5252
config.auto_instrument_active_job = true # Instrument background jobs (default: false)
53-
config.capture_user_context = true # Include user info in exceptions
53+
config.use_tracing_headers = true # Use PostHog tracing headers for identity/session context (default: true)
54+
config.capture_user_context = true # Include authenticated user info in exceptions
5455
config.current_user_method = :current_user # Method to get current user
5556
config.user_id_method = nil # Method to get ID from user (auto-detect)
5657

@@ -252,7 +253,8 @@ Configure these via `PostHog::Rails.configure` or `PostHog::Rails.config`:
252253
| `auto_capture_exceptions` | Boolean | `false` | Automatically capture exceptions |
253254
| `report_rescued_exceptions` | Boolean | `false` | Report exceptions Rails rescues |
254255
| `auto_instrument_active_job` | Boolean | `false` | Instrument ActiveJob |
255-
| `capture_user_context` | Boolean | `true` | Include user info |
256+
| `use_tracing_headers` | Boolean | `true` | Use PostHog tracing headers as request-scoped default `distinct_id` and `$session_id` values |
257+
| `capture_user_context` | Boolean | `true` | Include authenticated user info in exceptions |
256258
| `current_user_method` | Symbol | `:current_user` | Controller method for user |
257259
| `user_id_method` | Symbol | `nil` | Method to extract ID from user object (auto-detect if nil) |
258260
| `excluded_exceptions` | Array | `[]` | Additional exceptions to ignore |
@@ -306,9 +308,19 @@ The following exceptions are not reported by default (common 4xx errors):
306308

307309
You can add more with `PostHog::Rails.config.excluded_exceptions = ['MyException']`.
308310

311+
## Request Context
312+
313+
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.
314+
315+
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:
316+
317+
```ruby
318+
PostHog::Rails.config.use_tracing_headers = false
319+
```
320+
309321
## User Context
310322

311-
PostHog Rails automatically captures user information from your controllers:
323+
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:
312324

313325
```ruby
314326
class ApplicationController < ActionController::Base
@@ -402,7 +414,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for package-specific development instruct
402414
PostHog Rails uses the following components:
403415

404416
- **Railtie** - Hooks into Rails initialization
405-
- **Middleware** - Two middleware components capture exceptions:
417+
- **Middleware** - Three middleware components provide request context and capture exceptions:
418+
- `RequestContext` - Applies request metadata and optional PostHog tracing header identity/session context during Rails requests
406419
- `RescuedExceptionInterceptor` - Catches rescued exceptions
407420
- `CaptureExceptions` - Reports all exceptions to PostHog
408421
- **ActiveJob** - Prepends exception handling to `perform_now`

posthog-rails/examples/posthog.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@
2222
# Set to true to enable automatic ActiveJob exception tracking
2323
# config.auto_instrument_active_job = true
2424

25-
# Capture user context with exceptions (default: true)
25+
# Use PostHog tracing headers for request-scoped identity/session context (default: true)
26+
# Request metadata (current URL, method, path, user agent, and IP) is always captured during Rails requests
27+
# Set to false to ignore client-supplied X-PostHog-Distinct-Id and X-PostHog-Session-Id headers
28+
# config.use_tracing_headers = true
29+
30+
# Capture authenticated user context with exceptions (default: true)
31+
# Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity
2632
# config.capture_user_context = true
2733

2834
# Controller method name to get current user (default: :current_user)

posthog-rails/lib/generators/posthog/templates/posthog.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@
2222
# Set to true to enable automatic ActiveJob exception tracking
2323
# config.auto_instrument_active_job = true
2424

25-
# Capture user context with exceptions (default: true)
25+
# Use PostHog tracing headers for request-scoped identity/session context (default: true)
26+
# Request metadata (current URL, method, path, user agent, and IP) is always captured during Rails requests
27+
# Set to false to ignore client-supplied X-PostHog-Distinct-Id and X-PostHog-Session-Id headers
28+
# config.use_tracing_headers = true
29+
30+
# Capture authenticated user context with exceptions (default: true)
31+
# Authenticated Rails user context takes precedence over client-supplied tracing headers for exception identity
2632
# config.capture_user_context = true
2733

2834
# Controller method name to get current user (default: :current_user)

posthog-rails/lib/posthog/rails.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# frozen_string_literal: true
22

33
require 'posthog/rails/configuration'
4+
require 'posthog/rails/tracing_headers'
5+
require 'posthog/rails/request_metadata'
6+
require 'posthog/rails/request_context'
47
require 'posthog/rails/capture_exceptions'
58
require 'posthog/rails/rescued_exception_interceptor'
69
require 'posthog/rails/active_job'

posthog-rails/lib/posthog/rails/capture_exceptions.rb

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def call(env)
1818
PostHog::Rails.enter_web_request
1919

2020
response = @app.call(env)
21+
env['posthog.response_status_code'] = response_status(response)
2122

2223
# Check if there was an exception that Rails handled
2324
exception = collect_exception(env)
@@ -51,7 +52,7 @@ def should_capture?(exception)
5152

5253
def capture_exception(exception, env)
5354
request = ActionDispatch::Request.new(env)
54-
distinct_id = extract_distinct_id(env, request)
55+
distinct_id = extract_distinct_id(env)
5556
additional_properties = build_properties(request, env)
5657

5758
PostHog.capture_exception(exception, distinct_id, additional_properties)
@@ -60,20 +61,21 @@ def capture_exception(exception, env)
6061
PostHog::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}")
6162
end
6263

63-
def extract_distinct_id(env, request)
64-
# Try to get user from controller if capture_user_context is enabled
64+
def extract_distinct_id(env)
65+
# Prefer authenticated Rails user context. Request/tracing context is
66+
# applied later by the core capture path if this returns nil.
6567
if PostHog::Rails.config&.capture_user_context && env['action_controller.instance']
6668
controller = env['action_controller.instance']
6769
method_name = PostHog::Rails.config&.current_user_method || :current_user
6870

6971
if controller.respond_to?(method_name, true)
7072
user = controller.send(method_name)
71-
return extract_user_id(user) if user
73+
user_id = extract_user_id(user) if user
74+
return user_id if present?(user_id)
7275
end
7376
end
7477

75-
# Fallback to session ID or nil
76-
request.session&.id&.to_s
78+
nil
7779
end
7880

7981
def extract_user_id(user)
@@ -98,10 +100,7 @@ def extract_user_id(user)
98100

99101
def build_properties(request, env)
100102
properties = {
101-
'$exception_source' => 'rails',
102-
'$current_url' => safe_serialize(request.url),
103-
'$request_method' => safe_serialize(request.method),
104-
'$request_path' => safe_serialize(request.path)
103+
'$exception_source' => 'rails'
105104
}
106105

107106
# Add controller and action if available
@@ -118,14 +117,23 @@ def build_properties(request, env)
118117
properties['$request_params'] = safe_serialize(filtered_params) unless filtered_params.empty?
119118
end
120119

121-
# Add user agent
122-
properties['$user_agent'] = safe_serialize(request.user_agent) if request.user_agent
120+
response_status_code = env['posthog.response_status_code']
121+
properties['$response_status_code'] = response_status_code if response_status_code
123122

124123
# Add referrer
125124
properties['$referrer'] = safe_serialize(request.referrer) if request.referrer
126125

127126
properties
128127
end
128+
129+
def response_status(response)
130+
status = response.respond_to?(:[]) ? response[0] : nil
131+
status if status.is_a?(Integer)
132+
end
133+
134+
def present?(value)
135+
!(value.nil? || (value.respond_to?(:empty?) && value.empty?))
136+
end
129137
end
130138
end
131139
end

0 commit comments

Comments
 (0)