logstruct 0.1.11 → 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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +146 -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 +126 -22
  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 +9 -3
  22. data/lib/log_struct/integrations/active_record.rb +45 -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/merge_additional_data_fields.rb +14 -1
  84. data/lib/log_struct/shared/serialize_common.rb +2 -7
  85. data/lib/log_struct/string_scrubber.rb +229 -20
  86. data/lib/log_struct/version.rb +1 -1
  87. data/lib/log_struct.rb +6 -4
  88. data/logstruct.gemspec +19 -10
  89. metadata +63 -11
  90. data/lib/log_struct/builders/active_job.rb +0 -84
  91. data/lib/log_struct/integrations/good_job/log_subscriber.rb +0 -179
  92. data/lib/log_struct/integrations/rack.rb +0 -31
@@ -5,78 +5,41 @@ require "semantic_logger"
5
5
  require_relative "formatter"
6
6
  require_relative "color_formatter"
7
7
  require_relative "logger"
8
+ require_relative "reopen_after_fork"
8
9
 
9
10
  module LogStruct
10
- # SemanticLogger Integration
11
- #
12
- # LogStruct uses SemanticLogger as its core logging engine, providing significant
13
- # performance and functionality benefits over Rails' default logger:
14
- #
15
- # ## Performance Benefits
16
- # - **Asynchronous logging**: Logs are written in a background thread, eliminating
17
- # I/O blocking in your main application threads
18
- # - **High throughput**: Can handle 100,000+ log entries per second
19
- # - **Memory efficient**: Structured data processing with minimal allocations
20
- # - **Zero-copy serialization**: Direct JSON generation without intermediate objects
21
- #
22
- # ## Reliability Benefits
23
- # - **Thread-safe**: All operations are thread-safe by design
24
- # - **Graceful degradation**: Continues logging even if appenders fail
25
- # - **Error isolation**: Logging errors don't crash your application
26
- # - **Buffered writes**: Reduces disk I/O with intelligent batching
27
- #
28
- # ## Feature Benefits
29
- # - **Multiple appenders**: Log to files, STDOUT, databases, cloud services simultaneously
30
- # - **Structured metadata**: Rich context including process ID, thread ID, tags, and more
31
- # - **Log filtering**: Runtime filtering by logger name, level, or custom rules
32
- # - **Formatters**: Pluggable output formatting (JSON, colorized, custom)
33
- # - **Metrics integration**: Built-in performance metrics and timing data
34
- #
35
- # ## Development Experience
36
- # - **Colorized output**: Beautiful, readable logs in development with ANSI colors
37
- # - **Tagged logging**: Hierarchical context tracking (requests, jobs, etc.)
38
- # - **Debugging tools**: Detailed timing and memory usage information
39
- # - **Hot reloading**: Configuration changes without application restart
40
- #
41
- # ## Production Benefits
42
- # - **Log rotation**: Automatic file rotation with size/time-based policies
43
- # - **Compression**: Automatic log compression to save disk space
44
- # - **Cloud integration**: Direct integration with CloudWatch, Splunk, etc.
45
- # - **Alerting**: Built-in support for error alerting and monitoring
46
- #
47
- # ## LogStruct Specific Enhancements
48
- # - **Type safety**: Full Sorbet type annotations for compile-time error detection
49
- # - **Structured data**: Native support for LogStruct's typed log structures
50
- # - **Filtering integration**: Seamless integration with LogStruct's data filters
51
- # - **Error handling**: Enhanced error reporting with full stack traces and context
52
- #
53
- # SemanticLogger is a production-grade logging framework used by companies processing
54
- # millions of requests per day. It provides the performance and reliability needed
55
- # for high-traffic Rails applications while maintaining an elegant developer experience.
11
+ # LogStruct writes its lines through SemanticLogger. It replaces Rails.logger and the framework loggers with a
12
+ # SemanticLogger logger whose formatter writes LogStruct's JSON lines. SemanticLogger formats and writes each line on
13
+ # a background thread, so a request or job doesn't wait for the write, and it can write to several appenders
14
+ # (STDOUT, a file, a log service) at once.
56
15
  module SemanticLogger
57
- # Handles setup and configuration of SemanticLogger for Rails applications
58
- #
59
- # This module provides the core integration between LogStruct and SemanticLogger,
60
- # configuring appenders, formatters, and logger replacement to provide optimal
61
- # logging performance while maintaining full compatibility with Rails conventions.
16
+ # Sets up SemanticLogger for a Rails app: its level, appenders and formatters, and the loggers it replaces
62
17
  module Setup
