logstruct 0.1.12 → 0.2.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +76 -68
  4. data/lib/log_struct/concerns/configuration.rb +139 -91
  5. data/lib/log_struct/concerns/error_handling.rb +36 -6
  6. data/lib/log_struct/config_struct/error_handling_modes.rb +3 -1
  7. data/lib/log_struct/config_struct/filters.rb +116 -33
  8. data/lib/log_struct/config_struct/integrations.rb +0 -4
  9. data/lib/log_struct/config_struct/limits.rb +29 -0
  10. data/lib/log_struct/configuration.rb +2 -0
  11. data/lib/log_struct/enums/event.rb +3 -1
  12. data/lib/log_struct/enums/log_field.rb +5 -0
  13. data/lib/log_struct/formatter.rb +304 -56
  14. data/lib/log_struct/hash_utils.rb +33 -4
  15. data/lib/log_struct/integrations/action_mailer/error_handling.rb +78 -20
  16. data/lib/log_struct/integrations/action_mailer/fallback_error_handlers.rb +81 -0
  17. data/lib/log_struct/integrations/action_mailer/metadata_collection.rb +0 -20
  18. data/lib/log_struct/integrations/action_mailer.rb +22 -17
  19. data/lib/log_struct/integrations/active_job/log_subscriber.rb +177 -25
  20. data/lib/log_struct/integrations/active_job.rb +2 -7
  21. data/lib/log_struct/integrations/active_model_serializers.rb +7 -1
  22. data/lib/log_struct/integrations/active_record.rb +39 -17
  23. data/lib/log_struct/integrations/active_storage.rb +31 -31
  24. data/lib/log_struct/integrations/ahoy.rb +19 -15
  25. data/lib/log_struct/integrations/carrierwave.rb +55 -51
  26. data/lib/log_struct/integrations/dotenv.rb +22 -32
  27. data/lib/log_struct/integrations/good_job/logger.rb +30 -24
  28. data/lib/log_struct/integrations/good_job.rb +111 -40
  29. data/lib/log_struct/integrations/host_authorization.rb +47 -36
  30. data/lib/log_struct/integrations/lograge.rb +39 -4
  31. data/lib/log_struct/integrations/puma.rb +74 -34
  32. data/lib/log_struct/integrations/rack_error_handler/debug_exceptions_logging.rb +35 -0
  33. data/lib/log_struct/integrations/rack_error_handler/error_lines.rb +160 -0
  34. data/lib/log_struct/integrations/rack_error_handler/middleware.rb +14 -169
  35. data/lib/log_struct/integrations/rack_error_handler/parse_error_logging.rb +36 -0
  36. data/lib/log_struct/integrations/rack_error_handler.rb +20 -4
  37. data/lib/log_struct/integrations/rack_middleware.rb +23 -0
  38. data/lib/log_struct/integrations/rails_log_subscribers.rb +32 -0
  39. data/lib/log_struct/integrations/request_context/middleware.rb +4 -9
  40. data/lib/log_struct/integrations/shrine.rb +48 -73
  41. data/lib/log_struct/integrations/sidekiq/job_context_middleware.rb +32 -0
  42. data/lib/log_struct/integrations/sidekiq/logger.rb +17 -9
  43. data/lib/log_struct/integrations/sidekiq.rb +98 -7
  44. data/lib/log_struct/integrations/sorbet.rb +10 -46
  45. data/lib/log_struct/integrations.rb +3 -2
  46. data/lib/log_struct/log/{good_job/enqueue.rb → active_job/discard.rb} +13 -13
  47. data/lib/log_struct/log/active_job/enqueue.rb +0 -4
  48. data/lib/log_struct/log/{good_job/schedule.rb → active_job/error.rb} +15 -15
  49. data/lib/log_struct/log/{good_job/start.rb → active_job/retry.rb} +13 -13
  50. data/lib/log_struct/log/{good_job/finish.rb → active_job/retry_stopped.rb} +13 -17
  51. data/lib/log_struct/log/active_job.rb +4 -0
  52. data/lib/log_struct/log/active_storage/delete.rb +6 -2
  53. data/lib/log_struct/log/active_storage/download.rb +2 -4
  54. data/lib/log_struct/log/active_storage/exist.rb +2 -2
  55. data/lib/log_struct/log/active_storage/metadata.rb +4 -4
  56. data/lib/log_struct/log/active_storage/upload.rb +2 -10
  57. data/lib/log_struct/log/active_storage/url.rb +2 -6
  58. data/lib/log_struct/log/active_storage.rb +2 -3
  59. data/lib/log_struct/log/error.rb +24 -1
  60. data/lib/log_struct/log/good_job.rb +0 -4
  61. data/lib/log_struct/log/puma/error.rb +63 -0
  62. data/lib/log_struct/log/puma.rb +1 -0
  63. data/lib/log_struct/log/security/blocked_host.rb +0 -4
  64. data/lib/log_struct/log/shrine/exist.rb +0 -2
  65. data/lib/log_struct/log/shrine/metadata.rb +2 -4
  66. data/lib/log_struct/log/shrine/upload.rb +4 -0
  67. data/lib/log_struct/log/sidekiq/error.rb +63 -0
  68. data/lib/log_struct/log/{active_storage/stream.rb → sidekiq/log.rb} +17 -11
  69. data/lib/log_struct/log/sidekiq.rb +3 -45
  70. data/lib/log_struct/log.rb +4 -2
  71. data/lib/log_struct/logged_errors.rb +25 -0
  72. data/lib/log_struct/monkey_patches/active_support/tagged_logging/formatter.rb +2 -3
  73. data/lib/log_struct/param_filters.rb +51 -38
  74. data/lib/log_struct/params_errors.rb +78 -0
  75. data/lib/log_struct/rails_boot_banner_silencer.rb +7 -2
  76. data/lib/log_struct/railtie.rb +29 -14
  77. data/lib/log_struct/semantic_logger/color_formatter.rb +60 -53
  78. data/lib/log_struct/semantic_logger/concerns/log_methods.rb +65 -57
  79. data/lib/log_struct/semantic_logger/formatter.rb +79 -57
  80. data/lib/log_struct/semantic_logger/logger.rb +29 -53
  81. data/lib/log_struct/semantic_logger/reopen_after_fork.rb +40 -0
  82. data/lib/log_struct/semantic_logger/setup.rb +115 -112
  83. data/lib/log_struct/shared/serialize_common.rb +2 -7
  84. data/lib/log_struct/string_scrubber.rb +229 -20
  85. data/lib/log_struct/version.rb +1 -1
  86. data/lib/log_struct.rb +6 -4
  87. data/logstruct.gemspec +19 -10
  88. metadata +63 -11
  89. data/lib/log_struct/builders/active_job.rb +0 -87
  90. data/lib/log_struct/integrations/good_job/log_subscriber.rb +0 -189
  91. data/lib/log_struct/integrations/rack.rb +0 -31
