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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 879dfb3d0d8142a294407c70771d234e55d410032dbc50f035bf85f3bd33b79a
4
- data.tar.gz: bfa9d0637d5e84f58acdf407ce782feda91b584c5c63700b93ec2aa4e6e59dd4
3
+ metadata.gz: 2b40f43e3cd600bfc139665fa3e6cc9df7a4a56eab58bec7f760b986faa9d2f7
4
+ data.tar.gz: d8d3352e482dc9698aa94a499f9905a1fdec0ca157cedf4e8a61194dfd7e7fba
5
5
  SHA512:
6
- metadata.gz: e4ed4ef5739e411af61e6186581ec30aa3f0653c557d7f2bc088abf264a3e8608c483b0790b892a7e1c10674d55811f95a5a3e4c33d073cd59949469e732e875
7
- data.tar.gz: ec86db106814dee2731695159275fb7c56700d36564571edd72e54013d076dd1a3328a050eae06429d16eaaf1c0538404ae8345605d5a2ec63684ca1f71f40b7
6
+ metadata.gz: 4b0befde4733aeade6f332adaf529735ed718fc5b4ad03dbbcbcd6d66e0124ed107e508f47fc3efa70f4fc31479d61ac42508d364c185c846a9c93ce51924cac
7
+ data.tar.gz: 83b705a450abaf4b389495652843bc4ed537de7485bb1158423b5a9055e40616024ed928844a485d65aa26cadfbe64746570dd823943dc724b29e849dcbd7ecf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.3.0 (2026-07-25)
4
+
5
+ - BREAKING: Require Ruby 3.2+ and Rails 7+ (drop Ruby 2.7–3.1 and Rails 6 support)
6
+ - Add opt-in `JsonLogging::EventSubscriber` for Rails 8.1 `ActiveSupport::EventReporter` / `Rails.event` structured events
7
+
8
+ ## 1.2.3 (2026-07-13)
9
+
10
+ - Reduce per-line work for plain string, hash, tagged, and context-scoped log messages on common shapes
11
+ - Sanitize flat and nested structured hash payloads without deep-copying the source when values are logging-safe primitives
12
+ - Reuse ISO8601 microsecond UTC timestamps when callers already supply them
13
+ - Skip context merging when tags and additional context are both empty
14
+ - Cache empty thread context as a frozen hash on the hot path
15
+ - Expand hot-path contract specs for fast-path logging and sanitization
16
+ - 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
17
+
18
+ ## 1.2.2 (2026-07-08)
19
+
20
+ - Isolate log context per execution via `ActiveSupport::IsolatedExecutionState` when available
21
+ - Sanitize fallback formatter output when JSON encoding fails
22
+ - Refactor emit path into `LineEncoder`, `PayloadBuilder`, and `Severity` modules
23
+ - Cache sanitized `additional_context` per `with_context` scope on the hot path
24
+ - Memoize `ActiveSupport::ParameterFilter` when `filter_parameters` is unchanged
25
+ - Fast-path `sanitize_string` for clean ASCII text within length limits
26
+ - Sanitize tags at push time instead of on every log line
27
+ - Skip `deep_stringify_keys` when payload structure already uses string keys
28
+ - Add hot-path optimization specs
29
+
3
30
  ## 1.2.1 (2026-01-18)
4
31
 
5
32
  - Refactor tag stack handling in JsonLogging formatter for improved maintainability
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. 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
 
@@ -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,146 @@
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
+ else
125
+ resolve_logger << line
126
+ end
127
+ end
128
+
129
+ def resolve_logger
130
+ logger = @logger
131
+ # Proc responds to << (composition); Logger responds to add.
132
+ return logger.call if logger.respond_to?(:call) && !logger.respond_to?(:add)
133
+
134
+ logger
135
+ end
136
+
137
+ def report_write_error(error)
138
+ return unless defined?(ActiveSupport) && ActiveSupport.respond_to?(:error_reporter)
139
+ return if ActiveSupport.error_reporter.nil?
140
+
141
+ ActiveSupport.error_reporter.report(error, handled: true, severity: :error)
142
+ rescue
143
+ nil
144
+ end
145
+ end
146
+ end
@@ -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
@@ -18,12 +18,15 @@ module JsonLogging
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
@@ -89,14 +92,14 @@ module JsonLogging
89
92
  timestamp_str = Helpers.normalize_timestamp(timestamp)
90
93
  fallback_payload = {
91
94
  timestamp: timestamp_str,
92
- severity: severity,
93
- message: Helpers.safe_string(msg),
95
+ severity: Severity.name_for(severity),
96
+ message: Sanitizer.sanitize_string(Helpers.safe_string(msg)),
94
97
  formatter_error: {
95
98
  class: Sanitizer.sanitize_string(error.class.name),
96
99
  message: Sanitizer.sanitize_string(Helpers.safe_string(error.message))
97
100
  }
98
101
  }
99
- "#{fallback_payload.compact.to_json}\n"
102
+ LineEncoder.to_json_line(fallback_payload)
100
103
  end
101
104
  end
102
105
  end
@@ -1,13 +1,28 @@
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
- time = timestamp || current_time
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)
8
19
  time.utc.iso8601(6)
9
20
  end
10
21
 
22
+ def iso8601_microsecond_utc?(timestamp)
23
+ timestamp.is_a?(String) && timestamp.match?(ISO8601_MICROSECOND_UTC_PATTERN)
24
+ end
25
+
11
26
  # Get current time, using Time.zone if available, otherwise Time.now
12
27
  def current_time
13
28
  if defined?(Time.zone) && Time.zone
@@ -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
 
@@ -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(Helpers.current_time),
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,13 +67,35 @@ 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
97
95
 
98
96
  def current_tags
99
97
  # Use IsolatedExecutionState (Rails 7.1+) for better thread/Fiber safety
100
- # Falls back to Thread.current for Rails 6-7.0
98
+ # Falls back to Thread.current for Rails 7.0
101
99
  if defined?(ActiveSupport::IsolatedExecutionState)
102
100
  ActiveSupport::IsolatedExecutionState[tags_key] ||= []
103
101
  else
@@ -107,7 +105,7 @@ module JsonLogging
107
105
 
108
106
  def set_tags(new_tags)
109
107
  # Use IsolatedExecutionState (Rails 7.1+) for better thread/Fiber safety
110
- # Falls back to Thread.current for Rails 6-7.0
108
+ # Falls back to Thread.current for Rails 7.0
111
109
  if defined?(ActiveSupport::IsolatedExecutionState)
112
110
  ActiveSupport::IsolatedExecutionState[tags_key] = new_tags
113
111
  else
@@ -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(Helpers.current_time)
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,8 +154,9 @@ 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