activesupport-json_logging 1.2.0 → 1.2.2

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,245 @@
1
+ require "active_support/core_ext/hash/keys"
2
+
3
+ require_relative "message_parser"
4
+ require_relative "severity"
5
+ require_relative "payload_builder"
6
+ require_relative "structured_hash_json_encoder"
7
+
8
+ module JsonLogging
9
+ module LineEncoder
10
+ module_function
11
+
12
+ def build_line(msg:, severity:, timestamp:, tags:, additional_context:, additional_context_sanitized: false, sanitize_tags: true)
13
+ if simple_string_line?(msg, tags, additional_context)
14
+ return simple_string_json_line(
15
+ message: msg,
16
+ severity: Severity.name_for(severity),
17
+ timestamp: timestamp
18
+ )
19
+ end
20
+
21
+ if tagged_simple_string_line?(msg, tags, additional_context)
22
+ return tagged_simple_string_json_line(
23
+ message: msg,
24
+ severity: Severity.name_for(severity),
25
+ timestamp: timestamp,
26
+ tags: tags,
27
+ sanitize_tags: sanitize_tags
28
+ )
29
+ end
30
+
31
+ if contextual_simple_string_line?(msg, tags, additional_context, additional_context_sanitized: additional_context_sanitized)
32
+ return contextual_simple_string_json_line(
33
+ message: msg,
34
+ severity: Severity.name_for(severity),
35
+ timestamp: timestamp,
36
+ additional_context: additional_context
37
+ )
38
+ end
39
+
40
+ if standalone_hash_line?(msg, tags, additional_context)
41
+ return standalone_hash_json_line(
42
+ message: msg,
43
+ severity: Severity.name_for(severity),
44
+ timestamp: timestamp
45
+ )
46
+ end
47
+
48
+ if tagged_hash_line?(msg, tags, additional_context)
49
+ return tagged_hash_json_line(
50
+ message: msg,
51
+ severity: Severity.name_for(severity),
52
+ timestamp: timestamp,
53
+ tags: tags,
54
+ sanitize_tags: sanitize_tags
55
+ )
56
+ end
57
+
58
+ if contextual_hash_line?(msg, tags, additional_context, additional_context_sanitized: additional_context_sanitized)
59
+ return contextual_hash_json_line(
60
+ message: msg,
61
+ severity: Severity.name_for(severity),
62
+ timestamp: timestamp,
63
+ additional_context: additional_context
64
+ )
65
+ end
66
+
67
+ sev = Severity.name_for(severity)
68
+ payload = PayloadBuilder.build_base_payload(msg, severity: sev, timestamp: timestamp)
69
+ unless tags.empty? && PayloadBuilder.empty_additional_context?(additional_context)
70
+ payload = PayloadBuilder.merge_context(
71
+ payload,
72
+ additional_context: additional_context,
73
+ tags: tags,
74
+ additional_context_sanitized: additional_context_sanitized,
75
+ sanitize_tags: sanitize_tags
76
+ )
77
+ end
78
+ to_json_line(payload)
79
+ end
80
+
81
+ def to_json_line(payload_hash)
82
+ compacted = payload_hash.compact
83
+ json_payload = string_keyed_structure?(compacted) ? compacted : compacted.deep_stringify_keys
84
+ "#{json_payload.to_json}\n"
85
+ end
86
+
87
+ def string_keyed_structure?(object)
88
+ case object
89
+ when Hash
90
+ object.keys.all?(String) && object.values.all? { |value| string_keyed_structure?(value) }
91
+ when Array
92
+ object.all? { |value| string_keyed_structure?(value) }
93
+ else
94
+ true
95
+ end
96
+ end
97
+
98
+ def simple_string_line?(message, tags, additional_context)
99
+ message.is_a?(String) &&
100
+ !MessageParser.json_string?(message) &&
101
+ tags.respond_to?(:empty?) && tags.empty? &&
102
+ additional_context.respond_to?(:empty?) && additional_context.empty?
103
+ end
104
+
105
+ def simple_string_json_line(message:, severity:, timestamp:)
106
+ sanitized_message = Sanitizer.sanitize_string(message)
107
+ "#{JSON.generate("message" => sanitized_message, "severity" => severity, "timestamp" => timestamp)}\n"
108
+ end
109
+
110
+ def tagged_simple_string_line?(message, tags, additional_context)
111
+ message.is_a?(String) &&
112
+ !MessageParser.json_string?(message) &&
113
+ tags.respond_to?(:empty?) && !tags.empty? &&
114
+ additional_context.respond_to?(:empty?) && additional_context.empty?
115
+ end
116
+
117
+ def tagged_simple_string_json_line(message:, severity:, timestamp:, tags:, sanitize_tags:)
118
+ sanitized_message = Sanitizer.sanitize_string(message)
119
+ prepared_tags = sanitize_tags ? Sanitizer.prepare_tags(tags) : tags
120
+ "#{JSON.generate("message" => sanitized_message, "severity" => severity, "timestamp" => timestamp, "tags" => prepared_tags.uniq)}\n"
121
+ end
122
+
123
+ def contextual_simple_string_line?(message, tags, additional_context, additional_context_sanitized:)
124
+ message.is_a?(String) &&
125
+ !MessageParser.json_string?(message) &&
126
+ tags.respond_to?(:empty?) && tags.empty? &&
127
+ !PayloadBuilder.empty_additional_context?(additional_context) &&
128
+ additional_context_sanitized
129
+ end
130
+
131
+ def contextual_simple_string_json_line(message:, severity:, timestamp:, additional_context:)
132
+ sanitized_message = Sanitizer.sanitize_string(message)
133
+ payload = {
134
+ "message" => sanitized_message,
135
+ "severity" => severity,
136
+ "timestamp" => timestamp
137
+ }
138
+ context = PayloadBuilder.context_payload_portion(additional_context)
139
+ payload["context"] = context unless context.empty?
140
+ "#{JSON.generate(payload)}\n"
141
+ end
142
+
143
+ def standalone_hash_line?(message, tags, additional_context)
144
+ hash_message?(message) &&
145
+ tags.respond_to?(:empty?) && tags.empty? &&
146
+ additional_context.respond_to?(:empty?) && additional_context.empty?
147
+ end
148
+
149
+ def hash_message?(message)
150
+ message.is_a?(Hash) || (message.respond_to?(:to_hash) && !message.is_a?(String))
151
+ end
152
+
153
+ def standalone_hash_json_line(message:, severity:, timestamp:)
154
+ hash = message.is_a?(Hash) ? message : message.to_hash
155
+ line = StructuredHashJsonEncoder.try_encode_line(hash, severity: severity, timestamp: timestamp)
156
+ return line if line
157
+
158
+ payload = Sanitizer.sanitize_hash(hash)
159
+ payload["severity"] = severity
160
+ payload["timestamp"] = timestamp
161
+ "#{JSON.generate(payload)}\n"
162
+ end
163
+
164
+ def tagged_hash_line?(message, tags, additional_context)
165
+ hash_message?(message) &&
166
+ tags.respond_to?(:empty?) && !tags.empty? &&
167
+ additional_context.respond_to?(:empty?) && additional_context.empty?
168
+ end
169
+
170
+ def tagged_hash_json_line(message:, severity:, timestamp:, tags:, sanitize_tags:)
171
+ hash = message.is_a?(Hash) ? message : message.to_hash
172
+ prepared_tags = sanitize_tags ? Sanitizer.prepare_tags(tags) : tags
173
+ merged_tags = (Array(hash[:tags] || hash["tags"]) + prepared_tags).uniq
174
+ field_overrides = {"tags" => merged_tags}
175
+
176
+ line = StructuredHashJsonEncoder.try_encode_line(
177
+ hash,
178
+ severity: severity,
179
+ timestamp: timestamp,
180
+ field_overrides: field_overrides
181
+ )
182
+ return line if line
183
+
184
+ payload = Sanitizer.sanitize_hash(hash)
185
+ payload["severity"] = severity
186
+ payload["timestamp"] = timestamp
187
+ payload["tags"] = merged_tags
188
+ "#{JSON.generate(payload)}\n"
189
+ end
190
+
191
+ def contextual_hash_line?(message, tags, additional_context, additional_context_sanitized:)
192
+ hash_message?(message) &&
193
+ tags.respond_to?(:empty?) && tags.empty? &&
194
+ !PayloadBuilder.empty_additional_context?(additional_context) &&
195
+ additional_context_sanitized
196
+ end
197
+
198
+ def contextual_hash_json_line(message:, severity:, timestamp:, additional_context:)
199
+ hash = message.is_a?(Hash) ? message : message.to_hash
200
+ context = PayloadBuilder.context_payload_portion(additional_context)
201
+ merged_context = {}
202
+ unless context.empty?
203
+ existing_context = hash[:context] || hash["context"]
204
+ merged_context = existing_context.is_a?(Hash) ? existing_context.merge(context) : context
205
+ end
206
+ field_overrides = merged_context.empty? ? {} : {"context" => merged_context}
207
+
208
+ line = StructuredHashJsonEncoder.try_encode_line(
209
+ hash,
210
+ severity: severity,
211
+ timestamp: timestamp,
212
+ field_overrides: field_overrides
213
+ )
214
+ return line if line
215
+
216
+ payload = Sanitizer.sanitize_hash(hash)
217
+ payload["severity"] = severity
218
+ payload["timestamp"] = timestamp
219
+ unless merged_context.empty?
220
+ existing_context = payload["context"].is_a?(Hash) ? payload["context"] : {}
221
+ payload["context"] = existing_context.merge(merged_context)
222
+ end
223
+ "#{JSON.generate(payload)}\n"
224
+ end
225
+
226
+ def build_sanitized_hash_payload(message:, severity:, timestamp:)
227
+ hash = message.is_a?(Hash) ? message : message.to_hash
228
+ payload = Sanitizer.sanitize_hash(hash)
229
+ payload["severity"] = severity
230
+ payload["timestamp"] = timestamp
231
+ payload
232
+ end
233
+
234
+ def deep_stringify_structure(obj)
235
+ case obj
236
+ when Hash
237
+ obj.deep_stringify_keys
238
+ when Array
239
+ obj.map { |v| deep_stringify_structure(v) }
240
+ else
241
+ obj
242
+ end
243
+ end
244
+ end
245
+ end
@@ -1,5 +1,7 @@
1
1
  module JsonLogging
