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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +1 -1
- data/lib/log_struct/concerns/configuration.rb +3 -3
- data/lib/log_struct/concerns/error_handling.rb +11 -0
- data/lib/log_struct/formatter.rb +16 -35
- data/lib/log_struct/integrations/shrine.rb +1 -1
- data/lib/log_struct/monkey_patches/active_support/tagged_logging/formatter.rb +5 -3
- data/lib/log_struct/param_filters.rb +5 -4
- data/lib/log_struct/string_scrubber.rb +7 -6
- data/lib/log_struct/version.rb +1 -1
- data/logstruct.gemspec +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9e72813c2641a2cc336184ab2e6f15b0d38e1f005da1ade37e136487a9c4369
|
|
4
|
+
data.tar.gz: b91cbaa7d64ea075db6937d415db0d0e7f86e58902083d54e0b985570a2573c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
294
|
+
true
|
|
295
295
|
end
|
|
296
296
|
rescue => e
|
|
297
297
|
handle_filter_error(e, filter, key)
|
|
298
|
-
|
|
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
|
data/lib/log_struct/formatter.rb
CHANGED
|
@@ -194,12 +194,12 @@ module LogStruct
|
|
|
194
194
|
StringScrubber.valid_utf8(string)
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
-
#
|
|
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,
|
|
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[
|
|
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
|
-
|
|
288
|
-
symbolize_keys(log_value.serialize)
|
|
262
|
+
log_value.serialize
|
|
289
263
|
|
|
290
264
|
when Hash
|
|
291
|
-
|
|
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] =
|
|
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: ...}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
105
|
-
_keys: hash.
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
data/lib/log_struct/version.rb
CHANGED
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
|
|
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.
|
|
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
|
|
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: []
|