diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cb1dea..3fa5d78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,91 @@ -name: Publish Release +name: "Release" on: - workflow_dispatch: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: read + +# Concurrency control: only one release process can run at a time +# This prevents race conditions if multiple PRs with 'release' label merge simultaneously +concurrency: + group: release + cancel-in-progress: false jobs: - publish: + check-release-label: + name: Check for release label runs-on: ubuntu-latest + # Run when PR with 'release' label is merged to main + if: | + github.event.pull_request.merged == true + && contains(github.event.pull_request.labels.*.name, 'release') + outputs: + should-release: ${{ steps.check.outputs.should-release }} + version: ${{ steps.check.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: main + + - name: Check version is new + id: check + run: | + # Extract version from source + version=$(grep "VERSION = '" lib/posthog/version.rb | grep -o "'[0-9]\+\.[0-9]\+\.[0-9]\+'" | tr -d "'") + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Get currently published version from RubyGems + published=$(gem info posthog-ruby --remote 2>/dev/null | grep -o 'posthog-ruby ([0-9.]*' | grep -o '[0-9].*' || echo "none") + + echo "Local version: $version" + echo "Published version: $published" + + if [ "$version" = "$published" ]; then + echo "Version $version is already published, skipping release" + echo "should-release=false" >> "$GITHUB_OUTPUT" + else + echo "Ready to release version $version" + echo "should-release=true" >> "$GITHUB_OUTPUT" + fi + + notify-approval-needed: + name: Notify Slack - Approval Needed + needs: check-release-label + if: needs.check-release-label.outputs.should-release == 'true' + uses: posthog/.github/.github/workflows/notify-approval-needed.yml@main + with: + slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }} + slack_user_group_id: ${{ vars.GROUP_CLIENT_LIBRARIES_SLACK_GROUP_ID }} + secrets: + slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }} + posthog_project_api_key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} + + release: + name: Release and publish + needs: [check-release-label, notify-approval-needed] + runs-on: ubuntu-latest + if: always() && needs.check-release-label.outputs.should-release == 'true' + environment: "Release" # This will require an approval from a maintainer, they are notified in Slack above permissions: - contents: read + contents: write id-token: write steps: - - name: Checkout - uses: actions/checkout@v5 + - name: Notify Slack - Approved + if: needs.notify-approval-needed.outputs.slack_ts != '' + uses: posthog/.github/.github/actions/slack-thread-reply@main + with: + slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }} + slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }} + thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }} + message: "βœ… Release approved! Publishing v${{ needs.check-release-label.outputs.version }}..." + emoji_reaction: "white_check_mark" + + - name: Checkout repository + uses: actions/checkout@v4 with: ref: main fetch-depth: 0 @@ -22,4 +96,73 @@ jobs: bundler-cache: true ruby-version: ruby - - uses: rubygems/release-gem@v1 + - name: Configure trusted publishing credentials + uses: rubygems/configure-rubygems-credentials@v1.0.0 + + # Build and publish posthog-ruby first (posthog-rails depends on it) + - name: Build posthog-ruby + run: gem build posthog-ruby.gemspec + + - name: Publish posthog-ruby + run: gem push posthog-ruby-*.gem + + - name: Wait for posthog-ruby to be available + run: gem exec rubygems-await posthog-ruby-*.gem + + # Build and publish posthog-rails + - name: Build posthog-rails + run: gem build posthog-rails/posthog-rails.gemspec + + - name: Publish posthog-rails + run: gem push posthog-rails-*.gem + + - name: Wait for posthog-rails to be available + run: gem exec rubygems-await posthog-rails-*.gem + + # Create and push git tag + - name: Create git tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v${{ needs.check-release-label.outputs.version }}" -m "Release v${{ needs.check-release-label.outputs.version }}" + git push origin "v${{ needs.check-release-label.outputs.version }}" + + # Notify in case of a failure + - name: Send failure event to PostHog + if: ${{ failure() }} + uses: PostHog/posthog-github-action@v0.1 + with: + posthog-token: "${{ secrets.POSTHOG_PROJECT_API_KEY }}" + event: "posthog-ruby-github-release-workflow-failure" + properties: >- + { + "commitSha": "${{ github.sha }}", + "jobStatus": "${{ job.status }}", + "ref": "${{ github.ref }}", + "version": "v${{ needs.check-release-label.outputs.version }}" + } + + - name: Notify Slack - Failed + if: ${{ failure() && needs.notify-approval-needed.outputs.slack_ts != '' }} + uses: posthog/.github/.github/actions/slack-thread-reply@main + with: + slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }} + slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }} + thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }} + message: "❌ Failed to release `posthog-ruby@v${{ needs.check-release-label.outputs.version }}`! " + emoji_reaction: "x" + + notify-released: + name: Notify Slack - Released + needs: [check-release-label, notify-approval-needed, release] + runs-on: ubuntu-latest + if: always() && needs.release.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != '' + steps: + - name: Notify Slack - Released + uses: posthog/.github/.github/actions/slack-thread-reply@main + with: + slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }} + slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }} + thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }} + message: "πŸš€ posthog-ruby and posthog-rails v${{ needs.check-release-label.outputs.version }} released successfully!" + emoji_reaction: "rocket" diff --git a/.rubocop.yml b/.rubocop.yml index 66a8949..2b96a3c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,3 @@ -inherit_from: .rubocop_todo.yml - AllCops: NewCops: enable SuggestExtensions: false @@ -7,6 +5,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 @@ -75,7 +77,8 @@ Metrics/MethodLength: Metrics/ParameterLists: Enabled: false -# Allow longer modules in spec files since they contain many test cases +# Ideally need to bring this down Metrics/ModuleLength: + Max: 4055 Exclude: - - 'spec/**/*_spec.rb' + - "spec/**/*_spec.rb" diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml deleted file mode 100644 index 9d27730..0000000 --- a/.rubocop_todo.yml +++ /dev/null @@ -1,12 +0,0 @@ -# This configuration was generated by -# `rubocop --auto-gen-config` -# on 2025-05-20 14:44:39 UTC using RuboCop version 1.75.6. -# The point is for the user to remove these configuration records -# one by one as the offenses are removed from the code base. -# Note that changes in the inspected code, or installation of new -# versions of RuboCop, may require this file to be generated again. - -# Offense count: 5 -# Configuration parameters: CountComments, CountAsOne. -Metrics/ModuleLength: - Max: 4055 diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e1a37..ae083de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 3.5.0 - 2026-02-05 + +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.4.0 - 2025-12-04 1. feat: Add ETag support for feature flag definitions polling ([#84](https://github.com/PostHog/posthog-ruby/pull/84)) diff --git a/README.md b/README.md index 67bb595..430ff63 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` @@ -31,18 +35,22 @@ Specifically, the [Ruby integration](https://posthog.com/docs/integrations/ruby- ## How to release -1. Get access to RubyGems from @dmarticus, @daibhin or @mariusandra -2. Install [`gh`](https://cli.github.com/) and authenticate with `gh auth login` -3. Update `lib/posthog/version.rb` with the new version & add to `CHANGELOG.md` making sure to add the current date. Commit the changes: +Both `posthog-ruby` and `posthog-rails` are released together with the same version number. + +1. Create a PR that: + - Updates `lib/posthog/version.rb` with the new version + - Updates `CHANGELOG.md` with the changes and current date + +2. Add the `release` label to the PR + +3. Merge the PR to `main` -```shell -VERSION=1.2.3 #Β Replace with the new version here -git commit -am "Version $VERSION" -git tag -a $VERSION -m "Version $VERSION" -git push && git push --tags -gh release create $VERSION --generate-notes --fail-on-no-commits -``` +4. The release workflow will: + - Notify the Client Libraries team in Slack + - Wait for approval via the GitHub `Release` environment + - Publish both gems to RubyGems (via trusted publishing) + - Create and push a git tag -4. [Trigger](https://github.com/PostHog/posthog-ruby/actions) "Publish Release" GitHub Action +5. Approve the release in GitHub when prompted -5. Authenticate with your RubyGems account and approve the publish! +The workflow handles publishing both `posthog-ruby` and `posthog-rails` in the correct order (since `posthog-rails` depends on `posthog-ruby`). 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/lib/posthog/version.rb b/lib/posthog/version.rb index c75d9eb..5a4b6c8 100644 --- a/lib/posthog/version.rb +++ b/lib/posthog/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module PostHog - VERSION = '3.4.0' + VERSION = '3.5.0' end diff --git a/posthog-rails/README.md b/posthog-rails/README.md new file mode 100644 index 0000000..78c1292 --- /dev/null +++ b/posthog-rails/README.md @@ -0,0 +1,453 @@ +# 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. + +### Generate the Initializer + +Run the install generator to create the PostHog initializer: + +```bash +rails generate posthog:install +``` + +This will create `config/initializers/posthog.rb` with sensible defaults and documentation. + +## Configuration + +The generated initializer at `config/initializers/posthog.rb` includes all available options: + +```ruby +# Rails-specific configuration +PostHog::Rails.configure do |config| + config.auto_capture_exceptions = true # Enable automatic exception capture (default: false) + config.report_rescued_exceptions = true # Report exceptions Rails rescues (default: false) + config.auto_instrument_active_job = true # Instrument background jobs (default: false) + config.capture_user_context = true # Include user info in exceptions + config.current_user_method = :current_user # Method to get current user + config.user_id_method = nil # Method to get ID from user (auto-detect) + + # Add additional exceptions to ignore + config.excluded_exceptions = ['MyCustomError'] +end + +# Core PostHog client initialization +PostHog.init do |config| + # Required: Your PostHog API key + config.api_key = ENV['POSTHOG_API_KEY'] + + # Optional: Your PostHog instance URL + config.host = 'https://us.i.posthog.com' # or https://eu.i.posthog.com + + # Optional: Personal API key for feature flags + config.personal_api_key = ENV['POSTHOG_PERSONAL_API_KEY'] + + # Error callback + config.on_error = proc { |status, msg| + Rails.logger.error("PostHog error: #{msg}") + } +end +``` + +You can also configure Rails options directly: + +```ruby +PostHog::Rails.config.auto_capture_exceptions = true +``` + +### 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 + +When `auto_capture_exceptions` is enabled, 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 + +When `auto_instrument_active_job` is enabled, 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 +``` + +#### Associating Jobs with Users + +By default, PostHog tries to extract a `distinct_id` from job arguments by looking for a `user_id` key in hash arguments: + +```ruby +class ProcessOrderJob < ApplicationJob + def perform(order_id, options = {}) + # PostHog will automatically use options[:user_id] or options['user_id'] + # as the distinct_id if present + end +end + +# Call with user context +ProcessOrderJob.perform_later(order.id, user_id: current_user.id) +``` + +#### Custom Distinct ID Extraction + +For more control, use the `posthog_distinct_id` class method to define exactly how to extract the user's distinct ID from your job arguments: + +```ruby +class SendWelcomeEmailJob < ApplicationJob + posthog_distinct_id ->(user, options) { user.id } + + def perform(user, options = {}) + UserMailer.welcome(user).deliver_now + end +end +``` + +You can also use a block: + +```ruby +class ProcessOrderJob < ApplicationJob + posthog_distinct_id do |order, notify_user_id| + notify_user_id + end + + def perform(order, notify_user_id) + # Process the order... + end +end +``` + +The proc/block receives the same arguments as `perform`, so you can extract the distinct ID however makes sense for your job. + +> **Note:** Currently only ActiveJob is supported. Support for other job runners (Sidekiq, Resque, Good Job, etc.) is planned for future releases. Contributions are welcome! + +### 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 }) +``` + +PostHog will automatically extract the user's distinct ID from either `user_id` or `distinct_id` in the context hash (checking `user_id` first). Any other context keys are included as properties on the exception event. + +## Configuration Options + +### Core PostHog Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `api_key` | String | **required** | Your PostHog project API key | +| `host` | String | `https://us.i.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 + +Configure these via `PostHog::Rails.configure` or `PostHog::Rails.config`: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `auto_capture_exceptions` | Boolean | `false` | Automatically capture exceptions | +| `report_rescued_exceptions` | Boolean | `false` | Report exceptions Rails rescues | +| `auto_instrument_active_job` | Boolean | `false` | Instrument ActiveJob | +| `capture_user_context` | Boolean | `true` | Include user info | +| `current_user_method` | Symbol | `:current_user` | Controller method for user | +| `user_id_method` | Symbol | `nil` | Method to extract ID from user object (auto-detect if nil) | +| `excluded_exceptions` | Array | `[]` | Additional exceptions to ignore | + +### Understanding Exception Tracking Options + +**`auto_capture_exceptions`** - Master switch for all automatic error tracking (default: `false`) +- When `true`: All exceptions are automatically captured and sent to PostHog +- When `false` (default): No automatic error tracking (you must manually call `PostHog.capture_exception`) +- **Use case:** Enable to get automatic error tracking + +**`report_rescued_exceptions`** - Control exceptions that Rails handles gracefully (default: `false`) +- When `true`: Capture exceptions that Rails rescues and shows error pages for (404s, 500s, etc.) +- When `false` (default): Only capture truly unhandled exceptions that crash your app +- **Use case:** Enable along with `auto_capture_exceptions` for complete error visibility + +**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 | +| `auto_capture_exceptions = true`
`report_rescued_exceptions = false` | ❌ Not captured (Rails rescued it) | +| `auto_capture_exceptions = false` | ❌ Not captured (automatic tracking disabled, this is the default) | + +**Recommendation:** Enable both options to get complete visibility into all errors. Set `report_rescued_exceptions = false` if you only want to track critical crashes. + +## Excluded Exceptions by Default + +The following exceptions are not reported by default (common 4xx errors): + +- `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` + +You can add more with `PostHog::Rails.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 +PostHog::Rails.config.current_user_method = :logged_in_user +``` + +### User ID Extraction + +By default, PostHog Rails auto-detects the user's distinct ID by trying these methods in order: + +1. `posthog_distinct_id` - Define this on your User model for full control +2. `distinct_id` - Common analytics convention +3. `id` - Standard ActiveRecord primary key +4. `pk` - Primary key alias +5. `uuid` - For UUID-based primary keys + +**Option 1: Configure a specific method** + +```ruby +# config/initializers/posthog.rb +PostHog::Rails.config.user_id_method = :email # or :external_id, :customer_id, etc. +``` + +**Option 2: Define a method on your User model** + +```ruby +class User < ApplicationRecord + def posthog_distinct_id + # Custom logic for your distinct ID + "user_#{id}" # or external_id, or any unique identifier + end +end +``` + +This approach is useful when you want to: +- Use a different identifier than the database ID (e.g., `external_id`) +- Prefix IDs to distinguish user types +- Use composite identifiers + +## 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 10,000 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 user object responds to one of: `posthog_distinct_id`, `distinct_id`, `id`, `pk`, or `uuid` +3. If using a custom identifier, set `PostHog::Rails.config.user_id_method = :your_method` +4. 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..3b6fc81 --- /dev/null +++ b/posthog-rails/examples/posthog.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +# PostHog Rails Initializer +# Place this file in config/initializers/posthog.rb + +# ============================================================================ +# RAILS-SPECIFIC CONFIGURATION +# ============================================================================ +# Configure Rails-specific options via PostHog::Rails.configure +# These settings control how PostHog integrates with Rails features. + +PostHog::Rails.configure do |config| + # Automatically capture exceptions (default: false) + # Set to true to enable automatic exception tracking + # config.auto_capture_exceptions = true + + # Report exceptions that Rails rescues (e.g., with rescue_from) (default: false) + # Set to true to capture rescued exceptions + # config.report_rescued_exceptions = true + + # Automatically instrument ActiveJob background jobs (default: false) + # Set to true to enable automatic ActiveJob exception tracking + # 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 (e.g., :authenticated_user) + # When configured, exceptions will include user context (distinct_id, email, name), + # making it easier to identify affected users and debug user-specific issues. + # 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' + # ] +end + +# You can also configure Rails options directly: +# PostHog::Rails.config.auto_capture_exceptions = true + +# ============================================================================ +# CORE POSTHOG CONFIGURATION +# ============================================================================ +# Initialize the PostHog client with core SDK options. + +PostHog.init do |config| + # ============================================================================ + # REQUIRED CONFIGURATION + # ============================================================================ + + # Your PostHog project API key (required) + # Get this from: PostHog Project Settings > API Keys + # https://app.posthog.com/settings/project-details#variables + config.api_key = ENV.fetch('POSTHOG_API_KEY', nil) + + # ============================================================================ + # OPTIONAL CONFIGURATION + # ============================================================================ + + # For PostHog Cloud, use: https://us.i.posthog.com or https://eu.i.posthog.com + config.host = ENV.fetch('POSTHOG_HOST', 'https://us.i.posthog.com') + + # Personal API key (optional, but required for local feature flag evaluation) + # Get this from: PostHog Settings > Personal API Keys + # https://app.posthog.com/settings/user-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 + # } + + # ============================================================================ + # ENVIRONMENT-SPECIFIC CONFIGURATION + # ============================================================================ + + # Disable in test environment + config.test_mode = true if Rails.env.test? + + # Optional: Disable in development + # config.test_mode = true if Rails.env.test? || Rails.env.development? +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. diff --git a/posthog-rails/lib/generators/posthog/install_generator.rb b/posthog-rails/lib/generators/posthog/install_generator.rb new file mode 100644 index 0000000..f13b10e --- /dev/null +++ b/posthog-rails/lib/generators/posthog/install_generator.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'rails/generators' + +module Posthog + module Generators + class InstallGenerator < ::Rails::Generators::Base + desc 'Creates a PostHog initializer file at config/initializers/posthog.rb' + + source_root File.expand_path('../../..', __dir__) + + def copy_initializer + copy_file 'examples/posthog.rb', 'config/initializers/posthog.rb' + end + + def show_readme + say '' + say 'PostHog Rails has been installed!', :green + say '' + say 'Next steps:', :yellow + say ' 1. Edit config/initializers/posthog.rb with your PostHog API key' + say ' 2. Set environment variables:' + say ' - POSTHOG_API_KEY (required)' + say ' - POSTHOG_PERSONAL_API_KEY (optional, for feature flags)' + say '' + say 'For more information, see: https://posthog.com/docs/libraries/ruby' + say '' + end + end + end +end diff --git a/posthog-rails/lib/posthog-rails.rb b/posthog-rails/lib/posthog-rails.rb new file mode 100644 index 0000000..f2160c9 --- /dev/null +++ b/posthog-rails/lib/posthog-rails.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +unless defined?(Rails) + raise LoadError, 'posthog-rails requires Rails. Use the posthog-ruby gem directly for non-Rails applications.' +end + +# Load core PostHog Ruby SDK +require 'posthog' + +# Load Rails integration +require 'posthog/rails' diff --git a/posthog-rails/lib/posthog/rails.rb b/posthog-rails/lib/posthog/rails.rb new file mode 100644 index 0000000..450b3e1 --- /dev/null +++ b/posthog-rails/lib/posthog/rails.rb @@ -0,0 +1,46 @@ +# 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 + + # Thread-local key for tracking web request context + IN_WEB_REQUEST_KEY = :posthog_in_web_request + + class << self + def config + @config ||= Configuration.new + end + + attr_writer :config + + def configure + yield config if block_given? + end + + # Mark that we're in a web request context + # CaptureExceptions middleware will handle exception capture + def enter_web_request + Thread.current[IN_WEB_REQUEST_KEY] = true + end + + # Clear web request context (called at end of request) + def exit_web_request + Thread.current[IN_WEB_REQUEST_KEY] = false + end + + # Check if we're currently in a web request context + # Used by ErrorSubscriber to avoid duplicate captures + def in_web_request? + Thread.current[IN_WEB_REQUEST_KEY] == true + end + end + 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..c4b5b41 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/active_job.rb @@ -0,0 +1,106 @@ +# 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 self.prepended(base) + base.extend(ClassMethods) + end + + module ClassMethods + # DSL for defining how to extract distinct_id from job arguments + # Example: + # class MyJob < ApplicationJob + # posthog_distinct_id ->(user, arg1, arg2) { user.id } + # def perform(user, arg1, arg2) + # # ... + # end + # end + def posthog_distinct_id(proc = nil, &block) + @posthog_distinct_id_proc = proc || block + end + + def posthog_distinct_id_proc + @posthog_distinct_id_proc + end + end + + 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 + # First, check if the job class defines a custom extractor + return self.class.posthog_distinct_id_proc.call(*arguments) if self.class.posthog_distinct_id_proc + + # Fallback: look for explicit user_id in hash arguments only + arguments.each do |arg| + if 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 defined?(ActiveRecord::Base) && 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..c17534e --- /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) + # Signal that we're in a web request context + # ErrorSubscriber will skip capture for web requests to avoid duplicates + PostHog::Rails.enter_web_request + + 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 + ensure + PostHog::Rails.exit_web_request + 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 capture_user_context is enabled + if PostHog::Rails.config&.capture_user_context && env['action_controller.instance'] + controller = env['action_controller.instance'] + method_name = PostHog::Rails.config&.current_user_method || :current_user + + if controller.respond_to?(method_name, true) + user = controller.send(method_name) + return extract_user_id(user) if user + end + end + + # Fallback to session ID or nil + request.session&.id&.to_s + end + + def extract_user_id(user) + # Use configured method if specified + method_name = PostHog::Rails.config&.user_id_method + return user.send(method_name) if method_name && user.respond_to?(method_name) + + # Try explicit PostHog method (allows users to customize without config) + return user.posthog_distinct_id if user.respond_to?(:posthog_distinct_id) + return user.distinct_id if user.respond_to?(:distinct_id) + + # Try common ID methods + return user.id if user.respond_to?(:id) + return user['id'] if user.respond_to?(:[]) && user['id'] + return user.pk if user.respond_to?(:pk) + return user['pk'] if user.respond_to?(:[]) && user['pk'] + 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' => safe_serialize(request.url), + '$request_method' => safe_serialize(request.method), + '$request_path' => safe_serialize(request.path) + } + + # Add controller and action if available + if env['action_controller.instance'] + controller = env['action_controller.instance'] + properties['$controller'] = safe_serialize(controller.controller_name) + properties['$action'] = safe_serialize(controller.action_name) + end + + # Add request parameters (be careful with sensitive data) + if request.params.present? + filtered_params = filter_sensitive_params(request.params) + # Safe serialize to handle any complex objects in params + properties['$request_params'] = safe_serialize(filtered_params) unless filtered_params.empty? + end + + # Add user agent + properties['$user_agent'] = safe_serialize(request.user_agent) if request.user_agent + + # Add referrer + properties['$referrer'] = safe_serialize(request.referrer) if request.referrer + + properties + 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..f64d3bf --- /dev/null +++ b/posthog-rails/lib/posthog/rails/configuration.rb @@ -0,0 +1,69 @@ +# 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 + + # Method name to call on user object to get distinct_id (default: auto-detect) + # When nil, tries: posthog_distinct_id, distinct_id, id, pk, uuid in order + attr_accessor :user_id_method + + def initialize + @auto_capture_exceptions = false + @report_rescued_exceptions = false + @auto_instrument_active_job = false + @excluded_exceptions = [] + @capture_user_context = true + @current_user_method = :current_user + @user_id_method = nil + 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..0d163c7 --- /dev/null +++ b/posthog-rails/lib/posthog/rails/error_subscriber.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'posthog/rails/parameter_filter' + +module PostHog + module Rails + # Rails 7.0+ error reporter integration + # This integrates with Rails.error.handle and Rails.error.record + class ErrorSubscriber + include ParameterFilter + + 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) + # Skip if in a web request - CaptureExceptions middleware will handle it + # with richer context (URL, params, controller, etc.) + return if PostHog::Rails.in_web_request? + + distinct_id = context[:user_id] || context[:distinct_id] + + properties = { + '$exception_source' => source || 'rails_error_reporter', + '$exception_handled' => handled, + '$exception_severity' => severity.to_s + } + + # Add context information (safely serialized to avoid circular references) + if context.present? + context.each do |key, value| + next if key.in?(%i[user_id distinct_id]) + + properties["$context_#{key}"] = safe_serialize(value) + 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..6ae197f --- /dev/null +++ b/posthog-rails/lib/posthog/rails/parameter_filter.rb @@ -0,0 +1,128 @@ +# 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 + MAX_STRING_LENGTH = 10_000 + MAX_DEPTH = 10 + + 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) + return params unless ::Rails.application + + filter_parameters = ::Rails.application.config.filter_parameters + parameter_filter = ParameterFilter.backend.new(filter_parameters) + + parameter_filter.filter(params) + end + + # Safely serialize a value to a JSON-compatible format. + # + # Handles circular references and complex objects by converting them to + # simple primitives or string representations. This prevents SystemStackError + # when serializing objects with circular references (like ActiveRecord models). + # + # @param value [Object] The value to serialize + # @param seen [Set] Set of object_ids already visited (for cycle detection) + # @param depth [Integer] Current recursion depth + # @return [Object] A JSON-safe value (String, Numeric, Boolean, nil, Array, or Hash) + def safe_serialize(value, seen = Set.new, depth = 0) + return '[max depth exceeded]' if depth > MAX_DEPTH + + case value + when nil, true, false, Integer, Float + value + when String + truncate_string(value) + when Symbol + value.to_s + when Time, DateTime + value.iso8601(3) + when Date + value.iso8601 + when Array + serialize_array(value, seen, depth) + when Hash + serialize_hash(value, seen, depth) + else + serialize_object(value, seen) + end + rescue StandardError => e + "[serialization error: #{e.class}]" + end + + private + + def truncate_string(str) + return str if str.length <= MAX_STRING_LENGTH + + "#{str[0...MAX_STRING_LENGTH]}... (truncated)" + end + + def serialize_array(array, seen, depth) + return '[circular reference]' if seen.include?(array.object_id) + + seen = seen.dup.add(array.object_id) + array.first(100).map { |item| safe_serialize(item, seen, depth + 1) } + end + + def serialize_hash(hash, seen, depth) + return '[circular reference]' if seen.include?(hash.object_id) + + seen = seen.dup.add(hash.object_id) + result = {} + hash.first(100).each do |key, val| + result[key.to_s] = safe_serialize(val, seen, depth + 1) + end + result + end + + def serialize_object(obj, seen) + return '[circular reference]' if seen.include?(obj.object_id) + + # For ActiveRecord and similar objects, use id if available + return "#{obj.class.name}##{obj.id}" if obj.respond_to?(:id) && obj.respond_to?(:class) + + # Try to_s as fallback, but limit length + str = obj.to_s + truncate_string(str) + rescue StandardError + "[#{obj.class.name}]" + 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..292731d --- /dev/null +++ b/posthog-rails/lib/posthog/rails/railtie.rb @@ -0,0 +1,186 @@ +# 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 :client + + # Methods explicitly delegated to the client + DELEGATED_METHODS = %i[ + capture + capture_exception + identify + alias + group_identify + is_feature_enabled + get_feature_flag + get_all_flags + ].freeze + + # Initialize PostHog client with a block configuration + def init(options = {}) + # If block given, yield to configuration + if block_given? + config = PostHog::Rails::InitConfig.new(options) + yield config + options = config.to_client_options + end + + # Create the PostHog client + @client = PostHog::Client.new(options) + end + + # Define delegated methods using metaprogramming + DELEGATED_METHODS.each do |method_name| + define_method(method_name) do |*args, **kwargs, &block| + ensure_initialized! + client.public_send(method_name, *args, **kwargs, &block) + end + end + + def initialized? + !@client.nil? + end + + # Fallback for any client methods not explicitly defined + # rubocop:disable Lint/RedundantSafeNavigation + def method_missing(method_name, ...) + if client&.respond_to?(method_name) + ensure_initialized! + client.public_send(method_name, ...) + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + client&.respond_to?(method_name) || super + end + # rubocop:enable Lint/RedundantSafeNavigation + + 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 + insert_middleware_after( + app, ActionDispatch::DebugExceptions, + PostHog::Rails::RescuedExceptionInterceptor + ) + + # Insert after ShowExceptions to capture all exceptions + insert_middleware_after( + app, ActionDispatch::ShowExceptions, + PostHog::Rails::CaptureExceptions + ) + end + + # After initialization, set up remaining integrations + config.after_initialize do |_app| + # Hook into ActiveJob only if enabled + if PostHog::Rails.config&.auto_instrument_active_job + ActiveSupport.on_load(:active_job) do + prepend PostHog::Rails::ActiveJobExtensions + end + end + + 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 (register only once) + config.after_initialize do + next if @posthog_at_exit_registered + + @posthog_at_exit_registered = true + at_exit { PostHog.client&.shutdown if PostHog.initialized? } + end + + def self.insert_middleware_after(app, target, middleware) + if app.config.middleware.include?(target) + app.config.middleware.insert_after(target, middleware) + else + # Fallback: append to stack if target middleware is missing (e.g., API-only apps) + app.config.middleware.use(middleware) + 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 + def initialize(base_options = {}) + @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 + + 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..3658a69 --- /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