2
2
  module PayloadBuilder
3
+ SYSTEM_CONTROLLED_KEYS = [:tags, "tags", :severity, "severity", :timestamp, "timestamp", :message, "message", :context, "context"].freeze
4
+
3
5
  module_function
4
6
 
5
7
  def build_base_payload(msg, severity: nil, timestamp: nil)
@@ -9,46 +11,80 @@ module JsonLogging
9
11
  if parsed.is_a?(Hash)
10
12
  payload.merge!(parsed)
11
13
  else
12
- payload[:message] = parsed
14
+ payload["message"] = parsed
13
15
  end
14
16
 
15
- payload[:severity] = severity if severity
16
- payload[:timestamp] = timestamp if timestamp
17
+ payload["severity"] = severity if severity
18
+ payload["timestamp"] = timestamp if timestamp
17
19
 
18
20
  payload
19
21
  end
20
22
 
21
- def merge_context(payload, additional_context:, tags: [])
22
- existing_context = payload[:context].is_a?(Hash) ? payload[:context] : {}
23
+ def merge_context(payload, additional_context:, tags: [], additional_context_sanitized: false, sanitize_tags: true)
24
+ return payload if merge_context_skippable?(payload, additional_context: additional_context, tags: tags)
25
+ return merge_tags_only(payload, tags: tags, sanitize_tags: sanitize_tags) if tags_only_merge?(payload, additional_context: additional_context, tags: tags)
26
+
27
+ existing_context = payload["context"].is_a?(Hash) ? payload["context"] : {}
23
28
 
