activesupport-json_logging 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ module JsonLogging
2
+ module Sanitizer
3
+ module StructuredHash
4
+ JsonableResult = Struct.new(:tree, :owned, :leaf_count)
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, leaf_count: nil)
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, leaf_count: nil)
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, leaf_count: nil)
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.3.0"
3
3
  end
data/lib/json_logging.rb CHANGED
@@ -7,20 +7,37 @@ 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"
13
14
  require_relative "json_logging/json_logger"
15
+ require_relative "json_logging/event_subscriber"
14
16
 
15
17
  module JsonLogging
16
18
  THREAD_CONTEXT_KEY = :__json_logging_context
19
+ SANITIZED_CONTEXT_CACHE_KEY = :__json_logging_sanitized_context
20
+ @additional_context_warned = Set.new
21
+ @additional_context_transformer = nil
22
+
23
+ def self.context_storage
24
+ if defined?(ActiveSupport::IsolatedExecutionState)
25
+ ActiveSupport::IsolatedExecutionState
26
+ else
27
+ Thread.current
28
+ end
29
+ end
30
+ private_class_method :context_storage
17
31
 
18
32
  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))
33
+ storage = context_storage
34
+ original = storage[THREAD_CONTEXT_KEY]
35
+ storage[THREAD_CONTEXT_KEY] = (original || {}).merge(safe_hash(extra_context))
36
+ invalidate_sanitized_context_cache(storage)
21
37
  yield
22
38
  ensure
23
- Thread.current[THREAD_CONTEXT_KEY] = original
39
+ storage[THREAD_CONTEXT_KEY] = original
40
+ invalidate_sanitized_context_cache(storage)
24
41
  end
25
42
 
26
43
  # Returns the current thread-local context when called without arguments,
@@ -42,8 +59,9 @@ module JsonLogging
42
59
  end
43
60
 
44
61
  begin
45
- base_context = (Thread.current[THREAD_CONTEXT_KEY] || {}).dup
46
- rescue
62
+ base_context = (context_storage[THREAD_CONTEXT_KEY] || {}).dup
63
+ rescue => e
64
+ warn_additional_context_once(:dup, "thread context dup failed", e)
47
65
  base_context = {}
48
66
  end
49
67
 
@@ -51,7 +69,8 @@ module JsonLogging
51
69
  if transformer.is_a?(Proc)
52
70
  begin
53
71
  transformer.call(base_context)
54
- rescue
72
+ rescue => e
73
+ warn_additional_context_once(:transformer, "additional_context transformer failed", e)
55
74
  base_context
56
75
  end
57
76
  else
@@ -61,6 +80,26 @@ module JsonLogging
61
80
 
62
81
  def self.additional_context=(proc_or_block)
63
82
  @additional_context_transformer = proc_or_block
83
+ invalidate_sanitized_context_cache(context_storage)
84
+ end
85
+
86
+ def self.additional_context_for_payload
87
+ if @additional_context_transformer.is_a?(Proc)
88
+ return sanitized_context_from(compact_context(additional_context))
89
+ end
90
+
91
+ storage = context_storage
92
+ cached = storage[SANITIZED_CONTEXT_CACHE_KEY]
93
+ return cached if cached
94
+
95
+ raw_context = storage[THREAD_CONTEXT_KEY]
96
+ if raw_context.nil? || (raw_context.is_a?(Hash) && raw_context.empty?)
97
+ return storage[SANITIZED_CONTEXT_CACHE_KEY] = {}.freeze
98
+ end
99
+
100
+ sanitized = sanitized_context_from(compact_context(raw_context))
101
+ storage[SANITIZED_CONTEXT_CACHE_KEY] = sanitized.freeze
102
+ sanitized
64
103
  end
65
104
 
66
105
  def self.safe_hash(obj)
@@ -69,6 +108,34 @@ module JsonLogging
69
108
  {}
70
109
  end
71
110
 
