activesupport-json_logging 1.2.1 → 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 +4 -4
- data/CHANGELOG.md +22 -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_extension.rb +45 -69
- data/lib/json_logging/line_encoder.rb +245 -0
- data/lib/json_logging/payload_builder.rb +57 -21
- data/lib/json_logging/sanitizer.rb +111 -10
- 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 +84 -11
- data/sig/json_logging.rbs +3 -0
- metadata +45 -26
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71ac52d26c4cca14a0eaed618927f9432880865ad9b24e43232b5801ce10a055
|
|
4
|
+
data.tar.gz: 81ef2c90e5d44fc7910f319acdbbd4bb9b56572075c6a28050fd05759349e91b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f8ebdfeb94e456cdffd8e8d176c9dde7dff4187f2547b721dd4ec33e229c290663386a1f1c091ee54e95821be3bc11eb8f02fbf645f3a9bd8a54ab1e79596b9
|
|
7
|
+
data.tar.gz: cbfdad0f75642ec096bf18ca154a9aef9ce3364d92a43c263ceea81f877f018db3459b1a3fc239b2025aa6d96815db8f37063675f33a29d24fd24de4eaeb2299
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
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
|
+
|
|
3
25
|
## 1.2.1 (2026-01-18)
|
|
4
26
|
|
|
5
27
|
- Refactor tag stack handling in JsonLogging formatter for improved maintainability
|
|
@@ -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
|
|
@@ -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,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 =
|
|
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
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
require "active_support/core_ext/hash/keys"
|
|
2
|
+
|
|
3
|
+
require_relative "message_parser"
|
|
4
|
+
require_relative "severity"
|
|
5
|
+
require_relative "payload_builder"
|
|
6
|
+
require_relative "structured_hash_json_encoder"
|
|
7
|
+
|
|
8
|
+
module JsonLogging
|
|
9
|
+
module LineEncoder
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def build_line(msg:, severity:, timestamp:, tags:, additional_context:, additional_context_sanitized: false, sanitize_tags: true)
|
|
13
|
+
if simple_string_line?(msg, tags, additional_context)
|
|
14
|
+
return simple_string_json_line(
|
|
15
|
+
message: msg,
|
|
16
|
+
severity: Severity.name_for(severity),
|
|
17
|
+
timestamp: timestamp
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if tagged_simple_string_line?(msg, tags, additional_context)
|
|
22
|
+
return tagged_simple_string_json_line(
|
|
23
|
+
message: msg,
|
|
24
|
+
severity: Severity.name_for(severity),
|
|
25
|
+
timestamp: timestamp,
|
|
26
|
+
tags: tags,
|
|
27
|
+
sanitize_tags: sanitize_tags
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if contextual_simple_string_line?(msg, tags, additional_context, additional_context_sanitized: additional_context_sanitized)
|
|
32
|
+
return contextual_simple_string_json_line(
|
|
33
|
+
message: msg,
|
|
34
|
+
severity: Severity.name_for(severity),
|
|
35
|
+
timestamp: timestamp,
|
|
36
|
+
additional_context: additional_context
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if standalone_hash_line?(msg, tags, additional_context)
|
|
41
|
+
return standalone_hash_json_line(
|
|
42
|
+
message: msg,
|
|
43
|
+
severity: Severity.name_for(severity),
|
|
44
|
+
timestamp: timestamp
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if tagged_hash_line?(msg, tags, additional_context)
|
|
49
|
+
return tagged_hash_json_line(
|
|
50
|
+
message: msg,
|
|
51
|
+
severity: Severity.name_for(severity),
|
|
52
|
+
timestamp: timestamp,
|
|
53
|
+
tags: tags,
|
|
54
|
+
sanitize_tags: sanitize_tags
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if contextual_hash_line?(msg, tags, additional_context, additional_context_sanitized: additional_context_sanitized)
|
|
59
|
+
return contextual_hash_json_line(
|
|
60
|
+
message: msg,
|
|
61
|
+
severity: Severity.name_for(severity),
|
|
62
|
+
timestamp: timestamp,
|
|
63
|
+
additional_context: additional_context
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
sev = Severity.name_for(severity)
|
|
68
|
+
payload = PayloadBuilder.build_base_payload(msg, severity: sev, timestamp: timestamp)
|
|
69
|
+
unless tags.empty? && PayloadBuilder.empty_additional_context?(additional_context)
|
|
70
|
+
payload = PayloadBuilder.merge_context(
|
|
71
|
+
payload,
|
|
72
|
+
additional_context: additional_context,
|
|
73
|
+
tags: tags,
|
|
74
|
+
additional_context_sanitized: additional_context_sanitized,
|
|
75
|
+
sanitize_tags: sanitize_tags
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
to_json_line(payload)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_json_line(payload_hash)
|
|
82
|
+
compacted = payload_hash.compact
|
|
83
|
+
json_payload = string_keyed_structure?(compacted) ? compacted : compacted.deep_stringify_keys
|
|
84
|
+
"#{json_payload.to_json}\n"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def string_keyed_structure?(object)
|
|
88
|
+
case object
|
|
89
|
+
when Hash
|
|
90
|
+
object.keys.all?(String) && object.values.all? { |value| string_keyed_structure?(value) }
|
|
91
|
+
when Array
|
|
92
|
+
object.all? { |value| string_keyed_structure?(value) }
|
|
93
|
+
else
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def simple_string_line?(message, tags, additional_context)
|
|
99
|
+
message.is_a?(String) &&
|
|
100
|
+
!MessageParser.json_string?(message) &&
|
|
101
|
+
tags.respond_to?(:empty?) && tags.empty? &&
|
|
102
|
+
additional_context.respond_to?(:empty?) && additional_context.empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def simple_string_json_line(message:, severity:, timestamp:)
|
|
106
|
+
sanitized_message = Sanitizer.sanitize_string(message)
|
|
107
|
+
"#{JSON.generate("message" => sanitized_message, "severity" => severity, "timestamp" => timestamp)}\n"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def tagged_simple_string_line?(message, tags, additional_context)
|
|
111
|
+
message.is_a?(String) &&
|
|
112
|
+
!MessageParser.json_string?(message) &&
|
|
113
|
+
tags.respond_to?(:empty?) && !tags.empty? &&
|
|
114
|
+
additional_context.respond_to?(:empty?) && additional_context.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def tagged_simple_string_json_line(message:, severity:, timestamp:, tags:, sanitize_tags:)
|
|
118
|
+
sanitized_message = Sanitizer.sanitize_string(message)
|
|
119
|
+
prepared_tags = sanitize_tags ? Sanitizer.prepare_tags(tags) : tags
|
|
120
|
+
"#{JSON.generate("message" => sanitized_message, "severity" => severity, "timestamp" => timestamp, "tags" => prepared_tags.uniq)}\n"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def contextual_simple_string_line?(message, tags, additional_context, additional_context_sanitized:)
|
|
124
|
+
message.is_a?(String) &&
|
|
125
|
+
!MessageParser.json_string?(message) &&
|
|
126
|
+
tags.respond_to?(:empty?) && tags.empty? &&
|
|
127
|
+
!PayloadBuilder.empty_additional_context?(additional_context) &&
|
|
128
|
+
additional_context_sanitized
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def contextual_simple_string_json_line(message:, severity:, timestamp:, additional_context:)
|
|
132
|
+
sanitized_message = Sanitizer.sanitize_string(message)
|
|
133
|
+
payload = {
|
|
134
|
+
"message" => sanitized_message,
|
|
135
|
+
"severity" => severity,
|
|
136
|
+
"timestamp" => timestamp
|
|
137
|
+
}
|
|
138
|
+
context = PayloadBuilder.context_payload_portion(additional_context)
|
|
139
|
+
payload["context"] = context unless context.empty?
|
|
140
|
+
"#{JSON.generate(payload)}\n"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def standalone_hash_line?(message, tags, additional_context)
|
|
144
|
+
hash_message?(message) &&
|
|
145
|
+
tags.respond_to?(:empty?) && tags.empty? &&
|
|
146
|
+
additional_context.respond_to?(:empty?) && additional_context.empty?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def hash_message?(message)
|
|
150
|
+
message.is_a?(Hash) || (message.respond_to?(:to_hash) && !message.is_a?(String))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def standalone_hash_json_line(message:, severity:, timestamp:)
|
|
154
|
+
hash = message.is_a?(Hash) ? message : message.to_hash
|
|
155
|
+
line = StructuredHashJsonEncoder.try_encode_line(hash, severity: severity, timestamp: timestamp)
|
|
156
|
+
return line if line
|
|
157
|
+
|
|
158
|
+
payload = Sanitizer.sanitize_hash(hash)
|
|
159
|
+
payload["severity"] = severity
|
|
160
|
+
payload["timestamp"] = timestamp
|
|
161
|
+
"#{JSON.generate(payload)}\n"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def tagged_hash_line?(message, tags, additional_context)
|
|
165
|
+
hash_message?(message) &&
|
|
166
|
+
tags.respond_to?(:empty?) && !tags.empty? &&
|
|
167
|
+
additional_context.respond_to?(:empty?) && additional_context.empty?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def tagged_hash_json_line(message:, severity:, timestamp:, tags:, sanitize_tags:)
|
|
171
|
+
hash = message.is_a?(Hash) ? message : message.to_hash
|
|
172
|
+
prepared_tags = sanitize_tags ? Sanitizer.prepare_tags(tags) : tags
|
|
173
|
+
merged_tags = (Array(hash[:tags] || hash["tags"]) + prepared_tags).uniq
|
|
174
|
+
field_overrides = {"tags" => merged_tags}
|
|
175
|
+
|
|
176
|
+
line = StructuredHashJsonEncoder.try_encode_line(
|
|
177
|
+
hash,
|
|
178
|
+
severity: severity,
|
|
179
|
+
timestamp: timestamp,
|
|
180
|
+
field_overrides: field_overrides
|
|
181
|
+
)
|
|
182
|
+
return line if line
|
|
183
|
+
|
|
184
|
+
payload = Sanitizer.sanitize_hash(hash)
|
|
185
|
+
payload["severity"] = severity
|
|
186
|
+
payload["timestamp"] = timestamp
|
|
187
|
+
payload["tags"] = merged_tags
|
|
188
|
+
"#{JSON.generate(payload)}\n"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def contextual_hash_line?(message, tags, additional_context, additional_context_sanitized:)
|
|
192
|
+
hash_message?(message) &&
|
|
193
|
+
tags.respond_to?(:empty?) && tags.empty? &&
|
|
194
|
+
!PayloadBuilder.empty_additional_context?(additional_context) &&
|
|
195
|
+
additional_context_sanitized
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def contextual_hash_json_line(message:, severity:, timestamp:, additional_context:)
|
|
199
|
+
hash = message.is_a?(Hash) ? message : message.to_hash
|
|
200
|
+
context = PayloadBuilder.context_payload_portion(additional_context)
|
|
201
|
+
merged_context = {}
|
|
202
|
+
unless context.empty?
|
|
203
|
+
existing_context = hash[:context] || hash["context"]
|
|
204
|
+
merged_context = existing_context.is_a?(Hash) ? existing_context.merge(context) : context
|
|
205
|
+
end
|
|
206
|
+
field_overrides = merged_context.empty? ? {} : {"context" => merged_context}
|
|
207
|
+
|
|
208
|
+
line = StructuredHashJsonEncoder.try_encode_line(
|
|
209
|
+
hash,
|
|
210
|
+
severity: severity,
|
|
211
|
+
timestamp: timestamp,
|
|
212
|
+
field_overrides: field_overrides
|
|
213
|
+
)
|
|
214
|
+
return line if line
|
|
215
|
+
|
|
216
|
+
payload = Sanitizer.sanitize_hash(hash)
|
|
217
|
+
payload["severity"] = severity
|
|
218
|
+
payload["timestamp"] = timestamp
|
|
219
|
+
unless merged_context.empty?
|
|
220
|
+
existing_context = payload["context"].is_a?(Hash) ? payload["context"] : {}
|
|
221
|
+
payload["context"] = existing_context.merge(merged_context)
|
|
222
|
+
end
|
|
223
|
+
"#{JSON.generate(payload)}\n"
|
|
224
|
+
end
|
|
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
|
+
def deep_stringify_structure(obj)
|
|
235
|
+
case obj
|
|
236
|
+
when Hash
|
|
237
|
+
obj.deep_stringify_keys
|
|
238
|
+
when Array
|
|
239
|
+
obj.map { |v| deep_stringify_structure(v) }
|
|
240
|
+
else
|
|
241
|
+
obj
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module JsonLogging
|
|
2
2
|
module PayloadBuilder
|
|
3
|
+
SYSTEM_CONTROLLED_KEYS = [:tags, "tags", :severity, "severity", :timestamp, "timestamp", :message, "message", :context, "context"].freeze
|
|
4
|
+
|
|
3
5
|
module_function
|
|
4
6
|
|
|
5
7
|
def build_base_payload(msg, severity: nil, timestamp: nil)
|
|
@@ -9,46 +11,80 @@ module JsonLogging
|
|
|
9
11
|
if parsed.is_a?(Hash)
|
|
10
12
|
payload.merge!(parsed)
|
|
11
13
|
else
|
|
12
|
-
payload[
|
|
14
|
+
payload["message"] = parsed
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
payload[
|
|
16
|
-
payload[
|
|
17
|
+
payload["severity"] = severity if severity
|
|
18
|
+
payload["timestamp"] = timestamp if timestamp
|
|
17
19
|
|
|
18
20
|
payload
|
|
19
21
|
end
|
|
20
22
|
|
|
21
|
-
def merge_context(payload, additional_context:, tags: [])
|
|
22
|
-
|
|
23
|
+
def merge_context(payload, additional_context:, tags: [], additional_context_sanitized: false, sanitize_tags: true)
|
|
24
|
+
return payload if merge_context_skippable?(payload, additional_context: additional_context, tags: tags)
|
|
25
|
+
return merge_tags_only(payload, tags: tags, sanitize_tags: sanitize_tags) if tags_only_merge?(payload, additional_context: additional_context, tags: tags)
|
|
26
|
+
|
|
27
|
+
existing_context = payload["context"].is_a?(Hash) ? payload["context"] : {}
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
sanitized_context = if additional_context_sanitized
|
|
30
|
+
additional_context.is_a?(Hash) ? additional_context : {}
|
|
31
|
+
elsif additional_context.is_a?(Hash) && !additional_context.empty?
|
|
27
32
|
Sanitizer.sanitize_hash(additional_context)
|
|
28
33
|
else
|
|
29
34
|
additional_context || {}
|
|
30
35
|
end
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
# These keys should never be set by user context as they're controlled by the logger
|
|
34
|
-
system_controlled_keys = [:tags, "tags", :severity, "severity", :timestamp, "timestamp", :message, "message", :context, "context"]
|
|
35
|
-
user_context_filtered = sanitized_context.except(*system_controlled_keys)
|
|
37
|
+
user_context_filtered = sanitized_context.except(*SYSTEM_CONTROLLED_KEYS)
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
deduped_additional = user_context_filtered.reject { |k, _| payload.key?(k) }
|
|
39
|
+
deduped_additional = user_context_filtered.reject { |key, _| payload.key?(key) || payload.key?(key.to_s) }
|
|
39
40
|
merged_context = existing_context.merge(deduped_additional)
|
|
40
41
|
|
|
41
|
-
# Put tags at root level, separate from context
|
|
42
|
-
# Merge with existing tags from payload (e.g., when logging a hash with tags: [...] at root)
|
|
43
42
|
unless tags.empty?
|
|
44
|
-
existing_tags = Array(payload[
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
payload[:tags] = (existing_tags + sanitized_tags).uniq
|
|
43
|
+
existing_tags = Array(payload["tags"])
|
|
44
|
+
prepared_tags = sanitize_tags ? tags.map { |tag| Sanitizer.sanitize_string(tag.to_s) } : tags
|
|
45
|
+
payload["tags"] = (existing_tags + prepared_tags).uniq
|
|
48
46
|
end
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
unless merged_context.empty?
|
|
49
|
+
payload["context"] = merged_context.transform_keys(&:to_s)
|
|
50
|
+
end
|
|
51
|
+
payload
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def empty_additional_context?(additional_context)
|
|
55
|
+
additional_context.nil? || (additional_context.respond_to?(:empty?) && additional_context.empty?)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def merge_context_skippable?(payload, additional_context:, tags:)
|
|
59
|
+
tags.empty? &&
|
|
60
|
+
empty_additional_context?(additional_context) &&
|
|
61
|
+
payload_context_empty?(payload)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def payload_context_empty?(payload)
|
|
65
|
+
existing_context = payload["context"]
|
|
66
|
+
existing_context.nil? || (existing_context.respond_to?(:empty?) && existing_context.empty?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def tags_only_merge?(payload, additional_context:, tags:)
|
|
70
|
+
!tags.empty? &&
|
|
71
|
+
empty_additional_context?(additional_context) &&
|
|
72
|
+
payload_context_empty?(payload)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def merge_tags_only(payload, tags:, sanitize_tags:)
|
|
76
|
+
existing_tags = Array(payload["tags"])
|
|
77
|
+
prepared_tags = sanitize_tags ? tags.map { |tag| Sanitizer.sanitize_string(tag.to_s) } : tags
|
|
78
|
+
payload["tags"] = (existing_tags + prepared_tags).uniq
|
|
51
79
|
payload
|
|
52
80
|
end
|
|
81
|
+
|
|
82
|
+
def context_payload_portion(additional_context)
|
|
83
|
+
return {} unless additional_context.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
additional_context.reject do |key, _|
|
|
86
|
+
SYSTEM_CONTROLLED_KEYS.include?(key) || SYSTEM_CONTROLLED_KEYS.include?(key.to_s)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
53
89
|
end
|
|
54
90
|
end
|