Repository navigation
feat(rails): capture Action Cable exceptions - #335
marandaneto wants to merge 1 commit into
Conversation
posthog-ruby-sync Compliance ReportDate: 2026-11-06T14:36:20.999509+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 8ms |
| Format Validation.Event Has Uuid | ✅ | 5ms |
| Format Validation.Event Has Lib Properties | ✅ | 5ms |
| Format Validation.Distinct Id Is String | ✅ | 7ms |
| Format Validation.Token Is Present | ✅ | 6ms |
| Format Validation.Custom Properties Preserved | ✅ | 6ms |
| Format Validation.Event Has Timestamp | ✅ | 6ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 5ms |
| Retry Behavior.Retries On 503 | ✅ | 5317ms |
| Retry Behavior.Does Not Retry On 400 | ❌ | 5ms |
| Retry Behavior.Does Not Retry On 401 | ❌ | 5ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8013ms |
| Retry Behavior.Implements Backoff | ❌ | 300ms |
| Retry Behavior.Retries On 500 | ✅ | 5152ms |
| Retry Behavior.Retries On 502 | ✅ | 5152ms |
| Retry Behavior.Retries On 504 | ✅ | 5114ms |
| Retry Behavior.Max Retries Respected | ❌ | 466ms |
| Deduplication.Generates Unique Uuids | ✅ | 12ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5111ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10298ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5119ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 14ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 7ms |
| Compression.Sends Gzip When Enabled | ✅ | 4ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 4ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 3ms |
| Batch Format.Multiple Events Batched Together | ❌ | 11ms |
| Error Handling.Does Not Retry On 403 | ❌ | 4ms |
| Error Handling.Does Not Retry On 413 | ❌ | 3ms |
| Error Handling.Retries On 408 | ✅ | 5158ms |
Failures
retry_behavior.does_not_retry_on_400
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
retry_behavior.does_not_retry_on_401
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
retry_behavior.implements_backoff
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
retry_behavior.max_retries_respected
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
batch_format.multiple_events_batched_together
Expected 1 requests, got 5
error_handling.does_not_retry_on_403
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
error_handling.does_not_retry_on_413
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 7ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 7ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 7ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 6ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 5ms |
| Request Payload.Groups Round Trip | ✅ | 4ms |
| Request Payload.Groups Default To Empty Object | ✅ | 5ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 4ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 4ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 5ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 1ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 4ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 7ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 4ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 107ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 108ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 7ms |
Failures
request_payload.disable_geoip_omitted_defaults_to_false
Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']
posthog-ruby-async Compliance ReportDate: 2026-11-06T14:37:02.267017+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 10ms |
| Format Validation.Event Has Uuid | ✅ | 105ms |
| Format Validation.Event Has Lib Properties | ✅ | 109ms |
| Format Validation.Distinct Id Is String | ✅ | 107ms |
| Format Validation.Token Is Present | ✅ | 107ms |
| Format Validation.Custom Properties Preserved | ✅ | 107ms |
| Format Validation.Event Has Timestamp | ✅ | 107ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 109ms |
| Retry Behavior.Retries On 503 | ✅ | 5412ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2109ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2109ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8116ms |
| Retry Behavior.Implements Backoff | ✅ | 15723ms |
| Retry Behavior.Retries On 500 | ✅ | 5212ms |
| Retry Behavior.Retries On 502 | ✅ | 5213ms |
| Retry Behavior.Retries On 504 | ✅ | 5208ms |
| Retry Behavior.Max Retries Respected | ✅ | 15622ms |
| Deduplication.Generates Unique Uuids | ✅ | 112ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5211ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10317ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5215ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 112ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 107ms |
| Compression.Sends Gzip When Enabled | ✅ | 106ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 107ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 4ms |
| Batch Format.Multiple Events Batched Together | ✅ | 112ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2110ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2110ms |
| Error Handling.Retries On 408 | ✅ | 5213ms |
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 107ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 107ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 107ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 108ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 107ms |
| Request Payload.Groups Round Trip | ✅ | 108ms |
| Request Payload.Groups Default To Empty Object | ✅ | 107ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 108ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 108ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 107ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 4ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 105ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 112ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 107ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 243ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 210ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 111ms |
Failures
request_payload.disable_geoip_omitted_defaults_to_false
Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']
SDK performance: posthog-rubyBenchmarks completed. No changes flagged for review. Flags require at least a 5% change in the median and all six pairs agreeing on direction. This is a noise filter, not proof of a performance change or a CI gate. View all results
Values are medians of paired measurements. Not flagged does not mean unchanged. Unavailable means a measurement could not be provided, not zero. Estimated allocation measurements retain their instrumentation uncertainty. RSS measures the whole process, not just SDK allocations; observed peaks are sampled checkpoints. Paired ranges in the diagnostics describe variability, not confidence intervals. Each shard uses its own runner; compare Before PR against With PR within a row, not across shards. Compared 102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in |
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 72ba70a: 1 Must fix, 1 Should fix.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
| properties['$channel_params'] = safe_serialize(filter_sensitive_params(@channel.params)) | ||
| if @data | ||
| filtered_data = safe_serialize(filter_sensitive_params(@data)) |
There was a problem hiding this comment.
P1 · Include Action Cable's configured parameter filters
When an app configures sensitive keys through config.action_cable.filter_parameters, these calls ignore them: inherited filter_sensitive_params reads only the application-wide filters. Consequently, $channel_params and $action_data send values that Action Cable's own logging masks using connection.config.filter_parameters. Include the connection's filters before serializing these properties.
| rescue StandardError => e | ||
| capture_exception(e, env) | ||
| raise |
There was a problem hiding this comment.
P2 · Respect connection rescue handlers before capturing channel errors
When a channel error reaches a connection's rescue_from handler, Rails invokes that handler in Subscriptions#execute_command, outside these wrappers. ActionCableCapture#call therefore already emits an unhandled event even when the application consumes the error and report_rescued_exceptions is false. Defer capture until connection handlers have run.
| return properties unless @channel | ||
|
|
||
| properties['$channel_class'] = @channel.class.name | ||
| properties['$channel_params'] = safe_serialize(filter_sensitive_params(@channel.params)) |
There was a problem hiding this comment.
Medium: Cable-specific sensitive parameter filters are ignored
The inherited filter_sensitive_params reads only Rails.application.config.filter_parameters, whereas Action Cable redacts action arguments using connection.config.filter_parameters, which supports Cable-specific rules. (raw.githubusercontent.com) With a field such as :credential configured only in the Cable filters, captured exceptions send its value verbatim in $channel_params here and $action_data at line 93; merge the effective connection filters with the application filters before serializing either payload.
PR overviewThe PR adds Action Cable exception capture to the Rails integration, including channel parameters and action data in captured events. One issue remains open: exception capture uses application-level parameter filters but ignores Cable-specific filters, allowing values intended to be redacted to appear in exception telemetry. The exposure is limited to fields covered only by Cable-specific rules; no issues have been addressed yet. Open issues (1)
Fixed/addressed: 0 · PR risk: 5/10 |
dustinbyrne
left a comment
There was a problem hiding this comment.
Two reproduced cases need correction before approval: Cable-only sensitive-parameter filters are omitted, and exceptions consumed by a connection's rescue_from are emitted as unhandled. The existing Action Cable examples pass but do not cover these cases.
AI-assisted review.
| return properties unless @channel | ||
|
|
||
| properties['$channel_class'] = @channel.class.name | ||
| properties['$channel_params'] = safe_serialize(filter_sensitive_params(@channel.params)) |
There was a problem hiding this comment.
[P1] Apply effective Action Cable sensitive-parameter filters
IMPORTANT · REQUIRED · introduced
Location: posthog-rails/lib/posthog/rails/action_cable.rb:91–94 — inline line 91, RIGHT.
With automatic capture enabled, a channel failure puts plaintext values protected by Cable-specific filters into $channel_params and $action_data. Expected: those values remain redacted, as they are in Cable's own diagnostics.
An ordinary application can configure:
# Rails application configuration
config.filter_parameters = [:password]
config.action_cable.filter_parameters = [:credential]
# PostHog configuration
PostHog::Rails.config.auto_capture_exceptions = trueA client subscribing with credential: 'subscription-secret', then performing an action with credential: 'action-secret', triggers the issue when that action raises. The inherited helper reads only application-wide filters (posthog-rails/lib/posthog/rails/parameter_filter.rb:51–58), whereas Rails' channel filter reads connection.config.filter_parameters. Cable-only filters are not copied back into the application's filter list.
Executed result on head:
'$channel_params' => { 'credential' => 'subscription-secret' },
'$action_data' => {
'action' => 'speak',
'credential' => 'action-secret',
'password' => '[FILTERED]'
}The native Cable filter redacts credential in the same fixture, and the SDK correctly redacts the application-wide password: filtering runs, but uses an incomplete rule set. Both assertions below fail on head:
expect(properties['$channel_params']['credential']).to eq('[FILTERED]')
expect(properties['$action_data']['credential']).to eq('[FILTERED]')The equivalent base control emits no Cable event and therefore has no new disclosure. This is newly exported data, not a formatting change to an existing event. The reproduction uses real server/connection/channel objects; only application configuration is mocked. It requires enabled capture, an eligible failure, Cable-only sensitive rules and no later scrubber. No network delivery was performed; the plaintext was observed in the queued event consumed by normal transport.
Consider applying both application-wide and effective connection/Cable filters before serializing either property, preserving older-Rails/test-connection fallbacks. Add regression coverage for both payloads, both filter sources and nested values. The executed regression fixture and base/head results are included below; no fix or post-fix success is claimed.
Executed regression fixture and base/head results
Runtime: Ruby 3.4.7, Rails/Action Cable 7.2.3.1, JSON 2.21.2, equivalent dependencies and configuration. Execution used explicit gem activation and Ruby/RSpec (ruby -Ilib:posthog-rails/lib:spec -rrspec/autorun regression_spec.rb --format documentation) in an isolated, network-disabled environment. REVIEW_SNAPSHOT=base selects the base control; REVIEW_SNAPSHOT=head selects head assertions.
Base 58f5335f895ebebc417bb4a48d22351ff96b5fb0: 3 examples, 0 failures, exit 0. Head 72ba70aa210dad9f0bdb258e5825824d58a28fbc: 3 examples, 2 failed examples, exit 1. The failed examples are the two behaviors reviewed here; the channel-local rescue negative control passes on both. No fix was applied or tested.
require 'spec_helper'
require 'rails'
require 'action_dispatch'
require 'rack/mock'
require 'action_cable'
require 'posthog/rails'
class ReviewCableError < StandardError; end
class ReviewCableConnection < ActionCable::Connection::Base
attr_accessor :handled_error
rescue_from ReviewCableError do |error|
self.handled_error = error
end
end
class ReviewCableChannel < ActionCable::Channel::Base
def speak(data)
raise ReviewCableError, 'action failed'
end
end
class ReviewLocalRescueChannel < ActionCable::Channel::Base
rescue_from ReviewCableError do |_error| end
def speak(data)
raise ReviewCableError, 'handled locally'
end
end
RSpec.describe 'Action Cable review regressions' do
let(:client) { PostHog::Client.new(api_key: 'review-test', test_mode: true) }
let(:server) do
ActionCable::Server::Base.new.tap do |value|
value.config.logger = Logger.new(File::NULL)
value.config.filter_parameters = [:credential]
end
end
let(:connection) { ReviewCableConnection.new(server, Rack::MockRequest.env_for('/cable')) }
before do
PostHog.client = client
PostHog::Rails.config = PostHog::Rails::Configuration.new
PostHog::Rails.config.auto_capture_exceptions = true
allow(Rails).to receive(:application).and_return(
double('application', env_config: {}, config: double(root: nil, filter_parameters: [:password]))
)
initializer = PostHog::Rails::Railtie.initializers.find { |item| item.name == 'posthog.action_cable' }
initializer&.run(Rails.application)
end
after do
client.shutdown
PostHog.client = nil
end
it 'redacts secrets configured on Action Cable only' do
channel = ReviewCableChannel.new(connection, 'id', {'credential' => 'subscription-secret'})
expect(channel.send(:parameter_filter).filter('credential' => 'action-secret')).to eq('credential' => '[FILTERED]')
expect { channel.perform_action('action' => 'speak', 'credential' => 'action-secret', 'password' => 'app-secret') }.to raise_error(ReviewCableError)
if ENV['REVIEW_SNAPSHOT'] == 'base'
expect(client.queued_messages).to eq(0)
puts 'BASE privacy: no Cable event emitted'
else
properties = client.dequeue_last_message[:properties]
puts "HEAD privacy emitted: #{properties.slice('$channel_params', '$action_data').inspect}"
expect(properties['$action_data']['password']).to eq('[FILTERED]')
aggregate_failures do
expect(properties['$channel_params']['credential']).to eq('[FILTERED]')
expect(properties['$action_data']['credential']).to eq('[FILTERED]')
end
end
end
it 'waits for connection rescue_from before deciding to capture' do
expect(PostHog::Rails.config.report_rescued_exceptions).to eq(false)
identifier = {'channel' => 'ReviewCableChannel'}.to_json
connection.subscriptions.execute_command('command' => 'subscribe', 'identifier' => identifier)
expect(client.queued_messages).to eq(0)
connection.subscriptions.execute_command('command' => 'message', 'identifier' => identifier,
'data' => {'action' => 'speak'}.to_json)
expect(connection.handled_error).to be_a(ReviewCableError)
count = client.queued_messages
puts "#{ENV['REVIEW_SNAPSHOT']} connection handler ran; queued=#{count}"
if count > 0
puts "emitted mechanism=#{client.dequeue_last_message[:properties]['$exception_list'].first['mechanism'].inspect}"
end
expect(count).to eq(0)
end
it 'does not capture an action consumed by a channel-local handler' do
ReviewLocalRescueChannel.new(connection, 'local').perform_action('action' => 'speak')
expect(client.queued_messages).to eq(0)
end
end| enrich_current_span: false) do | ||
| yield | ||
| rescue StandardError => e | ||
| capture_exception(e, env) |
There was a problem hiding this comment.
[P1] Wait for connection rescue handling before reporting an unhandled failure
IMPORTANT · REQUIRED · introduced
Location: posthog-rails/lib/posthog/rails/action_cable.rb:24–26 — inline line 25, RIGHT.
When a connection's application rescue_from consumes a channel exception, this wrapper still queues it as unhandled. Expected: no automatic event under the documented application-consumed behavior and default report_rescued_exceptions = false.
This is a supported application pattern:
class ApplicationCable::Connection < ActionCable::Connection::Base
rescue_from ExpectedCableError do |error|
handle_expected_failure(error)
end
endFor a channel action raising ExpectedCableError, the ordinary dispatch sequence is:
- The channel raises.
- The new wrapper captures with
handled: false, then re-raises. - The enclosing Rails subscription dispatcher invokes the connection handler, which consumes the error.
Executed regression: Successfully subscribe and send a failing message through real Subscriptions#execute_command, verify the connection handler ran, then assert:
expect(PostHog::Rails.config.report_rescued_exceptions).to eq(false)
expect(connection.handled_error).to be_a(ReviewCableError)
expect(client.queued_messages).to eq(0)Base passes with zero events. Head fails with one event whose mechanism is:
{ 'type' => 'middleware', 'handled' => false, 'exception_id' => 0 }A channel-local rescue_from control passes on both revisions, isolating the missed enclosing handler. Application handling still works: the impact is unsolicited capture and incorrect error-tracking classification, not an application crash.
This contradicts the new README's application-consumed noncapture promise and existing rescued-exception configuration documentation. The exception-metadata contract distinguishes consumed from escaping failures; it does not independently mandate suppression of every handled event. Suppression here follows this integration's declared policy. Backend Cymbal derives $exception_handled from mechanism disposition, making the misclassification observable downstream.
Consider preserving operation context while deferring the reporting decision until applicable application rescue handling completes. Preserve genuinely unhandled capture, original errors/returns and single handler invocation. If reporting despite later connection handling is intentional, explicitly resolve that policy and make classification/documentation accurate. The executed base/head regression fixture and results are included below; no proposed fix was tested.
Executed regression fixture and base/head results
Runtime: Ruby 3.4.7, Rails/Action Cable 7.2.3.1, JSON 2.21.2, equivalent dependencies and configuration. Execution used explicit gem activation and Ruby/RSpec (ruby -Ilib:posthog-rails/lib:spec -rrspec/autorun regression_spec.rb --format documentation) in an isolated, network-disabled environment. REVIEW_SNAPSHOT=base selects the base control; REVIEW_SNAPSHOT=head selects head assertions.
Base 58f5335f895ebebc417bb4a48d22351ff96b5fb0: 3 examples, 0 failures, exit 0. Head 72ba70aa210dad9f0bdb258e5825824d58a28fbc: 3 examples, 2 failed examples, exit 1. The failed examples are the two behaviors reviewed here; the channel-local rescue negative control passes on both. No fix was applied or tested.
require 'spec_helper'
require 'rails'
require 'action_dispatch'
require 'rack/mock'
require 'action_cable'
require 'posthog/rails'
class ReviewCableError < StandardError; end
class ReviewCableConnection < ActionCable::Connection::Base
attr_accessor :handled_error
rescue_from ReviewCableError do |error|
self.handled_error = error
end
end
class ReviewCableChannel < ActionCable::Channel::Base
def speak(data)
raise ReviewCableError, 'action failed'
end
end
class ReviewLocalRescueChannel < ActionCable::Channel::Base
rescue_from ReviewCableError do |_error| end
def speak(data)
raise ReviewCableError, 'handled locally'
end
end
RSpec.describe 'Action Cable review regressions' do
let(:client) { PostHog::Client.new(api_key: 'review-test', test_mode: true) }
let(:server) do
ActionCable::Server::Base.new.tap do |value|
value.config.logger = Logger.new(File::NULL)
value.config.filter_parameters = [:credential]
end
end
let(:connection) { ReviewCableConnection.new(server, Rack::MockRequest.env_for('/cable')) }
before do
PostHog.client = client
PostHog::Rails.config = PostHog::Rails::Configuration.new
PostHog::Rails.config.auto_capture_exceptions = true
allow(Rails).to receive(:application).and_return(
double('application', env_config: {}, config: double(root: nil, filter_parameters: [:password]))
)
initializer = PostHog::Rails::Railtie.initializers.find { |item| item.name == 'posthog.action_cable' }
initializer&.run(Rails.application)
end
after do
client.shutdown
PostHog.client = nil
end
it 'redacts secrets configured on Action Cable only' do
channel = ReviewCableChannel.new(connection, 'id', {'credential' => 'subscription-secret'})
expect(channel.send(:parameter_filter).filter('credential' => 'action-secret')).to eq('credential' => '[FILTERED]')
expect { channel.perform_action('action' => 'speak', 'credential' => 'action-secret', 'password' => 'app-secret') }.to raise_error(ReviewCableError)
if ENV['REVIEW_SNAPSHOT'] == 'base'
expect(client.queued_messages).to eq(0)
puts 'BASE privacy: no Cable event emitted'
else
properties = client.dequeue_last_message[:properties]
puts "HEAD privacy emitted: #{properties.slice('$channel_params', '$action_data').inspect}"
expect(properties['$action_data']['password']).to eq('[FILTERED]')
aggregate_failures do
expect(properties['$channel_params']['credential']).to eq('[FILTERED]')
expect(properties['$action_data']['credential']).to eq('[FILTERED]')
end
end
end
it 'waits for connection rescue_from before deciding to capture' do
expect(PostHog::Rails.config.report_rescued_exceptions).to eq(false)
identifier = {'channel' => 'ReviewCableChannel'}.to_json
connection.subscriptions.execute_command('command' => 'subscribe', 'identifier' => identifier)
expect(client.queued_messages).to eq(0)
connection.subscriptions.execute_command('command' => 'message', 'identifier' => identifier,
'data' => {'action' => 'speak'}.to_json)
expect(connection.handled_error).to be_a(ReviewCableError)
count = client.queued_messages
puts "#{ENV['REVIEW_SNAPSHOT']} connection handler ran; queued=#{count}"
if count > 0
puts "emitted mechanism=#{client.dequeue_last_message[:properties]['$exception_list'].first['mechanism'].inspect}"
end
expect(count).to eq(0)
end
it 'does not capture an action consumed by a channel-local handler' do
ReviewLocalRescueChannel.new(connection, 'local').perform_action('action' => 'speak')
expect(client.queued_messages).to eq(0)
end
end
💡 Motivation and Context
Action Cable runs connection callbacks and channel actions outside the normal HTTP exception middleware. Errors escaping those operations currently go unreported by the Rails integration.
This adds automatic capture around connection open/close, channel subscribe/unsubscribe, and dispatched channel actions. It uses the existing
auto_capture_exceptionsoption, which remains false by default, and respectsexcluded_exceptions. No new public configuration or methods are added. The included changeset selects a minor release ofposthog-railsonly.Each operation gets isolated context from the connection environment and restores the caller's context afterward. Authenticated controller or connection users take precedence over sanitized PostHog header identity. Existing user lookup options apply.
capture_user_context = falsedisables user lookup, anduse_tracing_headers = falsedisables header correlation. A controller resolver is never passed a connection. Explicit capture IDs and properties keep their existing precedence.Channel parameters and action data use Rails
filter_parameters. Raw environments, cookies, authorization headers, subscription identifiers, and URL query strings are not attached. Applications should review filtering andbefore_sendfor their own sensitive data. Return values and escaping exceptions stay unchanged. Errors consumed by application handlers, including channelrescue_from, are not captured at these boundaries.Action Cable remains an optional runtime dependency and loads lazily. Its added dependency is development/test-only. The wrappers do not create or enrich spans. Rails 5.2 has a fallback load hook, but local validation used Rails 7.2.3.1 only.
The relevant pinned SDK specs cover exception metadata, tracing headers, exception capture, and
before_send; none defines an Action Cable-specific API. New events use sourcerails.action_cable, levelerror, and amiddlewaremechanism withhandled: false. One contract difference remains: this integration passes source and level through the existing Ruby exception property bag rather than a typed internal metadata channel. This PR does not redesign the core exception API.💚 How did you test it?
Validated on Ruby 4.0.7, Bundler 4.0.13, and Rails 7.2.3.1 against base
58f5335f895ebebc417bb4a48d22351ff96b5fb0:bundle exec rubocop: 152 files, no offenses.bundle exec rake public_api:checkandgit diff --check: passed. No public API snapshot change was needed.The Authoring test audit retained all new cases. It checked independent event oracles, identity precedence, filtered data, error/return preservation, nested capture deduplication, concurrent socket isolation, client ownership, instrumentation failures, and hook drops. The historical Ruby/Rails matrix was not run locally.
Exact-HEAD autoreview on commit
72ba70aa210dad9f0bdb258e5825824d58a28fbcagainstorigin/maincompleted with no findings. No review transcript is posted here.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset fileThe existing hand-authored
.changeset/rails-action-cable-exceptions.mdis included.pnpm changesetwas not run, and no duplicate changeset or version bump was added.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Human DRI: @marandaneto. Pi's
workersubagent reviewed and prepared this feature using local file tools, Bash, Git, GitHub CLI, RSpec, RuboCop, Rake, and theautoreviewhelper withclaude-code/claude-opus-5-5. Local Pi session ID:01a1b0e4-9039-71a4-a297-fed90e502ce9. There is no public session URL.The implementation keeps automatic capture opt-in, uses authenticated identity only for exception attribution, and avoids retaining the upgrade request's client or annotating unrelated spans. This draft requires human review before merge.