@@ -1,18 +1,25 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "active_support/parameter_filter"
5
+ require "active_support/core_ext/object/duplicable"
4
6
  require_relative "../configuration"
5
7
 
6
8
  module LogStruct
7
9
  module Concerns
8
- # Concern for handling errors according to configured modes
10
+ # LogStruct.configure, whether LogStruct is on in this process, and merging Rails' filter_parameters
9
11
  module Configuration
10
12
  module ClassMethods
11
13
  extend T::Sig
12
14
 
13
15
  CONSOLE_COMMAND_ARGS = T.let(["console", "c"].freeze, T::Array[String])
14
16
  EMPTY_ARGV = T.let([].freeze, T::Array[String])
15
- CI_FALSE_VALUES = T.let(["false", "0", "no"].freeze, T::Array[String])
17
+ RAILS_PATTERNS_LABEL = "Rails filter_parameters"
18
+ RAILS_REGEXPS_LABEL = "Rails filter_parameters (Regexps)"
19
+ RAILS_PATHS_LABEL = "Rails filter_parameters (paths)"
20
+ RAILS_PATTERN_LABELS = T.let([RAILS_PATTERNS_LABEL, RAILS_REGEXPS_LABEL].freeze, T::Array[String])
21
+ RAILS_FILTER_MASK = "[FILTERED]"
22
+ RAILS_FILTER_PROBE = "value"
16
23
 
17
24
  sig { params(block: T.proc.params(config: LogStruct::Configuration).void).void }
18
25
  def configure(&block)
@@ -43,26 +50,34 @@ module LogStruct
43
50
 
44
51
  sig { void }
45
52
  def set_enabled_from_rails_env!
46
- # Set enabled based on current Rails environment and the LOGSTRUCT_ENABLED env var.
47
- # Precedence:
48
- # 1. Check if LOGSTRUCT_ENABLED env var is defined (not an empty string)
49
- # - Sets enabled=true only when value is "true", "yes", "1", etc.
50
- # - Sets enabled=false when value is any other value
51
- # 2. Otherwise, check if current Rails environment is in enabled_environments
52
- # AND one of: Rails::Server is defined, OR test environment with CI=true
53
- # BUT NOT Rails::Console (to exclude interactive console)
54
- # 3. Otherwise, leave as config.enabled (defaults to true)
55
-
56
- # Then check if LOGSTRUCT_ENABLED env var is set
57
- config.enabled = if ENV["LOGSTRUCT_ENABLED"]
58
- %w[true t yes y 1].include?(ENV["LOGSTRUCT_ENABLED"]&.strip&.downcase)
59
- else
53
+ # 1. When LOGSTRUCT_ENABLED is set, it decides: on for true, t, yes, y or 1, off for anything else.
54
+ # 2. Otherwise LogStruct is on when the Rails environment is in enabled_environments, the process
55
+ # isn't a console, and it's a server (server_process?) or runs in the test environment.
56
+ env_var_enabled = enabled_from_env_var
57
+ config.enabled = if env_var_enabled.nil?
60
58
  is_console = console_process?