111
+ def self.warn_additional_context_once(key, message, error)
112
+ return if @additional_context_warned.include?(key)
113
+
114
+ @additional_context_warned.add(key)
115
+ warn "[activesupport-json_logging] #{message} (#{error.class}: #{error.message})"
116
+ end
117
+ private_class_method :warn_additional_context_once
118
+
119
+ def self.compact_context(context)
120
+ return {} unless context.is_a?(Hash)
121
+ return {} if context.empty?
122
+
123
+ context.compact
124
+ end
125
+ private_class_method :compact_context
126
+
127
+ def self.sanitized_context_from(compacted_context)
128
+ return {} if compacted_context.empty?
129
+
130
+ Sanitizer.sanitize_hash(compacted_context)
131
+ end
132
+ private_class_method :sanitized_context_from
133
+
134
+ def self.invalidate_sanitized_context_cache(storage)
135
+ storage[SANITIZED_CONTEXT_CACHE_KEY] = nil
136
+ end
137
+ private_class_method :invalidate_sanitized_context_cache
138
+
72
139
  # Returns an `ActiveSupport::Logger` that has already been wrapped with JSON logging concern.
73
140
  #
74
141
  # @param *args Arguments passed to ActiveSupport::Logger.new
@@ -97,6 +164,17 @@ module JsonLogging
97
164
  # logger.tagged("BCX") { logger.info("Stuff") } # Logs with tags
98
165
  # logger.tagged("BCX").info("Stuff") # Logs with tags (non-block form)
99
166
  def self.new(logger)
167
+ logger = prepare_logger_clone(logger)
168
+ logger.extend(JsonLoggerExtension)
169
+ formatter_with_tags = FormatterWithTags.new(logger)
170
+ logger.instance_variable_set(:@formatter_with_tags, formatter_with_tags)
171
+ # Custom formatters are not supported: JSON output requires FormatterWithTags.
172
+ logger.formatter = formatter_with_tags
173
+
174
+ logger
175
+ end
176
+
177
+ def self.prepare_logger_clone(logger)
100
178
  logger = logger.clone
101
179
  if logger.formatter
102
180
  logger.formatter = logger.formatter.clone
@@ -114,11 +192,7 @@ module JsonLogging
114
192
  end
115
193
  end
116
194
 
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
195
  logger
123
196
  end
197
+ private_class_method :prepare_logger_clone
124
198
  end
@@ -0,0 +1,8 @@
1
+ module JsonLogging
2
+ VERSION: String
3
+
4
+ class EventSubscriber
5
+ def initialize: (?logger: untyped, ?io: untyped) -> void
6
+ def emit: (untyped event) -> void
7
+ end
8
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activesupport-json_logging
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '6.0'
18
+ version: '7.0'
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: '9.0'
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: '6.0'
28
+ version: '7.0'
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9.0'
@@ -35,7 +35,7 @@ dependencies:
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: '6.0'
38
+ version: '7.0'
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
41
  version: '9.0'
@@ -45,7 +45,7 @@ dependencies:
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: '6.0'
48
+ version: '7.0'
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '9.0'
@@ -64,47 +64,47 @@ dependencies:
64
64
  - !ruby/object:Gem::Version
65
65
  version: '3'
66
66
  - !ruby/object:Gem::Dependency
67
- name: webmock
67
+ name: polyrun
68
68
  requirement: !ruby/object:Gem::Requirement
69
69
  requirements:
70
- - - "~>"
70
+ - - ">="
71
71
  - !ruby/object:Gem::Version
72
- version: '3'
72
+ version: 2.2.0
73
73
  type: :development
74
74
  prerelease: false
75
75
  version_requirements: !ruby/object:Gem::Requirement
76
76
  requirements:
77
- - - "~>"
77
+ - - ">="
78
78
  - !ruby/object:Gem::Version
79
- version: '3'
79
+ version: 2.2.0
80
80
  - !ruby/object:Gem::Dependency
81
- name: rake
81
+ name: webmock
82
82
  requirement: !ruby/object:Gem::Requirement
83
83
  requirements:
84
84
  - - "~>"
85
85
  - !ruby/object:Gem::Version
86
- version: '13'
86
+ version: '3'
87
87
  type: :development
88
88
  prerelease: false
89
89
  version_requirements: !ruby/object:Gem::Requirement
90
90
  requirements:
91
91
  - - "~>"
92
92
  - !ruby/object:Gem::Version
93
- version: '13'
93
+ version: '3'
94
94
  - !ruby/object:Gem::Dependency
95
- name: simplecov
95
+ name: rake
96
96
  requirement: !ruby/object:Gem::Requirement
97
97
  requirements:
98
98
  - - "~>"
99
99
  - !ruby/object:Gem::Version
100
- version: '0.22'
100
+ version: '13'
101
101
  type: :development
102
102
  prerelease: false
103
103
  version_requirements: !ruby/object:Gem::Requirement
104
104
  requirements:
105
105
  - - "~>"
106
106
  - !ruby/object:Gem::Version