24
- # Sanitize additional context before merging (filters sensitive keys, limits size, sanitizes strings only)
25
- # Only sanitize if it's a hash - preserve other types
26
- sanitized_context = if additional_context.is_a?(Hash) && !additional_context.empty?
29
+ sanitized_context = if additional_context_sanitized
30
+ additional_context.is_a?(Hash) ? additional_context : {}
31
+ elsif additional_context.is_a?(Hash) && !additional_context.empty?
27
32
  Sanitizer.sanitize_hash(additional_context)
28
33
  else
29
34
  additional_context || {}
30
35
  end
31
36
 
32
- # Filter out system-controlled keys from user context
33
- # These keys should never be set by user context as they're controlled by the logger
34
- system_controlled_keys = [:tags, "tags", :severity, "severity", :timestamp, "timestamp", :message, "message", :context, "context"]
35
- user_context_filtered = sanitized_context.except(*system_controlled_keys)
37
+ user_context_filtered = sanitized_context.except(*SYSTEM_CONTROLLED_KEYS)
36
38
 
37
- # Also prevent overriding any existing payload keys (additional safety)
38
- deduped_additional = user_context_filtered.reject { |k, _| payload.key?(k) }
39
+ deduped_additional = user_context_filtered.reject { |key, _| payload.key?(key) || payload.key?(key.to_s) }
39
40
  merged_context = existing_context.merge(deduped_additional)
