txlemetry-rails 4.0.0

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.
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Portions of this file are derived from getsentry/sentry-ruby
4
+ # Copyright (c) 2020 Sentry
5
+ # Licensed under the MIT License: https://github.com/getsentry/sentry-ruby/blob/master/LICENSE
6
+
7
+ module Txlemetry
8
+ module Rails
9
+ class Configuration
10
+ # Default cap on log records forwarded to Txlemetry Logs per minute.
11
+ DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE = 6_000
12
+
13
+ # @return [Boolean] Whether to automatically capture exceptions from Rails. Defaults to false.
14
+ attr_accessor :auto_capture_exceptions
15
+
16
+ # @return [Boolean] Whether to capture exceptions that Rails rescues (e.g., with rescue_from). Defaults to false.
17
+ attr_accessor :report_rescued_exceptions
18
+
19
+ # @return [Boolean] Whether to automatically instrument ActiveJob. Defaults to false.
20
+ attr_accessor :auto_instrument_active_job
21
+
22
+ # @return [Array<String>] Exception class names to ignore in addition to the defaults.
23
+ attr_accessor :excluded_exceptions
24
+
25
+ # @return [Boolean] Whether to use Txlemetry tracing headers for request-scoped identity/session context.
26
+ # Defaults to true.
27
+ attr_accessor :use_tracing_headers
28
+
29
+ # @return [Boolean] Whether to capture the current user context in exceptions. Defaults to true.
30
+ attr_accessor :capture_user_context
31
+
32
+ # @return [Symbol] Method name to call on controller to get the current user. Defaults to :current_user.
33
+ attr_accessor :current_user_method
34
+
35
+ # @return [Proc, nil] Callable used to resolve the current user. When set, this takes precedence over
36
+ # current_user_method. The callable may accept the controller instance or no arguments.
37
+ attr_accessor :current_user_resolver
38
+
39
+ # @return [Symbol, nil] Method name to call on the user object to get distinct_id. When nil, tries:
40
+ # txlemetry_distinct_id, distinct_id, id, pk, uuid in order.
41
+ attr_accessor :user_id_method
42
+
43
+ # @return [Boolean] Master switch for forwarding logs to Txlemetry Logs over OTLP. Defaults to false.
44
+ attr_accessor :logs_enabled
45
+
46
+ # @return [Boolean] Whether to broadcast Rails.logger output into the Txlemetry Logs sink. Defaults to true
47
+ # (only takes effect when {#logs_enabled} is true).
48
+ attr_accessor :logs_forward_rails_logger
49
+
50
+ # @return [Integer, Symbol, nil] Minimum severity to forward to Txlemetry Logs. When nil, inherits the
51
+ # current Rails.logger level. Accepts a Logger severity constant (e.g. Logger::INFO) or symbol (:info).
52
+ attr_accessor :logs_level
53
+
54
+ # @return [Integer, String, nil] Maximum log records forwarded to Txlemetry Logs per minute, protecting
55
+ # the ingestion quota from runaway log volume. Defaults to 6000. Numeric strings (e.g. from ENV) are
56
+ # coerced. Set to nil, 0, or a negative value to disable the cap; an unparseable value falls back to
57
+ # the default with a warning.
58
+ attr_accessor :logs_max_records_per_minute
59
+
60
+ # @return [Proc, nil] Callback invoked with each log record hash (:timestamp, :severity, :body,
61
+ # :attributes — where :severity is a symbol such as :warn) before it is sent to Txlemetry Logs.
62
+ # Return a (possibly modified) hash to send, or nil to drop the record — useful for scrubbing
63
+ # PII. If the callback raises, the record is dropped. Defaults to nil.
64
+ attr_accessor :logs_before_send
65
+
66
+ # @return [Txlemetry::Rails::Configuration]
67
+ def initialize
68
+ @auto_capture_exceptions = false
69
+ @report_rescued_exceptions = false
70
+ @auto_instrument_active_job = false
71
+ @excluded_exceptions = []
72
+ @use_tracing_headers = true
73
+ @capture_user_context = true
74
+ @current_user_method = :current_user
75
+ @current_user_resolver = nil
76
+ @user_id_method = nil
77
+ @logs_enabled = false
78
+ @logs_forward_rails_logger = true
79
+ @logs_level = nil
80
+ @logs_max_records_per_minute = DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE
81
+ @logs_before_send = nil
82
+ end
83
+
84
+ # Default exceptions that Rails apps typically don't want to track.
85
+ #
86
+ # @return [Array<String>]
87
+ def default_excluded_exceptions
88
+ [
89
+ 'AbstractController::ActionNotFound',
90
+ 'ActionController::BadRequest',
91
+ 'ActionController::InvalidAuthenticityToken',
92
+ 'ActionController::InvalidCrossOriginRequest',
93
+ 'ActionController::MethodNotAllowed',
94
+ 'ActionController::NotImplemented',
95
+ 'ActionController::ParameterMissing',
96
+ 'ActionController::RoutingError',
97
+ 'ActionController::UnknownFormat',
98
+ 'ActionController::UnknownHttpMethod',
99
+ 'ActionDispatch::Http::MimeNegotiation::InvalidType',
100
+ 'ActionDispatch::Http::Parameters::ParseError',
101
+ 'ActiveRecord::RecordNotFound',
102
+ 'ActiveRecord::RecordNotUnique'
103
+ ]
104
+ end
105
+
106
+ # @param exception [Exception] The exception to check.
107
+ # @return [Boolean] Whether the exception should be captured.
108
+ def should_capture_exception?(exception)
109
+ exception_name = exception.class.name
110
+ !all_excluded_exceptions.include?(exception_name)
111
+ end
112
+
113
+ private
114
+
115
+ def all_excluded_exceptions
116
+ default_excluded_exceptions + excluded_exceptions
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'txlemetry/rails/parameter_filter'
4
+
5
+ module Txlemetry
6
+ module Rails
7
+ # Rails 7.0+ error reporter integration.
8
+ # This integrates with Rails.error.handle and Rails.error.record.
9
+ #
10
+ # @api private
11
+ class ErrorSubscriber
12
+ include ParameterFilter
13
+
14
+ # @param error [Exception] Error reported by Rails.
15
+ # @param handled [Boolean]
16
+ # @param severity [Symbol, String]
17
+ # @param context [Hash]
18
+ # @param source [String, nil]
19
+ # @return [void]
20
+ def report(error, handled:, severity:, context:, source: nil)
21
+ return unless Txlemetry::Rails.config&.auto_capture_exceptions
22
+ return unless Txlemetry::Rails.config&.should_capture_exception?(error)
23
+ # Skip if in a web request - CaptureExceptions middleware will handle it
24
+ # with richer context (URL, params, controller, etc.)
25
+ return if Txlemetry::Rails.in_web_request?
26
+ # ActionDispatch::Executor reports the exception once the response has
27
+ # unwound past our middleware, so the flag above is already cleared.
28
+ # CaptureExceptions marks what it captured to catch that late report.
29
+ return if Txlemetry::Rails.web_exception_captured?(error)
30
+ return if Txlemetry::Rails.config&.auto_instrument_active_job &&
31
+ Txlemetry::Rails.active_job_exception_captured?(error)
32
+
33
+ distinct_id = context[:user_id] || context[:distinct_id]
34
+
35
+ properties = {
36
+ '$exception_source' => source || 'rails_error_reporter',
37
+ '$exception_handled' => handled,
38
+ '$exception_severity' => severity.to_s
39
+ }
40
+
41
+ # Add context information (safely serialized to avoid circular references)
42
+ if context.present?
43
+ context.each do |key, value|
44
+ next if key.in?(%i[user_id distinct_id])
45
+
46
+ properties["$context_#{key}"] = safe_serialize(value)
47
+ end
48
+ end
49
+
50
+ Txlemetry.capture_exception(
51
+ error,
52
+ distinct_id,
53
+ properties,
54
+ mechanism: { 'type' => 'rails_error_reporter', 'handled' => handled }
55
+ )
56
+ rescue StandardError => e
57
+ Txlemetry::Logging.logger.error("Failed to report error via subscriber: #{e.message}")
58
+ Txlemetry::Logging.logger.error("Backtrace: #{e.backtrace&.first(5)&.join("\n")}")
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'txlemetry/rails/version'
4
+
5
+ module Txlemetry
6
+ module Rails
7
+ # Install the Rails singleton-style Txlemetry facade at load time so Rails app
8
+ # initializers can call Txlemetry.init before Railtie initializers run.
9
+ #
10
+ # @api private
11
+ # @return [void]
12
+ def self.install_txlemetry_facade!
13
+ return if @txlemetry_facade_installed
14
+
15
+ Txlemetry.class_eval do
16
+ class << self
17
+ attr_accessor :client
18
+
19
+ # Initialize the singleton Txlemetry client used by Rails delegators.
20
+ #
21
+ # @param options [Hash] Core {Txlemetry::Client} options.
22
+ # @yieldparam config [Txlemetry::Rails::InitConfig] Block-based core SDK configuration.
23
+ # @return [Txlemetry::Client]
24
+ def init(options = {})
25
+ # If block given, yield to configuration
26
+ if block_given?
27
+ config = Txlemetry::Rails::InitConfig.new(options)
28
+ yield config
29
+ options = config.to_client_options
30
+ end
31
+
32
+ # Let the Txlemetry Logs pipeline reuse the same api_key/host without
33
+ # the core client exposing public readers.
34
+ Txlemetry::Rails::Logs::Setup.remember_client_options(options) if defined?(Txlemetry::Rails::Logs::Setup)
35
+
36
+ # Create the Txlemetry client. If a client already exists, shut it down
37
+ # after replacement so repeated init calls do not leave background
38
+ # resources from the previous instance running.
39
+ previous_client = @client
40
+ @client = Txlemetry::Client.new(
41
+ options.merge(_lib: 'txlemetry-rails', _lib_version: Txlemetry::Rails::VERSION)
42
+ )
43
+ begin
44
+ previous_client&.shutdown
45
+ rescue StandardError => e
46
+ Txlemetry::Logging.logger.warn("Failed to shut down previous Txlemetry client: #{e.message}")
47
+ end
48
+ @client
49
+ end
50
+
51
+ # @return [Boolean] Whether {Txlemetry.init} has created a client.
52
+ def initialized?
53
+ !@client.nil?
54
+ end
55
+
56
+ # Fallback for any client methods not explicitly defined.
57
+ #
58
+ # @api private
59
+ def method_missing(method_name, ...)
60
+ ensure_initialized!
61
+
62
+ if client.respond_to?(method_name)
63
+ client.public_send(method_name, ...)
64
+ else
65
+ super
66
+ end
67
+ end
68
+
69
+ # @api private
70
+ def respond_to_missing?(method_name, include_private = false)
71
+ ensure_initialized!
72
+ client.respond_to?(method_name, include_private) || super
73
+ end
74
+
75
+ private
76
+
77
+ def ensure_initialized!
78
+ return if initialized?
79
+
80
+ @client = Txlemetry::Client.new(api_key: nil, silence_disabled_client_error: true)
81
+ end
82
+ end
83
+ end
84
+
85
+ %i[
86
+ capture
87
+ capture_exception
88
+ identify
89
+ alias
90
+ group_identify
91
+ is_feature_enabled
92
+ get_feature_flag
93
+ get_all_flags
94
+ ].each do |method_name|
95
+ Txlemetry.define_singleton_method(method_name) do |*args, **kwargs, &block|
96
+ ensure_initialized!
97
+ client.public_send(method_name, *args, **kwargs, &block)
98
+ end
99
+ end
100
+
101
+ @txlemetry_facade_installed = true
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'time'
5
+ require 'txlemetry/internal/context'
6
+ require 'txlemetry/logging'
7
+ require 'txlemetry/rails/logs/severity'
8
+
9
+ module Txlemetry
10
+ module Rails
11
+ module Logs
12
+ # A `Logger`-compatible sink that forwards each log record to an
13
+ # OpenTelemetry logger as an OTLP log record.
14
+ #
15
+ # It is designed to be broadcast alongside the app's existing
16
+ # `Rails.logger` so that ordinary `Rails.logger.info(...)` calls flow to
17
+ # Txlemetry Logs in addition to the normal output. Each record is stamped
18
+ # with the request-scoped Txlemetry identity captured by
19
+ # {Txlemetry::Rails::RequestContext}.
20
+ #
21
+ # Thread-safety: intentionally lock-free apart from the optional rate
22
+ # limiter's counter. Emitting touches no shared mutable state
23
+ # (`@otel_logger` is assigned once, attributes are built per call, and
24
+ # `Internal::Context.current` is thread/fiber-local), and the OTel
25
+ # BatchLogRecordProcessor synchronizes its buffer internally — the same
26
+ # split as stdlib `Logger`, which locks in `LogDevice`, not
27
+ # `Logger#add`. A mutex around emit would serialize all app logging
28
+ # needlessly.
29
+ #
30
+ # @api private
31
+ class Appender < ::Logger
32
+ SELF_LOG_PREFIX = '[txlemetry-ruby]'
33
+ SELF_LOG_PROGNAME = 'Txlemetry'
34
+ # Maps Txlemetry event-property names (as stored in Internal::Context) to
35
+ # the OTel semantic-convention attribute names used on log records,
36
+ # matching the web SDK so one filter works across SDKs.
37
+ REQUEST_ATTRIBUTE_NAMES = {
38
+ '$current_url' => 'url.full',
39
+ '$request_method' => 'http.request.method',
40
+ '$request_path' => 'url.path'
41
+ }.freeze
42
+
43
+ # @param otel_logger [#on_emit] An OpenTelemetry logger.
44
+ # @param level [Integer, nil] Minimum severity to forward.
45
+ # @param rate_limiter [Txlemetry::Rails::Logs::RateLimiter, nil] Optional cap on
46
+ # forwarded records, protecting the ingestion quota from runaway log volume.
47
+ # @param before_send [#call, nil] Optional callback invoked with each record hash
48
+ # (:timestamp, :severity, :body, :attributes — where :severity is a symbol such
49
+ # as :warn) before it is emitted. Return a (possibly modified) hash to send, or
50
+ # nil to drop — useful for scrubbing PII. If the callback raises, the record is
51
+ # dropped.
52
+ def initialize(otel_logger, level: nil, rate_limiter: nil, before_send: nil)
53
+ super(nil)
54
+ @otel_logger = otel_logger
55
+ @rate_limiter = rate_limiter
56
+ @before_send = before_send
57
+ # The forwarding threshold deliberately does NOT live in Logger#level.
58
+ # Rails 7.1+ BroadcastLogger computes #level as the min and #debug?
59
+ # etc. as the any? across sinks, so storing it there would widen the
60
+ # app-wide predicates (logs_level = :debug would flip
61
+ # Rails.logger.debug? true and make e.g. ActiveRecord start
62
+ # generating SQL debug lines), and a broadcast-wide
63
+ # `Rails.logger.level =` would clobber the configured logs_level.
64
+ # Pinning the inherited level to UNKNOWN keeps this sink invisible
65
+ # to those calculations; filtering happens against @threshold in #add.
66
+ @threshold = level || ::Logger::DEBUG
67
+ self.level = ::Logger::UNKNOWN
68
+ end
69
+
70
+ # Re-entrancy guard key. Fiber-local (Thread.current[]), which is what
71
+ # recursion needs: if anything inside #add logs through a broadcast
72
+ # that includes this appender (e.g. a logs_before_send callback calling
73
+ # Rails.logger), the nested call would recurse until SystemStackError —
74
+ # which, as an Exception, escapes the rescue below and breaks the app.
75
+ REENTRANCY_KEY = :txlemetry_rails_logs_emitting
76
+
77
+ # Mirrors `Logger#add` message/progname resolution, then emits to OTel
78
+ # instead of writing to a log device.
79
+ #
80
+ # @return [Boolean] Always true so it composes with broadcast loggers.
81
+ def add(severity, message = nil, progname = nil)
82
+ return true if Thread.current[REENTRANCY_KEY]
83
+
84
+ begin
85
+ Thread.current[REENTRANCY_KEY] = true
86
+
87
+ severity ||= ::Logger::UNKNOWN
88
+ return true if severity < @threshold
89
+
90
+ if message.nil?
91
+ if block_given?
92
+ message = yield
93
+ else
94
+ message = progname
95
+ progname = nil
96
+ end
97
+ end
98
+
99
+ return true if message.nil?
100
+ return true if self_log?(message, progname)
101
+
102
+ record = apply_before_send(build_record(severity, message, progname))
103
+ return true if record.nil?
104
+
105
+ case @rate_limiter&.record
106
+ when :reject
107
+ return true
108
+ when :reject_first
109
+ emit_rate_cap_notice
110
+ return true
111
+ end
112
+
113
+ emit(record)
114
+ true
115
+ rescue StandardError => e
116
+ # Never let log forwarding break the calling code path, but leave
117
+ # one breadcrumb: a persistent emit failure would otherwise drop
118
+ # 100% of records with no signal anywhere.
119
+ warn_emit_error(e)
120
+ true
121
+ ensure
122
+ Thread.current[REENTRANCY_KEY] = nil
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def build_record(severity, message, progname)
129
+ {
130
+ timestamp: Time.now,
131
+ severity: Severity.name_for(severity),
132
+ body: body_for(message),
133
+ attributes: attributes_for(progname)
134
+ }
135
+ end
136
+
137
+ def emit(record)
138
+ # The before_send callback sees a single :severity enum; the OTel
139
+ # number/text pair is derived here so the two can never be set
140
+ # inconsistently.
141
+ severity_number, severity_text = Severity.for_name(record[:severity])
142
+ @otel_logger.on_emit(
143
+ timestamp: record[:timestamp],
144
+ severity_number: severity_number,
145
+ severity_text: severity_text,
146
+ body: record[:body],
147
+ attributes: record[:attributes]
148
+ )
149
+ end
150
+
151
+ # One discoverable notice per window so truncation isn't silent. Emitted
152
+ # directly (bypassing before_send) so a scrubber can't accidentally
153
+ # suppress the only signal that records are being dropped.
154
+ def emit_rate_cap_notice
155
+ emit(
156
+ timestamp: Time.now,
157
+ severity: :warn,
158
+ body: "Txlemetry Logs rate cap reached (#{@rate_limiter.limit} records/minute); " \
159
+ 'dropping further records for the remainder of this window',
160
+ attributes: {}
161
+ )
162
+ end
163
+
164
+ # Runs before the rate-cap check (matching the other Txlemetry SDKs) so
165
+ # records dropped by the callback never consume window budget — a
166
+ # before_send that drops noisy logs must not starve the legitimate
167
+ # records behind them.
168
+ #
169
+ # A failing callback drops the record: the likeliest use is PII
170
+ # scrubbing, where shipping the unscrubbed original is worse than losing
171
+ # the line.
172
+ def apply_before_send(record)
173
+ return record unless @before_send
174
+
175
+ result = @before_send.call(record)
176
+ return result if result.is_a?(Hash)
177
+
178
+ # nil is an intentional drop and stays silent; any other type is
179
+ # likely a bug (e.g. a proc whose last expression isn't the record).
180
+ warn_before_send("returned #{result.class} instead of a Hash or nil") unless result.nil?
181
+ nil
182
+ rescue StandardError => e
183
+ warn_before_send("raised (#{e.class}: #{e.message})")
184
+ nil
185
+ end
186
+
187
+ def warn_before_send(description)
188
+ # Benign race: concurrent first failures may warn more than once.
189
+ return if @before_send_warned
190
+
191
+ @before_send_warned = true
192
+ Txlemetry::Logging.logger.warn("logs_before_send #{description}; dropping the record")
193
+ end
194
+
195
+ def warn_emit_error(error)
196
+ # Benign race: concurrent first failures may warn more than once.
197
+ return if @emit_error_warned
198
+
199
+ @emit_error_warned = true
200
+ Txlemetry::Logging.logger.warn(
201
+ "Txlemetry Logs failed to emit a record (#{error.class}: #{error.message}); " \
202
+ 'further failures will be dropped silently'
203
+ )
204
+ end
205
+
206
+ def body_for(message)
207
+ str = stringify(message)
208
+ str = str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless str.encoding == Encoding::UTF_8
209
+ str.valid_encoding? ? str : str.scrub
210
+ end
211
+
212
+ # Mirrors stdlib Logger::Formatter#msg2str so the body matches what the
213
+ # file logger writes — most importantly, an Exception renders with its
214
+ # class, message, and backtrace instead of a backtrace-less #inspect.
215
+ # Array() guards a never-raised exception whose backtrace is nil. The
216
+ # returned String is always freshly built (dup/interpolation/inspect)
217
+ # so a logs_before_send callback can mutate it without touching frozen
218
+ # app strings.
219
+ def stringify(message)
220
+ case message
221
+ when String
222
+ message.dup
223
+ when Exception
224
+ "#{message.message} (#{message.class})\n#{Array(message.backtrace).join("\n")}"
225
+ else
226
+ message.inspect
227
+ end
228
+ end
229
+
230
+ def attributes_for(progname)
231
+ attributes = {}
232
+ # Ruby's progname is the closest analog to the OTel-world "logger name";
233
+ # logger.name is the key users coming from other ecosystems will expect.
234
+ attributes['logger.name'] = progname.to_s if progname
235
+
236
+ context = Internal::Context.current
237
+ return attributes unless context
238
+
239
+ attributes['posthogDistinctId'] = context.distinct_id if context.distinct_id
240
+ attributes['sessionId'] = context.session_id if context.session_id
241
+
242
+ properties = context.properties || {}
243
+ REQUEST_ATTRIBUTE_NAMES.each do |key, attribute_name|
244
+ value = properties[key] || properties[key.to_sym]
245
+ attributes[attribute_name] = value if value
246
+ end
247
+
248
+ attributes
249
+ end
250
+
251
+ def self_log?(message, progname)
252
+ return true if progname.to_s == SELF_LOG_PROGNAME
253
+
254
+ # PrefixedLogger always places the prefix at the start of the message,
255
+ # so start_with? suffices and avoids suppressing app logs that merely
256
+ # mention the SDK mid-string.
257
+ message.is_a?(String) && message.start_with?(SELF_LOG_PREFIX)
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txlemetry
4
+ module Rails
5
+ module Logs
6
+ # Fixed-window rate limiter protecting the Txlemetry Logs ingestion quota
7
+ # from runaway log volume (Txlemetry Logs bills by data ingested).
8
+ #
9
+ # Thread-safe: the counter is the one piece of shared mutable state in
10
+ # the logs pipeline, guarded by a mutex scoped to a counter bump.
11
+ #
12
+ # @api private
13
+ class RateLimiter
14
+ WINDOW_SECONDS = 60
15
+
16
+ # @return [Integer] Maximum records allowed per window.
17
+ attr_reader :limit
18
+
19
+ # @param limit [Integer] Maximum records allowed per {WINDOW_SECONDS} window.
20
+ def initialize(limit)
21
+ @limit = limit
22
+ @mutex = Mutex.new
23
+ @window = nil
24
+ @count = 0
25
+ end
26
+
27
+ # Records one attempt and returns the verdict.
28
+ #
29
+ # @return [Symbol] :allow when under the cap, :reject_first for the
30
+ # first rejection of a window (callers may emit a single notice),
31
+ # :reject thereafter.
32
+ def record
33
+ @mutex.synchronize do
34
+ window = Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i / WINDOW_SECONDS
35
+ if window != @window
36
+ @window = window
37
+ @count = 0
38
+ end
39
+ @count += 1
40
+ next :allow if @count <= @limit
41
+
42
+ @count == @limit + 1 ? :reject_first : :reject
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end