logstruct 0.2.1 → 0.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: 23182266ea1c9f53e0b824a8b806207b92255ed6a6d2755cf67c8f3d35680d19
4
- data.tar.gz: 4d684c903461a10aa875314f6dac3fca9978ac190ce0616c3f6d34076c3b8ba7
3
+ metadata.gz: c9e72813c2641a2cc336184ab2e6f15b0d38e1f005da1ade37e136487a9c4369
4
+ data.tar.gz: b91cbaa7d64ea075db6937d415db0d0e7f86e58902083d54e0b985570a2573c5
5
5
  SHA512:
6
- metadata.gz: 4a3dc3b56efac2d68703b77210006852059188425e612f537be307e7740da79781600dc4147d955c52c0e65a39978d476fefd0a796b07b7fe5ee170d2ce5b07a
7
- data.tar.gz: bb328f655eda7dbcfb2ace155586966bfc47a4a60df88dcb7aa2e355b5213f0e76623d52e83906063a5aaff63039c8937c59bdd373c783faa7013a7c860acfac
6
+ metadata.gz: cb4d9ebb39a98d73f022c853daac9069599fe83528d0ca6234f2f63517803f48df932762827b37e5b4aae3b89e1da16c6d3b85005f54da7cab95dcfe8ee263d0
7
+ data.tar.gz: 7843625a239673d36ac68c092568927a9f5c112c830dd1b00caaf3c0e9d495fd6c9fb7514c18b5119000dc940f9a6706bbc2fb987506d55fefdd6264d9f0c568
data/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.2] - 2026-10-21
11
+
12
+ ### Changed
13
+
14
+ - **Development**: Update Prettier, cspell and pnpm's setup action. The development tools require Node.js 22.18 or later in the 22.x series.
15
+ - **Docs**: Upgrade the site to Next.js 16 and align its Node type declarations with the Node.js 22 runtime used in CI.
16
+
17
+ ### Fixed
18
+
19
+ - **Filtering**: Nested URL query parameters such as `password[]` and `token[value]` are filtered, including URL-encoded names and paths in Rails' `filter_parameters`.
20
+ - **Filtering**: Basic credentials are scrubbed even when their Base64 encoding is shorter than eight characters.
21
+ - **Filtering**: Symbols beyond the nesting limit are summarized instead of being written without scrubbing.
22
+ - **Filtering**: When a key or path matcher raises and error handling lets logging continue, its value is filtered. This also applies to Rails filter blocks.
23
+ - **Errors**: A failed filter or scrubber does not keep queuing or reporting errors when it fails again while formatting its own error line or report context.
24
+ - **Formatting**: Hash keys are normalized during filtering, so arrays, hashes and filtered values are no longer copied recursively before size limits are applied. Circular references are summarized at their first recurrence.
25
+ - **Docs**: Correct the supported security release series and contributor instructions for releases, generated log types and schema fields. Describe size limits as limits per value, not a guarantee on total formatting time. Ensure the site's integration tests run when a local Watchman installation is broken.
26
+ - **Development**: `task ci` verifies committed RBI files, matching GitHub CI, instead of downloading new annotations concurrently with typechecking.
27
+ - **Generation**: A failed formatter or Sorbet JSON export stops code generation instead of leaving incomplete artifacts with a successful exit status.
28
+ - **Development**: Update the locked `crass` dependency to 1.0.7, which fixes a denial-of-service issue in CSS number parsing.
29
+
8
30
  ## [0.2.1] - 2026-10-18
9
31
 
10
32
  ### Fixed
data/README.md CHANGED
@@ -28,7 +28,7 @@ any other log platform. Sensitive values are filtered and scrubbed before a line
28
28
  `Bearer` and `Basic` credentials and sensitive URL query params (`?token=`) are replaced too. IP and MAC address
29
29
  scrubbing are opt-in.
30
30
  - **Limits.** Huge values are cut down (100 keys per Hash, 10 items per Array, 20 lines per backtrace, 32 KB per
31
- String by default), so one large request body can't slow down your logging.
31
+ String by default), reducing log size and the work spent formatting large values.
32
32
  - **Error handling.** A mode for each kind of error decides whether it's raised, logged or reported, and whether
33
33
  that differs between development and production. Reports go to Sentry, Bugsnag, Rollbar, Honeybadger or your own
34
34
  reporter.
@@ -260,7 +260,7 @@ module LogStruct
260
260
  !!result || (value.is_a?(String) && value != original_value)
261
261
  rescue => e
262
262
  handle_filter_error(e, filter, original_key || "")
263
- false
263
+ true
264
264
  end