61
59
  is_server = server_process?
62
- ci_build?
63
60
  in_enabled_env = config.enabled_environments.include?(::Rails.env.to_sym)
64
61
 
65
62
  in_enabled_env && !is_console && (is_server || ::Rails.env.test?)
63
+ else
64
+ env_var_enabled
65
+ end
66
+
67
+ # apply_initializer_settings! compares the settings with these
68
+ @enabled_from_rails_env = T.let(config.enabled, T.nilable(T::Boolean))
69
+ @enabled_environments_from_rails_env = T.let(config.enabled_environments.dup, T.nilable(T::Array[Symbol]))
70
+ end
71
+
72
+ # After config/initializers run: LOGSTRUCT_ENABLED takes precedence, then `config.enabled`
73
+ # if an initializer changed it, then `config.enabled_environments` if an initializer changed that
74
+ sig { void }
75
+ def apply_initializer_settings!
76
+ env_var_enabled = enabled_from_env_var
77
+ if !env_var_enabled.nil?
78
+ config.enabled = env_var_enabled
79
+ elsif config.enabled == @enabled_from_rails_env && config.enabled_environments != @enabled_environments_from_rails_env
80
+ set_enabled_from_rails_env!
66
81
  end
67
82
  end
68
83
 
@@ -84,44 +99,50 @@ module LogStruct
84
99
  return unless rails_filter_params.is_a?(Array)
85
100
  return if rails_filter_params.empty?
86
101
 
87
- symbol_filters = T.let([], T::Array[Symbol])
88
- matchers = T.let([], T::Array[ConfigStruct::FilterMatcher])
89
- leftovers = T.let([], T::Array[T.untyped])
90
-
91
- rails_filter_params.each do |entry|
92
- matcher = build_filter_matcher(entry)
102
+ blocks, patterns = rails_filter_params.partition { |entry| callable_filter?(entry) }
103
+ # Rails matches entries with a dot against a key's path (user.password), and the rest against the key
104
+ path_patterns, key_patterns = patterns.partition { |entry| path_pattern?(entry) }
105
+ regexps, partial_names = key_patterns.partition { |entry| entry.is_a?(Regexp) }
106
+ # Rails gives blocks with any arity but 2 the parameters as a third argument
107
+ path_blocks, key_blocks = blocks.partition { |block| arity_for_filter(block) == 3 }
93
108
 
94
- if matcher
95
- matchers << matcher
96
- next
97
- end
98
-
99
- normalized_symbol = normalize_filter_symbol(entry)
100
- if normalized_symbol
101
- symbol_filters << normalized_symbol
102
- else
103
- leftovers << entry
104
- end
109
+ matchers = key_blocks.map { |block| build_callable_filter_matcher(block) }
110
+ if partial_names.any?
111
+ matchers << build_rails_pattern_matcher(partial_names, label: RAILS_PATTERNS_LABEL, matches_logstruct_fields: false)
105
112
  end
106
-
107
- if symbol_filters.any?
108
- config.filters.filter_keys |= symbol_filters
113
+ if regexps.any?
114
+ matchers << build_rails_pattern_matcher(regexps, label: RAILS_REGEXPS_LABEL, matches_logstruct_fields: true)
109
115
  end
110
-
111
- if matchers.any?
112
- matchers.each do |matcher|
113
- existing = config.filters.filter_matchers.any? do |registered|
114
- registered.label == matcher.label
115
- end
116
- config.filters.filter_matchers << matcher unless existing
117
- end
116
+ path_matchers = path_blocks.map { |block| build_path_block_matcher(block) }
117
+ path_matchers << build_rails_path_matcher(path_patterns) if path_patterns.any?
118
+
119
+ # Exact names also filter LogStruct's own fields, which the partial matches skip
120
+ names = key_patterns.filter_map { |entry| entry.to_s.downcase.to_sym if entry.is_a?(Symbol) || entry.is_a?(String) }
121
+ config.filters.filter_keys |= names
122
+
123
+ # The latest list replaces the patterns merged before
124
+ registered = config.filters.filter_matchers
125
+ registered.reject! { |matcher| RAILS_PATTERN_LABELS.include?(matcher.label) }
126
+ matchers.each do |matcher|
127
+ registered << matcher unless registered.any? { |existing| existing.label == matcher.label }
128
+ end
129
+ registered_paths = config.filters.path_matchers
130
+ registered_paths.reject! { |matcher| matcher.label == RAILS_PATHS_LABEL }
131
+ path_matchers.each do |matcher|
132
+ registered_paths << matcher unless registered_paths.any? { |existing| existing.label == matcher.label }
118
133
  end
119
-
120
- replace_filter_parameters(rails_filter_params, leftovers)
121
134
  end
122
135
 
123
136
  private
124
137
 
138
+ sig { returns(T.nilable(T::Boolean)) }
139
+ def enabled_from_env_var
140
+ value = ENV["LOGSTRUCT_ENABLED"]
141
+ return nil if value.nil?
142
+
143
+ %w[true t yes y 1].include?(value.strip.downcase)
144
+ end
145
+
125
146
  sig { returns(T::Boolean) }