40
41
 
41
- # Put tags at root level, separate from context
42
- # Merge with existing tags from payload (e.g., when logging a hash with tags: [...] at root)
43
42
  unless tags.empty?
44
- existing_tags = Array(payload[:tags] || payload["tags"])
45
- # Sanitize tag strings (remove control chars, truncate)
46
- sanitized_tags = tags.map { |tag| Sanitizer.sanitize_string(tag.to_s) }
47
- payload[:tags] = (existing_tags + sanitized_tags).uniq
43
+ existing_tags = Array(payload["tags"])
44
+ prepared_tags = sanitize_tags ? tags.map { |tag| Sanitizer.sanitize_string(tag.to_s) } : tags
45
+ payload["tags"] = (existing_tags + prepared_tags).uniq
48
46
  end
49
47
 
50
- payload[:context] = merged_context unless merged_context.empty?
48
+ unless merged_context.empty?
49
+ payload["context"] = merged_context.transform_keys(&:to_s)
50
+ end
51
+ payload
52
+ end
53
+
54
+ def empty_additional_context?(additional_context)
55
+ additional_context.nil? || (additional_context.respond_to?(:empty?) && additional_context.empty?)
56
+ end
57
+
58
+ def merge_context_skippable?(payload, additional_context:, tags:)
59
+ tags.empty? &&
60
+ empty_additional_context?(additional_context) &&
61
+ payload_context_empty?(payload)
62
+ end
63
+
64
+ def payload_context_empty?(payload)
65
+ existing_context = payload["context"]
66
+ existing_context.nil? || (existing_context.respond_to?(:empty?) && existing_context.empty?)
67
+ end
68
+
69
+ def tags_only_merge?(payload, additional_context:, tags:)
70
+ !tags.empty? &&
71
+ empty_additional_context?(additional_context) &&
72
+ payload_context_empty?(payload)
73
+ end
74
+
75
+ def merge_tags_only(payload, tags:, sanitize_tags:)
76
+ existing_tags = Array(payload["tags"])
77
+ prepared_tags = sanitize_tags ? tags.map { |tag| Sanitizer.sanitize_string(tag.to_s) } : tags
78
+ payload["tags"] = (existing_tags + prepared_tags).uniq
51
79
  payload
52
80
  end
81
+
82
+ def context_payload_portion(additional_context)
83
+ return {} unless additional_context.is_a?(Hash)
84
+
85
+ additional_context.reject do |key, _|
86
+ SYSTEM_CONTROLLED_KEYS.include?(key) || SYSTEM_CONTROLLED_KEYS.include?(key.to_s)
87
+ end
88
+ end
53
89
  end
54
90
  end
@@ -1,3 +1,5 @@
1
+ require_relative "structured_hash_sanitizer"
2
+
1
3
  module JsonLogging
2
4
  module Sanitizer
3
5
  # Control characters that should be escaped or removed from log messages
@@ -18,6 +20,10 @@ module JsonLogging
18
20
  # Common sensitive key patterns (case insensitive) - fallback when Rails ParameterFilter not available
19
21
  SENSITIVE_KEY_PATTERNS = /\b(password|passwd|pwd|secret|token|api_key|apikey|access_token|auth_token|private_key|credential)\b/i
