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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +26 -31
- data/lib/json_logging/formatter.rb +13 -9
- data/lib/json_logging/formatter_with_tags.rb +38 -20
- data/lib/json_logging/helpers.rb +26 -1
- data/lib/json_logging/json_logger_extension.rb +47 -69
- data/lib/json_logging/line_encoder.rb +245 -0
- data/lib/json_logging/payload_builder.rb +57 -21
- data/lib/json_logging/sanitizer.rb +111 -10
- data/lib/json_logging/severity.rb +18 -0
- data/lib/json_logging/structured_hash_json_encoder.rb +111 -0
- data/lib/json_logging/structured_hash_sanitizer.rb +206 -0
- data/lib/json_logging/version.rb +1 -1
- data/lib/json_logging.rb +84 -11
- data/sig/json_logging.rbs +3 -0
- metadata +146 -18
|
@@ -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[
|
|
14
|
+
payload["message"] = parsed
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
payload[
|
|
16
|
-
payload[
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
146
|
+
key_string = key.to_s
|
|
88
147
|
|
|
89
148
|
# Skip sensitive keys
|
|
90
|
-
if SENSITIVE_KEY_PATTERNS.match?(
|
|
91
|
-
result[
|
|
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[
|
|
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
|