126
147
  def console_process?
127
148
  return true if defined?(::Rails::Console)
@@ -138,6 +159,7 @@ module LogStruct
138
159
  return true if defined?(::Falcon::Server)
139
160
  return true if defined?(::Rails::Server)
140
161
  return true if sidekiq_server?
162
+ return true if good_job_cli?
141
163
 
142
164
  false
143
165
  end
@@ -158,6 +180,12 @@ module LogStruct
158
180
  !!(defined?(::Sidekiq) && ::Sidekiq.respond_to?(:server?) && ::Sidekiq.server?)
159
181
  end
160
182
 
183
+ # GoodJob's executable (`good_job start`) sets within_exe before it loads the app
184
+ sig { returns(T::Boolean) }
185
+ def good_job_cli?
186
+ !!(defined?(::GoodJob::CLI) && ::GoodJob::CLI.respond_to?(:within_exe?) && ::GoodJob::CLI.within_exe?)
187
+ end
188
+
161
189
  sig { returns(T::Boolean) }
162
190
  def logstruct_server_mode?
163
191
  ::LogStruct.server_mode?
@@ -172,55 +200,81 @@ module LogStruct
172
200
  EMPTY_ARGV
173
201
  end
174
202
 
175
- sig { returns(T::Boolean) }
176
- def ci_build?
177
- value = ENV["CI"]
178
- return false if value.nil?
179
-
180
- normalized = value.strip.downcase
181
- return false if normalized.empty?
182
-
183
- !CI_FALSE_VALUES.include?(normalized)
203
+ # Rails matches Symbols and Strings as case-insensitive substrings (:passw filters
204
+ # "user_password") and Regexps as they are. Entries with a dot match a key by its path
205
+ # instead (build_rails_path_matcher).
206
+ #
207
+ # The substring matches skip LogStruct's own fields (Rails' :_key would hide GoodJob's
208
+ # cron_key); their exact names are added to filter_keys instead. A Regexp says exactly
209
+ # what it matches, so Regexps apply to every key, LogStruct's fields included.
210
+ sig { params(patterns: T::Array[T.untyped], label: String, matches_logstruct_fields: T::Boolean).returns(ConfigStruct::FilterMatcher) }
211
+ def build_rails_pattern_matcher(patterns, label:, matches_logstruct_fields:)
212
+ parameter_filter = ::ActiveSupport::ParameterFilter.new(patterns, mask: RAILS_FILTER_MASK)
213
+ callable = Kernel.lambda do |key, _value|
214
+ parameter_filter.filter_param(key, RAILS_FILTER_PROBE) == RAILS_FILTER_MASK
215
+ end
216
+ ConfigStruct::FilterMatcher.new(callable: callable, label: label, matches_logstruct_fields: matches_logstruct_fields)
184
217
  end
185
218
 
186
- sig { params(filter: T.untyped).returns(T.nilable(Symbol)) }
187
- def normalize_filter_symbol(filter)
188
- return filter if filter.is_a?(Symbol)
189
- return filter.downcase.to_sym if filter.is_a?(String)
190
-
191
- return nil unless filter.respond_to?(:to_sym)
192
-
193
- begin
194
- sym = filter.to_sym
195
- sym.is_a?(Symbol) ? sym : nil
196
- rescue
197
- nil
198
- end
219
+ sig { params(filter: T.untyped).returns(T::Boolean) }
220
+ def callable_filter?(filter)
221
+ filter.respond_to?(:call)
199
222
  end
200
223
 
201
- sig { params(filter: T.untyped).returns(T.nilable(ConfigStruct::FilterMatcher)) }
202
- def build_filter_matcher(filter)
203
- case filter
204
- when ::Regexp
205
- callable = Kernel.lambda do |key, _value|
206
- filter.match?(key)
207
- end
208
- return ConfigStruct::FilterMatcher.new(callable: callable, label: filter.inspect)
224
+ # Rails treats Strings and Symbols with a dot, and Regexps with an escaped dot, as paths
225
+ sig { params(entry: T.untyped).returns(T::Boolean) }
226
+ def path_pattern?(entry)
227
+ if entry.is_a?(Regexp)
228
+ entry.source.include?("\\.")
209
229
  else
210
- return build_callable_filter_matcher(filter) if callable_filter?(filter)
230
+ entry.to_s.include?(".")
211
231
  end
232
+ end
212
233
 
