activesupport-json_logging 1.2.2 → 1.3.1

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: 71ac52d26c4cca14a0eaed618927f9432880865ad9b24e43232b5801ce10a055
4
- data.tar.gz: 81ef2c90e5d44fc7910f319acdbbd4bb9b56572075c6a28050fd05759349e91b
3
+ metadata.gz: 8860a79aadc768a5173400d8c56041edde0fcd66b9e3f2f50e4918956c31b451
4
+ data.tar.gz: 07420fcc1f8aa91271ebf0484a338b921dba8fd41c16e2e50adf789a9eaaed33
5
5
  SHA512:
6
- metadata.gz: 0f8ebdfeb94e456cdffd8e8d176c9dde7dff4187f2547b721dd4ec33e229c290663386a1f1c091ee54e95821be3bc11eb8f02fbf645f3a9bd8a54ab1e79596b9
7
- data.tar.gz: cbfdad0f75642ec096bf18ca154a9aef9ce3364d92a43c263ceea81f877f018db3459b1a3fc239b2025aa6d96815db8f37063675f33a29d24fd24de4eaeb2299
6
+ metadata.gz: df9baad44f3b33dce3e18b795691fd6e7d8d620930826d2d9e423808b266b294b6ab3355619df9a2a406d51c7c0d3338be984599871579fbc67b69c5fc198a42
7
+ data.tar.gz: c93486338ce6906fb91bd96b55732a67525883971d7b7551690b34a4280337482160c096459b63623f0c2e629cb41b6af028fa008b274fc756137e5f21fc9958
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 1.3.1 (2026-09-04)
6
+
7
+ - Copy large structured hashes before Rails parameter filtering so caller fields stay intact
8
+ - Keep original key names on the large-hash encoder path when a Rails parameter filter is present
9
+ - Isolate nested hashes in `with_context` from later caller mutations
10
+ - Match fallback sensitive-key detection to Rails default filter substrings including email, otp, ssn, token, and credentials
11
+ - Rebuild the compiled Rails parameter filter after in-place `filter_parameters` changes
12
+ - Treat JSON object and array strings longer than 10,000 characters as truncated text
13
+ - Replace home-directory prefixes in log strings and backtraces with a tilde
14
+ - Skip EventSubscriber writes when the logger has INFO disabled; keep the Rails event timestamp as an integer
15
+ - Keep overflow sensitive keys as filtered after the 50-key hash cap without copying their values
16
+ - Require json 2.21.2 or newer
17
+
18
+ ## 1.3.0 (2026-07-25)
19
+
20
+ - BREAKING: Require Ruby 3.2+ and Rails 7+ (drop Ruby 2.7–3.1 and Rails 6 support)
21
+ - Add opt-in `JsonLogging::EventSubscriber` for Rails 8.1 `ActiveSupport::EventReporter` / `Rails.event` structured events
22
+
3
23
  ## 1.2.3 (2026-07-13)
4
24
 
5
25
  - Reduce per-line work for plain string, hash, tagged, and context-scoped log messages on common shapes
