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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c99bef73cc6bf2627075656ee38a3e3b756ac61ef54f2edcc628814b317ac7bf
4
- data.tar.gz: c4a0e1ecfd0ae8862db0167cc5507b8da4572e6a58ed4035ec7aa606c3154442
3
+ metadata.gz: 71ac52d26c4cca14a0eaed618927f9432880865ad9b24e43232b5801ce10a055
4
+ data.tar.gz: 81ef2c90e5d44fc7910f319acdbbd4bb9b56572075c6a28050fd05759349e91b
5
5
  SHA512:
6
- metadata.gz: 3abb724226828b28519f25dc8edb138c3b4d7dc1a07bf2946b952e46bee4bef5d71149a44df4a52cb12e41f892ca4d2f7147bfdeb9dbd7ad0ca3c4ee3873dddc
7
- data.tar.gz: 25b2c9f5ae0c8107e4f5c7385ff96fbb379cbfba851234be9800909978b14d1d81b5fab961a5d72bd1d4ede78c5d1d16eb5ab1534e6ed54d2074e1c21456395c
6
+ metadata.gz: 0f8ebdfeb94e456cdffd8e8d176c9dde7dff4187f2547b721dd4ec33e229c290663386a1f1c091ee54e95821be3bc11eb8f02fbf645f3a9bd8a54ab1e79596b9
7
+ data.tar.gz: cbfdad0f75642ec096bf18ca154a9aef9ce3364d92a43c263ceea81f877f018db3459b1a3fc239b2025aa6d96815db8f37063675f33a29d24fd24de4eaeb2299
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.2.3 (2026-07-13)
4
+
5
+ - Reduce per-line work for plain string, hash, tagged, and context-scoped log messages on common shapes
6
+ - Sanitize flat and nested structured hash payloads without deep-copying the source when values are logging-safe primitives
7
+ - Reuse ISO8601 microsecond UTC timestamps when callers already supply them
8
+ - Skip context merging when tags and additional context are both empty
9
+ - Cache empty thread context as a frozen hash on the hot path
10
+ - Expand hot-path contract specs for fast-path logging and sanitization
11
+ - Encode large nested structured hash log lines in one pass with copy-on-write sanitization and JSON.generate instead of building a full sanitized tree first
12
+
13
+ ## 1.2.2 (2026-07-08)
14
+
15
+ - Isolate log context per execution via `ActiveSupport::IsolatedExecutionState` when available
16
+ - Sanitize fallback formatter output when JSON encoding fails
17
+ - Refactor emit path into `LineEncoder`, `PayloadBuilder`, and `Severity` modules
18
+ - Cache sanitized `additional_context` per `with_context` scope on the hot path
19
+ - Memoize `ActiveSupport::ParameterFilter` when `filter_parameters` is unchanged
20
+ - Fast-path `sanitize_string` for clean ASCII text within length limits
21
+ - Sanitize tags at push time instead of on every log line
22
+ - Skip `deep_stringify_keys` when payload structure already uses string keys
23
+ - Add hot-path optimization specs
24
+
25
+ ## 1.2.1 (2026-01-18)
26
+
27
+ - Refactor tag stack handling in JsonLogging formatter for improved maintainability
28
+ - Refactor timestamp handling in JsonLogging helpers to support Time.zone
29
+ - Add Ruby 4.0 support in gemspec and Appraisal configurations
30
+
3
31
  ## 1.2.0 (2025-11-07)
4
32
 
