activesupport-json_logging 1.0.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 +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE.md +23 -0
- data/README.md +541 -0
- data/lib/activesupport/json_logging/railtie.rb +8 -0
- data/lib/activesupport/json_logging.rb +7 -0
- data/lib/json_logging/formatter.rb +36 -0
- data/lib/json_logging/formatter_with_tags.rb +57 -0
- data/lib/json_logging/helpers.rb +17 -0
- data/lib/json_logging/json_logger.rb +31 -0
- data/lib/json_logging/json_logger_extension.rb +177 -0
- data/lib/json_logging/message_parser.rb +32 -0
- data/lib/json_logging/payload_builder.rb +46 -0
- data/lib/json_logging/sanitizer.rb +158 -0
- data/lib/json_logging/version.rb +3 -0
- data/lib/json_logging.rb +124 -0
- metadata +186 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module JsonLogging
|
|
2
|
+
# Formatter proxy that delegates current_tags to the logger
|
|
3
|
+
# This is needed for ActiveJob which expects logger.formatter.current_tags
|
|
4
|
+
# Also implements call to ensure JSON formatting when formatter is used directly
|
|
5
|
+
class FormatterWithTags
|
|
6
|
+
def initialize(logger)
|
|
7
|
+
@logger = logger
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def current_tags
|
|
11
|
+
@logger.send(:current_tags)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(severity, timestamp, progname, msg)
|
|
15
|
+
tags = current_tags
|
|
16
|
+
timestamp_str = Helpers.normalize_timestamp(timestamp)
|
|
17
|
+
payload = PayloadBuilder.build_base_payload(msg, severity: severity, timestamp: timestamp_str)
|
|
18
|
+
payload = PayloadBuilder.merge_context(payload, additional_context: JsonLogging.additional_context.compact, tags: tags)
|
|
19
|
+
|
|
20
|
+
"#{payload.compact.to_json}\n"
|
|
21
|
+
rescue => e
|
|
22
|
+
build_fallback_output(severity, timestamp, msg, e)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Support tagged blocks for formatter
|
|
26
|
+
def tagged(*tags)
|
|
27
|
+
if block_given?
|
|
28
|
+
previous = @logger.send(:current_tags).dup
|
|
29
|
+
@logger.send(:push_tags, tags)
|
|
30
|
+
begin
|
|
31
|
+
yield @logger
|
|
32
|
+
ensure
|
|
33
|
+
@logger.send(:set_tags, previous)
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
@logger.send(:push_tags, tags)
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def build_fallback_output(severity, timestamp, msg, error)
|
|
44
|
+
timestamp_str = Helpers.normalize_timestamp(timestamp)
|
|
45
|
+
fallback_payload = {
|
|
46
|
+
timestamp: timestamp_str,
|
|
47
|
+
severity: severity,
|
|
48
|
+
message: Helpers.safe_string(msg),
|
|
49
|
+
formatter_error: {
|
|
50
|
+
class: Sanitizer.sanitize_string(error.class.name),
|
|
51
|
+
message: Sanitizer.sanitize_string(Helpers.safe_string(error.message))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
"#{fallback_payload.compact.to_json}\n"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module JsonLogging
|
|
2
|
+
module Helpers
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
# Normalize timestamp to ISO8601 with microseconds
|
|
6
|
+
def normalize_timestamp(timestamp)
|
|
7
|
+
(timestamp || Time.now).utc.iso8601(6)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Safely convert object to string, never raises
|
|
11
|
+
def safe_string(obj)
|
|
12
|
+
obj.to_s
|
|
13
|
+
rescue
|
|
14
|
+
"<unprintable>"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "active_support"
|
|
2
|
+
require "active_support/logger"
|
|
3
|
+
|
|
4
|
+
module JsonLogging
|
|
5
|
+
class JsonLogger < ActiveSupport::Logger
|
|
6
|
+
# Include the extension module to get all JSON logging functionality
|
|
7
|
+
include JsonLoggerExtension
|
|
8
|
+
|
|
9
|
+
def initialize(*args, **kwargs)
|
|
10
|
+
# Initialize with minimal args to avoid ActiveSupport::Logger threading issues
|
|
11
|
+
# Rails 7+ supports kwargs, Rails 6 uses positional args only
|
|
12
|
+
logdev = args.first || $stdout
|
|
13
|
+
shift_age = args[1] || 0
|
|
14
|
+
shift_size = args[2]
|
|
15
|
+
|
|
16
|
+
# Handle both positional and keyword arguments for Rails 6-8 compatibility
|
|
17
|
+
if kwargs.empty? && shift_size
|
|
18
|
+
super(logdev, shift_age, shift_size)
|
|
19
|
+
elsif kwargs.empty?
|
|
20
|
+
super(logdev, shift_age)
|
|
21
|
+
else
|
|
22
|
+
# Rails 7+ may pass kwargs - delegate to parent
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@formatter_with_tags = FormatterWithTags.new(self)
|
|
27
|
+
# Ensure parent class (Logger) also uses our formatter in case it uses it directly
|
|
28
|
+
instance_variable_set(:@formatter, @formatter_with_tags)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
module JsonLogging
|
|
2
|
+
# Module that extends any Logger with JSON formatting capabilities
|
|
3
|
+
# This is used by JsonLogging.new to wrap standard loggers
|
|
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
|
+
def formatter
|
|
15
|
+
@formatter_with_tags ||= begin
|
|
16
|
+
formatter_with_tags = FormatterWithTags.new(self)
|
|
17
|
+
instance_variable_set(:@formatter, formatter_with_tags)
|
|
18
|
+
formatter_with_tags
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def formatter=(formatter)
|
|
23
|
+
# Always use FormatterWithTags to ensure JSON formatting
|
|
24
|
+
formatter_with_tags = @formatter_with_tags || FormatterWithTags.new(self)
|
|
25
|
+
instance_variable_set(:@formatter, formatter_with_tags)
|
|
26
|
+
@formatter_with_tags = formatter_with_tags
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add(severity, message = nil, progname = nil)
|
|
30
|
+
return true if severity < level
|
|
31
|
+
|
|
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")
|
|
46
|
+
true
|
|
47
|
+
rescue => e
|
|
48
|
+
# Never fail logging - write a fallback entry
|
|
49
|
+
# Initialize msg if it wasn't set due to error
|
|
50
|
+
msg ||= "<uninitialized>"
|
|
51
|
+
|
|
52
|
+
fallback = {
|
|
53
|
+
timestamp: Helpers.normalize_timestamp(Time.now),
|
|
54
|
+
severity: severity_name(severity),
|
|
55
|
+
message: Sanitizer.sanitize_string(Helpers.safe_string(msg)),
|
|
56
|
+
logger_error: {
|
|
57
|
+
class: Sanitizer.sanitize_string(e.class.name),
|
|
58
|
+
message: Sanitizer.sanitize_string(Helpers.safe_string(e.message))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
@logdev&.write("#{fallback.compact.to_json}\n")
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Override format_message to ensure it uses JSON formatting even if called directly
|
|
66
|
+
def format_message(severity, datetime, progname, msg)
|
|
67
|
+
formatter.call(severity, datetime, progname, msg)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Native tag support compatible with Rails.logger.tagged
|
|
71
|
+
def tagged(*tags)
|
|
72
|
+
if block_given?
|
|
73
|
+
formatter.tagged(*tags) { yield self }
|
|
74
|
+
else
|
|
75
|
+
# Return a new wrapped logger with tags applied (similar to TaggedLogging)
|
|
76
|
+
logger = JsonLogging.new(self)
|
|
77
|
+
# Extend formatter with LocalTagStorage to preserve current tags when creating nested loggers
|
|
78
|
+
logger.formatter.extend(LocalTagStorage)
|
|
79
|
+
logger.formatter.push_tags(*formatter.current_tags, *tags)
|
|
80
|
+
logger
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Flush tags (used by Rails when request completes)
|
|
85
|
+
def flush
|
|
86
|
+
clear_tags!
|
|
87
|
+
super if defined?(super)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def tags_key
|
|
93
|
+
@tags_key ||= :"json_logging_tags_#{object_id}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def current_tags
|
|
97
|
+
# Use IsolatedExecutionState (Rails 7.1+) for better thread/Fiber safety
|
|
98
|
+
# Falls back to Thread.current for Rails 6-7.0
|
|
99
|
+
if defined?(ActiveSupport::IsolatedExecutionState)
|
|
100
|
+
ActiveSupport::IsolatedExecutionState[tags_key] ||= []
|
|
101
|
+
else
|
|
102
|
+
Thread.current[tags_key] ||= []
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def set_tags(new_tags)
|
|
107
|
+
# Use IsolatedExecutionState (Rails 7.1+) for better thread/Fiber safety
|
|
108
|
+
# Falls back to Thread.current for Rails 6-7.0
|
|
109
|
+
if defined?(ActiveSupport::IsolatedExecutionState)
|
|
110
|
+
ActiveSupport::IsolatedExecutionState[tags_key] = new_tags
|
|
111
|
+
else
|
|
112
|
+
Thread.current[tags_key] = new_tags
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def push_tags(tags)
|
|
117
|
+
flat = tags.flatten.compact.map(&:to_s).reject(&:empty?)
|
|
118
|
+
return if flat.empty?
|
|
119
|
+
set_tags(current_tags + flat)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def clear_tags!
|
|
123
|
+
set_tags([])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_payload(severity, _progname, msg)
|
|
127
|
+
payload = PayloadBuilder.build_base_payload(
|
|
128
|
+
msg,
|
|
129
|
+
severity: severity_name(severity),
|
|
130
|
+
timestamp: Helpers.normalize_timestamp(Time.now)
|
|
131
|
+
)
|
|
132
|
+
payload = PayloadBuilder.merge_context(
|
|
133
|
+
payload,
|
|
134
|
+
additional_context: JsonLogging.additional_context.compact,
|
|
135
|
+
tags: current_tags
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
payload.compact
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def severity_name(severity)
|
|
142
|
+
SEVERITY_NAMES[severity] || severity.to_s
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def stringify_keys(hash)
|
|
146
|
+
case hash
|
|
147
|
+
when Hash
|
|
148
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
149
|
+
result[k.to_s] = stringify_keys(v)
|
|
150
|
+
end
|
|
151
|
+
when Array
|
|
152
|
+
hash.map { |v| stringify_keys(v) }
|
|
153
|
+
else
|
|
154
|
+
hash
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Module for preserving current tags when creating nested tagged loggers
|
|
160
|
+
# Similar to ActiveSupport::TaggedLogging::LocalTagStorage
|
|
161
|
+
# When extended on a formatter, stores tags locally instead of using thread-local storage
|
|
162
|
+
module LocalTagStorage
|
|
163
|
+
def self.extended(base)
|
|
164
|
+
base.instance_variable_set(:@local_tags, [])
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def push_tags(*tags)
|
|
168
|
+
flat = tags.flatten.compact.map(&:to_s).reject(&:empty?)
|
|
169
|
+
return if flat.empty?
|
|
170
|
+
@local_tags = (@local_tags || []) + flat
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def current_tags
|
|
174
|
+
@local_tags || []
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module JsonLogging
|
|
2
|
+
module MessageParser
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def parse_message(msg)
|
|
6
|
+
if msg.is_a?(Hash) || (msg.respond_to?(:to_hash) && !msg.is_a?(String))
|
|
7
|
+
# Sanitize hash messages
|
|
8
|
+
Sanitizer.sanitize_hash(msg.to_hash)
|
|
9
|
+
elsif msg.is_a?(String) && json_string?(msg)
|
|
10
|
+
begin
|
|
11
|
+
parsed = JSON.parse(msg)
|
|
12
|
+
# Sanitize parsed JSON structure
|
|
13
|
+
Sanitizer.sanitize_value(parsed)
|
|
14
|
+
rescue JSON::ParserError
|
|
15
|
+
# If JSON parsing fails, sanitize the raw string
|
|
16
|
+
Sanitizer.sanitize_string(msg)
|
|
17
|
+
end
|
|
18
|
+
elsif msg.is_a?(Exception)
|
|
19
|
+
# Handle exceptions specially with sanitization
|
|
20
|
+
Sanitizer.sanitize_exception(msg)
|
|
21
|
+
else
|
|
22
|
+
# Sanitize other types
|
|
23
|
+
Sanitizer.sanitize_value(msg)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def json_string?(str)
|
|
28
|
+
(str.start_with?("{") && str.end_with?("}")) ||
|
|
29
|
+
(str.start_with?("[") && str.end_with?("]"))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module JsonLogging
|
|
2
|
+
module PayloadBuilder
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def build_base_payload(msg, severity: nil, timestamp: nil)
|
|
6
|
+
parsed = MessageParser.parse_message(msg)
|
|
7
|
+
payload = {}
|
|
8
|
+
|
|
9
|
+
if parsed.is_a?(Hash)
|
|
10
|
+
payload.merge!(parsed)
|
|
11
|
+
else
|
|
12
|
+
payload[:message] = parsed
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
payload[:severity] = severity if severity
|
|
16
|
+
payload[:timestamp] = timestamp if timestamp
|
|
17
|
+
|
|
18
|
+
payload
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def merge_context(payload, additional_context:, tags: [])
|
|
22
|
+
existing_context = payload[:context].is_a?(Hash) ? payload[:context] : {}
|
|
23
|
+
|
|
24
|
+
# Sanitize additional context before merging (filters sensitive keys, limits size, sanitizes strings only)
|
|
25
|
+
# Only sanitize if it's a hash - preserve other types
|
|
26
|
+
sanitized_context = if additional_context.is_a?(Hash) && !additional_context.empty?
|
|
27
|
+
Sanitizer.sanitize_hash(additional_context)
|
|
28
|
+
else
|
|
29
|
+
additional_context || {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
deduped_additional = sanitized_context.reject { |k, _| payload.key?(k) }
|
|
33
|
+
merged_context = existing_context.merge(deduped_additional)
|
|
34
|
+
|
|
35
|
+
unless tags.empty?
|
|
36
|
+
existing_tags = Array(merged_context[:tags])
|
|
37
|
+
# Sanitize tag strings (remove control chars, truncate)
|
|
38
|
+
sanitized_tags = tags.map { |tag| Sanitizer.sanitize_string(tag.to_s) }
|
|
39
|
+
merged_context[:tags] = (existing_tags + sanitized_tags).uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
payload[:context] = merged_context unless merged_context.empty?
|
|
43
|
+
payload
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
module JsonLogging
|
|
2
|
+
module Sanitizer
|
|
3
|
+
# Control characters that should be escaped or removed from log messages
|
|
4
|
+
CONTROL_CHARS = /[\x00-\x1F\x7F]/
|
|
5
|
+
|
|
6
|
+
# Maximum string length before truncation
|
|
7
|
+
MAX_STRING_LENGTH = 10_000
|
|
8
|
+
|
|
9
|
+
# Maximum context hash size (number of keys)
|
|
10
|
+
MAX_CONTEXT_SIZE = 50
|
|
11
|
+
|
|
12
|
+
# Maximum depth for nested structures
|
|
13
|
+
MAX_DEPTH = 10
|
|
14
|
+
|
|
15
|
+
# Maximum backtrace lines to include
|
|
16
|
+
MAX_BACKTRACE_LINES = 20
|
|
17
|
+
|
|
18
|
+
# Common sensitive key patterns (case insensitive) - fallback when Rails ParameterFilter not available
|
|
19
|
+
SENSITIVE_KEY_PATTERNS = /\b(password|passwd|pwd|secret|token|api_key|apikey|access_token|auth_token|private_key|credential)\b/i
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Get Rails ParameterFilter if available, nil otherwise
|
|
24
|
+
def rails_parameter_filter
|
|
25
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:application)
|
|
26
|
+
return nil unless Rails.application.respond_to?(:config)
|
|
27
|
+
|
|
28
|
+
filter_params = Rails.application.config.filter_parameters
|
|
29
|
+
return nil if filter_params.empty?
|
|
30
|
+
|
|
31
|
+
ActiveSupport::ParameterFilter.new(filter_params)
|
|
32
|
+
rescue
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sanitize a string by removing/escaping control characters and truncating
|
|
37
|
+
def sanitize_string(str)
|
|
38
|
+
return str unless str.is_a?(String)
|
|
39
|
+
|
|
40
|
+
# Remove or replace control characters
|
|
41
|
+
sanitized = str.gsub(CONTROL_CHARS, "")
|
|
42
|
+
|
|
43
|
+
# Truncate if too long
|
|
44
|
+
if sanitized.length > MAX_STRING_LENGTH
|
|
45
|
+
sanitized = sanitized[0, MAX_STRING_LENGTH] + "...[truncated]"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sanitized
|
|
49
|
+
rescue
|
|
50
|
+
"<sanitization_error>"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Sanitize a hash, removing sensitive keys and limiting size/depth
|
|
54
|
+
# Uses Rails ParameterFilter when available, falls back to pattern matching
|
|
55
|
+
def sanitize_hash(hash, depth: 0)
|
|
56
|
+
return hash unless hash.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
# Prevent excessive nesting
|
|
59
|
+
return {"error" => "max_depth_exceeded"} if depth > MAX_DEPTH
|
|
60
|
+
|
|
61
|
+
# Limit hash size first
|
|
62
|
+
limited_hash = if hash.size > MAX_CONTEXT_SIZE
|
|
63
|
+
truncated = hash.first(MAX_CONTEXT_SIZE).to_h
|
|
64
|
+
truncated["_truncated"] = true
|
|
65
|
+
truncated
|
|
66
|
+
else
|
|
67
|
+
hash
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Use Rails ParameterFilter if available (handles encrypted attributes automatically)
|
|
71
|
+
filter = rails_parameter_filter
|
|
72
|
+
if filter
|
|
73
|
+
# ParameterFilter will filter based on Rails.config.filter_parameters
|
|
74
|
+
# This includes encrypted attributes automatically
|
|
75
|
+
# Create a deep copy since filter modifies in place (Rails 6+)
|
|
76
|
+
filtered = limited_hash.respond_to?(:deep_dup) ? limited_hash.deep_dup : limited_hash.dup
|
|
77
|
+
filtered = filter.filter(filtered)
|
|
78
|
+
|
|
79
|
+
# Then sanitize values (strings, control chars, etc.) preserving filtered structure
|
|
80
|
+
filtered.each_with_object({}) do |(key, value), result|
|
|
81
|
+
result[key] = sanitize_value(value, depth: depth + 1)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
else
|
|
85
|
+
# Fallback: use pattern matching for sensitive keys
|
|
86
|
+
limited_hash.each_with_object({}) do |(key, value), result|
|
|
87
|
+
key_str = key.to_s
|
|
88
|
+
|
|
89
|
+
# Skip sensitive keys
|
|
90
|
+
if SENSITIVE_KEY_PATTERNS.match?(key_str)
|
|
91
|
+
result[key_str.gsub(/(?<!^)(?=[A-Z])/, "_").downcase + "_filtered"] = "[FILTERED]"
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
result[key] = sanitize_value(value, depth: depth + 1)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
rescue
|
|
99
|
+
{"sanitization_error" => true}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Sanitize a value (handles strings, hashes, arrays, etc.)
|
|
103
|
+
# Preserves numeric, boolean, and nil types
|
|
104
|
+
def sanitize_value(value, depth: 0)
|
|
105
|
+
case value
|
|
106
|
+
when String
|
|
107
|
+
sanitize_string(value)
|
|
108
|
+
when Hash
|
|
109
|
+
sanitize_hash(value, depth: depth)
|
|
110
|
+
when Array
|
|
111
|
+
# Limit array size
|
|
112
|
+
sanitized = value.first(MAX_CONTEXT_SIZE).map { |v| sanitize_value(v, depth: depth + 1) }
|
|
113
|
+
sanitized << "[truncated]" if value.size > MAX_CONTEXT_SIZE
|
|
114
|
+
sanitized
|
|
115
|
+
when Exception
|
|
116
|
+
sanitize_exception(value)
|
|
117
|
+
when Numeric, TrueClass, FalseClass, NilClass
|
|
118
|
+
# Preserve numeric, boolean, and nil types
|
|
119
|
+
value
|
|
120
|
+
else
|
|
121
|
+
# For other types, convert to string and sanitize
|
|
122
|
+
sanitize_string(value.to_s)
|
|
123
|
+
end
|
|
124
|
+
rescue
|
|
125
|
+
"<unprintable>"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Sanitize exception, including backtrace
|
|
129
|
+
def sanitize_exception(ex)
|
|
130
|
+
{
|
|
131
|
+
"error" => {
|
|
132
|
+
"class" => ex.class.name,
|
|
133
|
+
"message" => sanitize_string(ex.message.to_s),
|
|
134
|
+
"backtrace" => sanitize_backtrace(ex.backtrace)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
rescue
|
|
138
|
+
{"error" => {"class" => "Exception", "message" => "<sanitization_failed>"}}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Sanitize backtrace - truncate and remove sensitive paths
|
|
142
|
+
def sanitize_backtrace(backtrace)
|
|
143
|
+
return [] unless backtrace.is_a?(Array)
|
|
144
|
+
|
|
145
|
+
# Take first MAX_BACKTRACE_LINES, sanitize each
|
|
146
|
+
backtrace.first(MAX_BACKTRACE_LINES).map do |line|
|
|
147
|
+
sanitize_string(line.to_s)
|
|
148
|
+
end
|
|
149
|
+
rescue
|
|
150
|
+
[]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Check if a key looks sensitive
|
|
154
|
+
def sensitive_key?(key)
|
|
155
|
+
SENSITIVE_KEY_PATTERNS.match?(key.to_s)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
data/lib/json_logging.rb
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
require_relative "json_logging/version"
|
|
6
|
+
require_relative "json_logging/helpers"
|
|
7
|
+
require_relative "json_logging/sanitizer"
|
|
8
|
+
require_relative "json_logging/message_parser"
|
|
9
|
+
require_relative "json_logging/payload_builder"
|
|
10
|
+
require_relative "json_logging/formatter"
|
|
11
|
+
require_relative "json_logging/formatter_with_tags"
|
|
12
|
+
require_relative "json_logging/json_logger_extension"
|
|
13
|
+
require_relative "json_logging/json_logger"
|
|
14
|
+
|
|
15
|
+
module JsonLogging
|
|
16
|
+
THREAD_CONTEXT_KEY = :__json_logging_context
|
|
17
|
+
|
|
18
|
+
def self.with_context(extra_context)
|
|
19
|
+
original = Thread.current[THREAD_CONTEXT_KEY]
|
|
20
|
+
Thread.current[THREAD_CONTEXT_KEY] = (original || {}).merge(safe_hash(extra_context))
|
|
21
|
+
yield
|
|
22
|
+
ensure
|
|
23
|
+
Thread.current[THREAD_CONTEXT_KEY] = original
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the current thread-local context when called without arguments,
|
|
27
|
+
# or sets a transformer when called with a block or assigned a proc.
|
|
28
|
+
#
|
|
29
|
+
# @example Getting context
|
|
30
|
+
# JsonLogging.additional_context # => {user_id: 123, ...}
|
|
31
|
+
#
|
|
32
|
+
# @example Setting transformer with block
|
|
33
|
+
# JsonLogging.additional_context do |context|
|
|
34
|
+
# context.merge(environment: Rails.env, hostname: Socket.gethostname)
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# @example Setting transformer with assignment
|
|
38
|
+
# JsonLogging.additional_context = ->(context) { context.merge(env: Rails.env) }
|
|
39
|
+
def self.additional_context(*args, &block)
|
|
40
|
+
if args.any? || block_given?
|
|
41
|
+
return public_send(:additional_context=, args.first || block)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
base_context = (Thread.current[THREAD_CONTEXT_KEY] || {}).dup
|
|
46
|
+
rescue
|
|
47
|
+
base_context = {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
transformer = @additional_context_transformer
|
|
51
|
+
if transformer.is_a?(Proc)
|
|
52
|
+
begin
|
|
53
|
+
transformer.call(base_context)
|
|
54
|
+
rescue
|
|
55
|
+
base_context
|
|
56
|
+
end
|
|
57
|
+
else
|
|
58
|
+
base_context
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.additional_context=(proc_or_block)
|
|
63
|
+
@additional_context_transformer = proc_or_block
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.safe_hash(obj)
|
|
67
|
+
obj.is_a?(Hash) ? obj : {}
|
|
68
|
+
rescue
|
|
69
|
+
{}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns an `ActiveSupport::Logger` that has already been wrapped with JSON logging concern.
|
|
73
|
+
#
|
|
74
|
+
# @param *args Arguments passed to ActiveSupport::Logger.new
|
|
75
|
+
# @param **kwargs Keyword arguments passed to ActiveSupport::Logger.new
|
|
76
|
+
# @return [Logger] A logger wrapped with JSON formatting and tagged logging support
|
|
77
|
+
#
|
|
78
|
+
# @example
|
|
79
|
+
# logger = JsonLogging.logger($stdout)
|
|
80
|
+
# logger.info("Stuff") # Logs JSON formatted entry
|
|
81
|
+
def self.logger(*args, **kwargs)
|
|
82
|
+
new(ActiveSupport::Logger.new(*args, **kwargs))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Wraps any standard Logger object to provide JSON formatting capabilities.
|
|
86
|
+
# Similar to ActiveSupport::TaggedLogging.new
|
|
87
|
+
#
|
|
88
|
+
# @param logger [Logger] Any standard Logger object (Logger, ActiveSupport::Logger, etc.)
|
|
89
|
+
# @return [Logger] A logger extended with JSON formatting and tagged logging support
|
|
90
|
+
#
|
|
91
|
+
# @example
|
|
92
|
+
# logger = JsonLogging.new(Logger.new(STDOUT))
|
|
93
|
+
# logger.info("Stuff") # Logs JSON formatted entry
|
|
94
|
+
#
|
|
95
|
+
# @example With tagged logging
|
|
96
|
+
# logger = JsonLogging.new(Logger.new(STDOUT))
|
|
97
|
+
# logger.tagged("BCX") { logger.info("Stuff") } # Logs with tags
|
|
98
|
+
# logger.tagged("BCX").info("Stuff") # Logs with tags (non-block form)
|
|
99
|
+
def self.new(logger)
|
|
100
|
+
logger = logger.clone
|
|
101
|
+
if logger.formatter
|
|
102
|
+
logger.formatter = logger.formatter.clone
|
|
103
|
+
|
|
104
|
+
# Workaround for https://bugs.ruby-lang.org/issues/20250
|
|
105
|
+
# Can be removed when Ruby 3.4 is the least supported version.
|
|
106
|
+
logger.formatter.object_id if logger.formatter.is_a?(Proc)
|
|
107
|
+
else
|
|
108
|
+
# Ensure we set a default formatter so we aren't extending nil!
|
|
109
|
+
# Use ActiveSupport::Logger::SimpleFormatter if available, otherwise default Logger::Formatter
|
|
110
|
+
logger.formatter = if defined?(ActiveSupport::Logger::SimpleFormatter)
|
|
111
|
+
ActiveSupport::Logger::SimpleFormatter.new
|
|
112
|
+
else
|
|
113
|
+
::Logger::Formatter.new
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
logger.extend(JsonLoggerExtension)
|
|
118
|
+
formatter_with_tags = FormatterWithTags.new(logger)
|
|
119
|
+
logger.instance_variable_set(:@formatter_with_tags, formatter_with_tags)
|
|
120
|
+
logger.formatter = formatter_with_tags
|
|
121
|
+
|
|
122
|
+
logger
|
|
123
|
+
end
|
|
124
|
+
end
|