63
18
  extend T::Sig
64
19
 
65
- # Configures SemanticLogger as the primary logging engine for the Rails application
66
- #
67
- # This method replaces Rails' default logger with SemanticLogger, providing:
68
- # - **10-100x performance improvement** for high-volume logging
69
- # - **Non-blocking I/O** through background thread processing
70
- # - **Enhanced reliability** with graceful error handling
71
- # - **Multiple output destinations** (files, STDOUT, cloud services)
72
- # - **Structured metadata** including process/thread IDs and timing
73
- #
74
- # The configuration automatically:
75
- # - Determines optimal log levels based on environment
76
- # - Sets up appropriate appenders (console, file, etc.)
77
- # - Enables colorized output in development
78
- # - Replaces Rails.logger and component loggers
79
- # - Preserves full Rails.logger API compatibility
20
+ # Load hooks for the framework components whose loggers LogStruct replaces
21
+ COMPONENT_LOAD_HOOKS = T.let(
22
+ %i[active_record action_controller action_mailer active_job action_view action_cable].freeze,
23
+ T::Array[Symbol]
24
+ )
25
+
26
+ # The logger LogStruct installed, and the loggers it replaced
27
+ @logger = T.let(nil, T.nilable(LogStruct::SemanticLogger::Logger))
28
+ @original_rails_logger = T.let(nil, T.untyped)
29
+ @original_log_subscriber_logger = T.let(nil, T.untyped)
30
+ @original_app_logger = T.let(nil, T.untyped)
31
+ # The appenders add_appenders added. LogStruct replaces only these, and keeps any an app adds.
32
+ @appenders = T.let([], T::Array[T.untyped])
33
+
34
+ sig { returns(T::Boolean) }
35
+ def self.configured?
36
+ !@logger.nil?
37
+ end
38
+
39
+ # Configures SemanticLogger and replaces Rails' loggers with LogStruct's:
40
+ # - the level from config.log_level (info in production, debug elsewhere)
41
+ # - the appenders for the environment (see add_appenders)
42
+ # - Rails.logger and the framework loggers (ActiveRecord, ActionController, ActiveJob, ...)
80
43
  #
81
44
  # @param app [Rails::Application] The Rails application instance
82
45
  sig { params(app: T.untyped).void }
@@ -89,16 +52,34 @@ module LogStruct
89
52
  log_level = determine_log_level(app)
90
53
  ::SemanticLogger.default_level = log_level
91
54
 
92
- # Clear existing appenders
93
- ::SemanticLogger.clear_appenders!
94
-
95
55
  # Add appropriate appenders based on environment
56
+ remove_appenders
96
57
  add_appenders(app)
58
+ ReopenAfterFork.install!
97
59
 
98
60
  # Replace Rails.logger with SemanticLogger
99
61
  replace_rails_logger(app)
100
62
  end
101
63
 
64
+ # Adds the appenders again, with the settings config/initializers changed after the logger was set up
65
+ # (prefer_json_in_development, enable_color_output, color_map, filter_noisy_loggers, local_environments).
66
+ # Appenders the app added in config/initializers stay.
67
+ sig { params(app: T.untyped).void }
68
+ def self.reconfigure_appenders(app)
69
+ remove_appenders
70
+ add_appenders(app)
71
+ end
72
+
73
+ # Removes the appenders add_appenders added, after writing the lines already queued for them
74
+ sig { void }
75
+ def self.remove_appenders
76
+ return if @appenders.empty?
77
+
78
+ ::SemanticLogger.flush
79
+ @appenders.each { |appender| ::SemanticLogger.remove_appender(appender) }
80
+ @appenders = []
81
+ end
82
+
102
83
  sig { params(app: T.untyped).returns(Symbol) }
103
84
  def self.determine_log_level(app)
104
85
  level = if app.config.log_level
@@ -121,7 +102,13 @@ module LogStruct
121
102
  # Determine output destination
122
103
  io = determine_output(app)
123
104
 
124
- if Rails.env.development?
105
+ # Ruby buffers STDOUT when it isn't a terminal, so in a container lines waited until the buffer
106
+ # filled or the process exited. (GoodJob's executable only syncs STDOUT when it writes its own
107
+ # text logs there.)
108
+ io.sync = true if io.equal?($stdout)
109
+
110
+ appenders = []
111
+ appenders << if Rails.env.development?
125
112
  if config.prefer_json_in_development
126
113
  # Default to production-style JSON in development when enabled