107
- version: '0.22'
107
+ version: '13'
108
108
  - !ruby/object:Gem::Dependency
109
109
  name: rspec_junit_formatter
110
110
  requirement: !ruby/object:Gem::Requirement
@@ -119,20 +119,6 @@ dependencies:
119
119
  - - "~>"
120
120
  - !ruby/object:Gem::Version
121
121
  version: '0.6'
122
- - !ruby/object:Gem::Dependency
123
- name: simplecov-cobertura
124
- requirement: !ruby/object:Gem::Requirement
125
- requirements:
126
- - - "~>"
127
- - !ruby/object:Gem::Version
128
- version: '3'
129
- type: :development
130
- prerelease: false
131
- version_requirements: !ruby/object:Gem::Requirement
132
- requirements:
133
- - - "~>"
134
- - !ruby/object:Gem::Version
135
- version: '3'
136
122
  - !ruby/object:Gem::Dependency
137
123
  name: standard
138
124
  requirement: !ruby/object:Gem::Requirement
@@ -301,8 +287,36 @@ dependencies:
301
287
  - - "~>"
302
288
  - !ruby/object:Gem::Version
303
289
  version: '0.5'
290
+ - !ruby/object:Gem::Dependency
291
+ name: benchmark-ips
292
+ requirement: !ruby/object:Gem::Requirement
293
+ requirements:
294
+ - - "~>"
295
+ - !ruby/object:Gem::Version
296
+ version: '2'
297
+ type: :development
298
+ prerelease: false
299
+ version_requirements: !ruby/object:Gem::Requirement
300
+ requirements:
301
+ - - "~>"
302
+ - !ruby/object:Gem::Version
303
+ version: '2'
304
+ - !ruby/object:Gem::Dependency
305
+ name: stackprof
306
+ requirement: !ruby/object:Gem::Requirement
307
+ requirements:
308
+ - - "~>"
309
+ - !ruby/object:Gem::Version
310
+ version: '0.2'
311
+ type: :development
312
+ prerelease: false
313
+ version_requirements: !ruby/object:Gem::Requirement
314
+ requirements:
315
+ - - "~>"
316
+ - !ruby/object:Gem::Version
317
+ version: '0.2'
304
318
  description: Lightweight JSON logger and formatter integrating with Rails/ActiveSupport.
305
- No extra deps beyond Rails/Activesupport. Compatible with Rails 6–8.
319
+ No extra deps beyond Rails/Activesupport. Compatible with Rails 7–8.
306
320
  email:
307
321
  - contact@kiskolabs.com
308
322
  executables: []
@@ -315,15 +329,21 @@ files:
315
329
  - lib/activesupport/json_logging.rb
316
330
  - lib/activesupport/json_logging/railtie.rb
317
331
  - lib/json_logging.rb
332
+ - lib/json_logging/event_subscriber.rb
318
333
  - lib/json_logging/formatter.rb
319
334
  - lib/json_logging/formatter_with_tags.rb
320
335
  - lib/json_logging/helpers.rb
321
336
  - lib/json_logging/json_logger.rb
322
337
  - lib/json_logging/json_logger_extension.rb
338
+ - lib/json_logging/line_encoder.rb
323
339
  - lib/json_logging/message_parser.rb
324
340
  - lib/json_logging/payload_builder.rb
325
341
  - lib/json_logging/sanitizer.rb
342
+ - lib/json_logging/severity.rb
343
+ - lib/json_logging/structured_hash_json_encoder.rb
344
+ - lib/json_logging/structured_hash_sanitizer.rb
326
345
  - lib/json_logging/version.rb
346
+ - sig/json_logging.rbs
327
347
  homepage: https://github.com/amkisko/activesupport-json_logging.rb
328
348
  licenses:
329
349
  - MIT
@@ -338,14 +358,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
338
358
  requirements:
339
359
  - - ">="
340
360
  - !ruby/object:Gem::Version
341
- version: '2.7'
361
+ version: '3.2'
342
362
  required_rubygems_version: !ruby/object:Gem::Requirement
343
363
  requirements:
344
364
  - - ">="
345
365
  - !ruby/object:Gem::Version
346
366
  version: '0'
347
367
  requirements: []
348
- rubygems_version: 4.0.3
368
+ rubygems_version: 4.0.6
349
369
  specification_version: 4
350
370
  summary: Structured JSON logging for Rails/ActiveSupport with safe, single-line entries.
351
371
  test_files: []