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.
- checksums.yaml +7 -0
- data/LICENSE +48 -0
- data/NOTICE +5 -0
- data/lib/generators/txlemetry/install_generator.rb +39 -0
- data/lib/generators/txlemetry/templates/txlemetry.rb +186 -0
- data/lib/txlemetry/rails/active_job.rb +118 -0
- data/lib/txlemetry/rails/capture_exceptions.rb +170 -0
- data/lib/txlemetry/rails/configuration.rb +120 -0
- data/lib/txlemetry/rails/error_subscriber.rb +62 -0
- data/lib/txlemetry/rails/facade.rb +104 -0
- data/lib/txlemetry/rails/logs/appender.rb +262 -0
- data/lib/txlemetry/rails/logs/rate_limiter.rb +48 -0
- data/lib/txlemetry/rails/logs/setup.rb +261 -0
- data/lib/txlemetry/rails/logs/severity.rb +49 -0
- data/lib/txlemetry/rails/parameter_filter.rb +136 -0
- data/lib/txlemetry/rails/railtie.rb +356 -0
- data/lib/txlemetry/rails/request_context.rb +63 -0
- data/lib/txlemetry/rails/request_metadata.rb +84 -0
- data/lib/txlemetry/rails/rescued_exception_interceptor.rb +37 -0
- data/lib/txlemetry/rails/tracing_headers.rb +84 -0
- data/lib/txlemetry/rails/version.rb +7 -0
- data/lib/txlemetry/rails.rb +130 -0
- data/lib/txlemetry-rails.rb +11 -0
- metadata +91 -0
|
@@ -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
|