127
114
  ::SemanticLogger.add_appender(
@@ -154,14 +141,19 @@ module LogStruct
154
141
  )
155
142
  end
156
143
 
157
- # Add file appender if Rails has a log path configured (normal Rails behavior)
158
- if app.config.paths["log"].first
159
- ::SemanticLogger.add_appender(
144
+ # Log to Rails' log file too in development and test (config.local_environments), unless
145
+ # RAILS_LOG_TO_STDOUT says not to. Production and other servers log to STDOUT only: nothing
146
+ # rotates the file, and in a container it only fills the disk.
147
+ local = config.local_environments.include?(Rails.env.to_sym)
148
+ if local && app.config.paths["log"].first && ENV["RAILS_LOG_TO_STDOUT"].blank?
149
+ appenders << ::SemanticLogger.add_appender(
160
150
  file_name: app.config.paths["log"].first,
161
151
  formatter: LogStruct::SemanticLogger::Formatter.new,
162
152
  filter: determine_filter
163
153
  )
164
154
  end
155
+ # add_appender returns nil when it ignores a second STDOUT appender
156
+ @appenders = appenders.compact
165
157
  end
166
158
 
167
159
  sig { params(app: T.untyped).returns(T.untyped) }
@@ -178,64 +170,75 @@ module LogStruct
178
170
  end
179
171
  end
180
172
 
181
- sig { returns(T.nilable(Regexp)) }
173
+ NOISY_LOGGERS = /\A(ActionView|ActionController::RoutingError|ActiveRecord::SchemaMigration)/
174
+
175
+ # With filter_noisy_loggers on, an appender filter that leaves out NOISY_LOGGERS' lines. SemanticLogger
176
+ # writes a line when a Proc filter returns true (a Regexp filter would keep only the names it matches).
177
+ sig { returns(T.nilable(T.proc.params(log: ::SemanticLogger::Log).returns(T::Boolean))) }
182
178
  def self.determine_filter
183
- # Filter out noisy loggers if configured
184
- config = LogStruct.config
185
- return nil unless config.filter_noisy_loggers
179
+ return nil unless LogStruct.config.filter_noisy_loggers
186
180
 
187
- # Common noisy loggers to filter
188
- /\A(ActionView|ActionController::RoutingError|ActiveRecord::SchemaMigration)/
181
+ ->(log) { !NOISY_LOGGERS.match?(log.name.to_s) }
189
182
  end
190
183
 
191
- # Replaces Rails.logger and all component loggers with LogStruct's SemanticLogger
192
- #
193
- # This method provides seamless integration by replacing the default Rails logger
194
- # throughout the entire Rails stack, ensuring all logging flows through the
195
- # high-performance SemanticLogger system.
196
- #
197
- # ## Benefits of Complete Logger Replacement:
198
- # - **Consistent performance**: All Rails components benefit from SemanticLogger speed
199
- # - **Unified formatting**: All logs use the same structured JSON format
200
- # - **Centralized configuration**: Single point of control for all logging
201
- # - **Complete compatibility**: Maintains all Rails.logger API contracts
202
- #
203
- # ## Components Updated:
204
- # - Rails.logger (framework core)
205
- # - ActiveRecord::Base.logger (database queries)
206
- # - ActionController::Base.logger (request processing)
207
- # - ActionMailer::Base.logger (email delivery)
208
- # - ActiveJob::Base.logger (background jobs)
209
- # - ActionView::Base.logger (template rendering)
210
- # - ActionCable.server.config.logger (WebSocket connections)
211
- #
212
- # After replacement, all Rails logging maintains API compatibility while gaining
213
- # SemanticLogger's performance, reliability, and feature benefits.
184
+ # Replaces Rails.logger, ActiveSupport::LogSubscriber.logger, config.logger and the framework
185
+ # loggers (ActiveRecord, ActionController, ActionMailer, ActiveJob, ActionView, ActionCable)
186
+ # with LogStruct's SemanticLogger logger, so every Rails line is a LogStruct JSON line
214
187
  #
215
188
  # @param app [Rails::Application] The Rails application instance
216
189
  sig { params(app: T.untyped).void }
217
190
  def self.replace_rails_logger(app)
218
191
  # Create new SemanticLogger instance
219
192
  logger = LogStruct::SemanticLogger::Logger.new("Rails")
193
+ @logger = logger
194
+ @original_rails_logger = Rails.logger
195
+ @original_log_subscriber_logger = ActiveSupport::LogSubscriber.logger
196
+ @original_app_logger = app.config.logger
220
197
 
221
198
  # Replace Rails.logger
222
199
  Rails.logger = logger
223
200
 
224
- # Also replace various component loggers
225
- ActiveRecord::Base.logger = logger if defined?(ActiveRecord::Base)
226
- ActionController::Base.logger = logger if defined?(ActionController::Base)
227
- if defined?(ActionMailer::Base)
228
- ActionMailer::Base.logger = logger
229
- # Ensure ActionMailer.logger is also set (it might be accessed directly)
230
- T.unsafe(::ActionMailer).logger = logger if T.unsafe(::ActionMailer).respond_to?(:logger=)
201
+ # Rails assigns ActiveSupport::LogSubscriber.logger once during boot
202
+ # (in the active_support.set_log_subscriber_logger initializer), so log
203
+ # subscribers such as Lograge keep writing to the original boot logger
204
+ # unless this reference is updated along with Rails.logger.
205
+ ActiveSupport::LogSubscriber.logger = logger
206
+
207
+ # Also replace the component loggers. Use load hooks rather than referencing the base
208
+ # classes: loading them this early would apply their on_load hooks before the app's
209
+ # initializers run, e.g. ActiveRecord would copy an empty filter_parameters into
210
+ # filter_attributes.
211
+ COMPONENT_LOAD_HOOKS.each do |hook|
212
+ ActiveSupport.on_load(hook) { self.logger = logger }
231
213
  end
232
- ActiveJob::Base.logger = logger if defined?(ActiveJob::Base)
233
- ActionView::Base.logger = logger if defined?(ActionView::Base)
234
- ActionCable.server.config.logger = logger if defined?(ActionCable)
235
214
 
236
215
  # Store reference in app config
237
216
  app.config.logger = logger
238
217
  end
218
+
219
+ # Puts back the loggers that replace_rails_logger replaced. Used when an initializer
220
+ # disables LogStruct after the logger was replaced at boot.
221
+ sig { params(app: T.untyped).void }
222
+ def self.restore_rails_logger(app)
223
+ logger = @logger
224
+ return unless logger
225
+
226
+ original_logger = @original_rails_logger
227
+ Rails.logger = original_logger
228
+ ActiveSupport::LogSubscriber.logger = @original_log_subscriber_logger
229
+ app.config.logger = @original_app_logger
230
+ remove_appenders
231
+
232
+ # These hooks run after the ones that installed LogStruct's logger, including for
233
+ # components that are already loaded
234
+ COMPONENT_LOAD_HOOKS.each do |hook|
235
+ ActiveSupport.on_load(hook) do
236
+ self.logger = original_logger if self.logger.equal?(logger)
237
+ end
238
+ end
239
+
240
+ @logger = nil
241
+ end
239
242
  end
240
243
  end
241
244
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "interfaces/additional_data_field"
5
+ require_relative "../enums/source"
5
6
 
6
7
  module LogStruct
7
8
  module Log
@@ -13,12 +14,24 @@ module LogStruct
13
14
  requires_ancestor { T::Struct }
14
15
  requires_ancestor { Interfaces::AdditionalDataField }
15
16
 
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])
20
+
16
21
  sig { params(hash: T::Hash[Symbol, T.untyped]).void }
