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.
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module JsonLogging
2
- VERSION = "1.2.1"
2
+ VERSION = "1.2.2"
3
3
  end
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
- original = Thread.current[THREAD_CONTEXT_KEY]
20
- Thread.current[THREAD_CONTEXT_KEY] = (original || {}).merge(safe_hash(extra_context))
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
- Thread.current[THREAD_CONTEXT_KEY] = original
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 = (Thread.current[THREAD_CONTEXT_KEY] || {}).dup
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
@@ -0,0 +1,3 @@
1
+ module JsonLogging
2
+ VERSION: String
3
+ end