diff --git a/.rubocop.yml b/.rubocop.yml index 66a8949..4f4f0f9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,10 @@ AllCops: Style/Documentation: Enabled: false +Naming/FileName: + Exclude: + - 'posthog-rails/lib/posthog-rails.rb' + # Modern Ruby 3.0+ specific cops Style/HashTransformKeys: Enabled: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 18fc048..79876cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +1. feat: Add posthog-rails gem for automatic Rails exception tracking + - Automatic capture of unhandled exceptions via Rails middleware + - Automatic capture of rescued exceptions (configurable) + - Automatic instrumentation of ActiveJob failures + - Integration with Rails 7.0+ error reporter + - Configurable exception exclusion list + - User context capture from controllers + ## 3.3.3 - 2025-10-22 1. fix: fallback to API for multi-condition flags with static cohorts ([#80](https://github.com/PostHog/posthog-ruby/pull/80)) diff --git a/README.md b/README.md index 4f4380b..7b0941b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Specifically, the [Ruby integration](https://posthog.com/docs/integrations/ruby- > > All 2.x versions of the PostHog Ruby library are compatible with Ruby 2.0 and above if you need Ruby 2.0 support. +## Rails Integration + +**Using Rails?** Check out [posthog-rails](posthog-rails/README.md) for automatic exception tracking, ActiveJob instrumentation, and Rails-specific features. + ## Developing Locally 1. Install `asdf` to manage your Ruby version: `brew install asdf` diff --git a/lib/posthog/logging.rb b/lib/posthog/logging.rb index 8c9a0b0..acbfdca 100644 --- a/lib/posthog/logging.rb +++ b/lib/posthog/logging.rb @@ -41,8 +41,8 @@ def logger return @logger if @logger base_logger = - if defined?(Rails) - Rails.logger + if defined?(::Rails) + ::Rails.logger else logger = Logger.new $stdout logger.progname = 'PostHog' diff --git a/posthog-rails/IMPLEMENTATION.md b/posthog-rails/IMPLEMENTATION.md new file mode 100644 index 0000000..c4dd6c3 --- /dev/null +++ b/posthog-rails/IMPLEMENTATION.md @@ -0,0 +1,384 @@ +# PostHog Rails Implementation Summary + +This document provides an overview of the posthog-rails gem implementation, following the Sentry Rails integration pattern. + +## Architecture Overview + +PostHog Rails is a separate gem that provides Rails-specific integrations for the core `posthog-ruby` SDK. It follows a monorepo pattern where both gems live in the same repository but are published separately. + +## Directory Structure + +``` +posthog-rails/ +├── lib/ +│ ├── posthog-rails.rb # Main entry point +│ └── posthog/ +│ └── rails/ +│ ├── rails.rb # Module definition & requires +│ ├── railtie.rb # Rails integration hook +│ ├── configuration.rb # Rails-specific config +│ ├── capture_exceptions.rb # Exception capture middleware +│ ├── rescued_exception_interceptor.rb # Rescued exception middleware +│ ├── active_job.rb # ActiveJob instrumentation +│ └── error_subscriber.rb # Rails 7.0+ error reporter +├── examples/ +│ └── posthog.rb # Example initializer +├── posthog-rails.gemspec # Gem specification +├── README.md # User documentation +└── IMPLEMENTATION.md # This file +``` + +## Component Descriptions + +### 1. Gemspec (`posthog-rails.gemspec`) + +Defines the gem and its dependencies: +- Depends on `posthog-ruby` (core SDK) +- Depends on `railties >= 5.2.0` (minimal Rails dependency) +- Version is synced with posthog-ruby + +### 2. Main Entry Point (`lib/posthog-rails.rb`) + +Simple entry point that: +- Requires the core `posthog-ruby` gem +- Requires `posthog/rails` if Rails is defined + +### 3. Rails Module (`lib/posthog/rails.rb`) + +Loads all Rails-specific components in the correct order: +1. Configuration +2. Middleware components +3. ActiveJob integration +4. Error subscriber +5. Railtie (must be last) + +### 4. Railtie (`lib/posthog/rails/railtie.rb`) + +The core Rails integration hook that: + +#### Adds Module Methods +- Extends `PostHog` module with class methods +- Adds `PostHog.init` configuration block +- Adds delegation methods (`capture`, `capture_exception`, etc.) +- Stores singleton `client` and `rails_config` + +#### Middleware Registration +Inserts two middleware in the Rails stack: +```ruby +ActionDispatch::DebugExceptions + ↓ +PostHog::Rails::RescuedExceptionInterceptor # Catches exceptions early + ↓ +Application Code + ↓ +ActionDispatch::ShowExceptions + ↓ +PostHog::Rails::CaptureExceptions # Reports to PostHog +``` + +#### ActiveJob Hook +Uses `ActiveSupport.on_load(:active_job)` to prepend exception handling module before ActiveJob loads. + +#### After Initialize +- Configures Rails environment (logger, etc.) +- Registers Rails 7.0+ error subscriber +- Sets up graceful shutdown + +### 5. Configuration (`lib/posthog/rails/configuration.rb`) + +Rails-specific configuration options: +- `auto_capture_exceptions` - Enable/disable automatic capture +- `report_rescued_exceptions` - Report exceptions Rails rescues +- `auto_instrument_active_job` - Enable/disable job instrumentation +- `excluded_exceptions` - Additional exceptions to ignore +- `capture_user_context` - Include user info +- `current_user_method` - Controller method name for user + +Also includes: +- Default excluded exceptions list (404s, parameter errors, etc.) +- `should_capture_exception?` method for filtering + +### 6. CaptureExceptions Middleware (`lib/posthog/rails/capture_exceptions.rb`) + +Main exception capture middleware that: +1. Wraps application call in exception handler +2. Checks for exceptions in `env` from Rails or other middleware +3. Filters exceptions based on configuration +4. Extracts user context from controller +5. Builds request properties (URL, method, params, etc.) +6. Filters sensitive parameters +7. Calls `PostHog.capture_exception` + +**User Context Extraction:** +- Gets controller from `env['action_controller.instance']` +- Calls configured user method (default: `current_user`) +- Extracts ID from user object +- Falls back to session ID if no user + +**Request Context:** +- Request URL, method, path +- Controller and action names +- Filtered request parameters +- User agent and referrer + +### 7. RescuedExceptionInterceptor Middleware (`lib/posthog/rails/rescued_exception_interceptor.rb`) + +Lightweight middleware that: +- Catches exceptions before Rails rescues them +- Stores in `env['posthog.rescued_exception']` +- Re-raises the exception (doesn't suppress it) +- Only runs if `report_rescued_exceptions` is enabled + +This ensures we capture exceptions that Rails handles with `rescue_from` or similar. + +### 8. ActiveJob Integration (`lib/posthog/rails/active_job.rb`) + +Module prepended to `ActiveJob::Base`: +- Wraps `perform_now` method +- Catches exceptions during job execution +- Extracts job context (class, ID, queue, priority) +- Tries to extract user ID from job arguments +- Sanitizes job arguments (filters sensitive data) +- Calls `PostHog.capture_exception` + +**Argument Sanitization:** +- Keeps primitives (string, integer, boolean, nil) +- Filters sensitive hash keys +- Converts ActiveRecord objects to `{class, id}` +- Replaces complex objects with class name + +### 9. Error Subscriber (`lib/posthog/rails/error_subscriber.rb`) + +Rails 7.0+ integration: +- Subscribes to `Rails.error` reporter +- Receives errors from `Rails.error.handle` and `Rails.error.record` +- Captures error with context +- Includes handled/unhandled status and severity + +## Exception Flow + +### HTTP Request Exceptions + +``` +1. User makes request + ↓ +2. RescuedExceptionInterceptor catches and stores exception + ↓ +3. Exception bubbles up through Rails + ↓ +4. Rails may rescue it (rescue_from, etc.) + ↓ +5. Rails stores in env['action_dispatch.exception'] + ↓ +6. CaptureExceptions middleware checks env for exception + ↓ +7. Extracts user and request context + ↓ +8. Filters based on configuration + ↓ +9. Calls PostHog.capture_exception + ↓ +10. Response returned to user +``` + +### ActiveJob Exceptions + +``` +1. Job.perform_later called + ↓ +2. ActiveJob enqueues job + ↓ +3. Worker picks up job + ↓ +4. Calls perform_now (our wrapped version) + ↓ +5. Exception raised in perform + ↓ +6. Our module catches it + ↓ +7. Extracts job context + ↓ +8. Calls PostHog.capture_exception + ↓ +9. Re-raises exception for normal job error handling +``` + +## User Experience + +### Installation +```bash +# Gemfile +gem 'posthog-rails' + +bundle install +``` + +### Configuration +```ruby +# config/initializers/posthog.rb +PostHog.init do |config| + config.api_key = ENV['POSTHOG_API_KEY'] + config.personal_api_key = ENV['POSTHOG_PERSONAL_API_KEY'] + + # Rails options + config.auto_capture_exceptions = true + config.current_user_method = :current_user +end +``` + +### Usage +```ruby +# Automatic - just works! +class PostsController < ApplicationController + def show + @post = Post.find(params[:id]) + # Exceptions automatically captured + end +end + +# Manual tracking +PostHog.capture( + distinct_id: current_user.id, + event: 'post_viewed' +) +``` + +## Key Design Decisions + +### 1. Separate Gem +Following Sentry's pattern, posthog-rails is a separate gem. Benefits: +- Non-Rails users don't get Rails bloat +- Clear separation of concerns +- Independent versioning possible +- Rails-specific features don't affect core + +### 2. Middleware-Based Capture +Using middleware instead of monkey-patching: +- More reliable +- Works with any exception handling strategy +- Respects Rails conventions +- Easy to understand and debug + +### 3. Two Middleware +Why two middleware instead of one? +- `RescuedExceptionInterceptor` runs early to catch exceptions before rescue +- `CaptureExceptions` runs late to report after Rails processing +- This ensures we catch both rescued and unrescued exceptions + +### 4. Module Prepend for ActiveJob +Using `prepend` instead of `alias_method_chain`: +- Cleaner Ruby pattern +- Respects method resolution order +- Works with other gems that extend ActiveJob +- More maintainable + +### 5. Railtie for Integration +Railtie is the Rails-native way to integrate: +- Automatic discovery (no manual setup) +- Access to Rails lifecycle hooks +- Proper initialization order +- Follows Rails conventions + +### 6. InitConfig Wrapper +The `InitConfig` class wraps both core and Rails options: +- Single configuration block +- Type-safe option setting +- Clear separation of concerns +- Easy to extend + +### 7. Sensitive Data Filtering +Built-in filtering for security: +- Common sensitive parameter names +- Parameter value truncation +- Safe serialization fallbacks +- Fails gracefully if filtering errors + +## Testing Strategy + +To test this gem, you would: + +1. **Unit tests** for each component + - Configuration options + - Exception filtering + - User extraction + - Parameter sanitization + +2. **Integration tests** with Rails + - Middleware insertion + - Exception capture flow + - ActiveJob instrumentation + - Rails 7.0+ error reporter + +3. **Test Rails app** + - Dummy Rails app in spec/ + - Test actual exception capture + - Verify user context + - Test feature flags + +## Comparison with Sentry + +| Feature | PostHog Rails | Sentry Rails | +|---------|---------------|--------------| +| Separate gem | ✅ | ✅ | +| Middleware-based | ✅ | ✅ | +| ActiveJob | ✅ | ✅ | +| Railtie | ✅ | ✅ | +| Rails 7 errors | ✅ | ✅ | +| Performance tracing | ❌ | ✅ | +| Breadcrumbs | ❌ | ✅ | +| ActionCable | ❌ | ✅ | + +## Future Enhancements + +Possible additions: +1. **Performance tracing** - Track request/query times +2. **Breadcrumbs** - Capture logs leading up to errors +3. **ActionCable** - WebSocket exception tracking +4. **Background workers** - Sidekiq, Resque integrations +5. **Tests** - Full test suite +6. **Rails generators** - `rails generate posthog:install` +7. **Controller helpers** - `posthog_identify`, `posthog_capture` helpers + +## File Sizes + +Approximate lines of code: +- `railtie.rb`: ~200 lines +- `capture_exceptions.rb`: ~130 lines +- `configuration.rb`: ~60 lines +- `active_job.rb`: ~80 lines +- `error_subscriber.rb`: ~30 lines +- `rescued_exception_interceptor.rb`: ~25 lines + +Total: ~525 lines of implementation code + +## Dependencies + +Runtime: +- `posthog-ruby` (core SDK) +- `railties >= 5.2.0` + +Development (inherited from posthog-ruby): +- `rspec` +- `rubocop` + +## Compatibility + +- **Ruby**: 3.0+ +- **Rails**: 5.2+ +- **Tested on**: Rails 5.2, 6.0, 6.1, 7.0, 7.1 (planned) + +## Deployment + +To release: +```bash +cd posthog-rails +gem build posthog-rails.gemspec +gem push posthog-rails-3.3.3.gem +``` + +Users install with: +```ruby +gem 'posthog-rails' +``` + +This automatically brings in `posthog-ruby` as a dependency. diff --git a/posthog-rails/README.md b/posthog-rails/README.md new file mode 100644 index 0000000..2eb5e20 --- /dev/null +++ b/posthog-rails/README.md @@ -0,0 +1,340 @@ +# PostHog Rails + +Official PostHog integration for Ruby on Rails applications. Automatically track exceptions, instrument background jobs, and capture user analytics. + +## Features + +- 🚨 **Automatic exception tracking** - Captures unhandled and rescued exceptions +- 🔄 **ActiveJob instrumentation** - Tracks background job exceptions +- 👤 **User context** - Automatically associates exceptions with the current user +- 🎯 **Smart filtering** - Excludes common Rails exceptions (404s, etc.) by default +- 📊 **Rails 7.0+ error reporter** - Integrates with Rails' built-in error reporting +- ⚙️ **Highly configurable** - Customize what gets tracked + +## Installation + +Add to your Gemfile: + +```ruby +gem 'posthog-ruby' +gem 'posthog-rails' +``` + +Then run: + +```bash +bundle install +``` + +**Note:** `posthog-rails` depends on `posthog-ruby`, but it's recommended to explicitly include both gems in your Gemfile for clarity. + +## Configuration + +Create an initializer at `config/initializers/posthog.rb`: + +```ruby +PostHog.init do |config| + # Required: Your PostHog API key + config.api_key = ENV['POSTHOG_API_KEY'] + + # Optional: Your PostHog instance URL (defaults to https://app.posthog.com) + config.host = 'https://app.posthog.com' + + # Optional: Personal API key for feature flags + config.personal_api_key = ENV['POSTHOG_PERSONAL_API_KEY'] + + # Rails-specific configuration + config.auto_capture_exceptions = true # Capture exceptions automatically + config.report_rescued_exceptions = true # Report exceptions Rails rescues + config.auto_instrument_active_job = true # Instrument background jobs + config.capture_user_context = true # Include user info in exceptions + config.current_user_method = :current_user # Method to get current user + + # Add additional exceptions to ignore + config.excluded_exceptions = ['MyCustomError'] + + # Error callback + config.on_error = proc { |status, msg| + Rails.logger.error("PostHog error: #{msg}") + } +end +``` + +### Environment Variables + +The recommended approach is to use environment variables: + +```bash +# .env +POSTHOG_API_KEY=your_project_api_key +POSTHOG_PERSONAL_API_KEY=your_personal_api_key # Optional, for feature flags +``` + +## Usage + +### Automatic Exception Tracking + +Once configured, exceptions are automatically captured: + +```ruby +class PostsController < ApplicationController + def show + @post = Post.find(params[:id]) + # Any exception here is automatically captured + end +end +``` + +### Manual Event Tracking + +Track custom events anywhere in your Rails app: + +```ruby +# Track an event +PostHog.capture( + distinct_id: current_user.id, + event: 'post_created', + properties: { title: @post.title } +) + +# Identify a user +PostHog.identify( + distinct_id: current_user.id, + properties: { + email: current_user.email, + plan: current_user.plan + } +) + +# Track an exception manually +PostHog.capture_exception( + exception, + current_user.id, + { custom_property: 'value' } +) +``` + +### Background Jobs + +ActiveJob exceptions are automatically captured: + +```ruby +class EmailJob < ApplicationJob + def perform(user_id) + user = User.find(user_id) + UserMailer.welcome(user).deliver_now + # Exceptions are automatically captured with job context + end +end +``` + +### Feature Flags + +Use feature flags in your Rails app: + +```ruby +class PostsController < ApplicationController + def show + if PostHog.is_feature_enabled('new-post-design', current_user.id) + render 'posts/show_new' + else + render 'posts/show' + end + end +end +``` + +### Rails 7.0+ Error Reporter + +PostHog integrates with Rails' built-in error reporting: + +```ruby +# These errors are automatically sent to PostHog +Rails.error.handle do + # Code that might raise an error +end + +Rails.error.record(exception, context: { user_id: current_user.id }) +``` + +## Configuration Options + +### Core PostHog Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `api_key` | String | **required** | Your PostHog project API key | +| `host` | String | `https://app.posthog.com` | PostHog instance URL | +| `personal_api_key` | String | `nil` | For feature flag evaluation | +| `max_queue_size` | Integer | `10000` | Max events to queue | +| `test_mode` | Boolean | `false` | Don't send events (for testing) | +| `on_error` | Proc | `nil` | Error callback | +| `feature_flags_polling_interval` | Integer | `30` | Seconds between flag polls | + +### Rails-Specific Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `auto_capture_exceptions` | Boolean | `true` | Automatically capture exceptions | +| `report_rescued_exceptions` | Boolean | `true` | Report exceptions Rails rescues | +| `auto_instrument_active_job` | Boolean | `true` | Instrument ActiveJob | +| `capture_user_context` | Boolean | `true` | Include user info | +| `current_user_method` | Symbol | `:current_user` | Controller method for user | +| `excluded_exceptions` | Array | `[]` | Additional exceptions to ignore | + +### Understanding Exception Tracking Options + +**`auto_capture_exceptions`** - Master switch for all automatic error tracking +- When `true`: All exceptions are automatically captured and sent to PostHog +- When `false`: No automatic error tracking (you must manually call `PostHog.capture_exception`) +- **Use case:** Turn off automatic error tracking completely + +**`report_rescued_exceptions`** - Control exceptions that Rails handles gracefully +- When `true`: Capture exceptions that Rails rescues and shows error pages for (404s, 500s, etc.) +- When `false`: Only capture truly unhandled exceptions that crash your app +- **Use case:** Reduce noise by ignoring errors Rails already handles + +**Example:** + +```ruby +# Scenario: User visits /posts/999999 (post doesn't exist) +def show + @post = Post.find(params[:id]) # Raises ActiveRecord::RecordNotFound +end +``` + +| Configuration | Result | +|---------------|--------| +| `auto_capture_exceptions = true`
`report_rescued_exceptions = true` | ✅ Exception captured (default behavior) | +| `auto_capture_exceptions = true`
`report_rescued_exceptions = false` | ❌ Not captured (Rails rescued it) | +| `auto_capture_exceptions = false` | ❌ Not captured (automatic tracking disabled) | + +**Recommendation:** Keep both `true` (default) to get complete visibility into all errors. Set `report_rescued_exceptions = false` only if you want to track just critical crashes. + +## Excluded Exceptions by Default + +The following exceptions are not reported by default (common 4xx errors): + +- `AbstractController::ActionNotFound` +- `ActionController::BadRequest` +- `ActionController::InvalidAuthenticityToken` +- `ActionController::RoutingError` +- `ActionDispatch::Http::Parameters::ParseError` +- `ActiveRecord::RecordNotFound` +- `ActiveRecord::RecordNotUnique` + +You can add more with `config.excluded_exceptions = ['MyException']`. + +## User Context + +PostHog Rails automatically captures user information from your controllers: + +```ruby +class ApplicationController < ActionController::Base + # PostHog will automatically call this method + def current_user + @current_user ||= User.find_by(id: session[:user_id]) + end +end +``` + +If your user method has a different name, configure it: + +```ruby +config.current_user_method = :logged_in_user +``` + +## Sensitive Data Filtering + +PostHog Rails automatically filters sensitive parameters: + +- `password` +- `password_confirmation` +- `token` +- `secret` +- `api_key` +- `authenticity_token` + +Long parameter values are also truncated to 1000 characters. + +## Testing + +In your test environment, you can disable PostHog or use test mode: + +```ruby +# config/environments/test.rb +PostHog.init do |config| + config.test_mode = true # Events are queued but not sent +end +``` + +Or in your tests: + +```ruby +# spec/rails_helper.rb +RSpec.configure do |config| + config.before(:each) do + allow(PostHog).to receive(:capture) + end +end +``` + +## Development + +To run tests: + +```bash +cd posthog-rails +bundle install +bundle exec rspec +``` + +## Architecture + +PostHog Rails uses the following components: + +- **Railtie** - Hooks into Rails initialization +- **Middleware** - Two middleware components capture exceptions: + - `RescuedExceptionInterceptor` - Catches rescued exceptions + - `CaptureExceptions` - Reports all exceptions to PostHog +- **ActiveJob** - Prepends exception handling to `perform_now` +- **Error Subscriber** - Integrates with Rails 7.0+ error reporter + +## Troubleshooting + +### Exceptions not being captured + +1. Verify PostHog is initialized: + ```ruby + Rails.console + > PostHog.initialized? + => true + ``` + +2. Check your excluded exceptions list +3. Verify middleware is installed: + ```ruby + Rails.application.middleware + ``` + +### User context not working + +1. Verify `current_user_method` matches your controller method +2. Check that the method returns an object with an `id` attribute +3. Enable logging to see what's being captured + +### Feature flags not working + +Ensure you've set `personal_api_key`: + +```ruby +config.personal_api_key = ENV['POSTHOG_PERSONAL_API_KEY'] +``` + +## Contributing + +See the main [PostHog Ruby](../README.md) repository for contribution guidelines. + +## License + +MIT License. See [LICENSE](../LICENSE) for details. diff --git a/posthog-rails/examples/posthog.rb b/posthog-rails/examples/posthog.rb new file mode 100644 index 0000000..3d9a8bb --- /dev/null +++ b/posthog-rails/examples/posthog.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +# PostHog Rails Initializer +# Place this file in config/initializers/posthog.rb + +PostHog.init do |config| + # ============================================================================ + # REQUIRED CONFIGURATION + # ============================================================================ + + # Your PostHog project API key (required) + # Get this from: PostHog Project Settings > API Keys + config.api_key = ENV.fetch('POSTHOG_API_KEY', nil) + + # ============================================================================ + # CORE POSTHOG CONFIGURATION + # ============================================================================ + + # For PostHog Cloud, use: https://us.i.posthog.com or https://eu.i.posthog.com + config.host = ENV.fetch('POSTHOG_HOST', 'https://app.posthog.com') + + # Personal API key (optional, but required for local feature flag evaluation) + # Get this from: PostHog Settings > Personal API Keys + config.personal_api_key = ENV.fetch('POSTHOG_PERSONAL_API_KEY', nil) + + # Maximum number of events to queue before dropping (default: 10000) + config.max_queue_size = 10_000 + + # Feature flags polling interval in seconds (default: 30) + config.feature_flags_polling_interval = 30 + + # Feature flag request timeout in seconds (default: 3) + config.feature_flag_request_timeout_seconds = 3 + + # Error callback - called when PostHog encounters an error + config.on_error = proc { |status, message| + Rails.logger.error("[PostHog] Error #{status}: #{message}") + } + + # Before send callback - modify or filter events before sending + # Return nil to prevent the event from being sent + # config.before_send = proc { |event| + # # Filter out test users + # return nil if event[:properties]&.dig('$user_email')&.end_with?('@test.com') + # + # # Add custom properties to all events + # event[:properties] ||= {} + # event[:properties]['environment'] = Rails.env + # + # event + # } + + # ============================================================================ + # RAILS-SPECIFIC CONFIGURATION + # ============================================================================ + + # Automatically capture exceptions (default: true) + config.auto_capture_exceptions = true + + # Report exceptions that Rails rescues (e.g., with rescue_from) (default: true) + config.report_rescued_exceptions = true + + # Automatically instrument ActiveJob background jobs (default: true) + config.auto_instrument_active_job = true + + # Capture user context with exceptions (default: true) + config.capture_user_context = true + + # Controller method name to get current user (default: :current_user) + # Change this if your app uses a different method name + config.current_user_method = :current_user + + # Additional exception classes to exclude from reporting + # These are added to the default excluded exceptions + config.excluded_exceptions = [ + # 'MyCustom404Error', + # 'MyCustomValidationError' + ] + + # ============================================================================ + # ENVIRONMENT-SPECIFIC CONFIGURATION + # ============================================================================ + + # Disable in test environment + config.test_mode = true if Rails.env.test? + + # Optional: Disable in development + # if Rails.env.development? + # config.test_mode = true + # end +end + +# ============================================================================ +# DEFAULT EXCLUDED EXCEPTIONS +# ============================================================================ +# The following exceptions are excluded by default: +# +# - AbstractController::ActionNotFound +# - ActionController::BadRequest +# - ActionController::InvalidAuthenticityToken +# - ActionController::InvalidCrossOriginRequest +# - ActionController::MethodNotAllowed +# - ActionController::NotImplemented +# - ActionController::ParameterMissing +# - ActionController::RoutingError +# - ActionController::UnknownFormat +# - ActionController::UnknownHttpMethod +# - ActionDispatch::Http::Parameters::ParseError +# - ActiveRecord::RecordNotFound +# - ActiveRecord::RecordNotUnique +# +# These can be re-enabled by removing them from the exclusion list if needed. + +# ============================================================================ +# USAGE EXAMPLES +# ============================================================================ + +# Track custom events: +# PostHog.capture( +# distinct_id: current_user.id, +# event: 'user_signed_up', +# properties: { +# plan: 'pro', +# source: 'organic' +# } +# ) + +# Identify users: +# PostHog.identify( +# distinct_id: current_user.id, +# properties: { +# email: current_user.email, +# name: current_user.name, +# plan: current_user.plan +# } +# ) + +# Check feature flags: +# if PostHog.is_feature_enabled('new-checkout-flow', current_user.id) +# render 'checkout/new' +# else +# render 'checkout/old' +# end + +# Capture exceptions manually: +# begin +# dangerous_operation +# rescue => e +# PostHog.capture_exception(e, current_user.id, { context: 'manual' }) +# raise +# end diff --git a/posthog-rails/lib/posthog-rails.rb b/posthog-rails/lib/posthog-rails.rb new file mode 100644 index 0000000..d29d3d6 --- /dev/null +++ b/posthog-rails/lib/posthog-rails.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Load core PostHog Ruby SDK +require 'posthog' + +# Load Rails integration +require 'posthog/rails' if defined?(Rails) diff --git a/posthog-rails/lib/posthog/rails.rb b/posthog-rails/lib/posthog/rails.rb new file mode 100644 index 0000000..3502acb --- /dev/null +++ b/posthog-rails/lib/posthog/rails.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'posthog/rails/configuration' +require 'posthog/rails/capture_exceptions' +require 'posthog/rails/rescued_exception_interceptor' +require 'posthog/rails/active_job' +require 'posthog/rails/error_subscriber' +require 'posthog/rails/railtie' + +module PostHog + module Rails + VERSION = PostHog::VERSION + end +end diff --git a/posthog-rails/lib/posthog/rails/active_job.rb b/posthog-rails/lib/posthog/rails/active_job.rb new file mode 100644 index 0000000..4549738 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/active_job.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'posthog/rails/parameter_filter' + +module PostHog + module Rails + # ActiveJob integration to capture exceptions from background jobs + module ActiveJobExtensions + include ParameterFilter + + def perform_now + super + rescue StandardError => e + # Capture the exception with job context + capture_job_exception(e) + raise + end + + private + + def capture_job_exception(exception) + return unless PostHog.rails_config&.auto_instrument_active_job + + # Build distinct_id from job arguments if possible + distinct_id = extract_distinct_id_from_job + + properties = { + '$exception_source' => 'active_job', + '$job_class' => self.class.name, + '$job_id' => job_id, + '$queue_name' => queue_name, + '$job_priority' => priority, + '$job_executions' => executions + } + + # Add serialized job arguments (be careful with sensitive data) + properties['$job_arguments'] = sanitize_job_arguments(arguments) if arguments.present? + + PostHog.capture_exception(exception, distinct_id, properties) + rescue StandardError => e + # Don't let PostHog errors break job processing + PostHog::Logging.logger.error("Failed to capture job exception: #{e.message}") + end + + def extract_distinct_id_from_job + # Try to find a user ID in job arguments + arguments.each do |arg| + if arg.respond_to?(:id) + return arg.id + elsif arg.is_a?(Hash) && arg['user_id'] + return arg['user_id'] + elsif arg.is_a?(Hash) && arg[:user_id] + return arg[:user_id] + end + end + + nil # No user context found + end + + def sanitize_job_arguments(args) + # Convert arguments to a safe format + args.map do |arg| + case arg + when String + # Truncate long strings to prevent huge payloads + arg.length > 100 ? "[FILTERED: #{arg.length} chars]" : arg + when Integer, Float, TrueClass, FalseClass, NilClass + arg + when Hash + # Use Rails' filter_parameters to filter sensitive data + filter_sensitive_params(arg) + when ActiveRecord::Base + { class: arg.class.name, id: arg.id } + else + arg.class.name + end + end + rescue StandardError + [''] + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/capture_exceptions.rb b/posthog-rails/lib/posthog/rails/capture_exceptions.rb new file mode 100644 index 0000000..6350322 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/capture_exceptions.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'posthog/rails/parameter_filter' + +module PostHog + module Rails + # Middleware that captures exceptions and sends them to PostHog + class CaptureExceptions + include ParameterFilter + + def initialize(app) + @app = app + end + + def call(env) + response = @app.call(env) + + # Check if there was an exception that Rails handled + exception = collect_exception(env) + + capture_exception(exception, env) if exception && should_capture?(exception) + + response + rescue StandardError => e + # Capture unhandled exceptions + capture_exception(e, env) if should_capture?(e) + raise + end + + private + + def collect_exception(env) + # Rails stores exceptions in these env keys + env['action_dispatch.exception'] || + env['rack.exception'] || + env['posthog.rescued_exception'] + end + + def should_capture?(exception) + return false unless PostHog.rails_config&.auto_capture_exceptions + return false unless PostHog.rails_config&.should_capture_exception?(exception) + + true + end + + def capture_exception(exception, env) + request = ActionDispatch::Request.new(env) + distinct_id = extract_distinct_id(env, request) + additional_properties = build_properties(request, env) + + PostHog.capture_exception(exception, distinct_id, additional_properties) + rescue StandardError => e + PostHog::Logging.logger.error("Failed to capture exception: #{e.message}") + 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 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 + end + end + + # Fallback to session ID or nil + request.session_options&.dig(:id) + end + + def extract_user_id(user) + # Try common ID methods + return user.id if user.respond_to?(:id) + return user['id'] if user.respond_to?(:[]) && user['id'] + return user.uuid if user.respond_to?(:uuid) + return user['uuid'] if user.respond_to?(:[]) && user['uuid'] + + user.to_s + end + + def build_properties(request, env) + properties = { + '$exception_source' => 'rails', + '$request_url' => request.url, + '$request_method' => request.method, + '$request_path' => request.path + } + + # Add controller and action if available + if env['action_controller.instance'] + controller = env['action_controller.instance'] + properties['$controller'] = controller.controller_name + properties['$action'] = controller.action_name + end + + # Add request parameters (be careful with sensitive data) + if request.params.present? + filtered_params = filter_sensitive_params(request.params) + properties['$request_params'] = filtered_params unless filtered_params.empty? + end + + # Add user agent + properties['$user_agent'] = request.user_agent if request.user_agent + + # Add referrer + properties['$referrer'] = request.referrer if request.referrer + + properties + end + + def filter_sensitive_params(params) + # Use Rails' configured filter_parameters to filter sensitive data + # This respects the app's config.filter_parameters setting + filtered = super + + # Also truncate long values + filtered.transform_values do |value| + if value.is_a?(String) && value.length > 1000 + "#{value[0..1000]}... (truncated)" + else + value + end + end + rescue StandardError + {} # Return empty hash if filtering fails + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/configuration.rb b/posthog-rails/lib/posthog/rails/configuration.rb new file mode 100644 index 0000000..f36781d --- /dev/null +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module PostHog + module Rails + class Configuration + # Whether to automatically capture exceptions from Rails + attr_accessor :auto_capture_exceptions + + # Whether to capture exceptions that Rails rescues (e.g., with rescue_from) + attr_accessor :report_rescued_exceptions + + # Whether to automatically instrument ActiveJob + attr_accessor :auto_instrument_active_job + + # List of exception classes to ignore (in addition to default) + attr_accessor :excluded_exceptions + + # Whether to capture the current user context in exceptions + attr_accessor :capture_user_context + + # Method name to call on controller to get user ID (default: :current_user) + attr_accessor :current_user_method + + def initialize + @auto_capture_exceptions = true + @report_rescued_exceptions = true + @auto_instrument_active_job = true + @excluded_exceptions = [] + @capture_user_context = true + @current_user_method = :current_user + end + + # Default exceptions that Rails apps typically don't want to track + def default_excluded_exceptions + [ + 'AbstractController::ActionNotFound', + 'ActionController::BadRequest', + 'ActionController::InvalidAuthenticityToken', + 'ActionController::InvalidCrossOriginRequest', + 'ActionController::MethodNotAllowed', + 'ActionController::NotImplemented', + 'ActionController::ParameterMissing', + 'ActionController::RoutingError', + 'ActionController::UnknownFormat', + 'ActionController::UnknownHttpMethod', + 'ActionDispatch::Http::Parameters::ParseError', + 'ActiveRecord::RecordNotFound', + 'ActiveRecord::RecordNotUnique' + ] + end + + def should_capture_exception?(exception) + exception_name = exception.class.name + !all_excluded_exceptions.include?(exception_name) + end + + private + + def all_excluded_exceptions + default_excluded_exceptions + excluded_exceptions + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/error_subscriber.rb b/posthog-rails/lib/posthog/rails/error_subscriber.rb new file mode 100644 index 0000000..202ec9b --- /dev/null +++ b/posthog-rails/lib/posthog/rails/error_subscriber.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PostHog + module Rails + # Rails 7.0+ error reporter integration + # This integrates with Rails.error.handle and Rails.error.record + class ErrorSubscriber + def report(error, handled:, severity:, context:, source: nil) + return unless PostHog.rails_config&.auto_capture_exceptions + return unless PostHog.rails_config&.should_capture_exception?(error) + + distinct_id = context[:user_id] || context[:distinct_id] + + properties = { + '$exception_source' => source || 'rails_error_reporter', + '$exception_handled' => handled, + '$exception_severity' => severity + } + + # Add context information + if context.present? + context.each do |key, value| + properties["$context_#{key}"] = value unless key.in?(%i[user_id distinct_id]) + end + end + + PostHog.capture_exception(error, distinct_id, properties) + rescue StandardError => e + PostHog::Logging.logger.error("Failed to report error via subscriber: #{e.message}") + PostHog::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}") + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/parameter_filter.rb b/posthog-rails/lib/posthog/rails/parameter_filter.rb new file mode 100644 index 0000000..b46fc91 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/parameter_filter.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module PostHog + module Rails + # Shared utility module for filtering sensitive parameters + # + # This module provides consistent parameter filtering across all PostHog Rails + # components, leveraging Rails' built-in parameter filtering when available. + # It automatically detects the correct Rails parameter filtering API based on + # the Rails version. + # + # @example Usage in a class + # class MyClass + # include PostHog::Rails::ParameterFilter + # + # def my_method(params) + # filtered = filter_sensitive_params(params) + # PostHog.capture(event: 'something', properties: filtered) + # end + # end + module ParameterFilter + EMPTY_HASH = {}.freeze + + if ::Rails.version.to_f >= 6.0 + def self.backend + ActiveSupport::ParameterFilter + end + else + def self.backend + ActionDispatch::Http::ParameterFilter + end + end + + # Filter sensitive parameters from a hash, respecting Rails configuration. + # + # Uses Rails' configured filter_parameters (e.g., :password, :token, :api_key) + # to automatically filter sensitive data that the Rails app has configured. + # + # @param params [Hash] The parameters to filter + # @return [Hash] Filtered parameters with sensitive data masked + def filter_sensitive_params(params) + return EMPTY_HASH unless params.is_a?(Hash) + + filter_parameters = ::Rails.application.config.filter_parameters + parameter_filter = ParameterFilter.backend.new(filter_parameters) + + parameter_filter.filter(params) + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/railtie.rb b/posthog-rails/lib/posthog/rails/railtie.rb new file mode 100644 index 0000000..4bdd318 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +module PostHog + module Rails + class Railtie < ::Rails::Railtie + # Add PostHog module methods for accessing Rails-specific client + initializer 'posthog.set_configs' do |_app| + PostHog.class_eval do + class << self + attr_accessor :rails_config, :client + + # Initialize PostHog client with a block configuration + def init(options = {}) + @rails_config ||= PostHog::Rails::Configuration.new + + # If block given, yield to configuration + if block_given? + config = PostHog::Rails::InitConfig.new(@rails_config, options) + yield config + options = config.to_client_options + end + + # Create the PostHog client + @client = PostHog::Client.new(options) + end + + # Delegate common methods to the singleton client + def capture(*args, **kwargs) + ensure_initialized! + client.capture(*args, **kwargs) + end + + def capture_exception(*args, **kwargs) + ensure_initialized! + client.capture_exception(*args, **kwargs) + end + + def identify(*args, **kwargs) + ensure_initialized! + client.identify(*args, **kwargs) + end + + def alias(*args, **kwargs) + ensure_initialized! + client.alias(*args, **kwargs) + end + + def group_identify(*args, **kwargs) + ensure_initialized! + client.group_identify(*args, **kwargs) + end + + # NOTE: Method name matches the underlying PostHog Ruby Client for consistency. + # TODO: Rename to feature_flag_enabled? when the client method is updated. + def is_feature_enabled(*args, **kwargs) # rubocop:disable Naming/PredicateName + ensure_initialized! + client.is_feature_enabled(*args, **kwargs) + end + + def get_feature_flag(*args, **kwargs) + ensure_initialized! + client.get_feature_flag(*args, **kwargs) + end + + def get_all_flags(*args, **kwargs) + ensure_initialized! + client.get_all_flags(*args, **kwargs) + end + + def initialized? + !@client.nil? + end + + private + + def ensure_initialized! + return if initialized? + + raise 'PostHog is not initialized. Call PostHog.init in an initializer.' + end + end + end + end + + # Insert middleware for exception capturing + initializer 'posthog.insert_middlewares' do |app| + # Insert after DebugExceptions to catch rescued exceptions + app.config.middleware.insert_after( + ActionDispatch::DebugExceptions, + PostHog::Rails::RescuedExceptionInterceptor + ) + + # Insert after ShowExceptions to capture all exceptions + app.config.middleware.insert_after( + ActionDispatch::ShowExceptions, + PostHog::Rails::CaptureExceptions + ) + end + + # Hook into ActiveJob before classes are loaded + initializer 'posthog.active_job', before: :eager_load! do + ActiveSupport.on_load(:active_job) do + # Prepend our module to ActiveJob::Base to wrap perform_now + prepend PostHog::Rails::ActiveJobExtensions + end + end + + # After initialization, set up remaining integrations + config.after_initialize do |_app| + next unless PostHog.initialized? + + # Register with Rails error reporter (Rails 7.0+) + register_error_subscriber if rails_version_above_7? + end + + # Ensure PostHog shuts down gracefully + config.to_prepare do + at_exit do + PostHog.client&.shutdown if PostHog.initialized? + end + end + + def self.register_error_subscriber + return unless PostHog.rails_config&.auto_capture_exceptions + + subscriber = PostHog::Rails::ErrorSubscriber.new + ::Rails.error.subscribe(subscriber) + rescue StandardError => e + PostHog::Logging.logger.warn("Failed to register error subscriber: #{e.message}") + PostHog::Logging.logger.warn("Backtrace: #{e.backtrace&.first(5)&.join("\n")}") + end + + def self.rails_version_above_7? + ::Rails.version.to_f >= 7.0 + end + end + + # Configuration wrapper for the init block + class InitConfig + attr_reader :rails_config + + def initialize(rails_config, base_options = {}) + @rails_config = rails_config + @base_options = base_options + end + + # Core PostHog options + def api_key=(value) + @base_options[:api_key] = value + end + + def personal_api_key=(value) + @base_options[:personal_api_key] = value + end + + def host=(value) + @base_options[:host] = value + end + + def max_queue_size=(value) + @base_options[:max_queue_size] = value + end + + def test_mode=(value) + @base_options[:test_mode] = value + end + + def on_error=(value) + @base_options[:on_error] = value + end + + def feature_flags_polling_interval=(value) + @base_options[:feature_flags_polling_interval] = value + end + + def feature_flag_request_timeout_seconds=(value) + @base_options[:feature_flag_request_timeout_seconds] = value + end + + def before_send=(value) + @base_options[:before_send] = value + end + + # Rails-specific options + def auto_capture_exceptions=(value) + @rails_config.auto_capture_exceptions = value + end + + def report_rescued_exceptions=(value) + @rails_config.report_rescued_exceptions = value + end + + def auto_instrument_active_job=(value) + @rails_config.auto_instrument_active_job = value + end + + def excluded_exceptions=(value) + @rails_config.excluded_exceptions = value + end + + def capture_user_context=(value) + @rails_config.capture_user_context = value + end + + def current_user_method=(value) + @rails_config.current_user_method = value + end + + def to_client_options + @base_options + end + end + end +end diff --git a/posthog-rails/lib/posthog/rails/rescued_exception_interceptor.rb b/posthog-rails/lib/posthog/rails/rescued_exception_interceptor.rb new file mode 100644 index 0000000..8543ff7 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/rescued_exception_interceptor.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module PostHog + module Rails + # Middleware that intercepts exceptions that are rescued by Rails + # This middleware runs before ShowExceptions and captures the exception + # so we can report it even if Rails rescues it + class RescuedExceptionInterceptor + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + rescue StandardError => e + # Store the exception so CaptureExceptions middleware can report it + env['posthog.rescued_exception'] = e if should_intercept? + raise e + end + + private + + def should_intercept? + PostHog.rails_config&.report_rescued_exceptions + end + end + end +end diff --git a/posthog-rails/posthog-rails.gemspec b/posthog-rails/posthog-rails.gemspec new file mode 100644 index 0000000..95fe731 --- /dev/null +++ b/posthog-rails/posthog-rails.gemspec @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require File.expand_path('../lib/posthog/version', __dir__) + +Gem::Specification.new do |spec| + spec.name = 'posthog-rails' + spec.version = PostHog::VERSION + spec.files = Dir.glob('lib/**/*') + spec.require_paths = ['lib'] + spec.summary = 'PostHog integration for Rails' + spec.description = 'Automatic exception tracking and instrumentation for Ruby on Rails applications using PostHog' + spec.authors = ['PostHog'] + spec.email = 'hey@posthog.com' + spec.homepage = 'https://github.com/PostHog/posthog-ruby' + spec.license = 'MIT' + spec.required_ruby_version = '>= 3.0' + spec.metadata['rubygems_mfa_required'] = 'true' + + # Rails dependency - support Rails 5.2+ + spec.add_dependency 'railties', '>= 5.2.0' + # Core PostHog SDK + spec.add_dependency 'posthog-ruby', "~> #{PostHog::VERSION.split('.')[0..1].join('.')}" +end