265
265
  ConfigStruct::PathFilterMatcher.new(callable: callable, label: filter.inspect)
266
266
  end
@@ -291,11 +291,11 @@ module LogStruct
291
291
  !!filter.call(key)
292
292
  rescue => e
293
293
  handle_filter_error(e, filter, key)
294
- false
294
+ true
295
295
  end
296
296
  rescue => e
297
297
  handle_filter_error(e, filter, key)
298
- false
298
+ true
299
299
  end
300
300
  ConfigStruct::FilterMatcher.new(callable: callable, label: filter.inspect)
301
301
  end
@@ -78,10 +78,15 @@ module LogStruct
78
78
  # request's parameters or a mail's recipients
79
79
  sig { params(context: T.nilable(T::Hash[Symbol, T.untyped])).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
80
80
  def filtered_context(context)
81
+ previous_internal_error = Thread.current[:logstruct_formatting_internal_error]
82
+ # The same filter may fail on the error's context. Keep that failure from reporting itself.
83
+ Thread.current[:logstruct_formatting_internal_error] = true
81
84
  return context if context.nil? || context.empty?
82
85
 
83
86
  filtered = Formatter.new.process_values(context)
84
87
  filtered.is_a?(Hash) ? filtered : {}
88
+ ensure
89
+ Thread.current[:logstruct_formatting_internal_error] = previous_internal_error
85
90
  end
86
91
 
87
92
  # Handle an error according to the configured error handling mode (log, report, raise, etc).
@@ -95,6 +100,12 @@ module LogStruct
95
100
  raise(error) if !LogStruct.is_production?
96
101
  end
97
102
 
103
+ # A broken filter or scrubber can also fail on its own error line. Enqueuing another error
104
+ # there would repeat forever on SemanticLogger's writer thread. Raise modes still raise.
105
+ if source == Source::Internal && Thread.current[:logstruct_formatting_internal_error] && mode != ErrorHandlingMode::Raise
106
+ return
107
+ end
108
+
98
109
  case mode
99
110
  when ErrorHandlingMode::Ignore
100
111
  # Do nothing
@@ -194,12 +194,12 @@ module LogStruct
194
194
  StringScrubber.valid_utf8(string)
195
195
  end
196
196
 
197
- # A String or Symbol key as valid UTF-8 (symbolize_keys makes Symbols of binary String keys)
197
+ # Normalize only keys that will be logged, after applying the size limits.
198
198
  sig { params(key: T.untyped).returns(T.untyped) }
199
199
  def valid_utf8_key(key)
200
200
  case key
201
201
  when String
202
- valid_utf8(key)
202
+ valid_utf8(key).to_sym
203
203
  when Symbol
204
204
  name = key.name
205
205
  fixed = valid_utf8(name)
@@ -232,31 +232,6 @@ module LogStruct
232
232
  "#{kept}... (cut from #{string.bytesize} bytes)"
233
233
  end
234
234
 
235
- # Like deep_symbolize_keys, but stops at circular references and at the depth where
236
- # process_values stops, leaving those values for process_values to summarize
237
- sig { params(value: T.untyped, recursion_depth: Integer, ancestors: T::Hash[T.untyped, T::Boolean]).returns(T.untyped) }
238
- def symbolize_keys(value, recursion_depth: 0, ancestors: {}.compare_by_identity)
239
- return value if recursion_depth > MAX_RECURSION_DEPTH || ancestors.key?(value)
240
-
241
- case value
242
- when Hash
243
- ancestors[value] = true
244
- result = value.each_with_object({}) do |(key, nested_value), symbolized|
245
- symbol_key = key.respond_to?(:to_sym) ? key.to_sym : key
246
- symbolized[symbol_key] = symbolize_keys(nested_value, recursion_depth: recursion_depth + 1, ancestors: ancestors)
247
- end
248
- ancestors.delete(value)
249
- result
250
- when Array
251
- ancestors[value] = true
252
- result = value.map { |item| symbolize_keys(item, recursion_depth: recursion_depth + 1, ancestors: ancestors) }
253
- ancestors.delete(value)
254
- result
255
- else
256
- value
257
- end
258
- end
259
-
260
235
  # Describes a value without filtering, scrubbing or serializing it, so that values which
261
236
  # couldn't be processed are never logged raw. Hashes and arrays are not serialized because
262
237
  # they may contain circular references.
@@ -267,16 +242,16 @@ module LogStruct
267
242
  {_filtered: {_class: arg.class.name, _keys_count: arg.size}}
268
243
  when Array
269
244
  {_filtered: {_class: arg.class.name, _count: arg.size}}
270
- when String
245
+ when String, Symbol
271
246
  {_filtered: {_class: arg.class.name}}
272
- when Numeric, Symbol, TrueClass, FalseClass, NilClass
247
+ when Numeric, TrueClass, FalseClass, NilClass
273
248
  arg
274
249
  else
275
250
  {_filtered: {_class: arg.class.name}}
276
251
  end
277
252
  end
278
253
 
279
- sig { params(log_value: T.untyped, time: Time).returns(T::Hash[Symbol, T.untyped]) }
254
+ sig { params(log_value: T.untyped, time: Time).returns(T::Hash[T.untyped, T.untyped]) }
280
255
  def log_value_to_hash(log_value, time:)
281
256
  case log_value
282
257
  when Log::Interfaces::CommonFields
@@ -284,12 +259,10 @@ module LogStruct
284
259
  log_value.serialize
285
260
 
286
261
  when T::Struct
287
- # Default T::Struct.serialize methods returns a hash with string keys, so convert them to symbols
288
- symbolize_keys(log_value.serialize)
262
+ log_value.serialize
289
263
 
290
264
  when Hash
291
- # Use hash as is and convert string keys to symbols
292
- symbolize_keys(log_value)
265
+ log_value
293
266
 
294
267
  when Exception
295
268
  # logger.error(exception): its class, message and backtrace
@@ -357,9 +330,15 @@ module LogStruct
357
330
  ).returns(String)