213
- nil
234
+ # Rails joins a nested parameter's keys with dots ("user.password") and matches its dotted
235
+ # entries against that, as case-insensitive substrings or as Regexps. LogStruct's paths
236
+ # start at the line's field ("params.user.password"), so an entry matches the path from
237
+ # there or from the field's value down, as it would in Rails.
238
+ sig { params(patterns: T::Array[T.untyped]).returns(ConfigStruct::PathFilterMatcher) }
239
+ def build_rails_path_matcher(patterns)
240
+ regexps = patterns.select { |entry| entry.is_a?(Regexp) }
241
+ strings = patterns.reject { |entry| entry.is_a?(Regexp) }.map { |entry| Regexp.escape(entry.to_s) }
242
+ regexps << Regexp.new(strings.join("|"), true) if strings.any?
243
+ callable = Kernel.lambda do |path, _value, _root|
244
+ full_path = path.join(".")
245
+ field_path = path.drop(1).join(".")
246
+ regexps.any? { |regexp| regexp.match?(full_path) || (!field_path.empty? && regexp.match?(field_path)) }
247
+ end
248
+ ConfigStruct::PathFilterMatcher.new(callable: callable, label: RAILS_PATHS_LABEL, uses_root: false)
214
249
  end
215
250
 
216
- sig { params(filter: T.untyped).returns(T::Boolean) }
217
- def callable_filter?(filter)
218
- filter.respond_to?(:call)
251
+ # A Rails filter block with three arguments, given the parameters around the key: the
252
+ # line's top-level field's value (a request line's params), or the line for a top-level key
253
+ sig { params(filter: T.untyped).returns(ConfigStruct::PathFilterMatcher) }
254
+ def build_path_block_matcher(filter)
255
+ callable = Kernel.lambda do |path, original_value, root|
256
+ original_key = path.last.to_s
257
+ key = original_key.dup
258
+ value = original_value.duplicable? ? original_value.dup : original_value
259
+ result = filter.call(key, value, root)
260
+ !!result || (value.is_a?(String) && value != original_value)
261
+ rescue => e
262
+ handle_filter_error(e, filter, original_key || "")
263
+ false
264
+ end
265
+ ConfigStruct::PathFilterMatcher.new(callable: callable, label: filter.inspect)
219
266
  end
220
267
 
221
- sig { params(filter: T.untyped).returns(T.nilable(ConfigStruct::FilterMatcher)) }
268
+ # Rails gives filter blocks copies of the key and value, ignores what they return, and
269
+ # logs the value as the block left it: the Rails guides' example reverses it in place,
270
+ # and a block can replace it with "[FILTERED]". LogStruct filters a key when its block
271
+ # returns a truthy value or changes a String it's given, and never changes the logged
272
+ # value itself. Blocks with three arguments are path matchers (build_path_block_matcher).
273
+ sig { params(filter: T.untyped).returns(ConfigStruct::FilterMatcher) }
222
274
  def build_callable_filter_matcher(filter)
223
- callable = Kernel.lambda do |key, value|
275
+ callable = Kernel.lambda do |original_key, original_value|
276
+ key = original_key.dup
277
+ value = original_value.duplicable? ? original_value.dup : original_value
224
278
  call_args = case arity_for_filter(filter)
225
279
  when 0
226
280
  []
@@ -231,7 +285,7 @@ module LogStruct
231
285
  end
232
286
 
233
287
  result = filter.call(*call_args)
234
- !!result
288
+ !!result || (value.is_a?(String) && value != original_value)
235
289
  rescue ArgumentError
236
290
  begin
237
291
  !!filter.call(key)
@@ -251,12 +305,6 @@ module LogStruct
251
305
  filter.respond_to?(:arity) ? filter.arity : 2
252
306
  end
253
307
 
254
- sig { params(filter_params: T::Array[T.untyped], leftovers: T::Array[T.untyped]).void }
255
- def replace_filter_parameters(filter_params, leftovers)
256
- filter_params.clear
257
- filter_params.concat(leftovers)
258
- end
259
-
260
308
  sig { params(error: StandardError, filter: T.untyped, key: String).void }
261
309
  def handle_filter_error(error, filter, key)