17
22
  def merge_additional_data_fields(hash)
18
23
  ad = additional_data
19
24
  return unless ad
20
25
  ad.each do |key, value|
21
- hash[key.to_sym] = value
26
+ sym_key = key.to_sym
27
+ if RESERVED_KEYS.include?(sym_key)
28
+ LogStruct.handle_exception(
29
+ ArgumentError.new("additional_data attempted to overwrite reserved key: #{sym_key}"),
30
+ source: Source::Internal
31
+ )
32
+ next
33
+ end
34
+ hash[sym_key] = value
22
35
  end
23
36
  end
24
37
  end
@@ -25,13 +25,8 @@ module LogStruct
25
25
  field_hash.each do |log_field, value|
26
26
  next if value.nil?
27
27
  key = log_field.serialize
28
-
29
- # Limit backtrace to first 5 lines
30
- if key == :backtrace && value.is_a?(Array)
31
- value = value.first(5)
32
- end
33
-
34
- out[key] = value.is_a?(::Time) ? value.iso8601 : value
28
+ # (The formatter cuts a backtrace to config.limits.max_backtrace_lines)
29
+ out[key] = value.is_a?(::Time) ? value.iso8601(3) : value
35
30
  end
36
31
 
37
32
  # Merge any additional_data at top level if available