data/README.md CHANGED
@@ -1,17 +1,10 @@
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) [![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)
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://app.codecov.io/github/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/project/overview?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.
7
- Supports Rails versions from 6 to 8.
8
-
9
- Sponsored by [Kisko Labs](https://www.kiskolabs.com).
10
-
11
- <a href="https://www.kiskolabs.com">
12
- <img src="kisko.svg" width="200" alt="Sponsored by Kisko Labs" />
13
- </a>
14
-
7
+ Supports Ruby 3.2+ and Rails 7–8.
15
8
 
16
9
  ## Installation
17
10
 
@@ -46,6 +39,7 @@ end
46
39
  - Native `tagged` method support - use it just like Rails' tagged logger
47
40
  - Service-specific tagged loggers - create loggers with permanent tags using `logger.tagged("service")` without a block
48
41
  - Full compatibility with `ActiveSupport::BroadcastLogger` (Rails 7.1+)
42
+ - Opt-in `JsonLogging::EventSubscriber` for Rails 8.1 `ActiveSupport::EventReporter` / `Rails.event`
49
43
  - Automatic Rails integration via Railtie (auto-requires the gem in Rails apps)
50
44
 
51
45
  ## Basic usage
@@ -132,6 +126,37 @@ sidekiq_logger.info("Job enqueued") # Tagged with "sidekiq"
132
126
  api_logger.info("Request received") # Tagged with "api"
133
127
  ```
134
128
 
129
+ ### Rails 8.1 Event Reporter
130
+
131
+ Rails 8.1 adds `Rails.event` (`ActiveSupport::EventReporter`) for structured events that complement `Rails.logger`. Setting `config.logger` to a `JsonLogging` logger does not subscribe you to events. Register a subscriber:
132
+
133
+ ```ruby
134
+ # config/initializers/json_logging.rb
135
+ subscriber = JsonLogging::EventSubscriber.new(logger: -> { Rails.logger })
136
+ Rails.event.subscribe(subscriber)
137
+
138
+ # Optional: only a subset of events
139
+ Rails.event.subscribe(subscriber) { |event| event[:name].start_with?("app.") }
140
+ ```
141
+
142
+ Pass either `logger:` or `io:`, not both. A callable `logger:` is resolved on each emit (the Railtie opt-in uses this so a later `Rails.logger=` swap is picked up).
143
+
144
+ Or enable the Railtie opt-in (off by default):
145
+
146
+ ```ruby
147
+ # config/application.rb or an environment file
148
+ config.json_logging.subscribe_event_reporter = true
149
+ ```
150
+
151
+ The subscriber writes one JSON line per event and keeps the Rails event shape (`name`, `payload`, `tags`, `context`, `timestamp`, `source_location`). It writes through `Logger#<<` so the logger formatter does not wrap the event again. It skips that write when the logger reports that INFO is disabled (`logger.level` above INFO). An IO destination always writes. `timestamp` stays the Rails event integer (nanoseconds), not the ISO8601 string used by `JsonLoggerExtension`. Payload and tag objects that implement `#serialize` are encoded via that method. Encode and write failures never raise from `#emit`.
152
+
153
+ ```ruby
154
+ Rails.event.set_context(request_id: "abc123")
155
+ Rails.event.tagged("billing") do
156
+ Rails.event.notify("invoice.created", invoice_id: 3)
157
+ end
158
+ ```
159
+
135
160
  ### BroadcastLogger integration
136
161
 
137
162
  `ActiveSupport::BroadcastLogger` (Rails 7.1+) allows writing logs to multiple destinations simultaneously. `JsonLogging` works seamlessly with `BroadcastLogger`:
@@ -460,6 +485,16 @@ formatter.call("INFO", Time.now, nil, "message") # Output includes both tags
460
485
 
461
486
  **Note:** When used with a logger (via `JsonLogging.new`), the logger uses `FormatterWithTags` which automatically includes tags from the logger's tagged context. Use `Formatter` directly only when you need a standalone formatter without a logger instance.
462
487
 
488
+ ### JsonLogging::EventSubscriber
489
+
490
+ Rails 8.1+ subscriber for `ActiveSupport::EventReporter`. See [Rails 8.1 Event Reporter](#rails-81-event-reporter).
491
+
492
+ ```ruby
493
+ subscriber = JsonLogging::EventSubscriber.new(logger: -> { Rails.logger })
494
+ # or: JsonLogging::EventSubscriber.new(io: $stdout)
495
+ Rails.event.subscribe(subscriber)
496
+ ```
497
+
463
498
  ### JsonLogging.with_context
464
499
 
465
500
  Add thread-local context that appears in all log entries within the block:
@@ -554,7 +589,7 @@ This is useful for:
554
589
  - Temporary verbose logging in background jobs
555
590
  - Per-request log level changes
556
591
 
557
- **Note:** `local_level` is available in Rails 7.1+. In Rails 6-7.0, only the global `level` is available.
592
+ **Note:** `local_level` is available in Rails 7.1+. In Rails 7.0, only the global `level` is available.
558
593
 
559
594
  #### Standard Logger Methods
560
595
 
@@ -581,9 +616,9 @@ logger.reopen # Reopen the logger (if supported by logdev)
581
616
  ## Security & privacy
582
617
 
583
618
  - **Rails ParameterFilter integration**: Automatically uses `Rails.application.config.filter_parameters` to filter sensitive data (passwords, tokens, etc.). This includes encrypted attributes automatically. See [Rails parameter filtering guide](https://thoughtbot.com/blog/parameter-filtering).
584
- - **Input sanitization**: Removes control characters, truncates long strings, and limits structure depth/size:
619
+ - **Input sanitization**: Removes control characters, truncates long strings, redacts home-directory prefixes to `~`, and limits structure depth/size:
585
620
  - Maximum string length: 10,000 characters (truncated with `...[truncated]` suffix)
586
- - Maximum context hash size: 50 keys (additional keys are truncated)
621
+ - Maximum context hash size: 50 keys (additional keys are truncated; overflow keys that match sensitive patterns or `filter_parameters` still appear as `[FILTERED]`)
587
622
  - Maximum nesting depth: 10 levels (deeper structures return `{"error" => "max_depth_exceeded"}`)
588
623
  - Maximum backtrace lines: 20 lines per exception
589
624
  - **Single-line JSON**: Emits single-line JSON to avoid log injection via newlines
@@ -597,7 +632,7 @@ This gem automatically uses Rails' `config.filter_parameters` when available. Co
597
632
 
598
633
  ```ruby
599
634
  Rails.application.config.filter_parameters += [
600
- :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
635
+ :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
601
636
  ]
602
637
  ```
603
638
 
@@ -652,7 +687,28 @@ gem build activesupport-json_logging.gemspec
652
687
  gem push activesupport-json_logging-*.gem
653
688
  ```
654
689
 
690
+ ## Links
691
+
692
+ - [GitHub](https://github.com/amkisko/activesupport-json_logging.rb)
693
+ - [GitLab](https://gitlab.com/amkisko/activesupport-json_logging.rb)
694
+ - [RubyGems](https://rubygems.org/gems/activesupport-json_logging)
695
+ - [Versions Atom](https://rubygems.org/gems/activesupport-json_logging/versions.atom) (feed id `9801944970071`)
696
+ - [libraries.io](https://libraries.io/rubygems/activesupport-json_logging)
697
+ - [Deps.dev](https://deps.dev/rubygems/activesupport-json_logging)
698
+ - [SonarCloud](https://sonarcloud.io/project/overview?id=amkisko_activesupport-json_logging.rb)
699
+ - [Snyk](https://snyk.io/test/github/amkisko/activesupport-json_logging.rb)
700
+ - [Codecov](https://app.codecov.io/github/amkisko/activesupport-json_logging.rb)
701
+ - [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/amkisko/activesupport-json_logging.rb)
702
+
655
703
  ## License
656
704
 
657
705
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
658
706
 
707
+ ## Sponsors
708
+
709
+ Sponsored by [Kisko Labs](https://www.kiskolabs.com).
710
+
711
+ <a href="https://www.kiskolabs.com">
712
+ <img src="kisko.svg" width="200" alt="Sponsored by Kisko Labs" />
713
+ </a>
714
+
@@ -2,7 +2,18 @@ require "json_logging"
2
2
 
3
3
  module JsonLogging
4
4
  class Railtie < ::Rails::Railtie
5
+ config.json_logging = ActiveSupport::OrderedOptions.new
6
+ config.json_logging.subscribe_event_reporter = false
7
+
5
8
  # This Railtie ensures json_logging is automatically required when Rails loads
6
9
  # Without this, users would need to manually require "json_logging" in initializers
10
+
11
+ initializer "json_logging.subscribe_event_reporter", after: :load_config_initializers do |app|
12
+ next unless app.config.json_logging.subscribe_event_reporter
13
+ next unless defined?(ActiveSupport::EventReporter)
14
+ next unless Rails.respond_to?(:event)
15
+
16
+ Rails.event.subscribe(JsonLogging::EventSubscriber.new(logger: -> { Rails.logger }))
17
+ end
7
18
  end
8
19
  end
@@ -0,0 +1,150 @@
1
+ module JsonLogging
2
+ # Opt-in subscriber for ActiveSupport::EventReporter (Rails 8.1+).
3
+ # Preserves the Rails event hash shape as a single JSON line.
4
+ class EventSubscriber
5
+ def initialize(logger: nil, io: nil)
6
+ if !logger.nil? && !io.nil?
7
+ raise ArgumentError, "JsonLogging::EventSubscriber accepts only one of logger: or io:"
8
+ end
9
+ if logger.nil? && io.nil?
10
+ raise ArgumentError, "JsonLogging::EventSubscriber requires logger: or io:"
11
+ end
12
+
13
+ @logger = logger
14
+ @io = io
15
+ end
16
+
17
+ def emit(event)
18
+ line = begin
19
+ encode_event(event)
20
+ rescue => error
21
+ encode_fallback(event, error)
22
+ end
23
+
24
+ write_line(line)
25
+ rescue => error
26
+ report_write_error(error)
27
+ nil
28
+ end
29
+
30
+ private
31
+
32
+ def encode_event(event)
33
+ payload = {
34
+ "name" => Helpers.safe_string(event_field(event, :name)),
35
+ "payload" => serialize_payload(event_field(event, :payload)),
36
+ "tags" => serialize_tags(event_field(event, :tags)),
37
+ "context" => hash_or_empty(event_field(event, :context)),
38
+ "timestamp" => event_field(event, :timestamp)
39
+ }
40
+ source_location = event_field(event, :source_location)
41
+ payload["source_location"] = hash_or_empty(source_location) if source_location
42
+
43
+ LineEncoder.to_json_line(Sanitizer.sanitize_hash(payload))
44
+ end
45
+
46
+ def encode_fallback(event, error)
47
+ event_name = begin
48
+ Helpers.safe_string(event && event_field(event, :name))
49
+ rescue
50
+ nil
51
+ end
52
+
53
+ LineEncoder.to_json_line(
54
+ Sanitizer.sanitize_hash(
55
+ {
56
+ "name" => "json_logging.event_encode_error",
57
+ "event_name" => event_name,
58
+ "error" => {
59
+ "class" => error.class.name,
60
+ "message" => Helpers.safe_string(error.message)
61
+ }
62
+ }
63
+ )
64
+ )
65
+ end
66
+
67
+ def event_field(event, key)
68
+ return nil unless event.is_a?(Hash)
69
+
70
+ if event.key?(key)
71
+ event[key]
72
+ elsif event.key?(key.to_s)
73
+ event[key.to_s]
74
+ end
75
+ end
76
+
77
+ def serialize_payload(payload)
78
+ case payload
79
+ when Hash
80
+ payload
81
+ when nil
82
+ {}
83
+ else
84
+ serialize_object(payload)
85
+ end
86
+ end
87
+
88
+ def serialize_tags(tags)
89
+ return {} unless tags.is_a?(Hash)
90
+
91
+ tags.transform_values { |value| serialize_tag_value(value) }
92
+ end
93
+
94
+ def serialize_tag_value(value)
95
+ case value
96
+ when Hash, TrueClass, FalseClass, Numeric, NilClass, String
97
+ value
98
+ else
99
+ serialize_object(value)
100
+ end
101
+ end
102
+
103
+ def serialize_object(object)
104
+ if object.respond_to?(:serialize)
105
+ serialized = object.serialize
106
+ serialized.is_a?(Hash) ? serialized : {"value" => serialized}
107
+ elsif object.respond_to?(:to_h)
108
+ object.to_h
109
+ else
110
+ {
111
+ "class" => object.class.name,
112
+ "value" => Helpers.safe_string(object)
113
+ }
114
+ end
115
+ end
116
+
117
+ def hash_or_empty(value)
118
+ value.is_a?(Hash) ? value : {}
119
+ end
120
+
121
+ def write_line(line)
122
+ if @io
123
+ @io.write(line)
124
+ return
125
+ end
126
+
127
+ logger = resolve_logger
128
+ return if logger.respond_to?(:info?) && !logger.info?
129
+
130
+ logger << line
131
+ end
132
+
133
+ def resolve_logger
134
+ logger = @logger
135
+ # Proc responds to << (composition); Logger responds to add.
136
+ return logger.call if logger.respond_to?(:call) && !logger.respond_to?(:add)
137
+
138
+ logger
139
+ end
140
+
141
+ def report_write_error(error)
142
+ return unless defined?(ActiveSupport) && ActiveSupport.respond_to?(:error_reporter)
143
+ return if ActiveSupport.error_reporter.nil?
144
+
145
+ ActiveSupport.error_reporter.report(error, handled: true, severity: :error)
146
+ rescue
147
+ nil
148
+ end
149
+ end
150
+ end
@@ -8,18 +8,16 @@ module JsonLogging
8
8
 
9
9
  def initialize(*args, **kwargs)
10
10
  # Initialize with minimal args to avoid ActiveSupport::Logger threading issues
11
- # Rails 7+ supports kwargs, Rails 6 uses positional args only
12
11
  logdev = args.first || $stdout
13
12
  shift_age = args[1] || 0
14
13
  shift_size = args[2]
15
14
 
16
- # Handle both positional and keyword arguments for Rails 6-8 compatibility
15
+ # Handle both positional and keyword arguments for Rails 7–8 compatibility
17
16
  if kwargs.empty? && shift_size
18
17
  super(logdev, shift_age, shift_size)
19
18
  elsif kwargs.empty?
20
19
  super(logdev, shift_age)
21
20
  else
22
- # Rails 7+ may pass kwargs - delegate to parent
23
21
  super
24
22
  end
25
23
 
@@ -95,7 +95,7 @@ module JsonLogging
95
95
 
96
96
  def current_tags
97
97
  # Use IsolatedExecutionState (Rails 7.1+) for better thread/Fiber safety
98
- # Falls back to Thread.current for Rails 6-7.0
98
+ # Falls back to Thread.current for Rails 7.0
99
99
  if defined?(ActiveSupport::IsolatedExecutionState)
100
100
  ActiveSupport::IsolatedExecutionState[tags_key] ||= []
101
101
  else
@@ -105,7 +105,7 @@ module JsonLogging
105
105
 
106
106
  def set_tags(new_tags)
107
107
  # Use IsolatedExecutionState (Rails 7.1+) for better thread/Fiber safety
108
- # Falls back to Thread.current for Rails 6-7.0
108
+ # Falls back to Thread.current for Rails 7.0
109
109
  if defined?(ActiveSupport::IsolatedExecutionState)
110
110
  ActiveSupport::IsolatedExecutionState[tags_key] = new_tags
111
111
  else
@@ -223,14 +223,6 @@ module JsonLogging
223
223
  "#{JSON.generate(payload)}\n"
224
224
  end
225
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
226
  def deep_stringify_structure(obj)
235
227
  case obj
236
228
  when Hash
@@ -25,6 +25,8 @@ module JsonLogging
25
25
  end
26
26
 
27
27
  def json_string?(str)
28
+ return false if str.length > Sanitizer::MAX_STRING_LENGTH
29
+
28
30
  (str.start_with?("{") && str.end_with?("}")) ||
29
31
  (str.start_with?("[") && str.end_with?("]"))
30
32
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "structured_hash_sanitizer"
2
+ require_relative "sanitizer_hash_limit"
2
3
 
3
4
  module JsonLogging
4
5
  module Sanitizer
@@ -18,7 +19,7 @@ module JsonLogging
18
19
  MAX_BACKTRACE_LINES = 20
19
20
 
20
21
  # Common sensitive key patterns (case insensitive) - fallback when Rails ParameterFilter not available
21
- SENSITIVE_KEY_PATTERNS = /\b(password|passwd|pwd|secret|token|api_key|apikey|access_token|auth_token|private_key|credential)\b/i
22
+ SENSITIVE_KEY_PATTERNS = /(passw|pwd|email|secret|token|_key|crypt|salt|certificate|otp|ssn|cvv|cvc|credential)/i
22
23
 
23
24
  @parameter_filter = nil
24
25
  @parameter_filter_config = nil
@@ -38,7 +39,7 @@ module JsonLogging
38
39
  return @parameter_filter
39
40
  end
40
41
 
41
- @parameter_filter_config = filter_params
42
+ @parameter_filter_config = filter_params.dup.freeze
42
43
  @parameter_filter_requires_full_tree_walk = parameter_filter_requires_full_tree_walk?(filter_params)
43
44
  @parameter_filter = ActiveSupport::ParameterFilter.new(filter_params)
44
45
  rescue
@@ -76,12 +77,14 @@ module JsonLogging
76
77
  # Sanitize a string by removing/escaping control characters and truncating
77
78
  def sanitize_string(str)
78
79
  return str unless str.is_a?(String)
79
- return str if str.length <= MAX_STRING_LENGTH && !str.match?(CONTROL_CHARS)
80
80
 
81
- # Remove or replace control characters
81
+ home_prefix = home_directory_prefix
82
+ has_home_directory = home_prefix && str.include?(home_prefix)
83
+ return str if str.length <= MAX_STRING_LENGTH && !str.match?(CONTROL_CHARS) && !has_home_directory
84
+
82
85
  sanitized = str.gsub(CONTROL_CHARS, "")
86
+ sanitized = redact_home_directory(sanitized, home_prefix) if has_home_directory
83
87
 
84
- # Truncate if too long
85
88
  if sanitized.length > MAX_STRING_LENGTH
86
89
  sanitized = sanitized[0, MAX_STRING_LENGTH] + "...[truncated]"
87
90
  end
@@ -91,12 +94,8 @@ module JsonLogging
91
94
  "<sanitization_error>"
92
95
  end
93
96
 
94
- # Sanitize a hash, removing sensitive keys and limiting size/depth
95
- # Uses Rails ParameterFilter when available, falls back to pattern matching
96
97
  def sanitize_hash(hash, depth: 0)
97
98
  return hash unless hash.is_a?(Hash)
98
-
99
- # Prevent excessive nesting
100
99
  return {"error" => "max_depth_exceeded"} if depth > MAX_DEPTH
101
100
 
102
101
  limited_hash = limited_hash_for_sanitization(hash)
@@ -109,13 +108,7 @@ module JsonLogging
109
108
  end
110
109
 
111
110
  def limited_hash_for_sanitization(hash)
112
- if hash.size > MAX_CONTEXT_SIZE
113
- truncated = hash.first(MAX_CONTEXT_SIZE).to_h
114
- truncated["_truncated"] = true
115
- truncated
116
- else
117
- hash
118
- end
111
+ SanitizerHashLimit.apply(hash, filter_config: rails_parameter_filter && @parameter_filter_config)
119
112
  end
120
113
 
121
114
  def fast_path_sanitized_hash(hash, depth: 0)
@@ -126,38 +119,26 @@ module JsonLogging
126
119
  end
127
120
 
128
121
  def sanitize_hash_with_filtering(limited_hash, depth: 0)
129
- # Use Rails ParameterFilter if available (handles encrypted attributes automatically)
130
122
  filter = rails_parameter_filter
131
123
  if filter
132
- # ParameterFilter will filter based on Rails.config.filter_parameters
133
- # This includes encrypted attributes automatically
134
- # Create a deep copy since filter modifies in place (Rails 6+)
124
+ # ParameterFilter mutates in place; copy first
135
125
  filtered = limited_hash.respond_to?(:deep_dup) ? limited_hash.deep_dup : limited_hash.dup
136
126
  filtered = filter.filter(filtered)
137
-
138
- # Then sanitize values (strings, control chars, etc.) preserving filtered structure
139
127
  filtered.each_with_object({}) do |(key, value), result|
140
128
  result[key.to_s] = sanitize_value(value, depth: depth + 1)
141
129
  end
142
-
143
130
  else
144
- # Fallback: use pattern matching for sensitive keys
145
131
  limited_hash.each_with_object({}) do |(key, value), result|
146
132
  key_string = key.to_s
147
-
148
- # Skip sensitive keys
149
133
  if SENSITIVE_KEY_PATTERNS.match?(key_string)
150
134
  result[sensitive_filtered_key_name(key_string)] = "[FILTERED]"
151
135
  next
152
136
  end
153
-
154
137
  result[key_string] = sanitize_value(value, depth: depth + 1)
155
138
  end
156
139
  end
157
140
  end
158
141
 
159
- # Sanitize a value (handles strings, hashes, arrays, etc.)
160
- # Preserves numeric, boolean, and nil types
161
142
  def sanitize_value(value, depth: 0)
162
143
  case value
163
144
  when String
@@ -165,24 +146,20 @@ module JsonLogging
165
146
  when Hash
166
147
  sanitize_hash(value, depth: depth)
167
148
  when Array
168
- # Limit array size
169
149
  sanitized = value.first(MAX_CONTEXT_SIZE).map { |v| sanitize_value(v, depth: depth + 1) }
170
150
  sanitized << "[truncated]" if value.size > MAX_CONTEXT_SIZE
171
151
  sanitized
172
152
  when Exception
173
153
  sanitize_exception(value)
174
154
  when Numeric, TrueClass, FalseClass, NilClass
175
- # Preserve numeric, boolean, and nil types
176
155
  value
177
156
  else
178
- # For other types, convert to string and sanitize
179
157
  sanitize_string(value.to_s)
180
158
  end
181
159
  rescue
182
160
  "<unprintable>"
183
161
  end
184
162
 
185
- # Sanitize exception, including backtrace
186
163
  def sanitize_exception(ex)
187
164
  {
188
165
  "error" => {
@@ -195,11 +172,9 @@ module JsonLogging
195
172
  {"error" => {"class" => "Exception", "message" => "<sanitization_failed>"}}
196
173
  end
197
174
 
198
- # Sanitize backtrace - truncate and remove sensitive paths
199
175
  def sanitize_backtrace(backtrace)
200
176
  return [] unless backtrace.is_a?(Array)
201
177
 
202
- # Take first MAX_BACKTRACE_LINES, sanitize each
203
178
  backtrace.first(MAX_BACKTRACE_LINES).map do |line|
204
179
  sanitize_string(line.to_s)
205
180
  end
@@ -207,7 +182,6 @@ module JsonLogging
207
182
  []
208
183
  end
209
184
 
210
- # Check if a key looks sensitive
211
185
  def sensitive_key?(key)
212
186
  SENSITIVE_KEY_PATTERNS.match?(key.to_s)
213
187
  end
@@ -255,5 +229,22 @@ module JsonLogging
255
229
  def sensitive_filtered_key_name(key_string)
256
230
  key_string.gsub(/(?<!^)(?=[A-Z])/, "_").downcase + "_filtered"
257
231
  end
232
+
233
+ def home_directory_prefix
234
+ prefix = ENV["HOME"]
235
+ prefix = Dir.home if prefix.blank?
236
+ prefix = prefix.to_s.chomp("/").chomp("\\")
237
+ return if prefix.empty?
238
+
239
+ prefix
240
+ rescue ArgumentError, RuntimeError
241
+ nil
242
+ end
243
+
244
+ def redact_home_directory(str, prefix)
245
+ return str unless prefix
246
+
247
+ str.gsub(/#{Regexp.escape(prefix)}(?=\/|\\|\z)/, "~")
248
+ end
258
249
  end
259
250
  end
@@ -0,0 +1,42 @@
1
+ module JsonLogging
2
+ module SanitizerHashLimit
3
+ FILTERED = "[FILTERED]"
4
+
5
+ module_function
6
+
7
+ def apply(hash, filter_config:)
8
+ return hash if hash.size <= Sanitizer::MAX_CONTEXT_SIZE
9
+
10
+ truncated = {}
11
+ kept = 0
12
+ extra_filtered = 0
13
+ hash.each do |key, value|
14
+ if kept < Sanitizer::MAX_CONTEXT_SIZE
15
+ truncated[key] = value
16
+ kept += 1
17
+ elsif extra_filtered < Sanitizer::MAX_CONTEXT_SIZE && key_requires_filtering?(key, filter_config)
18
+ truncated[key] = FILTERED
19
+ extra_filtered += 1
20
+ end
21
+ end
22
+ truncated["_truncated"] = true
23
+ truncated
24
+ end
25
+
26
+ def key_requires_filtering?(key, filter_config)
27
+ return true if Sanitizer.sensitive_key?(key)
28
+
29
+ Array(filter_config).any? do |filter|
30
+ next false if filter.is_a?(Proc)
31
+
32
+ key_string = key.to_s
33
+ if filter.is_a?(Regexp)
34
+ filter.match?(key_string)
35
+ else
36
+ token = filter.to_s
37
+ !token.empty? && !token.include?(".") && key_string.match?(/#{Regexp.escape(token)}/i)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -6,35 +6,6 @@ module JsonLogging
6
6
 
7
7
  module_function
8
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
9
  def try_encode_line(hash, severity:, timestamp:, field_overrides: {})
39
10
  tree = prepared_tree(hash, field_overrides: field_overrides)
40
11
  return nil unless tree
@@ -88,13 +59,7 @@ module JsonLogging
88
59
  return jsonable.tree unless filter
89
60
  return jsonable.tree if Sanitizer.rails_parameter_filter_requires_full_tree_walk?
90
61
 
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)
62
+ filter.filter(Sanitizer::StructuredHash.stringify(jsonable.tree))
98
63
  end
99
64
 
100
65
  def append_json_line(tree, severity:, timestamp:, field_overrides:)
@@ -1,7 +1,7 @@
1
1
  module JsonLogging
2
2
  module Sanitizer
3
3
  module StructuredHash
4
- JsonableResult = Struct.new(:tree, :owned, :leaf_count, keyword_init: true)
4
+ JsonableResult = Struct.new(:tree, :owned, :leaf_count)
5
5
 
6
6
  module_function
7
7
 
@@ -44,11 +44,7 @@ module JsonLogging
44
44
 
45
45
  def sanitize_without_filter(hash, depth: 0)
46
46
  jsonable = jsonable_tree(hash, depth: depth)
47
- if jsonable.owned
48
- stringify(jsonable.tree)
49
- else
50
- jsonable.tree.dup
51
- end
47
+ stringify(jsonable.tree)
52
48
  end
53
49
 
54
50
  def jsonable_tree(hash, depth: 0, seen: nil, parent_key: nil, omit_keys: nil, count_leaves: false, leaf_counter: nil)
@@ -99,14 +95,14 @@ module JsonLogging
99
95
  when String
100
96
  leaf_counter[0] += 1 if leaf_counter
101
97
  sanitized = Sanitizer.sanitize_string(value)
102
- JsonableResult.new(tree: sanitized, owned: sanitized != value)
98
+ JsonableResult.new(tree: sanitized, owned: sanitized != value, leaf_count: nil)
103
99
  when Hash
104
100
  jsonable_tree(value, depth: depth + 1, seen: seen, parent_key: parent_key, leaf_counter: leaf_counter)
105
101
  when Array
106
102
  jsonable_array(value, depth: depth, seen: seen, parent_key: parent_key, leaf_counter: leaf_counter)
107
103
  else
108
104
  leaf_counter[0] += 1 if leaf_counter
109
- JsonableResult.new(tree: value, owned: false)
105
+ JsonableResult.new(tree: value, owned: false, leaf_count: nil)
110
106
  end
111
107
  end
112
108
 
@@ -123,7 +119,7 @@ module JsonLogging
123
119
  result[index] = child.tree
124
120
  end
125
121
 
126
- JsonableResult.new(tree: owned ? result : array, owned: owned)
122
+ JsonableResult.new(tree: owned ? result : array, owned: owned, leaf_count: nil)
127
123
  end
128
124
 
129
125
  def process_jsonable_entry(key, value, depth:, seen:, parent_key:, omit_keys:, leaf_counter:)
@@ -133,7 +129,8 @@ module JsonLogging
133
129
  return
134
130
  end
135
131
 
136
- if Sanitizer::SENSITIVE_KEY_PATTERNS.match?(key_string)
132
+ # ParameterFilter keeps original key names; fallback rename would hide them from it.
133
+ if Sanitizer.rails_parameter_filter.nil? && Sanitizer::SENSITIVE_KEY_PATTERNS.match?(key_string)
137
134
  return [Sanitizer.sensitive_filtered_key_name(key_string), "[FILTERED]", true]
138
135
  end
139
136
 
@@ -195,12 +192,6 @@ module JsonLogging
195
192
  value
196
193
  end
197
194
  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
195
  end
205
196
  end
206
197
  end
@@ -1,3 +1,3 @@
1
1
  module JsonLogging
2
- VERSION = "1.2.2"
2
+ VERSION = "1.3.1"
3
3
  end
data/lib/json_logging.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "json_logging/formatter"
12
12
  require_relative "json_logging/formatter_with_tags"
13
13
  require_relative "json_logging/json_logger_extension"
14
14
  require_relative "json_logging/json_logger"
15
+ require_relative "json_logging/event_subscriber"
15
16
 
16
17
  module JsonLogging
17
18
  THREAD_CONTEXT_KEY = :__json_logging_context
data/sig/json_logging.rbs CHANGED
@@ -1,3 +1,8 @@
1
1
  module JsonLogging
2
2
  VERSION: String
3
+
4
+ class EventSubscriber
5
+ def initialize: (?logger: untyped, ?io: untyped) -> void
6
+ def emit: (untyped event) -> void
7
+ end
3
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.2
4
+ version: 1.3.1
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,10 +45,24 @@ 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'
52
+ - !ruby/object:Gem::Dependency
53
+ name: json
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 2.21.2
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 2.21.2
52
66
  - !ruby/object:Gem::Dependency
53
67
  name: rspec
54
68
  requirement: !ruby/object:Gem::Requirement
@@ -77,20 +91,6 @@ dependencies:
77
91
  - - ">="
78
92
  - !ruby/object:Gem::Version
79
93
  version: 2.2.0
80
- - !ruby/object:Gem::Dependency
81
- name: webmock
82
- requirement: !ruby/object:Gem::Requirement
83
- requirements:
84
- - - "~>"
85
- - !ruby/object:Gem::Version
86
- version: '3'
87
- type: :development
88
- prerelease: false
89
- version_requirements: !ruby/object:Gem::Requirement
90
- requirements:
91
- - - "~>"
92
- - !ruby/object:Gem::Version
93
- version: '3'
94
94
  - !ruby/object:Gem::Dependency
95
95
  name: rake
96
96
  requirement: !ruby/object:Gem::Requirement
@@ -316,7 +316,7 @@ dependencies:
316
316
  - !ruby/object:Gem::Version
317
317
  version: '0.2'
318
318
  description: Lightweight JSON logger and formatter integrating with Rails/ActiveSupport.
319
- No extra deps beyond Rails/Activesupport. Compatible with Rails 6–8.
319
+ No extra deps beyond Rails/Activesupport. Compatible with Rails 7–8.
320
320
  email:
321
321
  - contact@kiskolabs.com
322
322
  executables: []
@@ -329,6 +329,7 @@ files:
329
329
  - lib/activesupport/json_logging.rb
330
330
  - lib/activesupport/json_logging/railtie.rb
331
331
  - lib/json_logging.rb
332
+ - lib/json_logging/event_subscriber.rb
332
333
  - lib/json_logging/formatter.rb
333
334
  - lib/json_logging/formatter_with_tags.rb
334
335
  - lib/json_logging/helpers.rb
@@ -338,6 +339,7 @@ files:
338
339
  - lib/json_logging/message_parser.rb
339
340
  - lib/json_logging/payload_builder.rb
340
341
  - lib/json_logging/sanitizer.rb
342
+ - lib/json_logging/sanitizer_hash_limit.rb
341
343
  - lib/json_logging/severity.rb
342
344
  - lib/json_logging/structured_hash_json_encoder.rb
343
345
  - lib/json_logging/structured_hash_sanitizer.rb
@@ -357,14 +359,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
357
359
  requirements:
358
360
  - - ">="
359
361
  - !ruby/object:Gem::Version
360
- version: '2.7'
362
+ version: '3.2'
361
363
  required_rubygems_version: !ruby/object:Gem::Requirement
362
364
  requirements:
363
365
  - - ">="
364
366
  - !ruby/object:Gem::Version
365
367
  version: '0'
366
368
  requirements: []
367
- rubygems_version: 4.0.3
369
+ rubygems_version: 4.0.6
368
370
  specification_version: 4
369
371
  summary: Structured JSON logging for Rails/ActiveSupport with safe, single-line entries.
370
372
  test_files: []