20
22
 
23
+ @parameter_filter = nil
24
+ @parameter_filter_config = nil
25
+ @parameter_filter_requires_full_tree_walk = false
26
+
21
27
  module_function
22
28
 
23
29
  # Get Rails ParameterFilter if available, nil otherwise
@@ -28,14 +34,49 @@ module JsonLogging
28
34
  filter_params = Rails.application.config.filter_parameters
29
35
  return nil if filter_params.empty?
30
36
 
31
- ActiveSupport::ParameterFilter.new(filter_params)
37
+ if @parameter_filter && @parameter_filter_config == filter_params
38
+ return @parameter_filter
39
+ end
40
+
41
+ @parameter_filter_config = filter_params
42
+ @parameter_filter_requires_full_tree_walk = parameter_filter_requires_full_tree_walk?(filter_params)
43
+ @parameter_filter = ActiveSupport::ParameterFilter.new(filter_params)
32
44
  rescue
33
45
  nil
34
46
  end
35
47
 
48
+ def rails_parameter_filter_requires_full_tree_walk?
49
+ rails_parameter_filter
50
+ @parameter_filter_requires_full_tree_walk
51
+ end
52
+
53
+ def parameter_filter_requires_full_tree_walk?(filter_params)
54
+ filter_params.any? do |filter|
55
+ case filter
56
+ when Proc
57
+ true
58
+ when Regexp
59
+ filter.to_s.include?("\\.")
60
+ else
61
+ filter.to_s.include?(".")
62
+ end
63
+ end
64
+ end
65
+
66
+ def reset_rails_parameter_filter_cache!
67
+ @parameter_filter = nil
68
+ @parameter_filter_config = nil
69
+ @parameter_filter_requires_full_tree_walk = false
70
+ end
71
+
72
+ def prepare_tags(tags)
73
+ tags.flatten.compact.map(&:to_s).reject(&:empty?).map { |tag| sanitize_string(tag) }
74
+ end
75
+
36
76
  # Sanitize a string by removing/escaping control characters and truncating
37
77
  def sanitize_string(str)
38
78
  return str unless str.is_a?(String)
79
+ return str if str.length <= MAX_STRING_LENGTH && !str.match?(CONTROL_CHARS)
39
80
 
40
81
  # Remove or replace control characters
41
82
  sanitized = str.gsub(CONTROL_CHARS, "")
@@ -58,15 +99,33 @@ module JsonLogging
58
99
  # Prevent excessive nesting
59
100
  return {"error" => "max_depth_exceeded"} if depth > MAX_DEPTH
60
101
 
61
- # Limit hash size first
62
- limited_hash = if hash.size > MAX_CONTEXT_SIZE
102
+ limited_hash = limited_hash_for_sanitization(hash)
103
+ fast_path_result = fast_path_sanitized_hash(limited_hash, depth: depth)
104
+ return fast_path_result if fast_path_result
105
+
106
+ sanitize_hash_with_filtering(limited_hash, depth: depth)
107
+ rescue
108
+ {"sanitization_error" => true}
109
+ end
110
+
111
+ def limited_hash_for_sanitization(hash)
112
+ if hash.size > MAX_CONTEXT_SIZE
63
113
  truncated = hash.first(MAX_CONTEXT_SIZE).to_h
64
114
  truncated["_truncated"] = true
65
115
  truncated
66
116
  else
67
117
  hash
68
118
  end
119
+ end
69
120
 
121
+ def fast_path_sanitized_hash(hash, depth: 0)
122
+ return sanitize_primitive_hash(hash) if primitive_log_hash?(hash)
123
+ return StructuredHash.sanitize(hash, depth: depth) if StructuredHash.structured_log_hash?(hash)
124
+
125
+ nil
126
+ end
127
+
128
+ def sanitize_hash_with_filtering(limited_hash, depth: 0)
70
129
  # Use Rails ParameterFilter if available (handles encrypted attributes automatically)
71
130
  filter = rails_parameter_filter
72
131
  if filter
@@ -78,25 +137,23 @@ module JsonLogging
78
137
 