@@ -1,64 +1,120 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require "digest"
4
+ require "base64"
5
+ require "ipaddr"
6
+ require "uri"
7
+ require_relative "hash_utils"
8
+ require_relative "param_filters"
5
9
 
6
10
  module LogStruct
7
11
  # StringScrubber is inspired by logstop by @ankane: https://github.com/ankane/logstop
8
12
  # Enhancements:
9
13
  # - Shows which type of data was filtered
10
- # - Includes an SHA256 hash with filtered emails for request tracing
14
+ # - Includes a hash of each filtered email (HashUtils) for request tracing
11
15
  # - Uses configuration options from LogStruct.config
12
16
  module StringScrubber
13
17
  class << self
14
18
  extend T::Sig
15
19
 
16
- # Also supports URL-encoded URLs like https%3A%2F%2Fuser%3Asecret%40example.com
20
+ # The password in a URL's user info (postgres://user:password@host, redis://:password@host).
21
+ # The user ends at its first colon; the password runs to the last @ before the host.
22
+ URL_PASSWORD_REGEX = %r{(//[^\s/:@]*:)[^\s/]+(@)}
23
+ # The same in a URL-encoded URL, like https%3A%2F%2Fuser%3Asecret%40example.com
17
24
  # cspell:ignore Fuser Asecret
18
- URL_PASSWORD_REGEX = /((?:\/\/|%2F%2F)[^\s\/]+(?::|%3A))[^\s\/]+(@|%40)/
25
+ ENCODED_URL_PASSWORD_REGEX = %r{(%2F%2F(?:(?!%3A|%2F|%40)[^\s/&])*%3A)(?:(?!%2F)[^\s/&])+(%40)}i
19
26
  URL_PASSWORD_REPLACEMENT = '\1[PASSWORD]\2'
27
+ # A URL's user info that runs past what the scrubbers saw of a long string, where the cut can separate the
28
+ # password from the @ that closes it. Everything after the user's colon goes: the password can contain an
29
+ # @ of its own (p@ss).
30
+ CUT_URL_PASSWORD_REGEX = %r{(//[^\s/:@]*:)[^\s/"'<>]*\z}
31
+ CUT_ENCODED_URL_PASSWORD_REGEX = %r{(%2F%2F(?:(?!%3A|%2F|%40)[^\s/&])*%3A)(?:(?!%2F)[^\s/&"'<>])*\z}i
32
+ CUT_URL_PASSWORD_REPLACEMENT = '\1[PASSWORD]'
33
+ # Basic credentials that run past what the scrubbers saw: a cut token doesn't decode, so it can't be checked
34
+ CUT_BASIC_CREDENTIALS_REGEX = %r{\b(Basic\s+)[A-Za-z0-9+/]+=*\z}i
20
35
 
21
- EMAIL_REGEX = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i
36
+ # A URL's query string, for the sensitive params in it
37
+ QUERY_STRING_REGEX = /\?([^\s#"'<>]+)/
38
+ # OAuth authorization codes, which no key pattern would filter
39
+ SENSITIVE_QUERY_PARAMS = T.let(["code"].freeze, T::Array[String])
40
+ QUERY_PARAM_REPLACEMENT = "[FILTERED]"
22
41
 
23
- CREDIT_CARD_REGEX_SHORT = /\b[3456]\d{15}\b/
24
- CREDIT_CARD_REGEX_DELIMITERS = /\b[3456]\d{3}[\s-]\d{4}[\s-]\d{4}[\s-]\d{4}\b/
42
+ # Letters and digits of any script (an accented name, a domain in another alphabet)
43
+ EMAIL_REGEX = /(?<![\p{L}\p{N}._%+-])[\p{L}\p{N}._%+-]+@[\p{L}\p{N}.-]+\.\p{L}{2,}(?![\p{L}\p{N}])/
44
+ # A whole String that is an email address
45
+ WHOLE_EMAIL_REGEX = /\A#{EMAIL_REGEX}\z/
46
+
47
+ # The credentials after Bearer or Basic, as in an Authorization header. A Bearer token has at least 16
48
+ # characters, and Basic credentials must decode to user:password, so "Bearer token" and "Basic plan" stay.
49
+ AUTHORIZATION_CREDENTIALS_REGEX = %r{\b(Bearer|Basic)(\s+)([A-Za-z0-9\-._~+/]+=*)}i
50
+ AUTHORIZATION_CREDENTIALS_REPLACEMENT = "[FILTERED]"
51
+
52
+ # 13 to 19 digits, written together, in fours (4111 1111 1111 1111, with a 19-digit card's last
53
+ # three) or in American Express and Diners Club groups (3782 822463 10005). Numbers that fail
54
+ # the Luhn check are left alone, so most IDs and timestamps are too.
55
+ CREDIT_CARD_REGEX = /
56
+ (?<![A-Za-z0-9.-])
57
+ (?:
58
+ [2-6]\d{12,18}
59
+ | [2-6]\d{3}(?<sep>[\ -])\d{4}\k<sep>\d{4}\k<sep>\d{4}(?:\k<sep>\d{3})?
60
+ | 3\d{3}(?<sep2>[\ -])\d{6}\k<sep2>\d{4,5}
61
+ )
62
+ (?![A-Za-z0-9]|-\d)
63
+ /x
25
64
  CREDIT_CARD_REPLACEMENT = "[CREDIT_CARD]"
26
65
 
27
- PHONE_REGEX = /\b\d{3}[\s-]\d{3}[\s-]\d{4}\b/
66
+ # US numbers: 555-123-4567, 555 123 4567, 555.123.4567 and (555) 123-4567, with or without +1 or 1
67
+ PHONE_REGEX = /(?<![\w+])(?:\+?1[\s.-]?)?(?:\(\d{3}\)[\s.-]?|\d{3}[\s.-])\d{3}[\s.-]\d{4}(?!\w)/
28
68
  PHONE_REPLACEMENT = "[PHONE]"
29
69
 
30
- SSN_REGEX = /\b\d{3}[\s-]\d{2}[\s-]\d{4}\b/
70
+ # 123-45-6789 and 123 45 6789, but not inside a longer number (9-123-45-6789, 123-45-6789-0)
71
+ SSN_REGEX = /(?<!\w|\d-)\d{3}[\s-]\d{2}[\s-]\d{4}(?!\w|-\d)/
31
72
  SSN_REPLACEMENT = "[SSN]"
32
73
 
33
74
  IP_REGEX = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/
75
+ # Text that could be an IPv6 address (2001:db8::1, ::ffff:192.168.1.1, fe80::1%en0 with its zone). IPAddr
76
+ # decides whether it is one, and it must have a digit, so Ruby constants (Face::Bead) and times (12:30:45)
77
+ # stay.
78
+ IPV6_CANDIDATE_REGEX = /(?<![\w:.])(?=[\h:.]*\d)(?:\h{0,4}:){2,7}(?:\d{1,3}(?:\.\d{1,3}){3}|\h{1,4})?(?:%\w+)?(?![\w:])/
34
79
  IP_REPLACEMENT = "[IP]"
35
80
 
36
- MAC_REGEX = /\b[0-9a-f]{2}(:[0-9a-f]{2}){5}\b/i
81
+ # Six pairs of hex digits, separated by colons or by dashes
82
+ MAC_REGEX = /\b[0-9a-f]{2}([:-])[0-9a-f]{2}(?:\1[0-9a-f]{2}){4}\b/i
37
83
  MAC_REPLACEMENT = "[MAC]"
38
84
 
85
+ # Hash keys and Symbols like these are never changed by the scrubbers, so they're skipped
86
+ IDENTIFIER_REGEX = /\A[A-Za-z_][A-Za-z0-9_]*\z/
87
+
39
88
  # Scrub sensitive information from a string
40
89
  sig { params(string: String).returns(String) }
41
90
  def scrub(string)
42
91
  return string if string.empty?
43
92
 
44
- string = string.to_s.dup
93
+ # The scrubbers match letters of any script, which needs valid UTF-8
94
+ string = valid_utf8(string.to_s).dup
45
95
  config = LogStruct.config.filters
46
96
 
97
+ scrub_authorization_credentials!(string) if config.authorization_credentials
98
+
99
+ # Sensitive params in URLs' query strings
100
+ scrub_query_params!(string) if config.url_query_params && string.include?("?")
101
+
47
102
  # Passwords in URLs
48
- string.gsub!(URL_PASSWORD_REGEX, URL_PASSWORD_REPLACEMENT) if config.url_passwords
103
+ if config.url_passwords
104
+ string.gsub!(URL_PASSWORD_REGEX, URL_PASSWORD_REPLACEMENT)
105
+ string.gsub!(ENCODED_URL_PASSWORD_REGEX, URL_PASSWORD_REPLACEMENT) if string.include?("%")
106
+ end
49
107
 
50
108
  # Emails
51
109
  if config.email_addresses
52
- string.gsub!(EMAIL_REGEX) do |email|
53
- email_hash = HashUtils.hash_value(email)
54
- "[EMAIL:#{email_hash}]"
55
- end
110
+ string.gsub!(EMAIL_REGEX) { |email| "[EMAIL:#{value_hash(email)}]" }
56
111
  end
57
112
 
58
113
  # Credit card numbers
59
114
  if config.credit_card_numbers
60
- string.gsub!(CREDIT_CARD_REGEX_SHORT, CREDIT_CARD_REPLACEMENT)
61
- string.gsub!(CREDIT_CARD_REGEX_DELIMITERS, CREDIT_CARD_REPLACEMENT)
115
+ string.gsub!(CREDIT_CARD_REGEX) do |number|
116
+ luhn_valid?(number.delete(" -")) ? CREDIT_CARD_REPLACEMENT : number
117
+ end
62
118
  end
63
119
 
64
120
  # Phone numbers
@@ -67,8 +123,11 @@ module LogStruct
67
123
  # SSNs
68
124
  string.gsub!(SSN_REGEX, SSN_REPLACEMENT) if config.ssns
69
125
 
70
- # IPs
71
- string.gsub!(IP_REGEX, IP_REPLACEMENT) if config.ip_addresses
126
+ # IPs: IPv6 first, which can end in an IPv4 address
127
+ if config.ip_addresses
128
+ scrub_ipv6_addresses!(string) if string.include?(":")
129
+ string.gsub!(IP_REGEX, IP_REPLACEMENT)
130
+ end
72
131
 
73
132
  # MAC addresses
74
133
  string.gsub!(MAC_REGEX, MAC_REPLACEMENT) if config.mac_addresses
@@ -79,6 +138,156 @@ module LogStruct
79
138
 
80
139
  string
81
140
  end
141
+
142
+ # The end of the part of a long string the scrubbers saw (Formatter#scrub_limited_string). They saw only the
143
+ # start of a value that runs past it, so a URL password separated from its closing @ (which
144
+ # URL_PASSWORD_REGEX needs) and Basic credentials cut short (which no longer decode) are replaced, however
145
+ # long they were.
146
+ sig { params(string: String).returns(String) }
147
+ def scrub_cut_end(string)
148
+ filters = LogStruct.config.filters
149
+ if filters.url_passwords
150
+ string = string.sub(CUT_URL_PASSWORD_REGEX, CUT_URL_PASSWORD_REPLACEMENT)
151
+ .sub(CUT_ENCODED_URL_PASSWORD_REGEX, CUT_URL_PASSWORD_REPLACEMENT)
152
+ end
153
+ if filters.authorization_credentials
154
+ string = string.sub(CUT_BASIC_CREDENTIALS_REGEX, "\\1#{AUTHORIZATION_CREDENTIALS_REPLACEMENT}")
155
+ end
156
+ string
157
+ end
158
+
159
+ # The hash of a value (HashUtils.hash_value). Email addresses are hashed in lowercase, so an address has
160
+ # one hash however it's written, and the same hash under a filtered key as in a scrubbed string.
161
+ sig { params(value: String).returns(String) }
162
+ def value_hash(value)
163
+ HashUtils.hash_value(WHOLE_EMAIL_REGEX.match?(value) ? value.downcase : value)
164
+ end
165
+
166
+ # A String as valid UTF-8: binary and other encodings are converted, and bytes that aren't valid
167
+ # become U+FFFD
168
+ sig { params(string: String).returns(String) }
169
+ def valid_utf8(string)
170
+ return string if string.ascii_only? || (string.encoding == Encoding::UTF_8 && string.valid_encoding?)
171
+
172
+ utf8 = if string.encoding == Encoding::BINARY || string.encoding == Encoding::US_ASCII
173
+ string.dup.force_encoding(Encoding::UTF_8)
174
+ else
175
+ begin
176
+ string.encode(Encoding::UTF_8)
177
+ rescue EncodingError
178
+ string.dup.force_encoding(Encoding::UTF_8)
179
+ end
180
+ end
181
+ utf8.valid_encoding? ? utf8 : utf8.scrub
182
+ end
183
+
184
+ # A Hash key or a Symbol, scrubbed like a String unless it's a plain identifier (user_id,
185
+ # createdAt). Returns it unchanged, or the scrubbed String when scrubbing changed it.
186
+ sig { params(name: T.untyped).returns(T.untyped) }
187
+ def scrub_name(name)
188
+ return name unless name.is_a?(String) || name.is_a?(Symbol)
189
+
190
+ string = name.to_s
191
+ return name if IDENTIFIER_REGEX.match?(string)
192
+
193
+ scrubbed = scrub(string)
194
+ (scrubbed == string) ? name : scrubbed
195
+ end
196
+
197
+ # The URL without its query string and fragment (for Referer headers)
198
+ sig { params(url: T.nilable(String)).returns(T.nilable(String)) }
199
+ def without_query(url)
200
+ return url if url.nil?
201
+
202
+ url.split(/[?#]/, 2).first || ""
203
+ end
204
+
205
+ private
206
+
207
+ sig { params(string: String).void }
208
+ def scrub_ipv6_addresses!(string)
209
+ string.gsub!(IPV6_CANDIDATE_REGEX) do |candidate|
210
+ # The zone (an interface's name) is replaced with its address
211
+ ipv6_address?(candidate.split("%", 2).first.to_s) ? IP_REPLACEMENT : candidate
212
+ end
213
+ end
214
+
215
+ sig { params(candidate: String).returns(T::Boolean) }
216
+ def ipv6_address?(candidate)
217
+ ::IPAddr.new(candidate).ipv6?
218
+ rescue ::IPAddr::Error
219
+ false
220
+ end
221
+
222
+ # Bearer tokens and Basic credentials (Authorization: Bearer eyJ... becomes Bearer [FILTERED])
223
+ sig { params(string: String).void }
224
+ def scrub_authorization_credentials!(string)
225
+ return unless string.match?(/bearer|basic/i)
226
+
227
+ string.gsub!(AUTHORIZATION_CREDENTIALS_REGEX) do |match|
228
+ scheme = Regexp.last_match(1).to_s
229
+ credentials = Regexp.last_match(3).to_s
230
+ if authorization_credentials?(scheme, credentials)
231
+ "#{scheme}#{Regexp.last_match(2)}#{AUTHORIZATION_CREDENTIALS_REPLACEMENT}"
232
+ else
233
+ match
234
+ end
235
+ end
236
+ end
237
+
238
+ sig { params(scheme: String, credentials: String).returns(T::Boolean) }
239
+ def authorization_credentials?(scheme, credentials)
240
+ return credentials.length >= 16 if scheme.casecmp?("bearer")
241
+
242
+ credentials.length >= 8 && Base64.strict_decode64(credentials).include?(":")
243
+ rescue ArgumentError
244
+ false
245
+ end
246
+
247
+ # Replaces the values of query params whose names would be filtered as keys
248
+ # (/reset?token=abc&page=2 becomes /reset?token=[FILTERED]&page=2)
249
+ sig { params(string: String).void }
250
+ def scrub_query_params!(string)
251
+ string.gsub!(QUERY_STRING_REGEX) do
252
+ query = Regexp.last_match(1).to_s
253
+ params = query.split("&", -1).map do |param|
254
+ name, value = param.split("=", 2)
255
+ next param if value.nil? || value.empty? || value == QUERY_PARAM_REPLACEMENT
256
+
257
+ sensitive_query_param?(name.to_s, value) ? "#{name}=#{QUERY_PARAM_REPLACEMENT}" : param
258
+ end
259
+ "?#{params.join("&")}"
260
+ end
261
+ end
262
+
263
+ # user[password] is checked as password, written plainly or URL-encoded
264
+ sig { params(name: String, value: String).returns(T::Boolean) }
265
+ def sensitive_query_param?(name, value)
266
+ decoded = begin
267
+ URI.decode_www_form_component(name)
268
+ rescue ArgumentError
269
+ name
270
+ end
271
+ key = decoded[/\[([^\[\]]*)\]\z/, 1] || decoded
272
+ return false if key.empty?
273
+
274
+ SENSITIVE_QUERY_PARAMS.include?(key.downcase) || ParamFilters.should_filter_key?(key, value)
275
+ end
276
+
277
+ # The Luhn checksum that every card number passes
278
+ sig { params(digits: String).returns(T::Boolean) }
279
+ def luhn_valid?(digits)
280
+ sum = 0
281
+ digits.reverse.each_char.with_index do |char, index|
282
+ digit = char.to_i
283
+ if index.odd?
284
+ digit *= 2
285
+ digit -= 9 if digit > 9
286
+ end
287
+ sum += digit
288
+ end
289
+ (sum % 10).zero?
290
+ end
82
291
  end
83
292
  end
84
293
  end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module LogStruct
5
- VERSION = "0.1.11"
5
+ VERSION = "0.2.0"
6
6
  end