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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 879dfb3d0d8142a294407c70771d234e55d410032dbc50f035bf85f3bd33b79a
4
- data.tar.gz: bfa9d0637d5e84f58acdf407ce782feda91b584c5c63700b93ec2aa4e6e59dd4
3
+ metadata.gz: 71ac52d26c4cca14a0eaed618927f9432880865ad9b24e43232b5801ce10a055
4
+ data.tar.gz: 81ef2c90e5d44fc7910f319acdbbd4bb9b56572075c6a28050fd05759349e91b
5
5
  SHA512:
6
- metadata.gz: e4ed4ef5739e411af61e6186581ec30aa3f0653c557d7f2bc088abf264a3e8608c483b0790b892a7e1c10674d55811f95a5a3e4c33d073cd59949469e732e875
7
- data.tar.gz: ec86db106814dee2731695159275fb7c56700d36564571edd72e54013d076dd1a3328a050eae06429d16eaaf1c0538404ae8345605d5a2ec63684ca1f71f40b7
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 = 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
@@ -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,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 = 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
@@ -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[:message] = parsed
14
+ payload["message"] = parsed
13
15
  end
14
16
 
15
- payload[:severity] = severity if severity
16
- payload[:timestamp] = timestamp if timestamp
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
- existing_context = payload[:context].is_a?(Hash) ? payload[:context] : {}
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
- # 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?
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
- # Filter out system-controlled keys from user context
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
- # Also prevent overriding any existing payload keys (additional safety)
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[:tags] || payload["tags"])
45
- # Sanitize tag strings (remove control chars, truncate)
46
- sanitized_tags = tags.map { |tag| Sanitizer.sanitize_string(tag.to_s) }
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
- payload[:context] = merged_context unless merged_context.empty?
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