posthog-rails 3.5.0 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c928e33ae64462619dd81b5b785402c17f4cc5ebba9d7858cb33d7b38b426d4a
4
- data.tar.gz: 5ee1084fde161538e21b53f257bc485aa9fafc0b6b4549776a28ab5d56c2de7a
3
+ metadata.gz: ea6edcb493fd06b49850a250f1c4c4b41ececd9c875918472419a8c7ccf7cca0
4
+ data.tar.gz: f2ef41e5646688bbd593b6f002b0a7b6514cce97a65d60013c6a4664cebf854d
5
5
  SHA512:
6
- metadata.gz: 3e3cc76336d84ab77d948e5a0504694926565bec21f8b535d70e5eb6671449b0676e60677f9257170b5e84c703e351baf9fe35ee091544df26859fdba7210f4c
7
- data.tar.gz: 54d4dd5aee80aa5f85961672934cb7c565e16342327678f9352ed7ed6e9d484479418585ef4e9cbf39eed6e00063290c2a9e253f50621796f8e50774c97a5ade
6
+ metadata.gz: 3941f7f40575f422511be6326f421bafa4d4b24b5996d84aa0c7a24b4e30b79770449ce6064248a1af6a9b61121990fd838394666a0fed16ae5cb661af53c608
7
+ data.tar.gz: 0edf54ddca0d8f08d674dd8bf0102a6bfd77378ce4ce92027256f7331178d20c56bc3787d30b1b9610c9b74ef5b08bcb0e3f4d66b040d10300ecb8584044b1bc
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Posthog
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ desc 'Creates a PostHog initializer file at config/initializers/posthog.rb'
9
+
10
+ source_root File.expand_path('../../..', __dir__)
11
+
12
+ def copy_initializer
13
+ copy_file 'examples/posthog.rb', 'config/initializers/posthog.rb'
14
+ end
15
+
16
+ def show_readme
17
+ say ''
18
+ say 'PostHog Rails has been installed!', :green
19
+ say ''
20
+ say 'Next steps:', :yellow
21
+ say ' 1. Edit config/initializers/posthog.rb with your PostHog API key'
22
+ say ' 2. Set environment variables:'
23
+ say ' - POSTHOG_API_KEY (required)'
24
+ say ' - POSTHOG_PERSONAL_API_KEY (optional, for feature flags)'
25
+ say ''
26
+ say 'For more information, see: https://posthog.com/docs/libraries/ruby'
27
+ say ''
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'posthog/rails/parameter_filter'
4
+
5
+ module PostHog
6
+ module Rails
7
+ # ActiveJob integration to capture exceptions from background jobs
8
+ module ActiveJobExtensions
9
+ include ParameterFilter
10
+
11
+ def self.prepended(base)
12
+ base.extend(ClassMethods)
13
+ end
14
+
15
+ module ClassMethods
16
+ # DSL for defining how to extract distinct_id from job arguments
17
+ # Example:
18
+ # class MyJob < ApplicationJob
19
+ # posthog_distinct_id ->(user, arg1, arg2) { user.id }
20
+ # def perform(user, arg1, arg2)
21
+ # # ...
22
+ # end
23
+ # end
24
+ def posthog_distinct_id(proc = nil, &block)
25
+ @posthog_distinct_id_proc = proc || block
26
+ end
27
+
28
+ def posthog_distinct_id_proc
29
+ @posthog_distinct_id_proc
30
+ end
31
+ end
32
+
33
+ def perform_now
34
+ super
35
+ rescue StandardError => e
36
+ # Capture the exception with job context
37
+ capture_job_exception(e)
38
+ raise
39
+ end
40
+
41
+ private
42
+
43
+ def capture_job_exception(exception)
44
+ return unless PostHog::Rails.config&.auto_instrument_active_job
45
+
46
+ # Build distinct_id from job arguments if possible
47
+ distinct_id = extract_distinct_id_from_job
48
+
49
+ properties = {
50
+ '$exception_source' => 'active_job',
51
+ '$job_class' => self.class.name,
52
+ '$job_id' => job_id,
53
+ '$queue_name' => queue_name,
54
+ '$job_priority' => priority,
55
+ '$job_executions' => executions
56
+ }
57
+
58
+ # Add serialized job arguments (be careful with sensitive data)
59
+ properties['$job_arguments'] = sanitize_job_arguments(arguments) if arguments.present?
60
+
61
+ PostHog.capture_exception(exception, distinct_id, properties)
62
+ rescue StandardError => e
63
+ # Don't let PostHog errors break job processing
64
+ PostHog::Logging.logger.error("Failed to capture job exception: #{e.message}")
65
+ end
66
+
67
+ def extract_distinct_id_from_job
68
+ # First, check if the job class defines a custom extractor
69
+ return self.class.posthog_distinct_id_proc.call(*arguments) if self.class.posthog_distinct_id_proc
70
+
71
+ # Fallback: look for explicit user_id in hash arguments only
72
+ arguments.each do |arg|
73
+ if arg.is_a?(Hash) && arg['user_id']
74
+ return arg['user_id']
75
+ elsif arg.is_a?(Hash) && arg[:user_id]
76
+ return arg[:user_id]
77
+ end
78
+ end
79
+
80
+ nil # No user context found
81
+ end
82
+
83
+ def sanitize_job_arguments(args)
84
+ # Convert arguments to a safe format
85
+ args.map do |arg|
86
+ case arg
87
+ when String
88
+ # Truncate long strings to prevent huge payloads
89
+ arg.length > 100 ? "[FILTERED: #{arg.length} chars]" : arg
90
+ when Integer, Float, TrueClass, FalseClass, NilClass
91
+ arg
92
+ when Hash
93
+ # Use Rails' filter_parameters to filter sensitive data
94
+ filter_sensitive_params(arg)
95
+ when defined?(ActiveRecord::Base) && ActiveRecord::Base
96
+ { class: arg.class.name, id: arg.id }
97
+ else
98
+ arg.class.name
99
+ end
100
+ end
101
+ rescue StandardError
102
+ ['<serialization error>']
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'posthog/rails/parameter_filter'
4
+
5
+ module PostHog
6
+ module Rails
7
+ # Middleware that captures exceptions and sends them to PostHog
8
+ class CaptureExceptions
9
+ include ParameterFilter
10
+
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ # Signal that we're in a web request context
17
+ # ErrorSubscriber will skip capture for web requests to avoid duplicates
18
+ PostHog::Rails.enter_web_request
19
+
20
+ response = @app.call(env)
21
+
22
+ # Check if there was an exception that Rails handled
23
+ exception = collect_exception(env)
24
+
25
+ capture_exception(exception, env) if exception && should_capture?(exception)
26
+
27
+ response
28
+ rescue StandardError => e
29
+ # Capture unhandled exceptions
30
+ capture_exception(e, env) if should_capture?(e)
31
+ raise
32
+ ensure
33
+ PostHog::Rails.exit_web_request
34
+ end
35
+
36
+ private
37
+
38
+ def collect_exception(env)
39
+ # Rails stores exceptions in these env keys
40
+ env['action_dispatch.exception'] ||
41
+ env['rack.exception'] ||
42
+ env['posthog.rescued_exception']
43
+ end
44
+
45
+ def should_capture?(exception)
46
+ return false unless PostHog::Rails.config&.auto_capture_exceptions
47
+ return false unless PostHog::Rails.config&.should_capture_exception?(exception)
48
+
49
+ true
50
+ end
51
+
52
+ def capture_exception(exception, env)
53
+ request = ActionDispatch::Request.new(env)
54
+ distinct_id = extract_distinct_id(env, request)
55
+ additional_properties = build_properties(request, env)
56
+
57
+ PostHog.capture_exception(exception, distinct_id, additional_properties)
58
+ rescue StandardError => e
59
+ PostHog::Logging.logger.error("Failed to capture exception: #{e.message}")
60
+ PostHog::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}")
61
+ end
62
+
63
+ def extract_distinct_id(env, request)
64
+ # Try to get user from controller if capture_user_context is enabled
65
+ if PostHog::Rails.config&.capture_user_context && env['action_controller.instance']
66
+ controller = env['action_controller.instance']
67
+ method_name = PostHog::Rails.config&.current_user_method || :current_user
68
+
69
+ if controller.respond_to?(method_name, true)
70
+ user = controller.send(method_name)
71
+ return extract_user_id(user) if user
72
+ end
73
+ end
74
+
75
+ # Fallback to session ID or nil
76
+ request.session&.id&.to_s
77
+ end
78
+
79
+ def extract_user_id(user)
80
+ # Use configured method if specified
81
+ method_name = PostHog::Rails.config&.user_id_method
82
+ return user.send(method_name) if method_name && user.respond_to?(method_name)
83
+
84
+ # Try explicit PostHog method (allows users to customize without config)
85
+ return user.posthog_distinct_id if user.respond_to?(:posthog_distinct_id)
86
+ return user.distinct_id if user.respond_to?(:distinct_id)
87
+
88
+ # Try common ID methods
89
+ return user.id if user.respond_to?(:id)
90
+ return user['id'] if user.respond_to?(:[]) && user['id']
91
+ return user.pk if user.respond_to?(:pk)
92
+ return user['pk'] if user.respond_to?(:[]) && user['pk']
93
+ return user.uuid if user.respond_to?(:uuid)
94
+ return user['uuid'] if user.respond_to?(:[]) && user['uuid']
95
+
96
+ user.to_s
97
+ end
98
+
99
+ def build_properties(request, env)
100
+ properties = {
101
+ '$exception_source' => 'rails',
102
+ '$request_url' => safe_serialize(request.url),
103
+ '$request_method' => safe_serialize(request.method),
104
+ '$request_path' => safe_serialize(request.path)
105
+ }
106
+
107
+ # Add controller and action if available
108
+ if env['action_controller.instance']
109
+ controller = env['action_controller.instance']
110
+ properties['$controller'] = safe_serialize(controller.controller_name)
111
+ properties['$action'] = safe_serialize(controller.action_name)
112
+ end
113
+
114
+ # Add request parameters (be careful with sensitive data)
115
+ if request.params.present?
116
+ filtered_params = filter_sensitive_params(request.params)
117
+ # Safe serialize to handle any complex objects in params
118
+ properties['$request_params'] = safe_serialize(filtered_params) unless filtered_params.empty?
119
+ end
120
+
121
+ # Add user agent
122
+ properties['$user_agent'] = safe_serialize(request.user_agent) if request.user_agent
123
+
124
+ # Add referrer
125
+ properties['$referrer'] = safe_serialize(request.referrer) if request.referrer
126
+
127
+ properties
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module Rails
5
+ class Configuration
6
+ # Whether to automatically capture exceptions from Rails
7
+ attr_accessor :auto_capture_exceptions
8
+
9
+ # Whether to capture exceptions that Rails rescues (e.g., with rescue_from)
10
+ attr_accessor :report_rescued_exceptions
11
+
12
+ # Whether to automatically instrument ActiveJob
13
+ attr_accessor :auto_instrument_active_job
14
+
15
+ # List of exception classes to ignore (in addition to default)
16
+ attr_accessor :excluded_exceptions
17
+
18
+ # Whether to capture the current user context in exceptions
19
+ attr_accessor :capture_user_context
20
+
21
+ # Method name to call on controller to get user ID (default: :current_user)
22
+ attr_accessor :current_user_method
23
+
24
+ # Method name to call on user object to get distinct_id (default: auto-detect)
25
+ # When nil, tries: posthog_distinct_id, distinct_id, id, pk, uuid in order
26
+ attr_accessor :user_id_method
27
+
28
+ def initialize
29
+ @auto_capture_exceptions = false
30
+ @report_rescued_exceptions = false
31
+ @auto_instrument_active_job = false
32
+ @excluded_exceptions = []
33
+ @capture_user_context = true
34
+ @current_user_method = :current_user
35
+ @user_id_method = nil
36
+ end
37
+
38
+ # Default exceptions that Rails apps typically don't want to track
39
+ def default_excluded_exceptions
40
+ [
41
+ 'AbstractController::ActionNotFound',
42
+ 'ActionController::BadRequest',
43
+ 'ActionController::InvalidAuthenticityToken',
44
+ 'ActionController::InvalidCrossOriginRequest',
45
+ 'ActionController::MethodNotAllowed',
46
+ 'ActionController::NotImplemented',
47
+ 'ActionController::ParameterMissing',
48
+ 'ActionController::RoutingError',
49
+ 'ActionController::UnknownFormat',
50
+ 'ActionController::UnknownHttpMethod',
51
+ 'ActionDispatch::Http::Parameters::ParseError',
52
+ 'ActiveRecord::RecordNotFound',
53
+ 'ActiveRecord::RecordNotUnique'
54
+ ]
55
+ end
56
+
57
+ def should_capture_exception?(exception)
58
+ exception_name = exception.class.name
59
+ !all_excluded_exceptions.include?(exception_name)
60
+ end
61
+
62
+ private
63
+
64
+ def all_excluded_exceptions
65
+ default_excluded_exceptions + excluded_exceptions
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'posthog/rails/parameter_filter'
4
+
5
+ module PostHog
6
+ module Rails
7
+ # Rails 7.0+ error reporter integration
8
+ # This integrates with Rails.error.handle and Rails.error.record
9
+ class ErrorSubscriber
10
+ include ParameterFilter
11
+
12
+ def report(error, handled:, severity:, context:, source: nil)
13
+ return unless PostHog::Rails.config&.auto_capture_exceptions
14
+ return unless PostHog::Rails.config&.should_capture_exception?(error)
15
+ # Skip if in a web request - CaptureExceptions middleware will handle it
16
+ # with richer context (URL, params, controller, etc.)
17
+ return if PostHog::Rails.in_web_request?
18
+
19
+ distinct_id = context[:user_id] || context[:distinct_id]
20
+
21
+ properties = {
22
+ '$exception_source' => source || 'rails_error_reporter',
23
+ '$exception_handled' => handled,
24
+ '$exception_severity' => severity.to_s
25
+ }
26
+
27
+ # Add context information (safely serialized to avoid circular references)
28
+ if context.present?
29
+ context.each do |key, value|
30
+ next if key.in?(%i[user_id distinct_id])
31
+
32
+ properties["$context_#{key}"] = safe_serialize(value)
33
+ end
34
+ end
35
+
36
+ PostHog.capture_exception(error, distinct_id, properties)
37
+ rescue StandardError => e
38
+ PostHog::Logging.logger.error("Failed to report error via subscriber: #{e.message}")
39
+ PostHog::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}")
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module Rails
5
+ # Shared utility module for filtering sensitive parameters
6
+ #
7
+ # This module provides consistent parameter filtering across all PostHog Rails
8
+ # components, leveraging Rails' built-in parameter filtering when available.
9
+ # It automatically detects the correct Rails parameter filtering API based on
10
+ # the Rails version.
11
+ #
12
+ # @example Usage in a class
13
+ # class MyClass
14
+ # include PostHog::Rails::ParameterFilter
15
+ #
16
+ # def my_method(params)
17
+ # filtered = filter_sensitive_params(params)
18
+ # PostHog.capture(event: 'something', properties: filtered)
19
+ # end
20
+ # end
21
+ module ParameterFilter
22
+ EMPTY_HASH = {}.freeze
23
+ MAX_STRING_LENGTH = 10_000
24
+ MAX_DEPTH = 10
25
+
26
+ if ::Rails.version.to_f >= 6.0
27
+ def self.backend
28
+ ActiveSupport::ParameterFilter
29
+ end
30
+ else
31
+ def self.backend
32
+ ActionDispatch::Http::ParameterFilter
33
+ end
34
+ end
35
+
36
+ # Filter sensitive parameters from a hash, respecting Rails configuration.
37
+ #
38
+ # Uses Rails' configured filter_parameters (e.g., :password, :token, :api_key)
39
+ # to automatically filter sensitive data that the Rails app has configured.
40
+ #
41
+ # @param params [Hash] The parameters to filter
42
+ # @return [Hash] Filtered parameters with sensitive data masked
43
+ def filter_sensitive_params(params)
44
+ return EMPTY_HASH unless params.is_a?(Hash)
45
+ return params unless ::Rails.application
46
+
47
+ filter_parameters = ::Rails.application.config.filter_parameters
48
+ parameter_filter = ParameterFilter.backend.new(filter_parameters)
49
+
50
+ parameter_filter.filter(params)
51
+ end
52
+
53
+ # Safely serialize a value to a JSON-compatible format.
54
+ #
55
+ # Handles circular references and complex objects by converting them to
56
+ # simple primitives or string representations. This prevents SystemStackError
57
+ # when serializing objects with circular references (like ActiveRecord models).
58
+ #
59
+ # @param value [Object] The value to serialize
60
+ # @param seen [Set] Set of object_ids already visited (for cycle detection)
61
+ # @param depth [Integer] Current recursion depth
62
+ # @return [Object] A JSON-safe value (String, Numeric, Boolean, nil, Array, or Hash)
63
+ def safe_serialize(value, seen = Set.new, depth = 0)
64
+ return '[max depth exceeded]' if depth > MAX_DEPTH
65
+
66
+ case value
67
+ when nil, true, false, Integer, Float
68
+ value
69
+ when String
70
+ truncate_string(value)
71
+ when Symbol
72
+ value.to_s
73
+ when Time, DateTime
74
+ value.iso8601(3)
75
+ when Date
76
+ value.iso8601
77
+ when Array
78
+ serialize_array(value, seen, depth)
79
+ when Hash
80
+ serialize_hash(value, seen, depth)
81
+ else
82
+ serialize_object(value, seen)
83
+ end
84
+ rescue StandardError => e
85
+ "[serialization error: #{e.class}]"
86
+ end
87
+
88
+ private
89
+
90
+ def truncate_string(str)
91
+ return str if str.length <= MAX_STRING_LENGTH
92
+
93
+ "#{str[0...MAX_STRING_LENGTH]}... (truncated)"
94
+ end
95
+
96
+ def serialize_array(array, seen, depth)
97
+ return '[circular reference]' if seen.include?(array.object_id)
98
+
99
+ seen = seen.dup.add(array.object_id)
100
+ array.first(100).map { |item| safe_serialize(item, seen, depth + 1) }
101
+ end
102
+
103
+ def serialize_hash(hash, seen, depth)
104
+ return '[circular reference]' if seen.include?(hash.object_id)
105
+
106
+ seen = seen.dup.add(hash.object_id)
107
+ result = {}
108
+ hash.first(100).each do |key, val|
109
+ result[key.to_s] = safe_serialize(val, seen, depth + 1)
110
+ end
111
+ result
112
+ end
113
+
114
+ def serialize_object(obj, seen)
115
+ return '[circular reference]' if seen.include?(obj.object_id)
116
+
117
+ # For ActiveRecord and similar objects, use id if available
118
+ return "#{obj.class.name}##{obj.id}" if obj.respond_to?(:id) && obj.respond_to?(:class)
119
+
120
+ # Try to_s as fallback, but limit length
121
+ str = obj.to_s
122
+ truncate_string(str)
123
+ rescue StandardError
124
+ "[#{obj.class.name}]"
125
+ end
126
+ end
127
+ end
128
+ end