logstruct 0.2.2 → 0.3.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/lib/log_struct/bounded_input.rb +101 -0
  4. data/lib/log_struct/concerns/configuration.rb +7 -7
  5. data/lib/log_struct/concerns/error_handling.rb +10 -0
  6. data/lib/log_struct/config_struct/filters.rb +23 -9
  7. data/lib/log_struct/config_struct/limits.rb +1 -1
  8. data/lib/log_struct/enums/log_field.rb +0 -3
  9. data/lib/log_struct/formatter.rb +38 -23
  10. data/lib/log_struct/hash_utils.rb +11 -4
  11. data/lib/log_struct/integrations/ahoy.rb +3 -2
  12. data/lib/log_struct/integrations/carrierwave.rb +3 -6
  13. data/lib/log_struct/integrations/lograge.rb +4 -10
  14. data/lib/log_struct/log/action_mailer/delivered.rb +10 -0
  15. data/lib/log_struct/log/action_mailer/delivery.rb +10 -0
  16. data/lib/log_struct/log/action_mailer/error.rb +10 -0
  17. data/lib/log_struct/log/carrierwave/delete.rb +0 -4
  18. data/lib/log_struct/log/carrierwave/download.rb +0 -6
  19. data/lib/log_struct/log/carrierwave/upload.rb +0 -6
  20. data/lib/log_struct/log/carrierwave.rb +2 -8
  21. data/lib/log_struct/log/error.rb +10 -0
  22. data/lib/log_struct/log/plain.rb +10 -0
  23. data/lib/log_struct/log/puma/error.rb +10 -0
  24. data/lib/log_struct/log/puma/shutdown.rb +10 -0
  25. data/lib/log_struct/log/puma/start.rb +10 -0
  26. data/lib/log_struct/log/request.rb +10 -0
  27. data/lib/log_struct/log/security/blocked_host.rb +10 -0
  28. data/lib/log_struct/log/security/csrf_violation.rb +10 -0
  29. data/lib/log_struct/log/security/ip_spoof.rb +10 -0
  30. data/lib/log_struct/log/sidekiq/error.rb +10 -0
  31. data/lib/log_struct/log/sidekiq/log.rb +10 -0
  32. data/lib/log_struct/log/sql.rb +10 -0
  33. data/lib/log_struct/param_filters.rb +7 -26
  34. data/lib/log_struct/shared/merge_additional_data_fields.rb +21 -5
  35. data/lib/log_struct/version.rb +1 -1
  36. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9e72813c2641a2cc336184ab2e6f15b0d38e1f005da1ade37e136487a9c4369
4
- data.tar.gz: b91cbaa7d64ea075db6937d415db0d0e7f86e58902083d54e0b985570a2573c5
3
+ metadata.gz: 647c97ba4368b67d39333204ca35a5161b0905dda2c8ef72d01e15d58665d367
4
+ data.tar.gz: e86e688d516ef584c5f8994ecb0739599c09595a021ebc949f494e6d631f349d
5
5
  SHA512:
