debugbundle 0.1.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +17 -0
  3. data/Makefile +43 -0
  4. data/README.md +168 -0
  5. data/debugbundle.gemspec +30 -0
  6. data/lib/debugbundle/client.rb +724 -0
  7. data/lib/debugbundle/config.rb +144 -0
  8. data/lib/debugbundle/logging.rb +77 -0
  9. data/lib/debugbundle/rack/middleware.rb +94 -0
  10. data/lib/debugbundle/rack/relay_middleware.rb +37 -0
  11. data/lib/debugbundle/rails/railtie.rb +35 -0
  12. data/lib/debugbundle/rails/relay_endpoint.rb +100 -0
  13. data/lib/debugbundle/rails.rb +10 -0
  14. data/lib/debugbundle/redaction.rb +151 -0
  15. data/lib/debugbundle/relay/handler.rb +231 -0
  16. data/lib/debugbundle/relay.rb +4 -0
  17. data/lib/debugbundle/remote_config.rb +153 -0
  18. data/lib/debugbundle/runtime.rb +22 -0
  19. data/lib/debugbundle/sidekiq/server_middleware.rb +34 -0
  20. data/lib/debugbundle/suppression.rb +121 -0
  21. data/lib/debugbundle/transport.rb +190 -0
  22. data/lib/debugbundle/trigger_token.rb +122 -0
  23. data/lib/debugbundle/version.rb +5 -0
  24. data/lib/debugbundle.rb +93 -0
  25. data/spec/client_spec.rb +236 -0
  26. data/spec/debugbundle_spec.rb +54 -0
  27. data/spec/file_transport_spec.rb +54 -0
  28. data/spec/logger_integration_spec.rb +118 -0
  29. data/spec/rack_integration_spec.rb +44 -0
  30. data/spec/rack_middleware_spec.rb +206 -0
  31. data/spec/rails_railtie_spec.rb +96 -0
  32. data/spec/rails_relay_spec.rb +121 -0
  33. data/spec/redaction_spec.rb +42 -0
  34. data/spec/relay_spec.rb +178 -0
  35. data/spec/remote_config_spec.rb +402 -0
  36. data/spec/sidekiq_integration_spec.rb +66 -0
  37. data/spec/sidekiq_middleware_spec.rb +50 -0
  38. data/spec/spec_helper.rb +20 -0
  39. data/spec/suppression_spec.rb +16 -0
  40. metadata +113 -0
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugBundle
4
+ class Config
5
+ DEFAULT_ENDPOINT = 'https://api.debugbundle.com/v1/events'
6
+ DEFAULT_PROJECT_MODE = :connected
7
+ DEFAULT_BATCH_SIZE = 25
8
+ DEFAULT_FLUSH_INTERVAL = 5
9
+ DEFAULT_SAMPLE_RATE = 1.0
10
+ DEFAULT_LOG_LEVEL = :warning
11
+ DEFAULT_LOCAL_EVENTS_DIR = '.debugbundle/local/events'
12
+ DEFAULT_SPOOL_DIR = '.debugbundle/local/browser-relay-spool'
13
+ DEFAULT_RELAY_RATE_LIMIT_PER_MINUTE = 60
14
+ DEFAULT_MAX_PROBE_LABELS = 50
15
+ DEFAULT_MAX_PROBE_ENTRIES_PER_LABEL = 10
16
+ DEFAULT_PROBE_FLUSH_ON_ERROR = true
17
+ DEFAULT_PROBES_POLL_INTERVAL = 60
18
+ DEFAULT_REDACT_FIELDS = [].freeze
19
+
20
+ VALID_PROJECT_MODES = %i[connected local_only].freeze
21
+ VALID_STATUSES = %i[healthy degraded disconnected].freeze
22
+
23
+ attr_reader :project_token,
24
+ :enabled,
25
+ :environment,
26
+ :service,
27
+ :endpoint,
28
+ :project_mode,
29
+ :local_events_dir,
30
+ :spool_dir,
31
+ :batch_size,
32
+ :flush_interval,
33
+ :sample_rate,
34
+ :log_level,
35
+ :relay_enabled,
36
+ :relay_rate_limit_per_minute,
37
+ :relay_durable_write,
38
+ :redact_fields,
39
+ :max_probe_labels,
40
+ :max_probe_entries_per_label,
41
+ :probe_flush_on_error,
42
+ :probes_poll_interval
43
+
44
+ def initialize(
45
+ project_token: nil,
46
+ enabled: true,
47
+ environment: nil,
48
+ service: nil,
49
+ endpoint: DEFAULT_ENDPOINT,
50
+ project_mode: DEFAULT_PROJECT_MODE,
51
+ local_events_dir: DEFAULT_LOCAL_EVENTS_DIR,
52
+ spool_dir: DEFAULT_SPOOL_DIR,
53
+ batch_size: DEFAULT_BATCH_SIZE,
54
+ flush_interval: DEFAULT_FLUSH_INTERVAL,
55
+ sample_rate: DEFAULT_SAMPLE_RATE,
56
+ log_level: DEFAULT_LOG_LEVEL,
57
+ relay_enabled: true,
58
+ relay_rate_limit_per_minute: DEFAULT_RELAY_RATE_LIMIT_PER_MINUTE,
59
+ relay_durable_write: true,
60
+ redact_fields: DEFAULT_REDACT_FIELDS,
61
+ max_probe_labels: DEFAULT_MAX_PROBE_LABELS,
62
+ max_probe_entries_per_label: DEFAULT_MAX_PROBE_ENTRIES_PER_LABEL,
63
+ probe_flush_on_error: DEFAULT_PROBE_FLUSH_ON_ERROR,
64
+ probes_poll_interval: DEFAULT_PROBES_POLL_INTERVAL
65
+ )
66
+ @project_token = project_token
67
+ @enabled = enabled
68
+ @environment = environment
69
+ @service = service
70
+ @endpoint = endpoint
71
+ @project_mode = normalize_project_mode(project_mode)
72
+ @local_events_dir = local_events_dir
73
+ @spool_dir = spool_dir
74
+ @batch_size = normalize_positive_integer(batch_size, DEFAULT_BATCH_SIZE)
75
+ @flush_interval = normalize_positive_number(flush_interval, DEFAULT_FLUSH_INTERVAL)
76
+ @sample_rate = normalize_sample_rate(sample_rate)
77
+ @log_level = log_level
78
+ @relay_enabled = relay_enabled
79
+ @relay_rate_limit_per_minute = normalize_positive_integer(
80
+ relay_rate_limit_per_minute,
81
+ DEFAULT_RELAY_RATE_LIMIT_PER_MINUTE
82
+ )
83
+ @relay_durable_write = relay_durable_write
84
+ @redact_fields = normalize_redact_fields(redact_fields)
85
+ @max_probe_labels = normalize_positive_integer(max_probe_labels, DEFAULT_MAX_PROBE_LABELS)
86
+ @max_probe_entries_per_label = normalize_positive_integer(
87
+ max_probe_entries_per_label,
88
+ DEFAULT_MAX_PROBE_ENTRIES_PER_LABEL
89
+ )
90
+ @probe_flush_on_error = probe_flush_on_error
91
+ @probes_poll_interval = normalize_positive_number(probes_poll_interval, DEFAULT_PROBES_POLL_INTERVAL)
92
+ freeze
93
+ end
94
+
95
+ def enabled?
96
+ enabled
97
+ end
98
+
99
+ def configured?
100
+ !project_token.to_s.empty?
101
+ end
102
+
103
+ private
104
+
105
+ def normalize_project_mode(value)
106
+ normalized = value.to_s.strip.downcase.tr('-', '_').to_sym
107
+ return normalized if VALID_PROJECT_MODES.include?(normalized)
108
+
109
+ DEFAULT_PROJECT_MODE
110
+ end
111
+
112
+ def normalize_positive_integer(value, fallback)
113
+ integer = Integer(value)
114
+ integer.positive? ? integer : fallback
115
+ rescue ArgumentError, TypeError
116
+ fallback
117
+ end
118
+
119
+ def normalize_positive_number(value, fallback)
120
+ number = Float(value)
121
+ number.positive? ? number : fallback
122
+ rescue ArgumentError, TypeError
123
+ fallback
124
+ end
125
+
126
+ def normalize_sample_rate(value)
127
+ number = Float(value)
128
+ number.clamp(0.0, 1.0)
129
+ rescue ArgumentError, TypeError
130
+ DEFAULT_SAMPLE_RATE
131
+ end
132
+
133
+ def normalize_redact_fields(value)
134
+ Array(value).filter_map do |entry|
135
+ case entry
136
+ when String, Symbol
137
+ entry.to_s
138
+ when Regexp
139
+ entry.source
140
+ end
141
+ end.freeze
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module DebugBundle
6
+ module Logging
7
+ RECURSION_GUARD_KEY = :__debugbundle_logger_capture_active__
8
+ LOGGER_LEVEL_NAMES = {
9
+ ::Logger::DEBUG => :debug,
10
+ ::Logger::INFO => :info,
11
+ ::Logger::WARN => :warning,
12
+ ::Logger::ERROR => :error,
13
+ ::Logger::FATAL => :fatal,
14
+ ::Logger::UNKNOWN => :critical
15
+ }.freeze
16
+
17
+ def self.install_stdlib_logger(logger, client:)
18
+ interceptor = Module.new do
19
+ define_method(:add) do |severity, message = nil, progname = nil, &block|
20
+ was_capturing = Thread.current[RECURSION_GUARD_KEY]
21
+ unless was_capturing
22
+ Thread.current[RECURSION_GUARD_KEY] = true
23
+ resolved_message = message
24
+ resolved_message = block.call if resolved_message.nil? && block
25
+ resolved_message = progname if resolved_message.nil?
26
+
27
+ client.capture_log(
28
+ resolved_message.to_s,
29
+ level: LOGGER_LEVEL_NAMES.fetch(severity || ::Logger::UNKNOWN, :warning),
30
+ context: { logger_name: logger.progname }
31
+ )
32
+ end
33
+
34
+ super(severity, message, progname, &block)
35
+ ensure
36
+ Thread.current[RECURSION_GUARD_KEY] = was_capturing
37
+ end
38
+ end
39
+
40
+ logger.singleton_class.prepend(interceptor)
41
+ interceptor
42
+ end
43
+
44
+ def self.install_semantic_logger(client:)
45
+ return nil unless defined?(::SemanticLogger)
46
+ return nil unless ::SemanticLogger.respond_to?(:add_appender)
47
+
48
+ appender = SemanticLoggerAppender.new(client: client)
49
+ ::SemanticLogger.add_appender(appender: appender)
50
+ appender
51
+ end
52
+
53
+ class SemanticLoggerAppender
54
+ def initialize(client: DebugBundle.client)
55
+ @client = client
56
+ end
57
+
58
+ def log(log)
59
+ was_capturing = Thread.current[RECURSION_GUARD_KEY]
60
+ return if was_capturing
61
+
62
+ Thread.current[RECURSION_GUARD_KEY] = true
63
+ @client.capture_log(
64
+ log.message,
65
+ level: log.level || :info,
66
+ context: {
67
+ logger_name: log.name,
68
+ payload: log.payload,
69
+ tags: log.tags
70
+ }
71
+ )
72
+ ensure
73
+ Thread.current[RECURSION_GUARD_KEY] = was_capturing
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module DebugBundle
6
+ module Rack
7
+ class Middleware
8
+ def initialize(app, client: DebugBundle.client)
9
+ @app = app
10
+ @client = client
11
+ end
12
+
13
+ def call(env)
14
+ request_context = build_request_context(env)
15
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
+ @client.with_request_trigger(request_context.fetch(:request)) do
17
+ status, headers, body = @app.call(env)
18
+ duration_ms = elapsed_ms(started_at)
19
+
20
+ @client.capture_request(
21
+ request_context.fetch(:request),
22
+ { status_code: status, headers: headers.to_h },
23
+ context: request_context.merge(duration_ms: duration_ms, route_template: route_template(env))
24
+ )
25
+
26
+ [status, headers, body]
27
+ end
28
+ rescue StandardError => e
29
+ duration_ms = elapsed_ms(started_at)
30
+ @client.capture_exception(
31
+ e,
32
+ context: request_context.merge(
33
+ response: { status_code: 500, headers: {} },
34
+ duration_ms: duration_ms,
35
+ route_template: route_template(env)
36
+ ),
37
+ handled: false
38
+ )
39
+ raise
40
+ end
41
+
42
+ private
43
+
44
+ def build_request_context(env)
45
+ {
46
+ request: {
47
+ method: env['REQUEST_METHOD'],
48
+ path: env['PATH_INFO'],
49
+ query: parse_query(env['QUERY_STRING']),
50
+ headers: request_headers(env),
51
+ body: {}
52
+ },
53
+ request_id: env['action_dispatch.request_id'] || env['HTTP_X_REQUEST_ID'],
54
+ trace_id: env['HTTP_X_DEBUGBUNDLE_TRACE_ID']
55
+ }.merge(rails_metadata(env))
56
+ end
57
+
58
+ def request_headers(env)
59
+ env.each_with_object({}) do |(key, value), headers|
60
+ next unless key.start_with?('HTTP_') || %w[CONTENT_TYPE ACCEPT].include?(key)
61
+
62
+ normalized_key = key.sub(/^HTTP_/, '').downcase.tr('_', '-')
63
+ headers[normalized_key] = value
64
+ end
65
+ end
66
+
67
+ def parse_query(query_string)
68
+ CGI.parse(query_string.to_s).transform_values do |values|
69
+ values.length == 1 ? values.first : values
70
+ end
71
+ end
72
+
73
+ def route_template(env)
74
+ env['debugbundle.route_template'] || env['action_dispatch.route_uri_pattern']
75
+ end
76
+
77
+ def rails_metadata(env)
78
+ parameters = env['action_dispatch.request.parameters'] || {}
79
+ metadata = {}
80
+ if env.key?('action_dispatch.request_id') || env.key?('action_dispatch.route_uri_pattern')
81
+ metadata[:framework] = 'rails'
82
+ end
83
+ metadata[:route_template] = route_template(env) if route_template(env)
84
+ metadata[:controller] = parameters['controller'] if parameters['controller']
85
+ metadata[:action] = parameters['action'] if parameters['action']
86
+ metadata
87
+ end
88
+
89
+ def elapsed_ms(started_at)
90
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module DebugBundle
6
+ module Rack
7
+ class RelayMiddleware
8
+ def initialize(app = nil, handler: DebugBundle::Relay::Handler.new)
9
+ @app = app
10
+ @handler = handler
11
+ end
12
+
13
+ def call(env)
14
+ response = @handler.handle(
15
+ method: env['REQUEST_METHOD'],
16
+ headers: relay_headers(env),
17
+ body: env.fetch('rack.input', StringIO.new).read,
18
+ ip_address: env['REMOTE_ADDR']
19
+ )
20
+
21
+ body = response.body ? JSON.generate(response.body) : ''
22
+ [response.status, { 'Content-Type' => 'application/json' }, [body]]
23
+ end
24
+
25
+ private
26
+
27
+ def relay_headers(env)
28
+ env.each_with_object({}) do |(key, value), headers|
29
+ next unless key.start_with?('HTTP_') || %w[CONTENT_TYPE HOST REFERER].include?(key)
30
+
31
+ normalized_key = key.sub(/^HTTP_/, '').downcase.tr('_', '-')
32
+ headers[normalized_key] = value
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(Rails::Railtie)
4
+ module DebugBundle
5
+ module Rails
6
+ class Railtie < ::Rails::Railtie
7
+ config.debugbundle = ActiveSupport::OrderedOptions.new
8
+
9
+ initializer 'debugbundle.configure' do |app|
10
+ options = app.config.debugbundle
11
+ next if options.respond_to?(:enabled) && options.enabled == false
12
+
13
+ client = DebugBundle.init(
14
+ project_token: options.project_token || ENV.fetch('DEBUGBUNDLE_TOKEN', nil),
15
+ service: options.service || app.class.module_parent_name.underscore.tr('_', '-'),
16
+ environment: options.environment || ::Rails.env,
17
+ project_mode: options.project_mode || :connected,
18
+ local_events_dir: options.local_events_dir || DebugBundle::Config::DEFAULT_LOCAL_EVENTS_DIR,
19
+ endpoint: options.endpoint || DebugBundle::Config::DEFAULT_ENDPOINT,
20
+ redact_fields: Array(options.redact_fields) + Array(app.config.filter_parameters)
21
+ )
22
+
23
+ app.middleware.use(DebugBundle::Rack::Middleware, client: client)
24
+ if DebugBundle::Rails.relay_route_enabled?(app)
25
+ app.routes.append do
26
+ post DebugBundle::Rails.relay_path(app), to: DebugBundle::Rails::RelayEndpoint.new(app: app)
27
+ end
28
+ end
29
+ DebugBundle.capture_logger(::Rails.logger) if ::Rails.logger
30
+ DebugBundle.capture_semantic_logger if defined?(::SemanticLogger)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugBundle
4
+ module Rails
5
+ class RelayEndpoint
6
+ def initialize(app:, handler: nil)
7
+ @app = app
8
+ @handler = handler
9
+ end
10
+
11
+ def call(env)
12
+ middleware.call(env)
13
+ end
14
+
15
+ private
16
+
17
+ def middleware
18
+ @middleware ||= DebugBundle::Rack::RelayMiddleware.new(nil, handler: @handler || DebugBundle::Rails.build_relay_handler(@app))
19
+ end
20
+ end
21
+
22
+ def self.build_relay_handler(app)
23
+ options = relay_options(app)
24
+ return options.relay_handler if relay_option_present?(options, :relay_handler)
25
+
26
+ Relay::Handler.new(
27
+ project_mode: relay_option(options, :project_mode, :connected),
28
+ project_token: relay_option(options, :project_token, ENV.fetch('DEBUGBUNDLE_TOKEN', nil)),
29
+ endpoint: relay_option(options, :endpoint, DebugBundle::Config::DEFAULT_ENDPOINT),
30
+ local_events_dir: relay_option(options, :local_events_dir, DebugBundle::Config::DEFAULT_LOCAL_EVENTS_DIR),
31
+ spool_dir: relay_option(options, :spool_dir, DebugBundle::Config::DEFAULT_SPOOL_DIR),
32
+ durable_write: relay_durable_write(options),
33
+ service: relay_service_name(app, options),
34
+ environment: relay_environment_name(options),
35
+ allowed_origins: relay_option(options, :relay_allowed_origins, nil),
36
+ max_body_bytes: relay_option(options, :relay_max_body_bytes, Relay::DEFAULT_MAX_BODY_BYTES),
37
+ rate_limit_per_minute: relay_rate_limit(options),
38
+ rate_limit_store: relay_option(options, :relay_rate_limit_store, nil),
39
+ forward_transport: relay_option(options, :relay_forward_transport, nil)
40
+ )
41
+ end
42
+
43
+ def self.relay_route_enabled?(app)
44
+ options = relay_options(app)
45
+ relay_option(options, :relay_enabled, true) != false
46
+ end
47
+
48
+ def self.relay_path(app)
49
+ options = relay_options(app)
50
+ path = relay_option(options, :relay_path, '').to_s
51
+ path.empty? ? '/debugbundle/browser' : path
52
+ end
53
+
54
+ def self.relay_options(app)
55
+ return nil unless app.respond_to?(:config)
56
+ return nil unless app.config.respond_to?(:debugbundle)
57
+
58
+ app.config.debugbundle
59
+ end
60
+
61
+ def self.relay_durable_write(options)
62
+ relay_option(options, :relay_durable_write, true) != false
63
+ end
64
+
65
+ def self.relay_rate_limit(options)
66
+ relay_option(options, :relay_rate_limit_per_minute, Relay::DEFAULT_RATE_LIMIT_PER_MINUTE)
67
+ end
68
+
69
+ def self.relay_service_name(app, options)
70
+ service_name = relay_option(options, :service, nil)
71
+ return service_name if service_name && !service_name.to_s.empty?
72
+
73
+ if app.class.respond_to?(:module_parent_name)
74
+ app_name = app.class.module_parent_name.to_s
75
+ return app_name.underscore.tr('_', '-') unless app_name.empty?
76
+ end
77
+
78
+ Client::DEFAULT_SERVICE_NAME
79
+ end
80
+
81
+ def self.relay_environment_name(options)
82
+ environment_name = relay_option(options, :environment, nil)
83
+ return environment_name if environment_name && !environment_name.to_s.empty?
84
+ return ::Rails.env if defined?(::Rails)
85
+
86
+ Client::DEFAULT_ENVIRONMENT
87
+ end
88
+
89
+ def self.relay_option(options, name, default)
90
+ return default if options.nil? || !options.respond_to?(name)
91
+
92
+ value = options.public_send(name)
93
+ value.nil? ? default : value
94
+ end
95
+
96
+ def self.relay_option_present?(options, name)
97
+ !options.nil? && options.respond_to?(name) && !options.public_send(name).nil?
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'rails/railtie'
5
+ rescue LoadError
6
+ nil
7
+ end
8
+
9
+ require_relative 'rails/relay_endpoint'
10
+ require_relative 'rails/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module DebugBundle
6
+ module Redaction
7
+ REDACTED_VALUE = '[REDACTED]'
8
+ CIRCULAR_VALUE = '[Circular]'
9
+ TRUNCATED_DEPTH_VALUE = '[Truncated:depth]'
10
+ TRUNCATED_COLLECTION_VALUE = '[Truncated:collection]'
11
+ DEFAULT_MAX_DEPTH = 5
12
+ DEFAULT_MAX_STRING_LENGTH = 1_024
13
+ DEFAULT_MAX_ARRAY_LENGTH = 50
14
+ DEFAULT_MAX_HASH_KEYS = 50
15
+ DEFAULT_SENSITIVE_FIELDS = %w[
16
+ password
17
+ secret
18
+ token
19
+ api_key
20
+ apikey
21
+ access_token
22
+ refresh_token
23
+ private_key
24
+ passwd
25
+ card_number
26
+ credit_card
27
+ cvv
28
+ cvc
29
+ pin
30
+ expiry
31
+ phone
32
+ bearer
33
+ session_id
34
+ otp
35
+ verification_code
36
+ authorization
37
+ cookie
38
+ ssn
39
+ ].freeze
40
+
41
+ class Redactor
42
+ def initialize(
43
+ sensitive_fields: DEFAULT_SENSITIVE_FIELDS,
44
+ max_depth: DEFAULT_MAX_DEPTH,
45
+ max_string_length: DEFAULT_MAX_STRING_LENGTH,
46
+ max_array_length: DEFAULT_MAX_ARRAY_LENGTH,
47
+ max_hash_keys: DEFAULT_MAX_HASH_KEYS
48
+ )
49
+ @sensitive_terms = sensitive_fields.map { |field| compile_sensitive_term(field) }
50
+ @max_depth = max_depth
51
+ @max_string_length = max_string_length
52
+ @max_array_length = max_array_length
53
+ @max_hash_keys = max_hash_keys
54
+ end
55
+
56
+ def redact_value(value)
57
+ sanitize(value, depth: 0, seen: {}.compare_by_identity)
58
+ end
59
+
60
+ private
61
+
62
+ def sanitize(value, depth:, seen:)
63
+ return TRUNCATED_DEPTH_VALUE if depth >= @max_depth
64
+
65
+ case value
66
+ when NilClass, TrueClass, FalseClass, Numeric
67
+ value
68
+ when String
69
+ truncate_string(value)
70
+ when Symbol
71
+ truncate_string(value.to_s)
72
+ when Time, DateTime
73
+ value.iso8601
74
+ when Array
75
+ return CIRCULAR_VALUE if circular?(value, seen)
76
+
77
+ mark_seen(value, seen)
78
+ value.first(@max_array_length).map { |item| sanitize(item, depth: depth + 1, seen: seen) }.tap do |items|
79
+ items << TRUNCATED_COLLECTION_VALUE if value.length > @max_array_length
80
+ end
81
+ when Hash
82
+ return CIRCULAR_VALUE if circular?(value, seen)
83
+
84
+ mark_seen(value, seen)
85
+ sanitize_hash(value, depth: depth + 1, seen: seen)
86
+ else
87
+ if value.respond_to?(:to_h)
88
+ sanitize(value.to_h, depth: depth + 1, seen: seen)
89
+ elsif value.respond_to?(:to_hash)
90
+ sanitize(value.to_hash, depth: depth + 1, seen: seen)
91
+ else
92
+ truncate_string(value.to_s)
93
+ end
94
+ end
95
+ end
96
+
97
+ def sanitize_hash(value, depth:, seen:)
98
+ value.each_with_index.with_object({}) do |((key, nested_value), index), result|
99
+ break result if index >= @max_hash_keys
100
+
101
+ key_string = key.to_s
102
+ result[key_string] =
103
+ sensitive_key?(key_string) ? REDACTED_VALUE : sanitize(nested_value, depth: depth, seen: seen)
104
+ end.tap do |result|
105
+ result['__truncated__'] = TRUNCATED_COLLECTION_VALUE if value.size > @max_hash_keys
106
+ end
107
+ end
108
+
109
+ def truncate_string(value)
110
+ return value if value.length <= @max_string_length
111
+
112
+ value[0, @max_string_length] + TRUNCATED_COLLECTION_VALUE
113
+ end
114
+
115
+ def sensitive_key?(key)
116
+ segments, joined = normalize_key(key)
117
+
118
+ @sensitive_terms.any? do |term|
119
+ joined == term[:joined] || contains_contiguous_segments?(segments, term[:segments])
120
+ end
121
+ end
122
+
123
+ def contains_contiguous_segments?(segments, target_segments)
124
+ return false if target_segments.empty? || segments.empty? || target_segments.length > segments.length
125
+
126
+ segments.each_index.any? do |index|
127
+ segments[index, target_segments.length] == target_segments
128
+ end
129
+ end
130
+
131
+ def normalize_key(key)
132
+ underscored = key.to_s.gsub(/([a-z\d])([A-Z])/, '\\1_\\2').downcase
133
+ segments = underscored.split(/[^a-z0-9]+/).reject(&:empty?)
134
+ [segments, segments.join]
135
+ end
136
+
137
+ def compile_sensitive_term(term)
138
+ segments, joined = normalize_key(term)
139
+ { segments: segments, joined: joined }
140
+ end
141
+
142
+ def circular?(value, seen)
143
+ seen.key?(value)
144
+ end
145
+
146
+ def mark_seen(value, seen)
147
+ seen[value] = true
148
+ end
149
+ end
150
+ end
151
+ end