activesupport-json_logging 1.2.1 → 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 +22 -0
- data/lib/json_logging/formatter.rb +13 -9
- data/lib/json_logging/formatter_with_tags.rb +12 -9
- data/lib/json_logging/helpers.rb +16 -1
- data/lib/json_logging/json_logger_extension.rb +45 -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 +45 -26
|
@@ -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
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
module JsonLogging
|
|
2
|
+
module Sanitizer
|
|
3
|
+
module StructuredHash
|
|
4
|
+
JsonableResult = Struct.new(:tree, :owned, :leaf_count, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def structured_log_hash?(hash, seen = nil)
|
|
9
|
+
seen ||= Set.new.compare_by_identity
|
|
10
|
+
return false unless seen.add?(hash)
|
|
11
|
+
|
|
12
|
+
result = hash.all? { |_key, value| structured_log_value?(value, seen) }
|
|
13
|
+
seen.delete(hash)
|
|
14
|
+
result
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def structured_log_value?(value, seen = nil)
|
|
18
|
+
case value
|
|
19
|
+
when String, Numeric, TrueClass, FalseClass, NilClass
|
|
20
|
+
Sanitizer.primitive_log_value?(value)
|
|
21
|
+
when Hash
|
|
22
|
+
structured_log_hash?(value, seen)
|
|
23
|
+
when Array
|
|
24
|
+
structured_log_array?(value, seen)
|
|
25
|
+
else
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def structured_log_array?(array, seen = nil)
|
|
31
|
+
array.all? { |value| structured_log_value?(value, seen) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sanitize(hash, depth: 0)
|
|
35
|
+
return {"error" => "max_depth_exceeded"} if depth > Sanitizer::MAX_DEPTH
|
|
36
|
+
|
|
37
|
+
filter = Sanitizer.rails_parameter_filter
|
|
38
|
+
if filter
|
|
39
|
+
filter.filter(stringify(hash))
|
|
40
|
+
else
|
|
41
|
+
sanitize_without_filter(hash, depth: depth)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def sanitize_without_filter(hash, depth: 0)
|
|
46
|
+
jsonable = jsonable_tree(hash, depth: depth)
|
|
47
|
+
if jsonable.owned
|
|
48
|
+
stringify(jsonable.tree)
|
|
49
|
+
else
|
|
50
|
+
jsonable.tree.dup
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def jsonable_tree(hash, depth: 0, seen: nil, parent_key: nil, omit_keys: nil, count_leaves: false, leaf_counter: nil)
|
|
55
|
+
seen ||= Set.new.compare_by_identity
|
|
56
|
+
leaf_counter = [0] if count_leaves && leaf_counter.nil?
|
|
57
|
+
if depth > Sanitizer::MAX_DEPTH
|
|
58
|
+
return JsonableResult.new(tree: {"error" => "max_depth_exceeded"}, owned: true, leaf_count: leaf_counter&.[](0))
|
|
59
|
+
end
|
|
60
|
+
unless seen.add?(hash)
|
|
61
|
+
return JsonableResult.new(tree: {"error" => "cyclic_reference"}, owned: true, leaf_count: leaf_counter&.[](0))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
entries = []
|
|
65
|
+
owned = false
|
|
66
|
+
|
|
67
|
+
hash.each do |key, value|
|
|
68
|
+
entry = process_jsonable_entry(
|
|
69
|
+
key,
|
|
70
|
+
value,
|
|
71
|
+
depth: depth,
|
|
72
|
+
seen: seen,
|
|
73
|
+
parent_key: parent_key,
|
|
74
|
+
omit_keys: omit_keys,
|
|
75
|
+
leaf_counter: leaf_counter
|
|
76
|
+
)
|
|
77
|
+
next unless entry
|
|
78
|
+
|
|
79
|
+
key_string, encoded_value, pair_owned = entry
|
|
80
|
+
owned ||= pair_owned
|
|
81
|
+
entries << [key_string, encoded_value, pair_owned]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
seen.delete(hash)
|
|
85
|
+
leaf_count = leaf_counter&.[](0)
|
|
86
|
+
unless owned
|
|
87
|
+
tree = omit_root_keys(hash, omit_keys, depth: depth)
|
|
88
|
+
return JsonableResult.new(tree: tree, owned: false, leaf_count: leaf_count)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
result = entries.each_with_object({}) do |(key_string, encoded_value, _pair_owned), object|
|
|
92
|
+
object[key_string] = encoded_value
|
|
93
|
+
end
|
|
94
|
+
JsonableResult.new(tree: result, owned: true, leaf_count: leaf_count)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def jsonable_value(value, depth:, seen:, parent_key:, leaf_counter: nil)
|
|
98
|
+
case value
|
|
99
|
+
when String
|
|
100
|
+
leaf_counter[0] += 1 if leaf_counter
|
|
101
|
+
sanitized = Sanitizer.sanitize_string(value)
|
|
102
|
+
JsonableResult.new(tree: sanitized, owned: sanitized != value)
|
|
103
|
+
when Hash
|
|
104
|
+
jsonable_tree(value, depth: depth + 1, seen: seen, parent_key: parent_key, leaf_counter: leaf_counter)
|
|
105
|
+
when Array
|
|
106
|
+
jsonable_array(value, depth: depth, seen: seen, parent_key: parent_key, leaf_counter: leaf_counter)
|
|
107
|
+
else
|
|
108
|
+
leaf_counter[0] += 1 if leaf_counter
|
|
109
|
+
JsonableResult.new(tree: value, owned: false)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def jsonable_array(array, depth:, seen:, parent_key:, leaf_counter: nil)
|
|
114
|
+
owned = false
|
|
115
|
+
result = nil
|
|
116
|
+
|
|
117
|
+
array.each_with_index do |entry, index|
|
|
118
|
+
child = jsonable_value(entry, depth: depth, seen: seen, parent_key: parent_key, leaf_counter: leaf_counter)
|
|
119
|
+
next unless child.owned
|
|
120
|
+
|
|
121
|
+
owned = true
|
|
122
|
+
result ||= array.dup
|
|
123
|
+
result[index] = child.tree
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
JsonableResult.new(tree: owned ? result : array, owned: owned)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def process_jsonable_entry(key, value, depth:, seen:, parent_key:, omit_keys:, leaf_counter:)
|
|
130
|
+
key_string = key.to_s
|
|
131
|
+
if root_key_omitted?(depth, key_string, omit_keys)
|
|
132
|
+
count_leaves_in_value(value, leaf_counter, depth: depth, seen: seen) if leaf_counter
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if Sanitizer::SENSITIVE_KEY_PATTERNS.match?(key_string)
|
|
137
|
+
return [Sanitizer.sensitive_filtered_key_name(key_string), "[FILTERED]", true]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
nested_parent = parent_key ? "#{parent_key}.#{key_string}" : key_string
|
|
141
|
+
child = jsonable_value(
|
|
142
|
+
value,
|
|
143
|
+
depth: depth,
|
|
144
|
+
seen: seen,
|
|
145
|
+
parent_key: nested_parent,
|
|
146
|
+
leaf_counter: leaf_counter
|
|
147
|
+
)
|
|
148
|
+
encoded_value = child.owned ? child.tree : value
|
|
149
|
+
[key_string, encoded_value, child.owned]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def root_key_omitted?(depth, key_string, omit_keys)
|
|
153
|
+
depth.zero? && omit_keys&.include?(key_string)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def omit_root_keys(hash, omit_keys, depth:)
|
|
157
|
+
return hash unless depth.zero? && omit_keys&.any?
|
|
158
|
+
|
|
159
|
+
hash.each_with_object({}) do |(key, value), object|
|
|
160
|
+
object[key] = value unless omit_keys.include?(key.to_s)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def count_leaves_in_value(value, leaf_counter, depth:, seen:)
|
|
165
|
+
case value
|
|
166
|
+
when Hash
|
|
167
|
+
return unless seen.add?(value)
|
|
168
|
+
|
|
169
|
+
value.each_value do |entry|
|
|
170
|
+
count_leaves_in_value(entry, leaf_counter, depth: depth + 1, seen: seen)
|
|
171
|
+
end
|
|
172
|
+
seen.delete(value)
|
|
173
|
+
when Array
|
|
174
|
+
value.each { |entry| count_leaves_in_value(entry, leaf_counter, depth: depth, seen: seen) }
|
|
175
|
+
else
|
|
176
|
+
leaf_counter[0] += 1
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def stringify(hash)
|
|
181
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
182
|
+
result[key.to_s] = stringify_value(value)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def stringify_value(value)
|
|
187
|
+
case value
|
|
188
|
+
when Hash
|
|
189
|
+
stringify(value)
|
|
190
|
+
when Array
|
|
191
|
+
value.map { |entry| stringify_value(entry) }
|
|
192
|
+
when String
|
|
193
|
+
Sanitizer.sanitize_string(value)
|
|
194
|
+
else
|
|
195
|
+
value
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def stringify_keys_copy(hash)
|
|
200
|
+
return hash if hash.keys.all? { |key| key.is_a?(String) }
|
|
201
|
+
|
|
202
|
+
hash.transform_keys(&:to_s)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
data/lib/json_logging/version.rb
CHANGED
data/lib/json_logging.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "json_logging/helpers"
|
|
|
7
7
|
require_relative "json_logging/sanitizer"
|
|
8
8
|
require_relative "json_logging/message_parser"
|
|
9
9
|
require_relative "json_logging/payload_builder"
|
|
10
|
+
require_relative "json_logging/line_encoder"
|
|
10
11
|
require_relative "json_logging/formatter"
|
|
11
12
|
require_relative "json_logging/formatter_with_tags"
|
|
12
13
|
require_relative "json_logging/json_logger_extension"
|
|
@@ -14,13 +15,28 @@ require_relative "json_logging/json_logger"
|
|
|
14
15
|
|
|
15
16
|
module JsonLogging
|
|
16
17
|
THREAD_CONTEXT_KEY = :__json_logging_context
|
|
18
|
+
SANITIZED_CONTEXT_CACHE_KEY = :__json_logging_sanitized_context
|
|
19
|
+
@additional_context_warned = Set.new
|
|
20
|
+
@additional_context_transformer = nil
|
|
21
|
+
|
|
22
|
+
def self.context_storage
|
|
23
|
+
if defined?(ActiveSupport::IsolatedExecutionState)
|
|
24
|
+
ActiveSupport::IsolatedExecutionState
|
|
25
|
+
else
|
|
26
|
+
Thread.current
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
private_class_method :context_storage
|
|
17
30
|
|
|
18
31
|
def self.with_context(extra_context)
|
|
19
|
-
|
|
20
|
-
|
|
32
|
+
storage = context_storage
|
|
33
|
+
original = storage[THREAD_CONTEXT_KEY]
|
|
34
|
+
storage[THREAD_CONTEXT_KEY] = (original || {}).merge(safe_hash(extra_context))
|
|
35
|
+
invalidate_sanitized_context_cache(storage)
|
|
21
36
|
yield
|
|
22
37
|
ensure
|
|
23
|
-
|
|
38
|
+
storage[THREAD_CONTEXT_KEY] = original
|
|
39
|
+
invalidate_sanitized_context_cache(storage)
|
|
24
40
|
end
|
|
25
41
|
|
|
26
42
|
# Returns the current thread-local context when called without arguments,
|
|
@@ -42,8 +58,9 @@ module JsonLogging
|
|
|
42
58
|
end
|
|
43
59
|
|
|
44
60
|
begin
|
|
45
|
-
base_context = (
|
|
46
|
-
rescue
|
|
61
|
+
base_context = (context_storage[THREAD_CONTEXT_KEY] || {}).dup
|
|
62
|
+
rescue => e
|
|
63
|
+
warn_additional_context_once(:dup, "thread context dup failed", e)
|
|
47
64
|
base_context = {}
|
|
48
65
|
end
|
|
49
66
|
|
|
@@ -51,7 +68,8 @@ module JsonLogging
|
|
|
51
68
|
if transformer.is_a?(Proc)
|
|
52
69
|
begin
|
|
53
70
|
transformer.call(base_context)
|
|
54
|
-
rescue
|
|
71
|
+
rescue => e
|
|
72
|
+
warn_additional_context_once(:transformer, "additional_context transformer failed", e)
|
|
55
73
|
base_context
|
|
56
74
|
end
|
|
57
75
|
else
|
|
@@ -61,6 +79,26 @@ module JsonLogging
|
|
|
61
79
|
|
|
62
80
|
def self.additional_context=(proc_or_block)
|
|
63
81
|
@additional_context_transformer = proc_or_block
|
|
82
|
+
invalidate_sanitized_context_cache(context_storage)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.additional_context_for_payload
|
|
86
|
+
if @additional_context_transformer.is_a?(Proc)
|
|
87
|
+
return sanitized_context_from(compact_context(additional_context))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
storage = context_storage
|
|
91
|
+
cached = storage[SANITIZED_CONTEXT_CACHE_KEY]
|
|
92
|
+
return cached if cached
|
|
93
|
+
|
|
94
|
+
raw_context = storage[THREAD_CONTEXT_KEY]
|
|
95
|
+
if raw_context.nil? || (raw_context.is_a?(Hash) && raw_context.empty?)
|
|
96
|
+
return storage[SANITIZED_CONTEXT_CACHE_KEY] = {}.freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sanitized = sanitized_context_from(compact_context(raw_context))
|
|
100
|
+
storage[SANITIZED_CONTEXT_CACHE_KEY] = sanitized.freeze
|
|
101
|
+
sanitized
|
|
64
102
|
end
|
|
65
103
|
|
|
66
104
|
def self.safe_hash(obj)
|
|
@@ -69,6 +107,34 @@ module JsonLogging
|
|
|
69
107
|
{}
|
|
70
108
|
end
|
|
71
109
|
|
|
110
|
+
def self.warn_additional_context_once(key, message, error)
|
|
111
|
+
return if @additional_context_warned.include?(key)
|
|
112
|
+
|
|
113
|
+
@additional_context_warned.add(key)
|
|
114
|
+
warn "[activesupport-json_logging] #{message} (#{error.class}: #{error.message})"
|
|
115
|
+
end
|
|
116
|
+
private_class_method :warn_additional_context_once
|
|
117
|
+
|
|
118
|
+
def self.compact_context(context)
|
|
119
|
+
return {} unless context.is_a?(Hash)
|
|
120
|
+
return {} if context.empty?
|
|
121
|
+
|
|
122
|
+
context.compact
|
|
123
|
+
end
|
|
124
|
+
private_class_method :compact_context
|
|
125
|
+
|
|
126
|
+
def self.sanitized_context_from(compacted_context)
|
|
127
|
+
return {} if compacted_context.empty?
|
|
128
|
+
|
|
129
|
+
Sanitizer.sanitize_hash(compacted_context)
|
|
130
|
+
end
|
|
131
|
+
private_class_method :sanitized_context_from
|
|
132
|
+
|
|
133
|
+
def self.invalidate_sanitized_context_cache(storage)
|
|
134
|
+
storage[SANITIZED_CONTEXT_CACHE_KEY] = nil
|
|
135
|
+
end
|
|
136
|
+
private_class_method :invalidate_sanitized_context_cache
|
|
137
|
+
|
|
72
138
|
# Returns an `ActiveSupport::Logger` that has already been wrapped with JSON logging concern.
|
|
73
139
|
#
|
|
74
140
|
# @param *args Arguments passed to ActiveSupport::Logger.new
|
|
@@ -97,6 +163,17 @@ module JsonLogging
|
|
|
97
163
|
# logger.tagged("BCX") { logger.info("Stuff") } # Logs with tags
|
|
98
164
|
# logger.tagged("BCX").info("Stuff") # Logs with tags (non-block form)
|
|
99
165
|
def self.new(logger)
|
|
166
|
+
logger = prepare_logger_clone(logger)
|
|
167
|
+
logger.extend(JsonLoggerExtension)
|
|
168
|
+
formatter_with_tags = FormatterWithTags.new(logger)
|
|
169
|
+
logger.instance_variable_set(:@formatter_with_tags, formatter_with_tags)
|
|
170
|
+
# Custom formatters are not supported: JSON output requires FormatterWithTags.
|
|
171
|
+
logger.formatter = formatter_with_tags
|
|
172
|
+
|
|
173
|
+
logger
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.prepare_logger_clone(logger)
|
|
100
177
|
logger = logger.clone
|
|
101
178
|
if logger.formatter
|
|
102
179
|
logger.formatter = logger.formatter.clone
|
|
@@ -114,11 +191,7 @@ module JsonLogging
|
|
|
114
191
|
end
|
|
115
192
|
end
|
|
116
193
|
|
|
117
|
-
logger.extend(JsonLoggerExtension)
|
|
118
|
-
formatter_with_tags = FormatterWithTags.new(logger)
|
|
119
|
-
logger.instance_variable_set(:@formatter_with_tags, formatter_with_tags)
|
|
120
|
-
logger.formatter = formatter_with_tags
|
|
121
|
-
|
|
122
194
|
logger
|
|
123
195
|
end
|
|
196
|
+
private_class_method :prepare_logger_clone
|
|
124
197
|
end
|