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,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'txlemetry/rails/facade'
4
+
5
+ module Txlemetry
6
+ module Rails
7
+ class Railtie < ::Rails::Railtie
8
+ # Keep the historical Railtie initializer name, but install the facade at
9
+ # load time so application initializers can call Txlemetry.init.
10
+ initializer 'txlemetry.set_configs' do |_app|
11
+ Txlemetry::Rails.install_txlemetry_facade!
12
+ end
13
+
14
+ # Insert middleware for request context and exception capturing
15
+ initializer 'txlemetry.insert_middlewares' do |app|
16
+ # Wrap the Rails exception middleware so request context is active for
17
+ # downstream handlers and exception capture.
18
+ insert_middleware_before(
19
+ app, ActionDispatch::ShowExceptions,
20
+ Txlemetry::Rails::RequestContext
21
+ )
22
+
23
+ # Insert after DebugExceptions to catch rescued exceptions
24
+ insert_middleware_after(
25
+ app, ActionDispatch::DebugExceptions,
26
+ Txlemetry::Rails::RescuedExceptionInterceptor
27
+ )
28
+
29
+ # Insert after ShowExceptions to capture all exceptions
30
+ insert_middleware_after(
31
+ app, ActionDispatch::ShowExceptions,
32
+ Txlemetry::Rails::CaptureExceptions
33
+ )
34
+ end
35
+
36
+ # After initialization, set up remaining integrations
37
+ config.after_initialize do |_app|
38
+ # Hook into ActiveJob only if enabled
39
+ if Txlemetry::Rails.config&.auto_instrument_active_job
40
+ ActiveSupport.on_load(:active_job) do
41
+ prepend Txlemetry::Rails::ActiveJobExtensions
42
+ end
43
+ end
44
+
45
+ next unless Txlemetry.initialized?
46
+
47
+ # Register with Rails error reporter (Rails 7.0+)
48
+ register_error_subscriber if rails_version_above_7?
49
+ end
50
+
51
+ # Opt-in: forward logs to Txlemetry Logs over OTLP
52
+ config.after_initialize do
53
+ install_txlemetry_logs if Txlemetry::Rails.config&.logs_enabled
54
+ end
55
+
56
+ # Ensure Txlemetry shuts down gracefully (register only once)
57
+ config.after_initialize do
58
+ next if @txlemetry_at_exit_registered
59
+
60
+ @txlemetry_at_exit_registered = true
61
+ at_exit do
62
+ Txlemetry::Rails::Logs::Setup.shutdown
63
+ Txlemetry.client&.shutdown if Txlemetry.initialized?
64
+ end
65
+ end
66
+
67
+ MISSING_MIDDLEWARE_MESSAGE = 'No such middleware to insert'
68
+ MIDDLEWARE_FALLBACK_OPERATION = :txlemetry_insert_middleware_with_fallback
69
+
70
+ module MiddlewareStackFallback
71
+ def txlemetry_insert_middleware_with_fallback(location, target, middleware)
72
+ Txlemetry::Rails::Railtie.instance.send(
73
+ :insert_middleware_with_fallback,
74
+ self,
75
+ location,
76
+ target,
77
+ middleware
78
+ )
79
+ end
80
+
81
+ private :txlemetry_insert_middleware_with_fallback
82
+ end
83
+
84
+ private_constant :MISSING_MIDDLEWARE_MESSAGE,
85
+ :MIDDLEWARE_FALLBACK_OPERATION,
86
+ :MiddlewareStackFallback
87
+
88
+ # @api private
89
+ # @return [void]
90
+ def insert_middleware_after(app, target, middleware)
91
+ insert_middleware(app.config.middleware, :after, target, middleware)
92
+ end
93
+
94
+ # @api private
95
+ # @return [void]
96
+ def insert_middleware_before(app, target, middleware)
97
+ insert_middleware(app.config.middleware, :before, target, middleware)
98
+ end
99
+
100
+ # During initialization, app.config.middleware is usually a
101
+ # Rails::Configuration::MiddlewareStackProxy. The proxy only records
102
+ # operations, so missing-target errors happen later when Rails builds the
103
+ # real ActionDispatch::MiddlewareStack. Add our own deferred operation so
104
+ # we can fall back at build time instead of crashing or silently skipping.
105
+ def insert_middleware(middleware_stack, location, target, middleware)
106
+ if middleware_stack_proxy?(middleware_stack)
107
+ append_middleware_operation(middleware_stack, location, target, middleware)
108
+ else
109
+ insert_middleware_with_fallback(middleware_stack, location, target, middleware)
110
+ end
111
+ end
112
+
113
+ def middleware_stack_proxy?(middleware_stack)
114
+ defined?(::Rails::Configuration::MiddlewareStackProxy) &&
115
+ middleware_stack.instance_of?(::Rails::Configuration::MiddlewareStackProxy) &&
116
+ middleware_stack.instance_variable_defined?(:@operations)
117
+ end
118
+
119
+ def append_middleware_operation(middleware_stack, location, target, middleware)
120
+ operations = middleware_stack.instance_variable_get(:@operations)
121
+
122
+ if callable_middleware_operations?(middleware_stack, operations)
123
+ operations << lambda do |resolved_stack|
124
+ insert_middleware_with_fallback(resolved_stack, location, target, middleware)
125
+ end
126
+ else
127
+ ensure_middleware_stack_fallback_operation!
128
+ operations << [MIDDLEWARE_FALLBACK_OPERATION, [location, target, middleware], nil]
129
+ end
130
+ end
131
+
132
+ def callable_middleware_operations?(middleware_stack, operations)
133
+ operation = operations.first || probe_middleware_operation(middleware_stack)
134
+
135
+ operation.respond_to?(:call)
136
+ end
137
+
138
+ def probe_middleware_operation(middleware_stack)
139
+ probe = middleware_stack.class.new
140
+ probe.use(Object)
141
+ probe.instance_variable_get(:@operations).first
142
+ end
143
+
144
+ def ensure_middleware_stack_fallback_operation!
145
+ require 'action_dispatch/middleware/stack' unless defined?(::ActionDispatch::MiddlewareStack)
146
+ return if ::ActionDispatch::MiddlewareStack < MiddlewareStackFallback
147
+
148
+ ::ActionDispatch::MiddlewareStack.include(MiddlewareStackFallback)
149
+ end
150
+
151
+ def insert_middleware_with_fallback(middleware_stack, location, target, middleware)
152
+ perform_middleware_insert(middleware_stack, location, target, middleware)
153
+ rescue RuntimeError => e
154
+ raise unless missing_middleware_error?(e)
155
+
156
+ fallback_insert_middleware(middleware_stack, location, target, middleware)
157
+ end
158
+
159
+ def perform_middleware_insert(middleware_stack, location, target, middleware)
160
+ if location == :after
161
+ middleware_stack.insert_after(target, middleware)
162
+ else
163
+ middleware_stack.insert_before(target, middleware)
164
+ end
165
+ end
166
+
167
+ def fallback_insert_middleware(middleware_stack, location, target, middleware)
168
+ fallback_position = if location == :before && middleware_stack.respond_to?(:unshift)
169
+ middleware_stack.unshift(middleware)
170
+ 'beginning'
171
+ else
172
+ middleware_stack.use(middleware)
173
+ 'end'
174
+ end
175
+
176
+ Txlemetry::Logging.logger.warn(
177
+ "Could not find #{target.inspect} in the Rails middleware stack; " \
178
+ "inserted #{middleware.inspect} at the #{fallback_position} " \
179
+ 'of the stack instead.'
180
+ )
181
+ end
182
+
183
+ def missing_middleware_error?(error)
184
+ error.message.start_with?(MISSING_MIDDLEWARE_MESSAGE)
185
+ end
186
+
187
+ private :insert_middleware,
188
+ :middleware_stack_proxy?,
189
+ :append_middleware_operation,
190
+ :callable_middleware_operations?,
191
+ :probe_middleware_operation,
192
+ :ensure_middleware_stack_fallback_operation!,
193
+ :insert_middleware_with_fallback,
194
+ :perform_middleware_insert,
195
+ :fallback_insert_middleware,
196
+ :missing_middleware_error?
197
+
198
+ # Build the Txlemetry Logs pipeline and broadcast Rails.logger into it.
199
+ #
200
+ # @api private
201
+ # @return [void]
202
+ def self.install_txlemetry_logs
203
+ unless Txlemetry.initialized?
204
+ # logs_enabled is an explicit opt-in, so leave a breadcrumb instead
205
+ # of silently skipping when Txlemetry.init never ran.
206
+ Txlemetry::Logging.logger.warn(
207
+ 'Txlemetry Logs is enabled but Txlemetry.init has not been called; ' \
208
+ 'skipping log forwarding. Call Txlemetry.init in your initializer.'
209
+ )
210
+ return
211
+ end
212
+
213
+ # Mirror the core client: when it is disabled (missing/blank api_key)
214
+ # every capture no-ops, so log forwarding should stay off too. The
215
+ # client already logs its own missing-api_key error, so skip quietly.
216
+ return unless Txlemetry.client.enabled?
217
+
218
+ appender = Txlemetry::Rails::Logs::Setup.install
219
+ return if appender.nil?
220
+
221
+ broadcast_rails_logger(appender) if Txlemetry::Rails.config&.logs_forward_rails_logger
222
+ rescue StandardError => e
223
+ Txlemetry::Logging.logger.warn("Failed to set up Txlemetry Logs: #{e.message}")
224
+ end
225
+
226
+ # Attach the appender to Rails.logger, supporting both the Rails 7.1+
227
+ # BroadcastLogger and the older ActiveSupport::Logger.broadcast mechanism.
228
+ #
229
+ # @api private
230
+ # @return [void]
231
+ def self.broadcast_rails_logger(appender)
232
+ logger = ::Rails.logger
233
+ return unless logger
234
+
235
+ if logger.respond_to?(:broadcast_to)
236
+ logger.broadcast_to(appender)
237
+ elsif defined?(ActiveSupport::Logger) && ActiveSupport::Logger.respond_to?(:broadcast)
238
+ logger.extend(ActiveSupport::Logger.broadcast(appender))
239
+ else
240
+ Txlemetry::Logging.logger.warn(
241
+ 'Txlemetry Logs could not broadcast Rails.logger; no compatible broadcast mechanism found.'
242
+ )
243
+ end
244
+ end
245
+
246
+ # @api private
247
+ # @return [void]
248
+ def self.register_error_subscriber
249
+ return unless Txlemetry::Rails.config&.auto_capture_exceptions
250
+
251
+ subscriber = Txlemetry::Rails::ErrorSubscriber.new
252
+ ::Rails.error.subscribe(subscriber)
253
+ rescue StandardError => e
254
+ Txlemetry::Logging.logger.warn("Failed to register error subscriber: #{e.message}")
255
+ Txlemetry::Logging.logger.warn("Backtrace: #{e.backtrace&.first(5)&.join("\n")}")
256
+ end
257
+
258
+ # @api private
259
+ # @return [Boolean]
260
+ def self.rails_version_above_7?
261
+ ::Rails.version.to_f >= 7.0
262
+ end
263
+ end
264
+
265
+ # Configuration wrapper for the init block
266
+ class InitConfig
267
+ # @param base_options [Hash] Initial core SDK options.
268
+ def initialize(base_options = {})
269
+ @base_options = base_options
270
+ end
271
+
272
+ # Core Txlemetry options
273
+ #
274
+ # @param value [String]
275
+ # @return [String]
276
+ def api_key=(value)
277
+ @base_options[:api_key] = value
278
+ end
279
+
280
+ # The credential used for local feature flag evaluation and remote config. Accepts either a
281
+ # Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`).
282
+ #
283
+ # @param value [String, nil]
284
+ # @return [String, nil]
285
+ def secret_key=(value)
286
+ @base_options[:secret_key] = value
287
+ end
288
+
289
+ # @deprecated Use {#secret_key=} instead. Retained as an alias.
290
+ # @param value [String, nil]
291
+ # @return [String, nil]
292
+ def personal_api_key=(value)
293
+ @base_options[:personal_api_key] = value
294
+ end
295
+
296
+ # @param value [String]
297
+ # @return [String]
298
+ def host=(value)
299
+ @base_options[:host] = value
300
+ end
301
+
302
+ # @param value [Integer]
303
+ # @return [Integer]
304
+ def max_queue_size=(value)
305
+ @base_options[:max_queue_size] = value
306
+ end
307
+
308
+ # @param value [Boolean]
309
+ # @return [Boolean]
310
+ def test_mode=(value)
311
+ @base_options[:test_mode] = value
312
+ end
313
+
314
+ # @param value [Boolean]
315
+ # @return [Boolean]
316
+ def sync_mode=(value)
317
+ @base_options[:sync_mode] = value
318
+ end
319
+
320
+ # @param value [Boolean]
321
+ # @return [Boolean]
322
+ def compress_request=(value)
323
+ @base_options[:compress_request] = value
324
+ end
325
+
326
+ # @param value [Proc]
327
+ # @return [Proc]
328
+ def on_error=(value)
329
+ @base_options[:on_error] = value
330
+ end
331
+
332
+ # @param value [Integer]
333
+ # @return [Integer]
334
+ def feature_flags_polling_interval=(value)
335
+ @base_options[:feature_flags_polling_interval] = value
336
+ end
337
+
338
+ # @param value [Integer]
339
+ # @return [Integer]
340
+ def feature_flag_request_timeout_seconds=(value)
341
+ @base_options[:feature_flag_request_timeout_seconds] = value
342
+ end
343
+
344
+ # @param value [Proc]
345
+ # @return [Proc]
346
+ def before_send=(value)
347
+ @base_options[:before_send] = value
348
+ end
349
+
350
+ # @return [Hash] Core SDK options suitable for {Txlemetry::Client.new}.
351
+ def to_client_options
352
+ @base_options
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'txlemetry/internal/context'
4
+ require 'txlemetry/rails/tracing_headers'
5
+ require 'txlemetry/rails/request_metadata'
6
+
7
+ module Txlemetry
8
+ module Rails
9
+ # Rack middleware that creates a request-local Txlemetry context from tracing headers.
10
+ #
11
+ # @api private
12
+ class RequestContext
13
+ # @param app [#call] Rack application.
14
+ def initialize(app)
15
+ @app = app
16
+ end
17
+
18
+ # @param env [Hash] Rack environment.
19
+ # @return [Array] Rack response.
20
+ def call(env)
21
+ request = build_request(env)
22
+
23
+ Internal::Context.with_context(context_data(request), fresh: true) do
24
+ @app.call(env)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def context_data(request)
31
+ data = { properties: request_properties(request) }
32
+ return data unless use_tracing_headers?
33
+
34
+ data.merge(
35
+ distinct_id: tracing_header(request, 'X-POSTHOG-DISTINCT-ID'),
36
+ session_id: tracing_header(request, 'X-POSTHOG-SESSION-ID')
37
+ )
38
+ end
39
+
40
+ def use_tracing_headers?
41
+ Txlemetry::Rails.config&.use_tracing_headers != false
42
+ end
43
+
44
+ def request_properties(request)
45
+ RequestMetadata.extract(request)
46
+ end
47
+
48
+ def tracing_header(request, header_name)
49
+ TracingHeaders.extract_header(request, header_name)
50
+ end
51
+
52
+ def build_request(env)
53
+ if defined?(ActionDispatch::Request)
54
+ ActionDispatch::Request.new(env)
55
+ elsif defined?(Rack::Request)
56
+ Rack::Request.new(env)
57
+ else
58
+ env
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'txlemetry/rails/tracing_headers'
4
+
5
+ module Txlemetry
6
+ module Rails
7
+ # Internal helpers for extracting request metadata owned by RequestContext.
8
+ #
9
+ # @api private
10
+ module RequestMetadata
11
+ module_function
12
+
13
+ # @param request [Object] Rack or Rails request object.
14
+ # @return [Hash] Event properties extracted from the request.
15
+ def extract(request)
16
+ properties = {}
17
+ add_property(properties, '$current_url', current_url(request))
18
+ request_method = request_value(request, :request_method) || request_value(request, :method)
19
+ add_property(properties, '$request_method', request_method)
20
+ add_property(properties, '$request_path', request_value(request, :path) || request_value(request, :path_info))
21
+ user_agent = TracingHeaders.extract_header(request, 'User-Agent')
22
+ add_property(properties, '$user_agent', user_agent)
23
+ # Mirrored into $raw_user_agent, the standardized property Txlemetry's
24
+ # server-side classification (e.g. bot detection) reads
25
+ add_property(properties, '$raw_user_agent', user_agent)
26
+ add_property(properties, '$ip', client_ip(request))
27
+ properties
28
+ end
29
+
30
+ def current_url(request)
31
+ url = request_value(request, :url)
32
+ return if url.nil?
33
+
34
+ url.to_s.split('?', 2).first
35
+ end
36
+ private_class_method :current_url
37
+
38
+ def client_ip(request)
39
+ trusted_ip = request_value(request, :remote_ip) || request_value(request, :ip)
40
+ return trusted_ip if present?(trusted_ip)
41
+
42
+ forwarded_for = TracingHeaders.extract_header(request, 'X-Forwarded-For')
43
+ forwarded_ip = forwarded_for.split(',').first&.strip if forwarded_for
44
+ return forwarded_ip if present?(forwarded_ip)
45
+
46
+ env_value(request, 'REMOTE_ADDR')
47
+ end
48
+ private_class_method :client_ip
49
+
50
+ def present?(value)
51
+ !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
52
+ end
53
+ private_class_method :present?
54
+
55
+ def add_property(properties, key, value)
56
+ return if value.nil?
57
+
58
+ serialized = value.to_s
59
+ return if serialized.empty?
60
+
61
+ properties[key] = serialized
62
+ end
63
+ private_class_method :add_property
64
+
65
+ def request_value(request, method_name)
66
+ return unless request.respond_to?(method_name)
67
+
68
+ request.public_send(method_name)
69
+ rescue StandardError
70
+ nil
71
+ end
72
+ private_class_method :request_value
73
+
74
+ def env_value(request, key)
75
+ request.respond_to?(:get_header) ? request.get_header(key) : request.env[key]
76
+ rescue StandardError
77
+ nil
78
+ end
79
+ private_class_method :env_value
80
+ end
81
+
82
+ private_constant :RequestMetadata
83
+ end
84
+ end
@@ -0,0 +1,37 @@
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
+ # Middleware that intercepts exceptions that are rescued by Rails.
10
+ # This middleware runs before ShowExceptions and captures the exception
11
+ # so we can report it even if Rails rescues it.
12
+ #
13
+ # @api private
14
+ class RescuedExceptionInterceptor
15
+ # @param app [#call] Rack application.
16
+ def initialize(app)
17
+ @app = app
18
+ end
19
+
20
+ # @param env [Hash] Rack environment.
21
+ # @return [Array] Rack response.
22
+ def call(env)
23
+ @app.call(env)
24
+ rescue StandardError => e
25
+ # Store the exception so CaptureExceptions middleware can report it
26
+ env['txlemetry.rescued_exception'] = e if should_intercept?
27
+ raise e
28
+ end
29
+
30
+ private
31
+
32
+ def should_intercept?
33
+ Txlemetry::Rails.config&.report_rescued_exceptions
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txlemetry
4
+ module Rails
5
+ # Helpers for extracting and sanitizing Txlemetry tracing headers from Rack/Rails requests.
6
+ #
7
+ # @api private
8
+ module TracingHeaders
9
+ MAX_HEADER_VALUE_LENGTH = 1000
10
+ CONTROL_CHARACTERS = /[[:cntrl:]]/
11
+
12
+ module_function
13
+
14
+ # @param value [Object]
15
+ # @return [String, nil]
16
+ def sanitize_header_value(value)
17
+ return nil unless value.is_a?(String)
18
+
19
+ sanitized = value.strip.gsub(CONTROL_CHARACTERS, '').strip
20
+ return nil if sanitized.empty?
21
+
22
+ sanitized[0, MAX_HEADER_VALUE_LENGTH]
23
+ end
24
+
25
+ # @param request_or_env [Object, Hash] Rack request, Rails request, or Rack env hash.
26
+ # @param header_name [String]
27
+ # @return [String, nil]
28
+ def extract_header(request_or_env, header_name)
29
+ candidates = header_candidates(header_name)
30
+
31
+ candidates.each do |candidate|
32
+ value = header_value(request_or_env, candidate)
33
+ sanitized = sanitize_header_value(value)
34
+ return sanitized if sanitized
35
+ end
36
+
37
+ env = request_env(request_or_env)
38
+ return nil unless env.respond_to?(:each)
39
+
40
+ target_names = candidates.map { |candidate| normalize_header_name(candidate) }
41
+ env.each do |key, value|
42
+ next unless target_names.include?(normalize_header_name(key))
43
+
44
+ sanitized = sanitize_header_value(value)
45
+ return sanitized if sanitized
46
+ end
47
+
48
+ nil
49
+ end
50
+
51
+ def header_candidates(header_name)
52
+ canonical = header_name.to_s
53
+ rack = "HTTP_#{canonical.upcase.tr('-', '_')}"
54
+ [canonical, canonical.downcase, rack]
55
+ end
56
+ private_class_method :header_candidates
57
+
58
+ def header_value(request_or_env, header_name)
59
+ if request_or_env.respond_to?(:headers)
60
+ value = request_or_env.headers[header_name]
61
+ return value unless value.nil?
62
+ end
63
+
64
+ env = request_env(request_or_env)
65
+ return nil unless env.respond_to?(:[])
66
+
67
+ env[header_name]
68
+ end
69
+ private_class_method :header_value
70
+
71
+ def request_env(request_or_env)
72
+ request_or_env.respond_to?(:env) ? request_or_env.env : request_or_env
73
+ end
74
+ private_class_method :request_env
75
+
76
+ def normalize_header_name(header_name)
77
+ header_name.to_s.upcase.tr('-', '_')
78
+ end
79
+ private_class_method :normalize_header_name
80
+ end
81
+
82
+ private_constant :TracingHeaders
83
+ end
84
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txlemetry
4
+ module Rails
5
+ VERSION = '4.0.0'
6
+ end
7
+ end