5
33
  - Add support for service-specific tagged loggers: create loggers with permanent tags using `logger.tagged("service")` without a block
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # activesupport-json_logging
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/activesupport-json_logging.svg)](https://badge.fury.io/rb/activesupport-json_logging) [![Test Status](https://github.com/amkisko/activesupport-json_logging.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/activesupport-json_logging.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/activesupport-json_logging.rb/graph/badge.svg?token=UX80FTO0Y0)](https://codecov.io/gh/amkisko/activesupport-json_logging.rb)
3
+ [![Gem Version](https://badge.fury.io/rb/activesupport-json_logging.svg)](https://badge.fury.io/rb/activesupport-json_logging) [![Test Status](https://github.com/amkisko/activesupport-json_logging.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/activesupport-json_logging.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/activesupport-json_logging.rb/graph/badge.svg?token=UX80FTO0Y0)](https://codecov.io/gh/amkisko/activesupport-json_logging.rb) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=amkisko_activesupport-json_logging.rb&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=amkisko_activesupport-json_logging.rb)
4
4
 
5
5
  Structured JSON logging for Rails and ActiveSupport with a safe, single-line formatter.
6
6
  No dependencies beyond Rails and Activesupport.
@@ -283,6 +283,8 @@ end
283
283
 
284
284
  If you use Lograge, configure it to feed raw hashes and let this gem handle JSON formatting. This example shows a complete setup including all Rails component loggers and common third-party libraries:
285
285
 
286
+ **Important:** When using Lograge, `config.log_tags` does not work for adding fields to Lograge output. Instead, use `config.lograge.custom_options` to add custom fields to your request logs.
287
+
286
288
  ```ruby
287
289
  # config/initializers/lograge.rb
288
290
  # Note: require is optional in Rails apps (auto-loaded via Railtie)
@@ -295,8 +297,26 @@ Rails.application.configure do
295
297
  config.lograge.formatter = Lograge::Formatters::Raw.new
296
298
  config.lograge.keep_original_rails_log = ENV["DEBUG"] ? true : false
297
299
 
298
- # Merge additional context into Lograge output
299
- config.lograge.custom_options = ->(_event) { JsonLogging.additional_context }
300
+ # Add custom fields to Lograge output
301
+ # Note: config.log_tags does NOT work with Lograge - use custom_options instead
302
+ config.lograge.custom_options = ->(event) {
303
+ {
304
+ remote_ip: Current.remote_addr,
305
+ request_id: Current.request_id,
306
+ user_agent: Current.user_agent,
307
+ user_id: Current.user&.id
308
+ }
309
+ }
310
+
311
+ # Optionally merge additional context from JsonLogging.with_context
312
+ # config.lograge.custom_options = ->(event) {
313
+ # {
314
+ # remote_ip: Current.remote_addr,
315
+ # request_id: Current.request_id,
316
+ # user_agent: Current.user_agent,
317
+ # user_id: Current.user&.id
318
+ # }.merge(JsonLogging.additional_context)
319
+ # }
300
320
 
301
321
  # Build unified JSON logger
302
322
  logdev = Rails.env.production? ? Rails.root.join("log", "#{Rails.env}.log") : $stdout
@@ -306,7 +326,6 @@ Rails.application.configure do
306
326
 
307
327
  # Set the main Rails logger
308
328
  config.logger = json_logger
309
- config.log_tags = [:request_id, :remote_ip]
310
329
 
311
330
  # Override Rails.logger to ensure it uses our formatter
312
331
  Rails.logger = json_logger
@@ -327,13 +346,6 @@ Rails.application.configure do
327
346
 
328
347
  # Disable verbose enqueue logs to reduce noise
329
348
  config.active_job.verbose_enqueue_logs = false
330
-
331
- # Optional: Customize log tags based on request
332
- # config.log_tags = [
333
- # :request_id,
334
- # ->(request) { request.remote_ip },
335
- # ->(request) { request.subdomain }
336
- # ]
337
349
  end
338
350
  ```
339
351
 
@@ -594,27 +606,10 @@ The gem will automatically filter these from all log entries, including context
594
606
 
595
607
  ## Development
596
608
 
597
- ```bash
598
- # Install dependencies
599
- bundle install
600
- bundle exec appraisal generate
601
-
602
- # Run tests for current Rails version
603
- bundle exec rspec
609
+ Run release.rb script to prepare code for publishing, it has all the required checks and tests.
604
610
 
605
- # Run tests for all Rails versions (6.0, 6.1, 7.0, 7.1, 7.2, 8.0)
606
- bin/appraisals
607
-
608
- # Run tests for specific Rails version
609
- bin/appraisals rails-7.0
610
-
611
- # Run tests for multiple versions
612
- bin/appraisals rails-7.0 rails-8.0
613
-
614
- # Or use appraisal directly
615
- bundle exec appraisal rails-7.0 rspec
616
-
617
- bundle exec standardrb --fix
611
+ ```bash
612
+ usr/bin/release.rb
618
613
  ```
619
614
 
620
615
  ### Development: Using from Local Repository
@@ -2,17 +2,21 @@ module JsonLogging
2
2
  class Formatter < ::Logger::Formatter
3
3
  def initialize(tags: [])
4
4
  super()
5
- @tags = Array(tags)
5
+ @tags = Sanitizer.prepare_tags(tags)
6
6
  end
7
7
 
8
8
  attr_reader :tags
9
9
 
10
10
  def call(severity, timestamp, progname, msg)
11
- timestamp_str = Helpers.normalize_timestamp(timestamp)
12
- payload = PayloadBuilder.build_base_payload(msg, severity: severity, timestamp: timestamp_str)
13
- payload = PayloadBuilder.merge_context(payload, additional_context: JsonLogging.additional_context.compact, tags: @tags)
14
-
15
- "#{payload.compact.to_json}\n"
11
+ LineEncoder.build_line(
12
+ msg: msg,
13
+ severity: severity,
14
+ timestamp: Helpers.normalize_timestamp(timestamp),
15
+ tags: @tags,
16
+ additional_context: JsonLogging.additional_context_for_payload,
17
+ additional_context_sanitized: true,
18
+ sanitize_tags: false
19
+ )
16
20
  rescue => e
17
21
  build_fallback_output(severity, timestamp, msg, e)
18
22
  end
@@ -23,14 +27,14 @@ module JsonLogging
23
27
  timestamp_str = Helpers.normalize_timestamp(timestamp)
24
28
  fallback_payload = {
25
29
  timestamp: timestamp_str,
26
- severity: severity,
27
- message: Helpers.safe_string(msg),
30
+ severity: Severity.name_for(severity),
31
+ message: Sanitizer.sanitize_string(Helpers.safe_string(msg)),
28
32
  formatter_error: {
29
33
  class: Sanitizer.sanitize_string(error.class.name),
30
34
  message: Sanitizer.sanitize_string(Helpers.safe_string(error.message))
31
35
  }
32
36
  }
33
- "#{fallback_payload.compact.to_json}\n"
37
+ LineEncoder.to_json_line(fallback_payload)
34
38
  end
35
39
  end
36
40
  end
@@ -10,28 +10,31 @@ module JsonLogging
10
10
  def current_tags
11
11
  # If LocalTagStorage is extended on this formatter, use its tag_stack
12
12
  # This matches Rails' TaggedLogging behavior where tag_stack attribute shadows the method
13
- if respond_to?(:tag_stack, true) && instance_variable_defined?(:@tag_stack)
14
- tag_stack.tags
13
+ if (stack = current_tag_stack)
14
+ stack.tags
15
15
  else
16
16
  @logger.send(:current_tags)
17
17
  end
18
18
  end
19
19
 
20
20
  def call(severity, timestamp, progname, msg)
21
- tags = current_tags
22
- timestamp_str = Helpers.normalize_timestamp(timestamp)
23
- payload = PayloadBuilder.build_base_payload(msg, severity: severity, timestamp: timestamp_str)
24
- payload = PayloadBuilder.merge_context(payload, additional_context: JsonLogging.additional_context.compact, tags: tags)
25
-
26
- "#{payload.compact.to_json}\n"
21
+ LineEncoder.build_line(
22
+ msg: msg,
23
+ severity: severity,
24
+ timestamp: Helpers.normalize_timestamp(timestamp),
25
+ tags: current_tags,
26
+ additional_context: JsonLogging.additional_context_for_payload,
27
+ additional_context_sanitized: true,
28
+ sanitize_tags: false
29
+ )
27
30
  rescue => e
28
31
  build_fallback_output(severity, timestamp, msg, e)
29
32
  end
30
33
 
31
34
  def push_tags(*tags)
32
35
  # If LocalTagStorage is present, use it; otherwise use logger's thread-local storage
33
- if respond_to?(:tag_stack, true) && instance_variable_defined?(:@tag_stack)
34
- tag_stack.push_tags(tags)
36
+ if (stack = current_tag_stack)
37
+ stack.push_tags(tags)
35
38
  else
36
39
  @logger.send(:push_tags, tags)
37
40
  end
@@ -40,14 +43,12 @@ module JsonLogging
40
43
  # Support tagged blocks for formatter
41
44
  def tagged(*tags)
42
45
  if block_given?
43
- # If LocalTagStorage is present, use it; otherwise use logger's thread-local storage
44
- if respond_to?(:tag_stack, true) && instance_variable_defined?(:@tag_stack)
45
- previous_count = tag_stack.tags.size
46
- tag_stack.push_tags(tags)
46
+ if (stack = current_tag_stack)
47
+ pushed_count = stack.push_tags(tags).size
47
48
  begin
48
49
  yield @logger
49
50
  ensure
50
- tag_stack.pop_tags(tag_stack.tags.size - previous_count)
51
+ stack.pop_tags(pushed_count)
51
52
  end
52
53
  else
53
54
  previous = @logger.send(:current_tags).dup
@@ -59,8 +60,8 @@ module JsonLogging
59
60
  end
60
61
  end
61
62
  else
62
- if respond_to?(:tag_stack, true) && instance_variable_defined?(:@tag_stack)
63
- tag_stack.push_tags(tags)
63
+ if (stack = current_tag_stack)
64
+ stack.push_tags(tags)
64
65
  else
65
66
  @logger.send(:push_tags, tags)
66
67
  end
@@ -70,18 +71,35 @@ module JsonLogging
70
71
 
71
72
  private
72
73
 
74
+ # Returns the current tag_stack to use, checking in order:
75
+ # 1. This formatter's tag_stack (if LocalTagStorage is extended)
76
+ # 2. Logger's formatter's tag_stack (if it has LocalTagStorage)
77
+ # 3. nil (fall back to thread-local storage)
78
+ def current_tag_stack
79
+ if respond_to?(:tag_stack, true) && instance_variable_defined?(:@tag_stack)
80
+ tag_stack
81
+ elsif logger_formatter_has_tag_stack?
82
+ @logger.formatter.tag_stack
83
+ end
84
+ end
85
+
86
+ def logger_formatter_has_tag_stack?
87
+ @logger.formatter.respond_to?(:tag_stack, true) &&
88
+ @logger.formatter.instance_variable_defined?(:@tag_stack)
89
+ end
90
+
73
91
  def build_fallback_output(severity, timestamp, msg, error)
74
92
  timestamp_str = Helpers.normalize_timestamp(timestamp)
75
93
  fallback_payload = {
76
94
  timestamp: timestamp_str,
77
- severity: severity,
78
- message: Helpers.safe_string(msg),
95
+ severity: Severity.name_for(severity),
96
+ message: Sanitizer.sanitize_string(Helpers.safe_string(msg)),
79
97
  formatter_error: {
80
98
  class: Sanitizer.sanitize_string(error.class.name),
81
99
  message: Sanitizer.sanitize_string(Helpers.safe_string(error.message))
82
100
  }
83
101
  }
84
- "#{fallback_payload.compact.to_json}\n"
102
+ LineEncoder.to_json_line(fallback_payload)
85
103
  end
86
104
  end
87
105
  end
@@ -1,10 +1,35 @@
1
1
  module JsonLogging
2
2
  module Helpers
3
+ ISO8601_MICROSECOND_UTC_PATTERN = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z\z/
4
+
3
5
  module_function
4
6
 
5
7
  # Normalize timestamp to ISO8601 with microseconds
6
8
  def normalize_timestamp(timestamp)
7
- (timestamp || Time.now).utc.iso8601(6)
9
+ return timestamp if iso8601_microsecond_utc?(timestamp)
10
+
11
+ format_timestamp(timestamp || current_time)
12
+ end
13
+
14
+ def current_timestamp
15
+ format_timestamp(current_time)
16
+ end
17
+
18
+ def format_timestamp(time)
19
+ time.utc.iso8601(6)
20
+ end
21
+
22
+ def iso8601_microsecond_utc?(timestamp)
23
+ timestamp.is_a?(String) && timestamp.match?(ISO8601_MICROSECOND_UTC_PATTERN)
24
+ end
25
+
26
+ # Get current time, using Time.zone if available, otherwise Time.now
27
+ def current_time
28
+ if defined?(Time.zone) && Time.zone
29
+ Time.zone.now
30
+ else
31
+ Time.now
32
+ end
8
33
  end
9
34
 
10
35
  # Safely convert object to string, never raises
@@ -2,15 +2,6 @@ module JsonLogging
2
2
  # Module that extends any Logger with JSON formatting capabilities
3
3
  # This is used by JsonLogging.new to wrap standard loggers
4
4
  module JsonLoggerExtension
5
- SEVERITY_NAMES = {
6
- ::Logger::DEBUG => "DEBUG",
7
- ::Logger::INFO => "INFO",
8
- ::Logger::WARN => "WARN",
9
- ::Logger::ERROR => "ERROR",
10
- ::Logger::FATAL => "FATAL",
11
- ::Logger::UNKNOWN => "UNKNOWN"
12
- }.freeze
13
-
14
5
  def formatter
15
6
  @formatter_with_tags ||= begin
16
7
  formatter_with_tags = FormatterWithTags.new(self)
@@ -19,46 +10,31 @@ module JsonLogging
19
10
  end
20
11
  end
21
12
 
22
- def formatter=(formatter)
23
- # Always use FormatterWithTags to ensure JSON formatting
13
+ def formatter=(_ignored)
14
+ # Custom formatters are ignored; JSON output requires FormatterWithTags.
24
15
  formatter_with_tags = @formatter_with_tags || FormatterWithTags.new(self)
25
16
  instance_variable_set(:@formatter, formatter_with_tags)
26
17
  @formatter_with_tags = formatter_with_tags
27
18
  end
28
19
 
29
- def add(severity, message = nil, progname = nil)
20
+ def add(severity, message = nil, progname = nil, &block)
30
21
  return true if severity < level
31
22
 
32
- msg = if message.nil?
33
- if block_given?
34
- yield
35
- else
36
- progname
37
- end
38
- else
39
- message
40
- end
41
-
42
- payload = build_payload(severity, progname, msg)
43
-
44
- stringified = stringify_keys(payload)
45
- @logdev&.write("#{stringified.to_json}\n")
23
+ msg = nil
24
+ msg = extract_log_message(message, progname, &block)
25
+ line = LineEncoder.build_line(
26
+ msg: msg,
27
+ severity: severity,
28
+ timestamp: Helpers.current_timestamp,
29
+ tags: formatter.current_tags,
30
+ additional_context: JsonLogging.additional_context_for_payload,
31
+ additional_context_sanitized: true,
32
+ sanitize_tags: false
33
+ )
34
+ @logdev&.write(line)
46
35
  true
47
36
  rescue => e
48
- # Never fail logging - write a fallback entry
49
- # Initialize msg if it wasn't set due to error
50
- msg ||= "<uninitialized>"
51
-
52
- fallback = {
53
- timestamp: Helpers.normalize_timestamp(Time.now),
54
- severity: severity_name(severity),
55
- message: Sanitizer.sanitize_string(Helpers.safe_string(msg)),
56
- logger_error: {
57
- class: Sanitizer.sanitize_string(e.class.name),
58
- message: Sanitizer.sanitize_string(Helpers.safe_string(e.message))
59
- }
60
- }
61
- @logdev&.write("#{fallback.compact.to_json}\n")
37
+ write_add_fallback(severity, msg, e)
62
38
  true
63
39
  end
64
40
 
@@ -91,6 +67,28 @@ module JsonLogging
91
67
 
92
68
  private
93
69
 
70
+ def extract_log_message(message, progname, &block)
71
+ if message.nil?
72
+ block ? block.call : progname
73
+ else
74
+ message
75
+ end
76
+ end
77
+
78
+ def write_add_fallback(severity, msg, error)
79
+ msg ||= "<uninitialized>"
80
+ fallback = {
81
+ timestamp: Helpers.current_timestamp,
82
+ severity: Severity.name_for(severity),
83
+ message: Sanitizer.sanitize_string(Helpers.safe_string(msg)),
84
+ logger_error: {
85
+ class: Sanitizer.sanitize_string(error.class.name),
86
+ message: Sanitizer.sanitize_string(Helpers.safe_string(error.message))
87
+ }
88
+ }
89
+ @logdev&.write(LineEncoder.to_json_line(fallback))
90
+ end
91
+
94
92
  def tags_key
95
93
  @tags_key ||= :"json_logging_tags_#{object_id}"
96
94
  end
@@ -116,8 +114,9 @@ module JsonLogging
116
114
  end
117
115
 
118
116
  def push_tags(tags)
119
- flat = tags.flatten.compact.map(&:to_s).reject(&:empty?)
117
+ flat = Sanitizer.prepare_tags(tags)
120
118
  return if flat.empty?
119
+
121
120
  set_tags(current_tags + flat)
122
121
  end
123
122
 
@@ -125,36 +124,12 @@ module JsonLogging
125
124
  set_tags([])
126
125
  end
127
126
 
128
- def build_payload(severity, _progname, msg)
129
- payload = PayloadBuilder.build_base_payload(
130
- msg,
131
- severity: severity_name(severity),
132
- timestamp: Helpers.normalize_timestamp(Time.now)
133
- )
134
- payload = PayloadBuilder.merge_context(
135
- payload,
136
- additional_context: JsonLogging.additional_context.compact,
137
- tags: formatter.current_tags
138
- )
139
-
140
- payload.compact
141
- end
142
-
143
127
  def severity_name(severity)
144
- SEVERITY_NAMES[severity] || severity.to_s
128
+ Severity.name_for(severity)
145
129
  end
146
130
 
147
- def stringify_keys(hash)
148
- case hash
149
- when Hash
150
- hash.each_with_object({}) do |(k, v), result|
151
- result[k.to_s] = stringify_keys(v)
152
- end
153
- when Array
154
- hash.map { |v| stringify_keys(v) }
155
- else
156
- hash
157
- end
131
+ def stringify_keys(obj)
132
+ LineEncoder.deep_stringify_structure(obj)
158
133
  end
159
134
  end
160
135
 
@@ -179,13 +154,16 @@ module JsonLogging
179
154
  end
180
155
 
181
156
  def push_tags(tags)
182
- flat = Array(tags).flatten.compact.map(&:to_s).reject(&:empty?)
157
+ flat = Sanitizer.prepare_tags(tags)
183
158
  return [] if flat.empty?
159
+
184
160
  @tags.concat(flat)
185
161
  flat
186
162
  end
187
163
 
188
164
  def pop_tags(count = 1)
165
+ return [] if count <= 0
166
+
189
167
  @tags.pop(count)
190
168
  end
191
169