262
310
  context = {
@@ -42,10 +42,19 @@ module LogStruct
42
42
  LogStruct.error(error_log)
43
43
  end
44
44
 
45
- # Report an error using the configured handler or MultiErrorReporter
45
+ # Log an error and report it using the configured handler or MultiErrorReporter
46
46
  sig { params(error: StandardError, source: Source, context: T.nilable(T::Hash[Symbol, T.untyped])).void }
47
47
  def log_and_report_error(error, source:, context: nil)
48
48
  log_error(error, source: source, context: context)
49
+ report_error(error, source: source, context: context)
50
+ end
51
+
52
+ # Report an error using the configured handler or MultiErrorReporter, without logging it. A reporter that
53
+ # raises (a timeout calling its service) doesn't change what happens to the error: its own error is logged,
54
+ # and not reported through the same reporter.
55
+ sig { params(error: StandardError, source: Source, context: T.nilable(T::Hash[Symbol, T.untyped])).void }
56
+ def report_error(error, source:, context: nil)
57
+ context = filtered_context(context)
49
58
  error_handler = LogStruct.config.error_reporting_handler
50
59
  if error_handler
51
60
  # Use the configured handler
@@ -54,11 +63,31 @@ module LogStruct
54
63
  # Fall back to MultiErrorReporter (detects Sentry, Bugsnag, etc.)
55
64
  LogStruct::MultiErrorReporter.report_error(error, context || {})
56
65
  end
66
+ rescue => e
67
+ LogStruct.error(Log::Error.new(
68
+ source: Source::Internal,
69
+ error_class: e.class,
70
+ message: "The error reporter raised while reporting #{error.class}",
71
+ error_message: e.message,
72
+ backtrace: e.backtrace,
73
+ level: Level::Error
74
+ ))
57
75
  end
58
76
 
59
- # Handle an error according to the configured error handling mode (log, report, raise, etc)
60
- sig { params(error: StandardError, source: Source, context: T.nilable(T::Hash[Symbol, T.untyped])).void }
61
- def handle_exception(error, source:, context: nil)
77
+ # The context filtered and scrubbed like a log line, for error reporters: it can hold a
78
+ # request's parameters or a mail's recipients
79
+ sig { params(context: T.nilable(T::Hash[Symbol, T.untyped])).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
80
+ def filtered_context(context)
81
+ return context if context.nil? || context.empty?
82
+
83
+ filtered = Formatter.new.process_values(context)
84
+ filtered.is_a?(Hash) ? filtered : {}
85
+ end
86
+
87
+ # Handle an error according to the configured error handling mode (log, report, raise, etc).
88
+ # Pass `log: false` when the caller has already logged the error in its own structured line.
89
+ sig { params(error: StandardError, source: Source, context: T.nilable(T::Hash[Symbol, T.untyped]), log: T::Boolean).void }
90
+ def handle_exception(error, source:, context: nil, log: true)
62
91
  mode = error_handling_mode_for(source)
63
92
 
64
93
  # Log / report in production, raise locally (dev/test)
@@ -74,10 +103,11 @@ module LogStruct
74
103
  raise(error)
75
104
 
76
105
  when ErrorHandlingMode::Log, ErrorHandlingMode::LogProduction
77
- log_error(error, source: source, context: context)
106
+ log_error(error, source: source, context: context) if log
78
107
 
79
108
  when ErrorHandlingMode::Report, ErrorHandlingMode::ReportProduction
80
- log_and_report_error(error, source: source, context: context)
109
+ log_error(error, source: source, context: context) if log
110
+ report_error(error, source: source, context: context)
81
111
 
82
112
  else
83
113
  # Ensures the case statement is exhaustive
@@ -16,7 +16,9 @@ module LogStruct
16
16
  # - Raise - Always raise the error
17
17
 
18
18
  # Configurable error handling categories
19
- prop :type_checking_errors, ErrorHandlingMode, default: ErrorHandlingMode::LogProduction
19
+ # Sorbet type errors in your app and its gems: logged and reported in production, so they don't pass unseen
20
+ prop :type_checking_errors, ErrorHandlingMode, default: ErrorHandlingMode::ReportProduction
21
+ # LogStruct's own errors
20
22
  prop :logstruct_errors, ErrorHandlingMode, default: ErrorHandlingMode::LogProduction
21
23
  prop :security_errors, ErrorHandlingMode, default: ErrorHandlingMode::Report
22
24
  prop :standard_errors, ErrorHandlingMode, default: ErrorHandlingMode::Raise
@@ -1,13 +1,33 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "active_support/hash_with_indifferent_access"
5
+
4
6
  module LogStruct
7
+ # A key's words, lowercase and joined by underscores, so that keys match however they're written:
8
+ # apiKey, api-key, X-Api-Key and HTTP_X_API_KEY all contain "api_key"
9
+ module KeyWords
10
+ extend T::Sig
11
+
12
+ sig { params(key: T.untyped).returns(String) }
13
+ def self.normalize(key)
14
+ key.to_s
15
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
16
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
17
+ .tr("- .", "___")
18
+ .downcase
19
+ end
20
+ end
21
+
5
22
  module ConfigStruct
6
23
  class FilterMatcher < T::Struct
7
24
  extend T::Sig
8
25
 
9
26
  const :callable, T.proc.params(key: String, value: T.untyped).returns(T::Boolean)
10
27
  const :label, String
28
+ # Whether the matcher also applies to the names of LogStruct's own fields at the top of a line.
29
+ # LogStruct's matcher for Rails' partial matches doesn't: Rails' default :_key would filter cron_key.
30
+ const :matches_logstruct_fields, T::Boolean, default: true
11
31
 
12
32
  sig { params(key: String, value: T.untyped).returns(T::Boolean) }
13
33
  def matches?(key, value)
@@ -15,22 +35,67 @@ module LogStruct
15
35
  end
16
36
  end
17
37
 
38
+ # A filter that needs more than the key: Rails' entries with a dot (`card.number`) match a key by its
39
+ # path, and Rails gives filter blocks with three arguments the parameters around the key. The path
40
+ # 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.
43
+ class PathFilterMatcher < T::Struct
44
+ extend T::Sig
45
+
46
+ const :callable, T.proc.params(path: T::Array[String], value: T.untyped, root: T.untyped).returns(T::Boolean)
47
+ const :label, String
48
+ const :uses_root, T::Boolean, default: true
49
+
50
+ sig { params(path: T::Array[String], value: T.untyped, root: T.untyped).returns(T::Boolean) }
51
+ def matches?(path, value, root)
52
+ root = nil unless uses_root
53
+ callable.call(path, value, root.is_a?(LazyRoot) ? root.value : root)
54
+ end
55
+ end
56
+
57
+ # The root a PathFilterMatcher gets, built the first time a matcher reads it
58
+ class LazyRoot
59
+ extend T::Sig
60
+
61
+ sig { params(value: T.untyped).void }
62
+ def initialize(value)
63
+ @source = value
64
+ @built = T.let(false, T::Boolean)
65
+ @value = T.let(nil, T.untyped)
66
+ end
67
+
68
+ # The value with indifferent access, as Rails gives filter blocks the request's parameters
69
+ sig { returns(T.untyped) }
70
+ def value
71
+ return @value if @built
72
+
73
+ @built = true
74
+ @value = begin
75
+ @source.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(@source) : @source
76
+ rescue SystemStackError, StandardError
77
+ @source
78
+ end
79
+ end
80
+ end
81
+
18
82
  class Filters < T::Struct
19
83
  include Sorbet::SerializeSymbolKeys
20
84
 
21
85
  # Keys that should be filtered in nested structures such as request params and job arguments.
22
- # Filtered data includes information about Hashes and Arrays.
86
+ # A filtered value is replaced by a summary of it, with no length (which would say how long a
87
+ # secret is):
23
88
  #
24
- # { _filtered: {
25
- # _class: "Hash", # Class of the filtered value
26
- # _bytes: 1234, # Length of JSON string in bytes
27
- # _keys_count: 3, # Number of keys in the hash
28
- # _keys: [:key1, :key2, :key3], # First 10 keys in the hash
29
- # }
30
- # }
89
+ # { _filtered: { _class: "Hash", _keys_count: 3, _keys: [:key1, :key2, :key3] } } # first 10 keys
90
+ # { _filtered: { _class: "Array", _count: 2 } }
91
+ # { _filtered: { _class: "String" } } # plus its _hash for filter_keys_with_hashes
31
92
  #
32
- # Default: [:password, :password_confirmation, :pass, :pw, :token, :secret,
33
- # :credentials, :creds, :auth, :authentication, :authorization]
93
+ # A key matches when its name, or its words joined by underscores (KeyWords.normalize), equal an
94
+ # entry: :api_key also filters apiKey and X-Api-Key, but doesn't by itself filter api_key_id (the
95
+ # default filter_matchers' sensitive words do).
96
+ #
97
+ # Default: [:password, :password_confirmation, :pass, :pw, :token, :secret, :credentials, :auth,
98
+ # :authentication, :authorization, :credit_card, :ssn, :social_security]
34
99
  #
35
100
  prop :filter_keys,
36
101
  T::Array[Symbol],
@@ -42,22 +107,22 @@ module LogStruct
42
107
  ]
43
108
  }
