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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -1
- data/README.md +76 -68
- data/lib/log_struct/concerns/configuration.rb +139 -91
- data/lib/log_struct/concerns/error_handling.rb +36 -6
- data/lib/log_struct/config_struct/error_handling_modes.rb +3 -1
- data/lib/log_struct/config_struct/filters.rb +116 -33
- data/lib/log_struct/config_struct/integrations.rb +0 -4
- data/lib/log_struct/config_struct/limits.rb +29 -0
- data/lib/log_struct/configuration.rb +2 -0
- data/lib/log_struct/enums/event.rb +3 -1
- data/lib/log_struct/enums/log_field.rb +5 -0
- data/lib/log_struct/formatter.rb +304 -56
- data/lib/log_struct/hash_utils.rb +33 -4
- data/lib/log_struct/integrations/action_mailer/error_handling.rb +78 -20
- data/lib/log_struct/integrations/action_mailer/fallback_error_handlers.rb +81 -0
- data/lib/log_struct/integrations/action_mailer/metadata_collection.rb +0 -20
- data/lib/log_struct/integrations/action_mailer.rb +22 -17
- data/lib/log_struct/integrations/active_job/log_subscriber.rb +177 -25
- data/lib/log_struct/integrations/active_job.rb +2 -7
- data/lib/log_struct/integrations/active_model_serializers.rb +7 -1
- data/lib/log_struct/integrations/active_record.rb +39 -17
- data/lib/log_struct/integrations/active_storage.rb +31 -31
- data/lib/log_struct/integrations/ahoy.rb +19 -15
- data/lib/log_struct/integrations/carrierwave.rb +55 -51
- data/lib/log_struct/integrations/dotenv.rb +22 -32
- data/lib/log_struct/integrations/good_job/logger.rb +30 -24
- data/lib/log_struct/integrations/good_job.rb +111 -40
- data/lib/log_struct/integrations/host_authorization.rb +47 -36
- data/lib/log_struct/integrations/lograge.rb +39 -4
- data/lib/log_struct/integrations/puma.rb +74 -34
- data/lib/log_struct/integrations/rack_error_handler/debug_exceptions_logging.rb +35 -0
- data/lib/log_struct/integrations/rack_error_handler/error_lines.rb +160 -0
- data/lib/log_struct/integrations/rack_error_handler/middleware.rb +14 -169
- data/lib/log_struct/integrations/rack_error_handler/parse_error_logging.rb +36 -0
- data/lib/log_struct/integrations/rack_error_handler.rb +20 -4
- data/lib/log_struct/integrations/rack_middleware.rb +23 -0
- data/lib/log_struct/integrations/rails_log_subscribers.rb +32 -0
- data/lib/log_struct/integrations/request_context/middleware.rb +4 -9
- data/lib/log_struct/integrations/shrine.rb +48 -73
- data/lib/log_struct/integrations/sidekiq/job_context_middleware.rb +32 -0
- data/lib/log_struct/integrations/sidekiq/logger.rb +17 -9
- data/lib/log_struct/integrations/sidekiq.rb +98 -7
- data/lib/log_struct/integrations/sorbet.rb +10 -46
- data/lib/log_struct/integrations.rb +3 -2
- data/lib/log_struct/log/{good_job/enqueue.rb → active_job/discard.rb} +13 -13
- data/lib/log_struct/log/active_job/enqueue.rb +0 -4
- data/lib/log_struct/log/{good_job/schedule.rb → active_job/error.rb} +15 -15
- data/lib/log_struct/log/{good_job/start.rb → active_job/retry.rb} +13 -13
- data/lib/log_struct/log/{good_job/finish.rb → active_job/retry_stopped.rb} +13 -17
- data/lib/log_struct/log/active_job.rb +4 -0
- data/lib/log_struct/log/active_storage/delete.rb +6 -2
- data/lib/log_struct/log/active_storage/download.rb +2 -4
- data/lib/log_struct/log/active_storage/exist.rb +2 -2
- data/lib/log_struct/log/active_storage/metadata.rb +4 -4
- data/lib/log_struct/log/active_storage/upload.rb +2 -10
- data/lib/log_struct/log/active_storage/url.rb +2 -6
- data/lib/log_struct/log/active_storage.rb +2 -3
- data/lib/log_struct/log/error.rb +24 -1
- data/lib/log_struct/log/good_job.rb +0 -4
- data/lib/log_struct/log/puma/error.rb +63 -0
- data/lib/log_struct/log/puma.rb +1 -0
- data/lib/log_struct/log/security/blocked_host.rb +0 -4
- data/lib/log_struct/log/shrine/exist.rb +0 -2
- data/lib/log_struct/log/shrine/metadata.rb +2 -4
- data/lib/log_struct/log/shrine/upload.rb +4 -0
- data/lib/log_struct/log/sidekiq/error.rb +63 -0
- data/lib/log_struct/log/{active_storage/stream.rb → sidekiq/log.rb} +17 -11
- data/lib/log_struct/log/sidekiq.rb +3 -45
- data/lib/log_struct/log.rb +4 -2
- data/lib/log_struct/logged_errors.rb +25 -0
- data/lib/log_struct/monkey_patches/active_support/tagged_logging/formatter.rb +2 -3
- data/lib/log_struct/param_filters.rb +51 -38
- data/lib/log_struct/params_errors.rb +78 -0
- data/lib/log_struct/rails_boot_banner_silencer.rb +7 -2
- data/lib/log_struct/railtie.rb +29 -14
- data/lib/log_struct/semantic_logger/color_formatter.rb +60 -53
- data/lib/log_struct/semantic_logger/concerns/log_methods.rb +65 -57
- data/lib/log_struct/semantic_logger/formatter.rb +79 -57
- data/lib/log_struct/semantic_logger/logger.rb +29 -53
- data/lib/log_struct/semantic_logger/reopen_after_fork.rb +40 -0
- data/lib/log_struct/semantic_logger/setup.rb +115 -112
- data/lib/log_struct/shared/serialize_common.rb +2 -7
- data/lib/log_struct/string_scrubber.rb +229 -20
- data/lib/log_struct/version.rb +1 -1
- data/lib/log_struct.rb +6 -4
- data/logstruct.gemspec +19 -10
- metadata +63 -11
- data/lib/log_struct/builders/active_job.rb +0 -87
- data/lib/log_struct/integrations/good_job/log_subscriber.rb +0 -189
- 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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
config = LogStruct.config
|
|
185
|
-
return nil unless config.filter_noisy_loggers
|
|
179
|
+
return nil unless LogStruct.config.filter_noisy_loggers
|
|
186
180
|
|
|
187
|
-
|
|
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
|
|
192
|
-
#
|
|
193
|
-
#
|
|
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
|
-
#
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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 "
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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!(
|
|
61
|
-
|
|
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
|
-
|
|
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
|
data/lib/log_struct/version.rb
CHANGED