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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +66 -10
- data/lib/activesupport/json_logging/railtie.rb +11 -0
- data/lib/json_logging/event_subscriber.rb +146 -0
- data/lib/json_logging/formatter.rb +13 -9
- data/lib/json_logging/formatter_with_tags.rb +12 -9
- data/lib/json_logging/helpers.rb +16 -1
- data/lib/json_logging/json_logger.rb +1 -3
- data/lib/json_logging/json_logger_extension.rb +47 -71
- data/lib/json_logging/line_encoder.rb +245 -0
- data/lib/json_logging/payload_builder.rb +57 -21
- data/lib/json_logging/sanitizer.rb +112 -11
- data/lib/json_logging/severity.rb +18 -0
- data/lib/json_logging/structured_hash_json_encoder.rb +111 -0
- data/lib/json_logging/structured_hash_sanitizer.rb +206 -0
- data/lib/json_logging/version.rb +1 -1
- data/lib/json_logging.rb +85 -11
- data/sig/json_logging.rbs +8 -0
- metadata +53 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b40f43e3cd600bfc139665fa3e6cc9df7a4a56eab58bec7f760b986faa9d2f7
|
|
4
|
+
data.tar.gz: d8d3352e482dc9698aa94a499f9905a1fdec0ca157cedf4e8a61194dfd7e7fba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[](https://badge.fury.io/rb/activesupport-json_logging) [](https://github.com/amkisko/activesupport-json_logging.rb/actions/workflows/test.yml) [](https://codecov.io/
|
|
3
|
+
[](https://badge.fury.io/rb/activesupport-json_logging) [](https://github.com/amkisko/activesupport-json_logging.rb/actions/workflows/test.yml) [](https://app.codecov.io/github/amkisko/activesupport-json_logging.rb) [](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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
102
|
+
LineEncoder.to_json_line(fallback_payload)
|
|
100
103
|
end
|
|
101
104
|
end
|
|
102
105
|
end
|
data/lib/json_logging/helpers.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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=(
|
|
23
|
-
#
|
|
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 =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
128
|
+
Severity.name_for(severity)
|
|
145
129
|
end
|
|
146
130
|
|
|
147
|
-
def stringify_keys(
|
|
148
|
-
|
|
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 =
|
|
157
|
+
flat = Sanitizer.prepare_tags(tags)
|
|
183
158
|
return [] if flat.empty?
|
|
159
|
+
|
|
184
160
|
@tags.concat(flat)
|
|
185
161
|
flat
|
|
186
162
|
end
|