6
- metadata.gz: cb4d9ebb39a98d73f022c853daac9069599fe83528d0ca6234f2f63517803f48df932762827b37e5b4aae3b89e1da16c6d3b85005f54da7cab95dcfe8ee263d0
7
- data.tar.gz: 7843625a239673d36ac68c092568927a9f5c112c830dd1b00caaf3c0e9d495fd6c9fb7514c18b5119000dc940f9a6706bbc2fb987506d55fefdd6264d9f0c568
6
+ metadata.gz: 6709f7868f43c348891761749400bb93c762e3df6f2d7093dab12f51c50a2ce33e26bfd94dab009093a1c4207f746735764c56ce20768394a9b8e81cce5d346d
7
+ data.tar.gz: 7ab05d71a64c08cd4ad747c7684d3b75553e2740c0539cefa2cab2f906f2c5023dd8696bb78129790472896f83008addde117f9e41229d563ad9003a76911fd9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-23
11
+
12
+ ### Changed
13
+
14
+ - **Breaking**: CarrierWave lines log `file_id` as an HMAC of the file's identifier (keyed like email hashes, see `hash_salt`), and no longer log `filename`, `store_path` or `ext`. CarrierWave names stored files after the uploaded file by default, and an uploaded file's name often names a person. `LogField::Filename`, `LogField::StorePath` and `LogField::Extension` are removed.
15
+ - **Breaking**: A filtered Hash's summary has its `_keys_count` but not its first 10 keys (`_keys`): a Hash's keys can be secrets themselves, like tokens or email addresses used as keys.
16
+ - **Breaking**: `additional_data` can't set any field of its line's own type, whether or not the line has it (an error line's `msg`, `backtrace` or `error_message`), or `req_id`. Like `src`, `evt`, `lvl` and `ts`, such a key is left out and handled as a LogStruct error (raised in `local_environments`). It used to replace the field. Other types' field names, like `name` on a plain line, can still be used.
17
+ - **Filtering**: Without a `hash_salt`, values hashed before the Rails app has its `secret_key_base` use a random key for each process, instead of a fixed key that was public in LogStruct's source. Set `hash_salt` to keep these hashes the same across processes.
18
+ - **Development**: `task ci` checks the locked Ruby and docs dependencies for known advisories, with `bundler-audit` and `pnpm audit`.
19
+
20
+ ### Fixed
21
+
22
+ - **Formatting**: Hash keys longer than `max_string_bytes` are left out with their values, and counted in `_oversized_keys`. A request's parameter names could make a line any size.
23
+ - **Formatting**: `ActionController::Parameters` are written like a Hash, within the size limits, instead of being converted whole first. Lograge's `params` and Ahoy's `properties` are converted at their first level only, and only as many keys as the line keeps; `_more_keys` and `_oversized_keys` count the rest.
24
+ - **Filtering**: A Rails filter block with three arguments gets a copy of the parameters that stops at 10,000 values, 20 levels of nesting or a value that contains itself. Past those, every key the block would check is filtered. The parameters used to be copied whole.
25
+ - **Errors**: An error raised while formatting, serializing or filtering a value is reported with its class and backtrace but not its message, which can quote the value. Errors in Rails filter blocks and LogStruct's filter matchers aren't reported with the key, which can be the secret. An object whose `as_json` raises is logged as `[SERIALIZATION_ERROR]` instead of its `to_s`, and its `inspect` isn't reported.
26
+ - **Formatting**: A Hash key that isn't a String or Symbol is written as its string, scrubbed, so an email address in its `to_s` is scrubbed.
27
+ - **Filtering**: A value longer than `max_string_bytes` is hashed by its first `max_string_bytes` bytes and its length.
28
+ - **Development**: Update Next.js to 16.4.1 for the docs site, which fixes three advisories in features the static site doesn't use, and the test-only Dalli to 3.2.13.
29
+
10
30
  ## [0.2.2] - 2026-10-21
11
31
 
12
32
  ### Changed
@@ -0,0 +1,101 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "action_controller"
5
+
6
+ module LogStruct
7
+ # Copies of logged Hashes and Arrays that stop before an input too large to copy in full
8
+ module BoundedInput
9
+ extend T::Sig
10
+
11
+ # A Rails filter block with three arguments gets every parameter, so a copy for it keeps what the formatter
12
+ # leaves out of the line. These limits only stop values no request sends: nesting deeper than the formatter
13
+ # writes, and more values than a form or JSON body has. A copy that reaches one is truncated, and the
14
+ # filters that read it fail closed.
15
+ MAX_DEPTH = 20
16
+ MAX_NODES = 10_000
17
+
18
+ # The keys that count what a Hash's copy left out. The formatter writes them as it writes its own counts:
19
+ # Rails' default filter_parameters include :_key, which would filter them.
20
+ COUNT_KEYS = T.let(Set[:_more_keys, :_oversized_keys].freeze, T::Set[Symbol])
21
+
22
+ class State < T::Struct
23
+ prop :remaining_nodes, Integer, default: MAX_NODES
24
+ prop :truncated, T::Boolean, default: false
25
+ end
26
+
27
+ sig do
28
+ params(value: T.untyped, state: State, depth: Integer, ancestors: T::Hash[T.untyped, T::Boolean]).returns(T.untyped)
29
+ end
30
+ def self.copy(value, state:, depth: 0, ancestors: {}.compare_by_identity)
31
+ if depth > MAX_DEPTH || ancestors.key?(value) || state.remaining_nodes <= 0
32
+ state.truncated = true
33
+ return nil
34
+ end
35
+ state.remaining_nodes -= 1
36
+
37
+ case value
38
+ when Hash, ::ActionController::Parameters
39
+ ancestors[value] = true
40
+ begin
41
+ result = {}
42
+ value.each_pair do |key, child|
43
+ break if state.truncated
44
+
45
+ result[key] = copy(child, state: state, depth: depth + 1, ancestors: ancestors)
46
+ end
47
+ result
48
+ ensure
49
+ ancestors.delete(value)
50
+ end
51
+ when Array
52
+ ancestors[value] = true
53
+ begin
54
+ result = []
55
+ value.each do |child|
56
+ break if state.truncated
57
+
58
+ result << copy(child, state: state, depth: depth + 1, ancestors: ancestors)
59
+ end
60
+ result
61
+ ensure
62
+ ancestors.delete(value)
63
+ end
64
+ else
65
+ value
66
+ end
67
+ end
68
+
69
+ # How many keys a Hash or ActionController::Parameters has, without converting its values
70
+ sig { params(hash: T.untyped).returns(Integer) }
71
+ def self.key_count(hash)
72
+ hash.is_a?(Hash) ? hash.size : hash.keys.size
73
+ end
74
+
75
+ # The first keys of a Hash or ActionController::Parameters as Symbols, for a log struct's Hash field, without
76
+ # converting the keys the formatter would leave out. The formatter writes max_hash_keys entries of a Hash,
77
+ # and the counts of the keys left out here are entries too: the last key's slot goes to _more_keys, and an
78
+ # omitted oversized key's slot to _oversized_keys, so the formatter writes both counts as they are.
79
+ sig { params(hash: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
80
+ def self.symbolize_keys(hash)
81
+ limits = LogStruct.config.limits
82
+ total = key_count(hash)
83
+ examined = (total > limits.max_hash_keys) ? [limits.max_hash_keys - 1, 0].max : total
84
+ result = T.let({}, T::Hash[Symbol, T.untyped])
85
+ oversized_keys = 0
86
+ hash.each_pair.with_index do |(key, value), index|
87
+ break if index >= examined
88
+
89
+ if key.to_s.bytesize > limits.max_string_bytes
90
+ oversized_keys += 1
91
+ next
92
+ end
93
+
94
+ result[key.to_s.to_sym] = value
95
+ end
96
+ result[:_more_keys] = total - examined if examined < total
97
+ result[:_oversized_keys] = oversized_keys if oversized_keys.positive?
98
+ result
99
+ end
100
+ end
101
+ end
@@ -259,7 +259,7 @@ module LogStruct
259
259
  result = filter.call(key, value, root)
260
260
  !!result || (value.is_a?(String) && value != original_value)
261
261
  rescue => e
262
- handle_filter_error(e, filter, original_key || "")
262
+ handle_filter_error(e, filter)
263
263
  true
264
264
  end
265
265
  ConfigStruct::PathFilterMatcher.new(callable: callable, label: filter.inspect)
@@ -290,11 +290,11 @@ module LogStruct
290
290
  begin
291
291
  !!filter.call(key)
292
292
  rescue => e
293
- handle_filter_error(e, filter, key)
293
+ handle_filter_error(e, filter)
294
294
  true
295
295
  end
296
296
  rescue => e
297
- handle_filter_error(e, filter, key)
297
+ handle_filter_error(e, filter)
298
298
  true
299
299
  end
300
300
  ConfigStruct::FilterMatcher.new(callable: callable, label: filter.inspect)
@@ -305,11 +305,11 @@ module LogStruct
305
305
  filter.respond_to?(:arity) ? filter.arity : 2
306
306
  end
307
307
 
308
- sig { params(error: StandardError, filter: T.untyped, key: String).void }
309
- def handle_filter_error(error, filter, key)
308
+ # The key isn't reported: a key can be the secret (an email address or a token used as a key)
309
+ sig { params(error: StandardError, filter: T.untyped).void }
310
+ def handle_filter_error(error, filter)
310
311
  context = {
311
312
  filter: filter.class.name,
312
- key: key,
313
313
  filter_label: begin
314
314
  filter.inspect
315
315
  rescue
@@ -317,7 +317,7 @@ module LogStruct
317
317
  end
318
318
  }
319
319
 
320
- LogStruct.handle_exception(error, source: Source::Internal, context: context)
320
+ LogStruct.handle_value_error(error, "filter_parameters entry failed", context: context)
321
321
  end
322
322
  end
323
323
  end
@@ -89,6 +89,16 @@ module LogStruct
89
89
  Thread.current[:logstruct_formatting_internal_error] = previous_internal_error
90
90
  end
91
91
 
92
+ # Handle an error raised while LogStruct read a value it was logging, without the error's message: the
93
+ # message can quote that value, as a NoMethodError quotes its receiver's inspect before Ruby 3.4.
94
+ # failure says what failed, and the reported error keeps the original's class and backtrace.
95
+ sig { params(error: Exception, failure: String, context: T.nilable(T::Hash[Symbol, T.untyped])).void }
96
+ def handle_value_error(error, failure, context: nil)
97
+ reported = RuntimeError.new("#{error.class}: #{failure}")
98
+ reported.set_backtrace(error.backtrace)
99
+ handle_exception(reported, source: Source::Internal, context: context)
100
+ end
101
+
92
102
  # Handle an error according to the configured error handling mode (log, report, raise, etc).
93
103
  # Pass `log: false` when the caller has already logged the error in its own structured line.
94
104
  sig { params(error: StandardError, source: Source, context: T.nilable(T::Hash[Symbol, T.untyped]), log: T::Boolean).void }
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "active_support/hash_with_indifferent_access"
5
+ require_relative "../bounded_input"
5
6
 
6
7
  module LogStruct
7
8
  # A key's words, lowercase and joined by underscores, so that keys match however they're written:
@@ -38,8 +39,8 @@ module LogStruct
38
39
  # A filter that needs more than the key: Rails' entries with a dot (`card.number`) match a key by its
39
40
  # path, and Rails gives filter blocks with three arguments the parameters around the key. The path
40
41
  # starts at the line's top-level field (`["params", "card", "number"]`); `root` is that field's value,
41
- # or the whole line for a top-level key, with indifferent access as Rails gives it. Building it copies
42
- # every value inside it, so a matcher that doesn't read it sets uses_root to false and gets nil.
42
+ # or the whole line for a top-level key, with indifferent access as Rails gives it. A matcher
43
+ # that doesn't read the root sets uses_root to false and gets nil.
43
44
  class PathFilterMatcher < T::Struct
44
45
  extend T::Sig
45
46
 
@@ -50,7 +51,10 @@ module LogStruct
50
51
  sig { params(path: T::Array[String], value: T.untyped, root: T.untyped).returns(T::Boolean) }
51
52
  def matches?(path, value, root)
52
53
  root = nil unless uses_root
53
- callable.call(path, value, root.is_a?(LazyRoot) ? root.value : root)
54
+ root = root.value if root.is_a?(LazyRoot)
55
+ return true if root.equal?(LazyRoot::UNAVAILABLE)
56
+
57
+ callable.call(path, value, root)
54
58
  end
55
59
  end
56
60
 
@@ -58,6 +62,8 @@ module LogStruct
58
62
  class LazyRoot
59
63
  extend T::Sig
60
64
 
65
+ UNAVAILABLE = T.let(Object.new.freeze, Object)
66
+
61
67
  sig { params(value: T.untyped).void }
62
68
  def initialize(value)
63
69
  @source = value
@@ -70,12 +76,20 @@ module LogStruct
70
76
  def value
71
77
  return @value if @built
72
78
 
73
- @built = true
74
- @value = begin
75
- @source.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(@source) : @source
76
- rescue SystemStackError, StandardError
77
- @source
79
+ state = BoundedInput::State.new
80
+ bounded = BoundedInput.copy(@source, state: state)
81
+ @value = if state.truncated
82
+ UNAVAILABLE
83
+ elsif bounded.is_a?(Hash)
84
+ ActiveSupport::HashWithIndifferentAccess.new(bounded)
85
+ else
86
+ bounded
78
87
  end
88
+ @built = true
89
+ @value
90
+ rescue SystemStackError, StandardError
91
+ @built = true
92
+ @value = UNAVAILABLE
79
93
  end
80
94
  end
81
95
 
@@ -86,7 +100,7 @@ module LogStruct
86
100
  # A filtered value is replaced by a summary of it, with no length (which would say how long a
87
101
  # secret is):
88
102
  #
89
- # { _filtered: { _class: "Hash", _keys_count: 3, _keys: [:key1, :key2, :key3] } } # first 10 keys
103
+ # { _filtered: { _class: "Hash", _keys_count: 3 } }
90
104
  # { _filtered: { _class: "Array", _count: 2 } }
91
105
  # { _filtered: { _class: "String" } } # plus its _hash for filter_keys_with_hashes
92
106
  #
@@ -21,7 +21,7 @@ module LogStruct
21
21
  # Default: 20
22
22
  prop :max_backtrace_lines, Integer, default: 20
23
23
 
24
- # Strings longer than this many bytes are cut, and say how long they were
24
+ # Strings longer than this many bytes are cut; oversized Hash keys and their values are omitted
25
25
  # Default: 32768 (32 KB)
26
26
  prop :max_string_bytes, Integer, default: 32_768
27
27
  end
@@ -120,7 +120,6 @@ module LogStruct
120
120
  Storage = new(:storage)
121
121
  Operation = new(:op)
122
122
  FileId = new(:file_id)
123
- Filename = new(:filename)
124
123
  MimeType = new(:mime_type)
125
124
  Size = new(:size)
126
125
  Metadata = new(:metadata)
@@ -134,8 +133,6 @@ module LogStruct
134
133
  Model = new(:model)
135
134
  MountPoint = new(:mount_point)
136
135
  Version = new(:version)
137
- StorePath = new(:store_path)
138
- Extension = new(:ext)
139
136
 
140
137
  # SQL-specific fields
141
138
  Sql = new(:sql)
@@ -10,6 +10,7 @@ require_relative "enums/source"
10
10
  require_relative "enums/event"
11
11
  require_relative "enums/log_field"
12
12
  require_relative "string_scrubber"
13
+ require_relative "bounded_input"
13
14
  require_relative "log"
14
15
  require_relative "param_filters"
15
16
  require_relative "multi_error_reporter"
@@ -80,13 +81,13 @@ module LogStruct
80
81
  return summarize_unprocessed_value(arg) if recursion_depth > MAX_RECURSION_DEPTH || ancestors.key?(arg)
81
82
 
82
83
  case arg
83
- when Hash, Array
84
+ when Hash, Array, ::ActionController::Parameters
84
85
  ancestors[arg] = true
85
86
  begin
86
- if arg.is_a?(Hash)
87
- process_hash(arg, recursion_depth: recursion_depth, ancestors: ancestors, path: path, root: root)
88
- else
87
+ if arg.is_a?(Array)
89
88
  process_array(arg, recursion_depth: recursion_depth, ancestors: ancestors, path: path, root: root)
89
+ else
90
+ process_hash(arg, recursion_depth: recursion_depth, ancestors: ancestors, path: path, root: root)
90
91
  end
91
92
  ensure
92
93
  ancestors.delete(arg)
@@ -107,7 +108,7 @@ module LogStruct
107
108
  scrub_string(String(object))
108
109
  end
109
110
  rescue => e
110
- LogStruct.handle_exception(e, source: Source::Internal)
111
+ LogStruct.handle_value_error(e, "GlobalID formatting failed")
111
112
  "[GLOBALID_ERROR]"
112
113
  end
113
114
  end
@@ -125,26 +126,24 @@ module LogStruct
125
126
  when Numeric, TrueClass, FalseClass, NilClass
126
127
  arg
127
128
  else
128
- # Structs, ActionController::Parameters and other objects: filter and scrub what
129
+ # Structs and other objects: filter and scrub what
129
130
  # to_json would write
130
131
  process_values(arg.as_json, recursion_depth: recursion_depth + 1, ancestors: ancestors, path: path, root: root)
131
132
  end
132
133
  rescue SystemStackError, StandardError => e
133
134
  # A SystemStackError comes from an object whose as_json refers back to itself
134
- e = RuntimeError.new("#{e.class}: #{e.message}") unless e.is_a?(StandardError)
135
- # Report error through LogStruct's framework
136
135
  context = {
137
136
  processor_method: "process_values",
138
137
  value_type: arg.class.name,
139
138
  recursion_depth: recursion_depth
140
139
  }
141
- LogStruct.handle_exception(e, source: Source::Internal, context: context)
140
+ LogStruct.handle_value_error(e, "value formatting failed", context: context)
142
141
  summarize_unprocessed_value(arg)
143
142
  end
144
143
 
145
144
  sig do
146
145
  params(
147
- hash: T::Hash[T.untyped, T.untyped],
146
+ hash: T.untyped,
148
147
  recursion_depth: Integer,
149
148
  ancestors: T::Hash[T.untyped, T::Boolean],
150
149
  path: T.nilable(T::Array[String]),
@@ -158,13 +157,30 @@ module LogStruct
158
157
  line_root = (path && recursion_depth == 0) ? ConfigStruct::LazyRoot.new(hash) : nil
159
158
 
160
159
  max_keys = LogStruct.config.limits.max_hash_keys
161
- hash.each_with_index do |(key, value), index|
160
+ max_key_bytes = LogStruct.config.limits.max_string_bytes
161
+ oversized_keys = 0
162
+ hash.each_pair.with_index do |(key, value), index|
162
163
  if index >= max_keys
163
- result[:_more_keys] = hash.size - max_keys
164
+ result[:_more_keys] = BoundedInput.key_count(hash) - max_keys
164
165
  break
165
166
  end
166
167
 
168
+ if BoundedInput::COUNT_KEYS.include?(key) && value.is_a?(Integer)
169
+ result[key] = value
170
+ next
171
+ end
172
+
173
+ if key.to_s.bytesize > max_key_bytes
174
+ oversized_keys += 1
175
+ next
176
+ end
177
+
178
+ # Checked again: each byte that isn't valid UTF-8 becomes a 3-byte U+FFFD
167
179
  key = valid_utf8_key(key)
180
+ if key.to_s.bytesize > max_key_bytes
181
+ oversized_keys += 1
182
+ next
183
+ end
168
184
  logstruct_field = recursion_depth == 0 && LOG_FIELD_KEYS.include?(key)
169
185
  key_path = path && [*path, key.to_s]
170
186
  # A top-level key's root is the line; the keys below it get the top-level field's value
@@ -173,7 +189,9 @@ module LogStruct
173
189
  # Check if this key should be filtered at any depth
174
190
  filtered = ParamFilters.should_filter_key?(key, value, logstruct_field: logstruct_field, path: key_path, root: key_root)
175
191
  # Keys are scrubbed too: an email address can be a key
176
- result[StringScrubber.scrub_name(key)] = if !filtered
192
+ scrubbed_key = StringScrubber.scrub_name(key)
193
+ scrubbed_key = scrub_limited_string(scrubbed_key) if scrubbed_key.is_a?(String) && scrubbed_key.bytesize > max_key_bytes
194
+ result[scrubbed_key] = if !filtered
177
195
  process_values(value, recursion_depth: recursion_depth + 1, ancestors: ancestors, path: key_path, root: children_root)
178
196
  elsif value == RAILS_FILTERED_MASK
179
197
  # Rails' parameter filter has already replaced the value
@@ -183,6 +201,8 @@ module LogStruct
183
201
  end
184
202
  end
185
203
 
204
+ result[:_oversized_keys] = oversized_keys if oversized_keys.positive?
205
+
186
206
  result
187
207
  end
188
208
 
@@ -205,7 +225,7 @@ module LogStruct
205
225
  fixed = valid_utf8(name)
206
226
  fixed.equal?(name) ? key : fixed.to_sym
207
227
  else
208
- key
228
+ valid_utf8(key.to_s)
209
229
  end
210
230
  end
211
231
 
@@ -295,15 +315,10 @@ module LogStruct
295
315
  log_value.to_s
296
316
  end
297
317
  rescue => e
298
- # Handle serialization errors
299
- context = {
300
- object_class: log_value.class.name,
301
- object_inspect: log_value.inspect.truncate(100)
302
- }
303
- LogStruct.handle_exception(e, source: Source::Internal, context: context)
304
-
305
- # Fall back to the string representation to ensure we continue processing
306
- log_value.to_s
318
+ LogStruct.handle_value_error(e, "value serialization failed", context: {object_class: log_value.class.name})
319
+
320
+ # An object's string representation may contain the same secret that broke serialization.
321
+ "[SERIALIZATION_ERROR]"
307
322
  end
308
323
  end
309
324
 
@@ -2,13 +2,14 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "openssl"
5
+ require "securerandom"
5
6
 
6
7
  module LogStruct
7
8
  # Utility module for hashing sensitive data
8
9
  module HashUtils
9
- # The key when there's neither a configured hash_salt nor a Rails app with a secret_key_base.
10
- # It's public, so these hashes can be matched to a list of candidate values.
11
- FALLBACK_KEY = "l0g5t0p"
10
+ # The key without a hash_salt, before the Rails app has its secret_key_base. A fixed key would be public,
11
+ # and anyone could match its hashes to a list of candidate emails; these hashes differ between processes.
12
+ FALLBACK_KEY = T.let(::SecureRandom.random_bytes(32).freeze, String)
12
13
 
13
14
  class << self
14
15
  extend T::Sig
@@ -17,7 +18,13 @@ module LogStruct
17
18
  sig { params(value: String).returns(String) }
18
19
  def hash_value(value)
19
20
  length = LogStruct.config.filters.hash_length
20
- OpenSSL::HMAC.hexdigest("SHA256", hash_key, value)[0...length] || "error"
21
+ max_bytes = LogStruct.config.limits.max_string_bytes
22
+ input = if value.bytesize > max_bytes
23
+ value.byteslice(0, max_bytes).to_s.b + "\0".b + value.bytesize.to_s.b
24
+ else
25
+ value
26
+ end
27
+ OpenSSL::HMAC.hexdigest("SHA256", hash_key, input)[0...length] || "error"
21
28
  end
22
29
 
23
30
  # The configured hash_salt, or a key derived from the app's secret_key_base
@@ -1,6 +1,8 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "../bounded_input"
5
+
4
6
  module LogStruct
5
7
  module Integrations
6
8
  # Ahoy analytics integration. If Ahoy is present, prepend a small hook to
@@ -45,10 +47,9 @@ module LogStruct
45
47
  # Event properties can come from the browser, so they can be anything; a non-Hash is logged as its class
46
48
  sig { params(properties: T.untyped).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
47
49
  def self.properties_for_log(properties)
48
- properties = properties.to_unsafe_h if properties.respond_to?(:to_unsafe_h)
49
50
  case properties
50
51
  when nil then nil
51
- when Hash then properties.to_h { |key, value| [key.to_s.to_sym, value] }
52
+ when Hash, ::ActionController::Parameters then BoundedInput.symbolize_keys(properties)
52
53
  else {_class: properties.class.name}
53
54
  end
54
55
  end
@@ -6,6 +6,7 @@ begin
6
6
  rescue LoadError
7
7
  # CarrierWave gem is not available, integration will be skipped
8
8
  end
9
+ require_relative "../hash_utils"
9
10
 
10
11
  module LogStruct
11
12
  module Integrations
@@ -49,7 +50,6 @@ module LogStruct
49
50
  # file_fields is a Hash Sorbet can't check against the struct's keyword arguments
50
51
  Log::CarrierWave::Upload.new(
51
52
  **T.unsafe(file_fields),
52
- filename: file&.filename,
53
53
  mime_type: file&.content_type,
54
54
  size: file&.size,
55
55
  duration_ms: duration_ms
@@ -72,7 +72,6 @@ module LogStruct
72
72
  log_file_operation do
73
73
  Log::CarrierWave::Download.new(
74
74
  **T.unsafe(file_fields),
75
- filename: file&.filename,
76
75
  mime_type: file&.content_type,
77
76
  size: file&.size
78
77
  )
@@ -87,13 +86,11 @@ module LogStruct
87
86
  def file_fields
88
87
  {
89
88
  storage: storage.class.name.to_s.split("::").last.to_s.downcase.to_sym,
90
- file_id: identifier.to_s,
89
+ file_id: HashUtils.hash_value(identifier.to_s),
91
90
  uploader: self.class.name,
92
91
  model: model&.class&.name,
93
92
  mount_point: mounted_as&.to_s,
94
- version: version_name&.to_s,
95
- store_path: store_path,
96
- extension: file&.extension
93
+ version: version_name&.to_s
97
94
  }
98
95
  end
99
96
 
@@ -6,6 +6,7 @@ begin
6
6
  rescue LoadError
7
7
  # Lograge gem is not available, integration will be skipped
8
8
  end
9
+ require_relative "../bounded_input"
9
10
 
10
11
  module LogStruct
11
12
  module Integrations
@@ -196,12 +197,12 @@ module LogStruct
196
197
  result = custom_options_proc.call(event, options)
197
198
  return if !result.is_a?(Hash) || result.equal?(options)
198
199
 
199
- options.merge!(result.to_h { |key, value| [key.to_s.to_sym, value] })
200
+ options.merge!(BoundedInput.symbolize_keys(result))
200
201
  end
201
202
 
202
203
  sig { params(data: T::Hash[T.any(Symbol, String), T.untyped]).returns(Log::Request) }
203
204
  def build_request_log(data)
204
- normalized_data = normalize_lograge_data(data)
205
+ normalized_data = BoundedInput.symbolize_keys(data)
205
206
 
206
207
  # Coerce common fields to expected types
207
208
  status = ((s = normalized_data[:status]) && s.respond_to?(:to_i)) ? s.to_i : s
@@ -210,7 +211,7 @@ module LogStruct
210
211
  db = ((b = normalized_data[:db]) && b.respond_to?(:to_f)) ? b.to_f : b
211
212
 
212
213
  params = normalized_data[:params]
213
- params = params.deep_symbolize_keys if params&.respond_to?(:deep_symbolize_keys)
214
+ params = BoundedInput.symbolize_keys(params) if params.is_a?(Hash) || params.is_a?(::ActionController::Parameters)
214
215
 
215
216
  additional_data = extract_additional_data(normalized_data)
216
217
 
@@ -237,13 +238,6 @@ module LogStruct
237
238
  )
238
239
  end
239
240
 
240
- sig { params(data: T::Hash[T.any(Symbol, String), T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
241
- def normalize_lograge_data(data)
242
- data.each_with_object({}) do |(key, value), normalized|
243
- normalized[key.to_s.to_sym] = value
244
- end
245
- end
246
-
247
241
  sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
248
242
  def extract_additional_data(data)
249
243
  extras = T.let({}, T::Hash[Symbol, T.untyped])
@@ -42,6 +42,16 @@ module LogStruct
42
42
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
43
43
  include LogStruct::Log::Shared::MergeAdditionalDataFields
44
44
 
45
+ FIELD_KEYS = T.let(
46
+ [LogField::To, LogField::From, LogField::Subject, LogField::MessageId, LogField::MailerClass, LogField::MailerAction, LogField::AttachmentCount].to_set(&:serialize).freeze,
47
+ T::Set[Symbol]
48
+ )
49
+
50
+ sig { override.returns(T::Set[Symbol]) }
51
+ def field_keys
52
+ FIELD_KEYS
53
+ end
54
+
45
55
  # Serialize shared fields
46
56
  include LogStruct::Log::Interfaces::CommonFields
47
57
  include LogStruct::Log::Shared::SerializeCommon
@@ -42,6 +42,16 @@ module LogStruct
42
42
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
43
43
  include LogStruct::Log::Shared::MergeAdditionalDataFields
44
44
 
45
+ FIELD_KEYS = T.let(
46
+ [LogField::To, LogField::From, LogField::Subject, LogField::MessageId, LogField::MailerClass, LogField::MailerAction, LogField::AttachmentCount].to_set(&:serialize).freeze,
47
+ T::Set[Symbol]
48
+ )
49
+
50
+ sig { override.returns(T::Set[Symbol]) }
51
+ def field_keys
52
+ FIELD_KEYS
53
+ end
54
+
45
55
  # Serialize shared fields
46
56
  include LogStruct::Log::Interfaces::CommonFields
47
57
  include LogStruct::Log::Shared::SerializeCommon
@@ -47,6 +47,16 @@ module LogStruct
47
47
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
48
48
  include LogStruct::Log::Shared::MergeAdditionalDataFields
49
49
 
50
+ FIELD_KEYS = T.let(
51
+ [LogField::To, LogField::From, LogField::Subject, LogField::MessageId, LogField::MailerClass, LogField::MailerAction, LogField::AttachmentCount, LogField::ErrorClass, LogField::Message, LogField::Backtrace].to_set(&:serialize).freeze,
52
+ T::Set[Symbol]
53
+ )
54
+
55
+ sig { override.returns(T::Set[Symbol]) }
56
+ def field_keys
57
+ FIELD_KEYS
58
+ end
59
+
50
60
  # Serialize shared fields
51
61
  include LogStruct::Log::Interfaces::CommonFields
52
62
  include LogStruct::Log::Shared::SerializeCommon
@@ -35,8 +35,6 @@ module LogStruct
35
35
  const :model, T.nilable(String), default: nil
36
36
  const :mount_point, T.nilable(String), default: nil
37
37
  const :version, T.nilable(String), default: nil
38
- const :store_path, T.nilable(String), default: nil
39
- const :extension, T.nilable(String), default: nil
40
38
 
41
39
  # Serialize shared fields
42
40
  include LogStruct::Log::Interfaces::CommonFields
@@ -51,8 +49,6 @@ module LogStruct
51
49
  h[LogField::Model] = model unless model.nil?
52
50
  h[LogField::MountPoint] = mount_point unless mount_point.nil?
53
51
  h[LogField::Version] = version unless version.nil?
54
- h[LogField::StorePath] = store_path unless store_path.nil?
55
- h[LogField::Extension] = extension unless extension.nil?
56
52
  h
57
53
  end
58
54
  end
@@ -35,11 +35,8 @@ module LogStruct
35
35
  const :model, T.nilable(String), default: nil
36
36
  const :mount_point, T.nilable(String), default: nil
37
37
  const :version, T.nilable(String), default: nil
38
- const :store_path, T.nilable(String), default: nil
39
- const :extension, T.nilable(String), default: nil
40
38
 
41
39
  # Event-specific fields
42
- const :filename, T.nilable(String), default: nil
43
40
  const :mime_type, T.nilable(String), default: nil
44
41
  const :size, T.nilable(Integer), default: nil
45
42
 
@@ -56,9 +53,6 @@ module LogStruct
56
53
  h[LogField::Model] = model unless model.nil?
57
54
  h[LogField::MountPoint] = mount_point unless mount_point.nil?
58
55
  h[LogField::Version] = version unless version.nil?
59
- h[LogField::StorePath] = store_path unless store_path.nil?
60
- h[LogField::Extension] = extension unless extension.nil?
61
- h[LogField::Filename] = filename unless filename.nil?
62
56
  h[LogField::MimeType] = mime_type unless mime_type.nil?
63
57
  h[LogField::Size] = size unless size.nil?
64
58
  h
@@ -35,11 +35,8 @@ module LogStruct
35
35
  const :model, T.nilable(String), default: nil
36
36
  const :mount_point, T.nilable(String), default: nil
37
37
  const :version, T.nilable(String), default: nil
38
- const :store_path, T.nilable(String), default: nil
39
- const :extension, T.nilable(String), default: nil
40
38
 
41
39
  # Event-specific fields
42
- const :filename, T.nilable(String), default: nil
43
40
  const :mime_type, T.nilable(String), default: nil
44
41
  const :size, T.nilable(Integer), default: nil
45
42
  const :metadata, T.nilable(T::Hash[String, T.untyped]), default: nil
@@ -58,9 +55,6 @@ module LogStruct
58
55
  h[LogField::Model] = model unless model.nil?
59
56
  h[LogField::MountPoint] = mount_point unless mount_point.nil?
60
57
  h[LogField::Version] = version unless version.nil?
61
- h[LogField::StorePath] = store_path unless store_path.nil?
62
- h[LogField::Extension] = extension unless extension.nil?
63
- h[LogField::Filename] = filename unless filename.nil?
64
58
  h[LogField::MimeType] = mime_type unless mime_type.nil?
65
59
  h[LogField::Size] = size unless size.nil?
66
60
  h[LogField::Metadata] = metadata unless metadata.nil?
@@ -21,8 +21,6 @@ module LogStruct
21
21
  const :model, T.nilable(String), default: nil
22
22
  const :mount_point, T.nilable(String), default: nil
23
23
  const :version, T.nilable(String), default: nil
24
- const :store_path, T.nilable(String), default: nil
25
- const :extension, T.nilable(String), default: nil
26
24
 
27
25
  Kwargs = T.type_alias do
28
26
  {
@@ -31,9 +29,7 @@ module LogStruct
31
29
  uploader: T.nilable(String),
32
30
  model: T.nilable(String),
33
31
  mount_point: T.nilable(String),
34
- version: T.nilable(String),
35
- store_path: T.nilable(String),
36
- extension: T.nilable(String)
32
+ version: T.nilable(String)
37
33
  }
38
34
  end
39
35
 
@@ -45,9 +41,7 @@ module LogStruct
45
41
  uploader: uploader,
46
42
  model: model,
47
43
  mount_point: mount_point,
48
- version: version,
49
- store_path: store_path,
50
- extension: extension
44
+ version: version
51
45
  }
52
46
  end
53
47
  end
@@ -48,6 +48,16 @@ module LogStruct
48
48
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
49
49
  include LogStruct::Log::Shared::MergeAdditionalDataFields
50
50
 
51
+ FIELD_KEYS = T.let(
52
+ [LogField::Path, LogField::HttpMethod, LogField::SourceIp, LogField::UserAgent, LogField::Referer, LogField::ErrorClass, LogField::Message, LogField::ErrorMessage, LogField::Backtrace, LogField::Status, LogField::CauseClass, LogField::CauseMessage].to_set(&:serialize).freeze,
53
+ T::Set[Symbol]
54
+ )
55
+
56
+ sig { override.returns(T::Set[Symbol]) }
57
+ def field_keys
58
+ FIELD_KEYS
59
+ end
60
+
51
61
  # Request fields (optional)
52
62
  include LogStruct::Log::Interfaces::RequestFields
53
63
 
@@ -36,6 +36,16 @@ module LogStruct
36
36
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
37
37
  include LogStruct::Log::Shared::MergeAdditionalDataFields
38
38
 
39
+ FIELD_KEYS = T.let(
40
+ [LogField::Message].to_set(&:serialize).freeze,
41
+ T::Set[Symbol]
42
+ )
43
+
44
+ sig { override.returns(T::Set[Symbol]) }
45
+ def field_keys
46
+ FIELD_KEYS
47
+ end
48
+
39
49
  # Serialize shared fields
40
50
  include LogStruct::Log::Interfaces::CommonFields
41
51
  include LogStruct::Log::Shared::SerializeCommon
@@ -42,6 +42,16 @@ module LogStruct
42
42
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
43
43
  include LogStruct::Log::Shared::MergeAdditionalDataFields
44
44
 
45
+ FIELD_KEYS = T.let(
46
+ [LogField::ErrorClass, LogField::Message, LogField::Backtrace, LogField::ErrorContext, LogField::HttpMethod, LogField::Path].to_set(&:serialize).freeze,
47
+ T::Set[Symbol]
48
+ )
49
+
50
+ sig { override.returns(T::Set[Symbol]) }
51
+ def field_keys
52
+ FIELD_KEYS
53
+ end
54
+
45
55
  # Serialize shared fields
46
56
  include LogStruct::Log::Interfaces::CommonFields
47
57
  include LogStruct::Log::Shared::SerializeCommon
@@ -37,6 +37,16 @@ module LogStruct
37
37
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
38
38
  include LogStruct::Log::Shared::MergeAdditionalDataFields
39
39
 
40
+ FIELD_KEYS = T.let(
41
+ [LogField::ProcessId].to_set(&:serialize).freeze,
42
+ T::Set[Symbol]
43
+ )
44
+
45
+ sig { override.returns(T::Set[Symbol]) }
46
+ def field_keys
47
+ FIELD_KEYS
48
+ end
49
+
40
50
  # Serialize shared fields
41
51
  include LogStruct::Log::Interfaces::CommonFields
42
52
  include LogStruct::Log::Shared::SerializeCommon
@@ -45,6 +45,16 @@ module LogStruct
45
45
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
46
46
  include LogStruct::Log::Shared::MergeAdditionalDataFields
47
47
 
48
+ FIELD_KEYS = T.let(
49
+ [LogField::Mode, LogField::PumaVersion, LogField::PumaCodename, LogField::RubyVersion, LogField::MinThreads, LogField::MaxThreads, LogField::Environment, LogField::ProcessId, LogField::ListeningAddresses].to_set(&:serialize).freeze,
50
+ T::Set[Symbol]
51
+ )
52
+
53
+ sig { override.returns(T::Set[Symbol]) }
54
+ def field_keys
55
+ FIELD_KEYS
56
+ end
57
+
48
58
  # Serialize shared fields
49
59
  include LogStruct::Log::Interfaces::CommonFields
50
60
  include LogStruct::Log::Shared::SerializeCommon
@@ -52,6 +52,16 @@ module LogStruct
52
52
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
53
53
  include LogStruct::Log::Shared::MergeAdditionalDataFields
54
54
 
55
+ FIELD_KEYS = T.let(
56
+ [LogField::Path, LogField::HttpMethod, LogField::SourceIp, LogField::UserAgent, LogField::Referer, LogField::Format, LogField::Controller, LogField::Action, LogField::Status, LogField::DurationMs, LogField::View, LogField::Database, LogField::Params, LogField::Host, LogField::ContentType, LogField::Accept].to_set(&:serialize).freeze,
57
+ T::Set[Symbol]
58
+ )
59
+
60
+ sig { override.returns(T::Set[Symbol]) }
61
+ def field_keys
62
+ FIELD_KEYS
63
+ end
64
+
55
65
  # Request fields (optional)
56
66
  include LogStruct::Log::Interfaces::RequestFields
57
67
 
@@ -46,6 +46,16 @@ module LogStruct
46
46
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
47
47
  include LogStruct::Log::Shared::MergeAdditionalDataFields
48
48
 
49
+ FIELD_KEYS = T.let(
50
+ [LogField::Path, LogField::HttpMethod, LogField::SourceIp, LogField::UserAgent, LogField::Referer, LogField::Message, LogField::BlockedHost, LogField::BlockedHosts, LogField::XForwardedFor].to_set(&:serialize).freeze,
51
+ T::Set[Symbol]
52
+ )
53
+
54
+ sig { override.returns(T::Set[Symbol]) }
55
+ def field_keys
56
+ FIELD_KEYS
57
+ end
58
+
49
59
  # Request fields (optional)
50
60
  include LogStruct::Log::Interfaces::RequestFields
51
61
 
@@ -43,6 +43,16 @@ module LogStruct
43
43
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
44
44
  include LogStruct::Log::Shared::MergeAdditionalDataFields
45
45
 
46
+ FIELD_KEYS = T.let(
47
+ [LogField::Path, LogField::HttpMethod, LogField::SourceIp, LogField::UserAgent, LogField::Referer, LogField::Message].to_set(&:serialize).freeze,
48
+ T::Set[Symbol]
49
+ )
50
+
51
+ sig { override.returns(T::Set[Symbol]) }
52
+ def field_keys
53
+ FIELD_KEYS
54
+ end
55
+
46
56
  # Request fields (optional)
47
57
  include LogStruct::Log::Interfaces::RequestFields
48
58
 
@@ -45,6 +45,16 @@ module LogStruct
45
45
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
46
46
  include LogStruct::Log::Shared::MergeAdditionalDataFields
47
47
 
48
+ FIELD_KEYS = T.let(
49
+ [LogField::Path, LogField::HttpMethod, LogField::SourceIp, LogField::UserAgent, LogField::Referer, LogField::Message, LogField::ClientIp, LogField::XForwardedFor].to_set(&:serialize).freeze,
50
+ T::Set[Symbol]
51
+ )
52
+
53
+ sig { override.returns(T::Set[Symbol]) }
54
+ def field_keys
55
+ FIELD_KEYS
56
+ end
57
+
48
58
  # Request fields (optional)
49
59
  include LogStruct::Log::Interfaces::RequestFields
50
60
 
@@ -42,6 +42,16 @@ module LogStruct
42
42
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
43
43
  include LogStruct::Log::Shared::MergeAdditionalDataFields
44
44
 
45
+ FIELD_KEYS = T.let(
46
+ [LogField::ErrorClass, LogField::Message, LogField::Backtrace, LogField::ErrorContext, LogField::QueueName, LogField::RetryCount].to_set(&:serialize).freeze,
47
+ T::Set[Symbol]
48
+ )
49
+
50
+ sig { override.returns(T::Set[Symbol]) }
51
+ def field_keys
52
+ FIELD_KEYS
53
+ end
54
+
45
55
  # Serialize shared fields
46
56
  include LogStruct::Log::Interfaces::CommonFields
47
57
  include LogStruct::Log::Shared::SerializeCommon
@@ -40,6 +40,16 @@ module LogStruct
40
40
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
41
41
  include LogStruct::Log::Shared::MergeAdditionalDataFields
42
42
 
43
+ FIELD_KEYS = T.let(
44
+ [LogField::Message, LogField::Context, LogField::ProcessId, LogField::ThreadId].to_set(&:serialize).freeze,
45
+ T::Set[Symbol]
46
+ )
47
+
48
+ sig { override.returns(T::Set[Symbol]) }
49
+ def field_keys
50
+ FIELD_KEYS
51
+ end
52
+
43
53
  # Serialize shared fields
44
54
  include LogStruct::Log::Interfaces::CommonFields
45
55
  include LogStruct::Log::Shared::SerializeCommon
@@ -47,6 +47,16 @@ module LogStruct
47
47
  const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
48
48
  include LogStruct::Log::Shared::MergeAdditionalDataFields
49
49
 
50
+ FIELD_KEYS = T.let(
51
+ [LogField::Message, LogField::Sql, LogField::Name, LogField::DurationMs, LogField::RowCount, LogField::Adapter, LogField::BindParams, LogField::DatabaseName, LogField::ConnectionPoolSize, LogField::ActiveConnections, LogField::OperationType, LogField::TableNames].to_set(&:serialize).freeze,
52
+ T::Set[Symbol]
53
+ )
54
+
55
+ sig { override.returns(T::Set[Symbol]) }
56
+ def field_keys
57
+ FIELD_KEYS
58
+ end
59
+
50
60
  # Serialize shared fields
51
61
  include LogStruct::Log::Interfaces::CommonFields
52
62
  include LogStruct::Log::Shared::SerializeCommon
@@ -43,7 +43,7 @@ module LogStruct
43
43
 
44
44
  matcher.matches?(normalized_key, value)
45
45
  rescue => e
46
- handle_filter_matcher_error(e, matcher.label, normalized_key)
46
+ handle_filter_matcher_error(e, matcher.label)
47
47
  # An unsuccessful filter must never expose the value it was meant to protect.
48
48
  true
49
49
  end
@@ -55,7 +55,7 @@ module LogStruct
55
55
  filters.path_matchers.any? do |matcher|
56
56
  matcher.matches?(path, value, root)
57
57
  rescue => e
58
- handle_filter_matcher_error(e, matcher.label, normalized_key)
58
+ handle_filter_matcher_error(e, matcher.label)
59
59
  true
60
60
  end
61
61
  end
@@ -94,16 +94,14 @@ module LogStruct
94
94
  filtered_string
95
95
  end
96
96
 
97
- # Summarize a filtered Hash: its first 10 keys and how many there are. No size, which would say
98
- # how long the secrets in it are.
97
+ # A filtered Hash's child keys can themselves contain secrets.
99
98
  sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
100
99
  def summarize_hash(hash)
101
100
  return {_class: "Hash", _empty: true} if hash.empty?
102
101
 
103
102
  {
104
103
  _class: Hash,
105
- _keys_count: hash.size,
106
- _keys: hash.each_key.take(10).map { |key| normalize_summary_key(StringScrubber.scrub_name(key)) }
104
+ _keys_count: hash.size
107
105
  }
108
106
  end
109
107
 
@@ -120,26 +118,9 @@ module LogStruct
120
118
 
121
119
  private
122
120
 
123
- sig { params(key: T.any(String, Symbol, Integer, T.untyped)).returns(T.any(Symbol, String)) }
124
- def normalize_summary_key(key)
125
- if key.is_a?(Symbol)
126
- key
127
- elsif key.respond_to?(:to_sym)
128
- key.to_sym
129
- else
130
- key.to_s
131
- end
132
- rescue
133
- key.to_s
134
- end
135
-
136
- sig { params(error: StandardError, label: String, key: String).void }
137
- def handle_filter_matcher_error(error, label, key)
138
- context = {
139
- matcher: label,
140
- key: key
141
- }
142
- LogStruct.handle_exception(error, source: Source::Internal, context: context)
121
+ sig { params(error: StandardError, label: String).void }
122
+ def handle_filter_matcher_error(error, label)
123
+ LogStruct.handle_value_error(error, "filter matcher failed", context: {matcher: label})
143
124
  end
144
125
  end
145
126
  end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require_relative "interfaces/additional_data_field"
5
5
  require_relative "../enums/source"
6
+ require_relative "../enums/log_field"
6
7
 
7
8
  module LogStruct
8
9
  module Log
@@ -14,17 +15,32 @@ module LogStruct
14
15
  requires_ancestor { T::Struct }
15
16
  requires_ancestor { Interfaces::AdditionalDataField }
16
17
 
17
- # Reserved keys that cannot be overwritten by additional_data.
18
- # These are the core log structure fields that must not be modified.
19
- RESERVED_KEYS = T.let(%i[src evt lvl ts].freeze, T::Array[Symbol])
18
+ # Every line's own fields, and the request ID the formatter adds unless the line has one
19
+ RESERVED_KEYS = T.let(
20
+ [LogField::Source, LogField::Event, LogField::Level, LogField::Timestamp, LogField::RequestId].to_set(&:serialize).freeze,
21
+ T::Set[Symbol]
22
+ )
23
+
24
+ NO_FIELD_KEYS = T.let(Set.new.freeze, T::Set[Symbol])
25
+
26
+ # The keys of every field of the struct's type, set or not on this line, which additional_data can't
27
+ # set: it would replace the field, or stand in for one the type didn't write. LogStruct's log types list
28
+ # theirs. A custom type's serialize writes its own fields after merging additional_data, so they win.
29
+ sig { overridable.returns(T::Set[Symbol]) }
30
+ def field_keys
31
+ NO_FIELD_KEYS
32
+ end
20
33
 
21
34
  sig { params(hash: T::Hash[Symbol, T.untyped]).void }
22
35
  def merge_additional_data_fields(hash)
23
36
  ad = additional_data
24
37
  return unless ad
25
- ad.each do |key, value|
38
+ ad.each_pair.with_index do |(key, value), index|
39
+ break if index >= LogStruct.config.limits.max_hash_keys
40
+ next if key.to_s.bytesize > LogStruct.config.limits.max_string_bytes
41
+
26
42
  sym_key = key.to_sym
27
- if RESERVED_KEYS.include?(sym_key)
43
+ if RESERVED_KEYS.include?(sym_key) || field_keys.include?(sym_key)
28
44
  LogStruct.handle_exception(
29
45
  ArgumentError.new("additional_data attempted to overwrite reserved key: #{sym_key}"),
30
46
  source: Source::Internal
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module LogStruct
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.0"
6
6
  end
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.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - DocSpring
@@ -283,6 +283,7 @@ files:
283
283
  - README.md
284
284
  - lib/log_struct.rb
285
285
  - lib/log_struct/boot_buffer.rb
286
+ - lib/log_struct/bounded_input.rb
286
287
  - lib/log_struct/concerns/configuration.rb
287
288
  - lib/log_struct/concerns/error_handling.rb
288
289
  - lib/log_struct/concerns/logging.rb