44
109
 
45
- # Keys where string values should include an SHA256 hash.
46
- # Useful for tracing emails across requests (e.g. sign in, sign up) while protecting privacy.
110
+ # Keys that are filtered like filter_keys, and whose String values also get a _hash in their summary
111
+ # (see hash_salt), so lines about the same person can be followed without logging the address.
47
112
  # Default: [:email, :email_address]
48
113
  prop :filter_keys_with_hashes,
49
114
  T::Array[Symbol],
50
115
  factory: -> { %i[email email_address] }
51
116
 
52
- # Hash salt for SHA256 hashing (typically used for email addresses)
53
- # Used for both param filters and string scrubbing
54
- # Default: "l0g5t0p"
55
- prop :hash_salt, String, default: "l0g5t0p"
117
+ # The key for the HMAC-SHA256 hashes of emails and filter_keys_with_hashes values. Anyone who has
118
+ # the key and a list of candidate emails can match them to the hashes, so keep it secret.
119
+ # Default: nil, which uses a key derived from the Rails app's secret_key_base. Set it to keep the
120
+ # same hashes in every app that logs the same people.
121
+ prop :hash_salt, T.nilable(String), default: nil
56
122
 
57
- # Hash length for SHA256 hashing (typically used for email addresses)
58
- # Used for both param filters and string scrubbing
59
- # Default: 12
60
- prop :hash_length, Integer, default: 12
123
+ # How many hex characters of each hash to log
124
+ # Default: 16
125
+ prop :hash_length, Integer, default: 16
61
126
 
62
127
  # Filter email addresses. Also controls email filtering for the ActionMailer integration
63
128
  # (to, from, recipient fields, etc.)
@@ -68,6 +133,17 @@ module LogStruct
68
133
  # Default: true
69
134
  prop :url_passwords, T::Boolean, default: true
70
135
 