79
138
  # Then sanitize values (strings, control chars, etc.) preserving filtered structure
80
139
  filtered.each_with_object({}) do |(key, value), result|
81
- result[key] = sanitize_value(value, depth: depth + 1)
140
+ result[key.to_s] = sanitize_value(value, depth: depth + 1)
82
141
  end
83
142
 
84
143
  else
85
144
  # Fallback: use pattern matching for sensitive keys
86
145
  limited_hash.each_with_object({}) do |(key, value), result|
87
- key_str = key.to_s
146
+ key_string = key.to_s
88
147
 
89
148
  # Skip sensitive keys
90
- if SENSITIVE_KEY_PATTERNS.match?(key_str)
91
- result[key_str.gsub(/(?<!^)(?=[A-Z])/, "_").downcase + "_filtered"] = "[FILTERED]"
149
+ if SENSITIVE_KEY_PATTERNS.match?(key_string)
150
+ result[sensitive_filtered_key_name(key_string)] = "[FILTERED]"
92
151
  next
93
152
  end
94
153
 
95
- result[key] = sanitize_value(value, depth: depth + 1)
154
+ result[key_string] = sanitize_value(value, depth: depth + 1)
96
155
  end
97
156
  end
98
- rescue
99
- {"sanitization_error" => true}
100
157
  end
101
158
 
102
159
  # Sanitize a value (handles strings, hashes, arrays, etc.)
@@ -154,5 +211,49 @@ module JsonLogging
154
211
  def sensitive_key?(key)
155
212
  SENSITIVE_KEY_PATTERNS.match?(key.to_s)
156
213
  end
214
+
215
+ def primitive_log_hash?(hash)
216
+ hash.all? { |_key, value| primitive_log_value?(value) }
217
+ end
218
+
219
+ def primitive_log_value?(value)
220
+ case value
221
+ when String
222
+ value.length <= MAX_STRING_LENGTH && !value.match?(CONTROL_CHARS)
223
+ when Numeric, TrueClass, FalseClass, NilClass
224
+ true
225
+ else
226
+ false
227
+ end
228
+ end
229
+
230
+ def sanitize_primitive_hash(hash)
231
+ filter = rails_parameter_filter
232
+ if filter
233
+ stringified = stringify_primitive_hash(hash)
234
+ filter.filter(stringified.dup)
235
+ else
236
+ hash.each_with_object({}) do |(key, value), result|
237
+ key_string = key.to_s
238
+
239
+ if SENSITIVE_KEY_PATTERNS.match?(key_string)
240
+ result[sensitive_filtered_key_name(key_string)] = "[FILTERED]"
241
+ next
242
+ end
243
+
244
+ result[key_string] = value
245
+ end
246
+ end
247
+ end
248
+
249
+ def stringify_primitive_hash(hash)
250
+ hash.each_with_object({}) do |(key, value), result|
251
+ result[key.to_s] = value
252
+ end
253
+ end
254
+
255
+ def sensitive_filtered_key_name(key_string)
256
+ key_string.gsub(/(?<!^)(?=[A-Z])/, "_").downcase + "_filtered"
257
+ end
157
258
  end
158
259
  end
