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,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'txlemetry/logging'
5
+ require 'txlemetry/rails/configuration'
6
+ require 'txlemetry/rails/logs/appender'
7
+ require 'txlemetry/rails/logs/rate_limiter'
8
+
9
+ module Txlemetry
10
+ module Rails
11
+ module Logs
12
+ # Bootstraps the OpenTelemetry logs pipeline that ships Txlemetry Logs.
13
+ #
14
+ # The OpenTelemetry gems are optional/soft dependencies. They are required
15
+ # lazily here so that apps which do not enable logs (or run on a Ruby
16
+ # version the logs SDK does not support) are unaffected.
17
+ #
18
+ # @api private
19
+ module Setup
20
+ # Bounds the at_exit flush. Without a timeout, the batch processor
21
+ # joins its worker thread unbounded and the exporter retries each
22
+ # batch with backoff — during an outage that can eat the whole
23
+ # SIGTERM grace period and starve the events client of its flush.
24
+ SHUTDOWN_TIMEOUT_SECONDS = 2
25
+
26
+ class << self
27
+ # @return [OpenTelemetry::SDK::Logs::LoggerProvider, nil]
28
+ attr_reader :provider
29
+
30
+ # @return [Txlemetry::Rails::Logs::Appender, nil]
31
+ attr_reader :appender
32
+
33
+ # Build the logs pipeline and return the broadcastable appender.
34
+ #
35
+ # Idempotent: subsequent calls return the previously built appender
36
+ # (or nil if setup was skipped).
37
+ #
38
+ # @return [Txlemetry::Rails::Logs::Appender, nil]
39
+ def install
40
+ return @appender if @installed
41
+
42
+ @installed = true
43
+
44
+ # Respect the core client's test_mode: when it is on, the client
45
+ # swaps in a NoopWorker so events never ship, and the logs pipeline
46
+ # should likewise stay off so test suites don't emit real records.
47
+ # Intentional state, so skip quietly (no warning).
48
+ return nil if @client_test_mode
49
+
50
+ return nil unless require_otel_gems
51
+
52
+ config = Txlemetry::Rails.config
53
+ token = resolve_token
54
+ if token.nil?
55
+ warn_once(
56
+ 'Txlemetry Logs enabled but no project token could be resolved ' \
57
+ '(set config.api_key or TXLEMETRY_API_KEY); skipping.'
58
+ )
59
+ return nil
60
+ end
61
+
62
+ @provider = build_provider(token)
63
+ otel_logger = @provider.logger(name: 'txlemetry-rails', version: Txlemetry::Rails::VERSION)
64
+ level = resolve_level(config.logs_level) || rails_logger_level
65
+ @appender = Appender.new(
66
+ otel_logger,
67
+ level: level,
68
+ rate_limiter: build_rate_limiter(config),
69
+ before_send: config.logs_before_send
70
+ )
71
+ rescue StandardError => e
72
+ warn_once("Failed to initialize Txlemetry Logs: #{e.message}")
73
+ nil
74
+ end
75
+
76
+ # Shut the pipeline down, flushing buffered records.
77
+ #
78
+ # @param timeout [Numeric] Max seconds to spend; see {SHUTDOWN_TIMEOUT_SECONDS}.
79
+ # @return [void]
80
+ def shutdown(timeout: SHUTDOWN_TIMEOUT_SECONDS)
81
+ @provider&.shutdown(timeout: timeout)
82
+ rescue StandardError => e
83
+ logger.warn("Error shutting down Txlemetry Logs: #{e.message}")
84
+ end
85
+
86
+ # Remembers the api_key/host the Txlemetry client was initialized with
87
+ # (called by Txlemetry.init) so the logs pipeline can reuse them without
88
+ # the core client exposing public readers.
89
+ #
90
+ # @api private
91
+ # @param options [Hash] The options passed to {Txlemetry::Client.new}.
92
+ # @return [void]
93
+ def remember_client_options(options)
94
+ return unless options.is_a?(Hash)
95
+
96
+ @client_api_key = options[:api_key] || options['api_key']
97
+ @client_host = options[:host] || options['host']
98
+ @client_test_mode = options[:test_mode] || options['test_mode']
99
+ end
100
+
101
+ # Resets memoized state. Intended for tests.
102
+ #
103
+ # @return [void]
104
+ def reset
105
+ @installed = false
106
+ @provider = nil
107
+ @appender = nil
108
+ @warned = false
109
+ @client_api_key = nil
110
+ @client_host = nil
111
+ @client_test_mode = nil
112
+ end
113
+
114
+ private
115
+
116
+ # The logs token is the same project token the core client uses
117
+ # (i.e. config.api_key, captured by Txlemetry.init), falling back to
118
+ # ENV['TXLEMETRY_API_KEY'].
119
+ def resolve_token
120
+ normalize(@client_api_key) || normalize(ENV.fetch('TXLEMETRY_API_KEY', nil))
121
+ end
122
+
123
+ # The logs host follows the core client's configured host (captured by
124
+ # Txlemetry.init), falling back to ENV['TXLEMETRY_HOST'] and finally the
125
+ # US cloud endpoint.
126
+ def resolve_host
127
+ normalize(@client_host) ||
128
+ normalize(ENV.fetch('TXLEMETRY_HOST', nil)) ||
129
+ 'https://app.txlemetry.com'
130
+ end
131
+
132
+ # nil, 0, and negative values intentionally disable the cap. Numeric
133
+ # strings (e.g. from ENV) are coerced — deliberately via Integer()
134
+ # rather than to_i, since "abc".to_i == 0 would silently disable the
135
+ # cap. Unparseable values warn and fall back to the default cap:
136
+ # a misconfiguration should not switch the protection off.
137
+ def build_rate_limiter(config)
138
+ raw = config.logs_max_records_per_minute
139
+ return nil if raw.nil?
140
+
141
+ limit = Integer(raw, exception: false)
142
+ if limit.nil?
143
+ logger.warn(
144
+ "logs_max_records_per_minute=#{raw.inspect} is not a number; using the default cap " \
145
+ "of #{Configuration::DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE} records/minute"
146
+ )
147
+ limit = Configuration::DEFAULT_LOGS_MAX_RECORDS_PER_MINUTE
148
+ end
149
+ return nil unless limit.positive?
150
+
151
+ RateLimiter.new(limit)
152
+ end
153
+
154
+ def require_otel_gems
155
+ require 'opentelemetry-sdk'
156
+ require 'opentelemetry-logs-sdk'
157
+ require 'opentelemetry/exporter/otlp_logs'
158
+ true
159
+ rescue LoadError => e
160
+ warn_once(
161
+ "Txlemetry Logs enabled but the OpenTelemetry gems are missing (#{e.message}). " \
162
+ "Add 'opentelemetry-sdk', 'opentelemetry-logs-sdk', and " \
163
+ "'opentelemetry-exporter-otlp-logs' (each with require: false) to your Gemfile " \
164
+ 'to enable log forwarding.'
165
+ )
166
+ false
167
+ end
168
+
169
+ def build_provider(token)
170
+ resource = OpenTelemetry::SDK::Resources::Resource.create(resource_attributes)
171
+ provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: resource)
172
+ exporter = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
173
+ endpoint: logs_endpoint(resolve_host),
174
+ headers: { 'Authorization' => "Bearer #{token}" }
175
+ )
176
+ processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter)
177
+ provider.add_log_record_processor(processor)
178
+ provider
179
+ end
180
+
181
+ def resource_attributes
182
+ # service.version is intentionally omitted. Per OpenTelemetry semantic
183
+ # conventions it is the deployed application's version, not this gem's.
184
+ # The txlemetry-rails name/version travel with each record via the
185
+ # instrumentation scope (see LoggerProvider#logger above).
186
+ {
187
+ # Identifies these records as the Txlemetry Ruby SDK so Txlemetry's usage
188
+ # report can attribute log volume to Ruby (mirrors txlemetry-ios/android/
189
+ # flutter/react-native). Overrides the OpenTelemetry SDK default of
190
+ # 'opentelemetry'; the txlemetry-rails integration name stays on the scope.
191
+ 'telemetry.sdk.name' => 'txlemetry-ruby',
192
+ 'service.name' => service_name,
193
+ 'deployment.environment' => ::Rails.env.to_s
194
+ }
195
+ end
196
+
197
+ def service_name
198
+ app = ::Rails.application
199
+ return 'unknown_service' unless app
200
+
201
+ name = app.class.respond_to?(:module_parent_name) ? app.class.module_parent_name : nil
202
+ name && !name.empty? ? name.to_s : 'unknown_service'
203
+ rescue StandardError
204
+ 'unknown_service'
205
+ end
206
+
207
+ def logs_endpoint(host)
208
+ base = (host || 'https://app.txlemetry.com').to_s.sub(%r{/+\z}, '')
209
+ "#{base}/i/v1/logs"
210
+ end
211
+
212
+ def resolve_level(level)
213
+ return nil if level.nil?
214
+ return level if level.is_a?(Integer)
215
+
216
+ # const_get can return non-severity values without raising: Logger's
217
+ # own VERSION/SEV_LABEL, and (via the default inherited lookup) any
218
+ # top-level all-caps constant like ENV/ARGV/STDOUT. The Integer guard
219
+ # rejects them so they don't later blow up the severity comparison.
220
+ # (inherit must stay true — the severity constants live in the
221
+ # included Logger::Severity module.)
222
+ result = ::Logger.const_get(level.to_s.upcase)
223
+ raise NameError, 'not a severity integer' unless result.is_a?(Integer)
224
+
225
+ result
226
+ rescue NameError
227
+ warn_once(
228
+ "Invalid logs_level #{level.inspect}; expected one of :debug, :info, :warn, " \
229
+ ':error, :fatal, :unknown (or an Integer). Falling back to the Rails logger level.'
230
+ )
231
+ nil
232
+ end
233
+
234
+ def rails_logger_level
235
+ ::Rails.logger&.level
236
+ rescue StandardError
237
+ nil
238
+ end
239
+
240
+ def normalize(value)
241
+ return nil unless value.is_a?(String)
242
+
243
+ stripped = value.strip
244
+ stripped.empty? ? nil : stripped
245
+ end
246
+
247
+ def warn_once(message)
248
+ return if @warned
249
+
250
+ @warned = true
251
+ logger.warn(message)
252
+ end
253
+
254
+ def logger
255
+ Txlemetry::Logging.logger
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Txlemetry
6
+ module Rails
7
+ module Logs
8
+ # Maps Ruby `Logger` severities to OpenTelemetry log severity numbers and text.
9
+ #
10
+ # OpenTelemetry defines severity ranges (DEBUG=5-8, INFO=9-12, WARN=13-16,
11
+ # ERROR=17-20, FATAL=21-24); we map each Ruby level to the base of its range.
12
+ #
13
+ # @api private
14
+ module Severity
15
+ module_function
16
+
17
+ # @param severity [Integer, nil] A Ruby `Logger` severity constant.
18
+ # @return [Symbol] The severity name (:debug, :info, :warn, :error, :fatal).
19
+ def name_for(severity)
20
+ NAMES.fetch(severity, :info)
21
+ end
22
+
23
+ # @param name [Symbol, String, nil] A severity name such as :warn.
24
+ # @return [Array(Integer, String)] OpenTelemetry severity number and text;
25
+ # unrecognized names fall back to INFO.
26
+ def for_name(name)
27
+ OTEL.fetch(name.to_s.downcase.to_sym, OTEL[:info])
28
+ end
29
+
30
+ NAMES = {
31
+ ::Logger::DEBUG => :debug,
32
+ ::Logger::INFO => :info,
33
+ ::Logger::WARN => :warn,
34
+ ::Logger::ERROR => :error,
35
+ ::Logger::FATAL => :fatal,
36
+ ::Logger::UNKNOWN => :info
37
+ }.freeze
38
+
39
+ OTEL = {
40
+ debug: [5, 'DEBUG'],
41
+ info: [9, 'INFO'],
42
+ warn: [13, 'WARN'],
43
+ error: [17, 'ERROR'],
44
+ fatal: [21, 'FATAL']
45
+ }.freeze
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,136 @@
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
+ # Shared utility module for filtering sensitive parameters
10
+ #
11
+ # This module provides consistent parameter filtering across all Txlemetry Rails
12
+ # components, leveraging Rails' built-in parameter filtering when available.
13
+ # It automatically detects the correct Rails parameter filtering API based on
14
+ # the Rails version.
15
+ #
16
+ # @api private
17
+ #
18
+ # @example Usage in a class
19
+ # class MyClass
20
+ # include Txlemetry::Rails::ParameterFilter
21
+ #
22
+ # def my_method(params)
23
+ # filtered = filter_sensitive_params(params)
24
+ # Txlemetry.capture(event: 'something', properties: filtered)
25
+ # end
26
+ # end
27
+ module ParameterFilter
28
+ EMPTY_HASH = {}.freeze
29
+ MAX_STRING_LENGTH = 10_000
30
+ MAX_DEPTH = 10
31
+
32
+ if ::Rails.version.to_f >= 6.0
33
+ # @return [Class] Rails parameter filter backend.
34
+ def self.backend
35
+ ActiveSupport::ParameterFilter
36
+ end
37
+ else
38
+ # @return [Class] Rails parameter filter backend.
39
+ def self.backend
40
+ ActionDispatch::Http::ParameterFilter
41
+ end
42
+ end
43
+
44
+ # Filter sensitive parameters from a hash, respecting Rails configuration.
45
+ #
46
+ # Uses Rails' configured filter_parameters (e.g., :password, :token, :api_key)
47
+ # to automatically filter sensitive data that the Rails app has configured.
48
+ #
49
+ # @param params [Hash] The parameters to filter
50
+ # @return [Hash] Filtered parameters with sensitive data masked
51
+ def filter_sensitive_params(params)
52
+ return EMPTY_HASH unless params.is_a?(Hash)
53
+ return params unless ::Rails.application
54
+
55
+ filter_parameters = ::Rails.application.config.filter_parameters
56
+ parameter_filter = ParameterFilter.backend.new(filter_parameters)
57
+
58
+ parameter_filter.filter(params)
59
+ end
60
+
61
+ # Safely serialize a value to a JSON-compatible format.
62
+ #
63
+ # Handles circular references and complex objects by converting them to
64
+ # simple primitives or string representations. This prevents SystemStackError
65
+ # when serializing objects with circular references (like ActiveRecord models).
66
+ #
67
+ # @param value [Object] The value to serialize
68
+ # @param seen [Set] Set of object_ids already visited (for cycle detection)
69
+ # @param depth [Integer] Current recursion depth
70
+ # @return [Object] A JSON-safe value (String, Numeric, Boolean, nil, Array, or Hash)
71
+ def safe_serialize(value, seen = Set.new, depth = 0)
72
+ return '[max depth exceeded]' if depth > MAX_DEPTH
73
+
74
+ case value
75
+ when nil, true, false, Integer, Float
76
+ value
77
+ when String
78
+ truncate_string(value)
79
+ when Symbol
80
+ value.to_s
81
+ when Time, DateTime
82
+ value.iso8601(3)
83
+ when Date
84
+ value.iso8601
85
+ when Array
86
+ serialize_array(value, seen, depth)
87
+ when Hash
88
+ serialize_hash(value, seen, depth)
89
+ else
90
+ serialize_object(value, seen)
91
+ end
92
+ rescue StandardError => e
93
+ "[serialization error: #{e.class}]"
94
+ end
95
+
96
+ private
97
+
98
+ def truncate_string(str)
99
+ return str if str.length <= MAX_STRING_LENGTH
100
+
101
+ "#{str[0...MAX_STRING_LENGTH]}... (truncated)"
102
+ end
103
+
104
+ def serialize_array(array, seen, depth)
105
+ return '[circular reference]' if seen.include?(array.object_id)
106
+
107
+ seen = seen.dup.add(array.object_id)
108
+ array.first(100).map { |item| safe_serialize(item, seen, depth + 1) }
109
+ end
110
+
111
+ def serialize_hash(hash, seen, depth)
112
+ return '[circular reference]' if seen.include?(hash.object_id)
113
+
114
+ seen = seen.dup.add(hash.object_id)
115
+ result = {}
116
+ hash.first(100).each do |key, val|
117
+ result[key.to_s] = safe_serialize(val, seen, depth + 1)
118
+ end
119
+ result
120
+ end
121
+
122
+ def serialize_object(obj, seen)
123
+ return '[circular reference]' if seen.include?(obj.object_id)
124
+
125
+ # For ActiveRecord and similar objects, use id if available
126
+ return "#{obj.class.name}##{obj.id}" if obj.respond_to?(:id) && obj.respond_to?(:class)
127
+
128
+ # Try to_s as fallback, but limit length
129
+ str = obj.to_s
130
+ truncate_string(str)
131
+ rescue StandardError
132
+ "[#{obj.class.name}]"
133
+ end
134
+ end
135
+ end
136
+ end