136
+ # Filter sensitive params in URLs' query strings: params that filter_keys or the filter matchers
137
+ # would filter as keys (?token=, ?api_key=, user[password]=), and OAuth's ?code=
138
+ # Default: true
139
+ prop :url_query_params, T::Boolean, default: true
140
+
141
+ # Scrub the credentials after Bearer and Basic in strings (an Authorization header's value,
142
+ # logged as text): Bearer tokens of 16 or more characters, and Basic credentials that decode to
143
+ # user:password
144
+ # Default: true
145
+ prop :authorization_credentials, T::Boolean, default: true
146
+
71
147
  # Filter credit card numbers
72
148
  # Default: true
73
149
  prop :credit_card_numbers, T::Boolean, default: true
@@ -88,32 +164,39 @@ module LogStruct
88
164
  # Default: false
89
165
  prop :mac_addresses, T::Boolean, default: false
90
166
 
91
- # Default regex pattern for matching sensitive keys.
92
- # Matches keys containing: password, token, secret, auth, cred
93
- # Also matches specific key patterns: api_key, secret_key, private_key, access_key, encryption_key
94
- # Examples: access_token, api_key, auth_header, credentials
95
- # Uses start/end of string or underscore/hyphen boundaries to prevent
96
- # false positives like "keyboard" or "turkey" (which contain "key" mid-word)
97
- # Note: "key" alone is too broad (matches cron_key, primary_key), so we only match
98
- # specific sensitive key patterns
167
+ # Default pattern for sensitive keys, matched against a key's words joined by underscores
168
+ # (KeyWords.normalize: apiKey, api-key and API_KEY are all "api_key"):
169
+ # - these words anywhere in the key: password, passwd, passphrase, passcode, pwd, token, secret, auth,
170
+ # authorization, authentication, cred, credential, cookie, session, jwt, bearer, otp, apikey, cvv,
171
+ # cvc, ssn, social_security, social_security_number, credit_card, card_number, crypt, crypted (and
172
+ # their plurals): access_token, newPassword, Set-Cookie, HTTP_AUTHORIZATION, credit_card_number
173
+ # - api_key, secret_key, private_key, access_key, encryption_key, signing_key, master_key: X-Api-Key
174
+ # - signature, sig, salt and certificate as the last word: X-Amz-Signature (but not signature_field_id)
175
+ # Matching whole words avoids false positives like "keyboard", "author" or "cron_key".
99
176
  DEFAULT_SENSITIVE_KEY_PATTERN = T.let(
100
- /(^|[_-])(password|token|secret|auth|cred)([_-]|$)|(^|[_-])(api|secret|private|access|encryption)_key([_-]|$)/i,
177
+ /(?:\A|_)(?:passwords?|passwd|passphrases?|passcodes?|pwd|tokens?|secrets?|auth|authorization|authentication|creds?|credentials?|cookies?|sessions?|jwts?|bearer|otps?|apikeys?|cvv|cvc|ssn|social_security(?:_numbers?)?|credit_cards?|card_numbers?|crypt|crypted)(?:_|\z)|(?:\A|_)(?:api|secret|private|access|encryption|signing|master)_keys?(?:_|\z)|(?:\A|_)(?:signature|sig|salt|certificate)s?\z/,
101
178
  Regexp
102
179
  )
103
180
 
104
- # Additional filter matchers built from Rails filter_parameters entries that aren't simple symbols.
105
- # Each matcher receives the key (String) and optional value, returning true when the pair should be filtered.
106
- # By default, includes a regex matcher for common sensitive key patterns.
181
+ # Matchers for keys that aren't listed by name in filter_keys. Each matcher receives the key (String)
182
+ # and optional value, returning true when the pair should be filtered. Includes a regex matcher for
183
+ # common sensitive key patterns, and LogStruct adds matchers for Rails' filter_parameters at boot.
107
184
  prop :filter_matchers,
108
185
  T::Array[FilterMatcher],
109
186
  factory: -> {
110
187
  [
111
188
  FilterMatcher.new(
112
- callable: ->(key, _value) { DEFAULT_SENSITIVE_KEY_PATTERN.match?(key) },
189
+ callable: ->(key, _value) { DEFAULT_SENSITIVE_KEY_PATTERN.match?(KeyWords.normalize(key)) },
113
190
  label: "default_sensitive_pattern"
114
191
  )
115
192
  ]
116
193
  }
194
+
195
+ # Matchers that need a key's path or the parameters around it (see PathFilterMatcher). LogStruct
196
+ # adds them for Rails' filter_parameters at boot.
197
+ prop :path_matchers,
198
+ T::Array[PathFilterMatcher],
199
+ factory: -> { [] }
117
200
  end
118
201
  end
119
202
  end
@@ -61,10 +61,6 @@ module LogStruct
61
61
  # Default: true
62
62
  prop :enable_goodjob, T::Boolean, default: true
63
63
 
64
- # Enable SemanticLogger integration for high-performance logging
65
- # Default: true
66
- prop :enable_semantic_logger, T::Boolean, default: true
67
-
68
64
  # Enable SQL query logging through ActiveRecord instrumentation
69
65
  # Default: false (can be resource intensive)
70
66
  prop :enable_sql_logging, T::Boolean, default: false