@@ -0,0 +1,18 @@
1
+ module JsonLogging
2
+ module Severity
3
+ NAMES = {
4
+ ::Logger::DEBUG => "DEBUG",
5
+ ::Logger::INFO => "INFO",
6
+ ::Logger::WARN => "WARN",
7
+ ::Logger::ERROR => "ERROR",
8
+ ::Logger::FATAL => "FATAL",
9
+ ::Logger::UNKNOWN => "UNKNOWN"
10
+ }.freeze
11
+
12
+ module_function
13
+
14
+ def name_for(severity)
15
+ NAMES[severity] || severity.to_s
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,111 @@
1
+ require_relative "structured_hash_sanitizer"
2
+
3
+ module JsonLogging
4
+ module StructuredHashJsonEncoder
5
+ LEAF_THRESHOLD = 40
6
+
7
+ module_function
8
+
9
+ def eligible?(hash)
10
+ return false if Sanitizer.primitive_log_hash?(hash)
11
+ return false unless Sanitizer::StructuredHash.structured_log_hash?(hash)
12
+ return false unless large_structured_hash?(hash)
13
+
14
+ filter = Sanitizer.rails_parameter_filter
15
+ return true unless filter
16
+
17
+ !Sanitizer.rails_parameter_filter_requires_full_tree_walk?
18
+ end
19
+
20
+ def large_structured_hash?(hash, leaf_threshold: LEAF_THRESHOLD)
21
+ leaf_count = 0
22
+ walker = lambda do |value|
23
+ case value
24
+ when Hash
25
+ value.each_value { |entry| walker.call(entry) }
26
+ when Array
27
+ value.each { |entry| walker.call(entry) }
28
+ else
29
+ leaf_count += 1
30
+ throw(:enough, true) if leaf_count > leaf_threshold
31
+ end
32
+ end
33
+
34
+ catch(:enough) { walker.call(hash) }
35
+ leaf_count > leaf_threshold
36
+ end
37
+
38
+ def try_encode_line(hash, severity:, timestamp:, field_overrides: {})
39
+ tree = prepared_tree(hash, field_overrides: field_overrides)
40
+ return nil unless tree
41
+
42
+ append_json_line(
43
+ tree,
44
+ severity: severity,
45
+ timestamp: timestamp,
46
+ field_overrides: field_overrides
47
+ )
48
+ end
49
+
50
+ def encode_line(hash, severity:, timestamp:, field_overrides: {})
51
+ tree = encode_tree(hash, field_overrides: field_overrides)
52
+ append_json_line(
53
+ tree,
54
+ severity: severity,
55
+ timestamp: timestamp,
56
+ field_overrides: field_overrides
57
+ )
58
+ end
59
+
60
+ def encode_tree(hash, field_overrides: {})
61
+ limited_hash = Sanitizer.limited_hash_for_sanitization(hash)
62
+ filter = Sanitizer.rails_parameter_filter
63
+ omit_keys = override_keys(field_overrides)
64
+ jsonable = Sanitizer::StructuredHash.jsonable_tree(limited_hash, omit_keys: omit_keys)
65
+ filtered_jsonable_tree(jsonable, filter: filter)
66
+ end
67
+
68
+ def prepared_tree(hash, field_overrides: {})
69
+ return nil if Sanitizer.primitive_log_hash?(hash)
70
+
71
+ filter = Sanitizer.rails_parameter_filter
72
+ return nil if filter && Sanitizer.rails_parameter_filter_requires_full_tree_walk?
73
+ return nil unless Sanitizer::StructuredHash.structured_log_hash?(hash)
74
+
75
+ limited_hash = Sanitizer.limited_hash_for_sanitization(hash)
76
+ omit_keys = override_keys(field_overrides)
77
+ jsonable = Sanitizer::StructuredHash.jsonable_tree(
78
+ limited_hash,
79
+ omit_keys: omit_keys,
80
+ count_leaves: true
81
+ )
82
+ return nil if jsonable.leaf_count <= LEAF_THRESHOLD
83
+
84
+ filtered_jsonable_tree(jsonable, filter: filter)
85
+ end
86
+
87
+ def filtered_jsonable_tree(jsonable, filter:)
88
+ return jsonable.tree unless filter
89
+ return jsonable.tree if Sanitizer.rails_parameter_filter_requires_full_tree_walk?
90
+
91
+ tree = if jsonable.owned
92
+ jsonable.tree
93
+ else
94
+ Sanitizer::StructuredHash.stringify_keys_copy(jsonable.tree)
95
+ end
96
+
97
+ filter.filter(tree)
98
+ end
99
+
100
+ def append_json_line(tree, severity:, timestamp:, field_overrides:)
101
+ body = JSON.generate(tree)
102
+ extras = field_overrides.merge("severity" => severity, "timestamp" => timestamp)
103
+ extra_json = extras.map { |key, value| "#{JSON.generate(key.to_s)}:#{JSON.generate(value)}" }.join(",")
104
+ "#{body.chop},#{extra_json}}\n"
105
+ end
106
+
107
+ def override_keys(field_overrides)
108
+ field_overrides.keys.map(&:to_s)
109
+ end
110
+ end
111
+ end