358
331
  end
359
332
  def call(severity, time, progname, log_value, request_id: nil, context: nil, tags: nil)
333
+ previous_internal_error = Thread.current[:logstruct_formatting_internal_error]
360
334
  level_enum = Level.from_severity(severity)
361
335
 
362
336
  data = log_value_to_hash(log_value, time: time)
337
+ source = data[:src] || data["src"]
338
+ event = data[:evt] || data["evt"]
339
+ if [Source::Internal, :logstruct, "logstruct"].include?(source) && [Event::Error, :error, "error"].include?(event)
340
+ Thread.current[:logstruct_formatting_internal_error] = true
341
+ end
363
342
 
364
343
  # Filter params, scrub sensitive values, format ActiveJob GlobalID arguments
365
344
  data = process_values(data)
@@ -376,6 +355,8 @@ module LogStruct
376
355
  rescue SystemStackError, StandardError => e
377
356
  unwritable_line_json(data, e)
378
357
  end
358
+ ensure
359
+ Thread.current[:logstruct_formatting_internal_error] = previous_internal_error
379
360
  end
380
361
 
381
362
  # A line that can't be written as JSON is replaced by one that says so, instead of being lost
@@ -420,7 +401,7 @@ module LogStruct
420
401
  if request_id.is_a?(String) && !request_id.empty? && !data.key?(:req_id)
421
402
  metadata[:req_id] = request_id
422
403
  end
423
- metadata[:ctx] = symbolize_keys(context) if context.present?
404
+ metadata[:ctx] = context if context.present?
424
405
  return if metadata.empty?
425
406
 
426
407
  processed = process_values(metadata)
@@ -104,7 +104,7 @@ module LogStruct
104
104
  end
105
105
 
106
106
  # Upload options with the file names taken out of a Content-Disposition (upload_options:
107
- # {content_disposition: ...}, which the upload_options plugin often sets): they can name a person
107
+ # `{content_disposition: ...}`, which the upload_options plugin often sets): they can name a person
108
108
  sig { params(options: T.untyped).returns(T.untyped) }
109
109
  def self.without_file_names(options)
110
110
  return options unless options.is_a?(Hash)
@@ -6,8 +6,8 @@ require "active_support/tagged_logging"
6
6
  # Monkey-patch ActiveSupport::TaggedLogging::Formatter to work with structured logging.
7
7
  #
8
8
  # Problem: Rails' TaggedLogging prepends tags as text and converts messages to strings.
9
- # When we pass a hash to super(), Rails does "#{tags_text}#{msg}" which calls .to_s
10
- # on the hash, producing Ruby inspect format {message: "..."} instead of valid JSON.
9
+ # When we pass a hash to super(), Rails does `"#{tags_text}#{msg}"` which calls .to_s
10
+ # on the hash, producing Ruby inspect format `{message: "..."}` instead of valid JSON.
11
11
  #
12
12
  # Solution: When LogStruct is enabled, we add tags as a hash entry and delegate to
13
13
  # LogStruct::Formatter for proper JSON serialization with filtering and standard fields.
@@ -58,4 +58,6 @@ module ActiveSupport
58
58
  end
59
59
  end
60
60
 
61
- ActiveSupport::TaggedLogging::Formatter.prepend(ActiveSupport::TaggedLogging::FormatterExtension)
61
+ module ActiveSupport::TaggedLogging::Formatter
62
+ prepend ActiveSupport::TaggedLogging::FormatterExtension
63
+ end
@@ -44,7 +44,8 @@ module LogStruct
44
44
  matcher.matches?(normalized_key, value)
45
45
  rescue => e
46
46
  handle_filter_matcher_error(e, matcher.label, normalized_key)
47
- false
47
+ # An unsuccessful filter must never expose the value it was meant to protect.
48
+ true
48
49
  end
49
50
  return true if matched
50
51
  return false if filters.path_matchers.empty?
@@ -55,7 +56,7 @@ module LogStruct
55
56
  matcher.matches?(path, value, root)
56
57
  rescue => e
57
58
  handle_filter_matcher_error(e, matcher.label, normalized_key)
58
- false
59
+ true
59
60
  end
60
61
  end
61
62
 
@@ -101,8 +102,8 @@ module LogStruct
101
102
 
102
103
  {
103
104
  _class: Hash,
104
- _keys_count: hash.keys.size,
105
- _keys: hash.keys.take(10).map { |key| normalize_summary_key(StringScrubber.scrub_name(key)) }
105
+ _keys_count: hash.size,
106
+ _keys: hash.each_key.take(10).map { |key| normalize_summary_key(StringScrubber.scrub_name(key)) }
106
107
  }
107
108
  end
108
109
 
@@ -239,7 +239,7 @@ module LogStruct
239
239
  def authorization_credentials?(scheme, credentials)
240
240
  return credentials.length >= 16 if scheme.casecmp?("bearer")
241
241
 
242
- credentials.length >= 8 && Base64.strict_decode64(credentials).include?(":")
242
+ Base64.strict_decode64(credentials).include?(":")
243
243
  rescue ArgumentError
244
244
  false
245
245
  end
@@ -260,7 +260,7 @@ module LogStruct
260
260
  end
261
261
  end
262
262
 
263
- # user[password] is checked as password, written plainly or URL-encoded
263
+ # Every part of a nested name is checked: both password[] and token[value] carry secrets.
264
264
  sig { params(name: String, value: String).returns(T::Boolean) }
265
265
  def sensitive_query_param?(name, value)
266
266
  decoded = begin
@@ -268,10 +268,11 @@ module LogStruct
268
268
  rescue ArgumentError
269
269
  name
270
270
  end
271
- key = decoded[/\[([^\[\]]*)\]\z/, 1] || decoded
272
- return false if key.empty?
273
-
274
- SENSITIVE_QUERY_PARAMS.include?(key.downcase) || ParamFilters.should_filter_key?(key, value)
271
+ path = T.let([], T::Array[String])
272
+ decoded.split(/[\[\]]/).reject(&:empty?).any? do |key|
273
+ path << key
274
+ SENSITIVE_QUERY_PARAMS.include?(key.downcase) || ParamFilters.should_filter_key?(key, value, path: path)
275
+ end
275
276
  end
276
277
 
277
278
  # The Luhn checksum that every card number passes
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module LogStruct
5
- VERSION = "0.2.1"
5
+ VERSION = "0.2.2"
6
6
  end
data/logstruct.gemspec CHANGED
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "An opinionated and type-safe structured logging solution. " \
13
13
  "Configures any Rails app to log JSON to stdout. " \
14
14
  "Structured logging is automatically configured for many gems, including Sidekiq, Carrierwave, Shrine, etc. " \
15
- "Log messages, params, and job args are automatically filtered and scrubbed to remove any sensitive info."
15
+ "Log messages, params, and job args are filtered by sensitive keys and scrubbed for common credential and personal data patterns."
16
16
  spec.homepage = "https://logstruct.com"
17
17
  spec.license = "MIT"
18
18
  spec.required_ruby_version = ">= 3.3.0"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logstruct
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - DocSpring
@@ -270,7 +270,8 @@ dependencies:
270
270
  description: An opinionated and type-safe structured logging solution. Configures
271
271
  any Rails app to log JSON to stdout. Structured logging is automatically configured
272
272
  for many gems, including Sidekiq, Carrierwave, Shrine, etc. Log messages, params,
273
- and job args are automatically filtered and scrubbed to remove any sensitive info.
273
+ and job args are filtered by sensitive keys and scrubbed for common credential and
274
+ personal data patterns.
274
275
  email:
275
276
  - support@docspring.com
276
277
  executables: []