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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +146 -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 +126 -22
- 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 +9 -3
- data/lib/log_struct/integrations/active_record.rb +45 -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/merge_additional_data_fields.rb +14 -1
- 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 -84
- data/lib/log_struct/integrations/good_job/log_subscriber.rb +0 -179
- data/lib/log_struct/integrations/rack.rb +0 -31
|
@@ -5,6 +5,7 @@ require_relative "handlers"
|
|
|
5
5
|
require_relative "config_struct/error_handling_modes"
|
|
6
6
|
require_relative "config_struct/integrations"
|
|
7
7
|
require_relative "config_struct/filters"
|
|
8
|
+
require_relative "config_struct/limits"
|
|
8
9
|
|
|
9
10
|
module LogStruct
|
|
10
11
|
# Core configuration class that provides a type-safe API
|
|
@@ -35,6 +36,7 @@ module LogStruct
|
|
|
35
36
|
|
|
36
37
|
const :integrations, ConfigStruct::Integrations, factory: -> { ConfigStruct::Integrations.new }
|
|
37
38
|
const :filters, ConfigStruct::Filters, factory: -> { ConfigStruct::Filters.new }
|
|
39
|
+
const :limits, ConfigStruct::Limits, factory: -> { ConfigStruct::Limits.new }
|
|
38
40
|
|
|
39
41
|
# Custom log scrubbing handler for any additional string scrubbing
|
|
40
42
|
# Default: nil
|
|
@@ -16,6 +16,9 @@ module LogStruct
|
|
|
16
16
|
Schedule = new(:schedule)
|
|
17
17
|
Start = new(:start)
|
|
18
18
|
Finish = new(:finish)
|
|
19
|
+
Retry = new(:retry)
|
|
20
|
+
RetryStopped = new(:retry_stopped)
|
|
21
|
+
Discard = new(:discard)
|
|
19
22
|
|
|
20
23
|
# File storage events (ActiveStorage, Shrine, CarrierWave, etc.)
|
|
21
24
|
Upload = new(:upload)
|
|
@@ -23,7 +26,6 @@ module LogStruct
|
|
|
23
26
|
Delete = new(:delete)
|
|
24
27
|
Metadata = new(:metadata)
|
|
25
28
|
Exist = new(:exist)
|
|
26
|
-
Stream = new(:stream)
|
|
27
29
|
Url = new(:url)
|
|
28
30
|
|
|
29
31
|
# Data generation events
|
|
@@ -91,6 +91,11 @@ module LogStruct
|
|
|
91
91
|
Priority = new(:priority)
|
|
92
92
|
CronKey = new(:cron_key)
|
|
93
93
|
ErrorMessage = new(:error_message)
|
|
94
|
+
# What the integration was doing when the error happened, e.g. Sidekiq's "Job raised exception"
|
|
95
|
+
ErrorContext = new(:error_context)
|
|
96
|
+
# The cause of an error the app didn't handle in a request (Exception#cause)
|
|
97
|
+
CauseClass = new(:cause_class)
|
|
98
|
+
CauseMessage = new(:cause_message)
|
|
94
99
|
Result = new(:result)
|
|
95
100
|
EnqueueCaller = new(:enqueue_caller)
|
|
96
101
|
|
data/lib/log_struct/formatter.rb
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
require "logger"
|
|
5
5
|
require "active_support/core_ext/object/blank"
|
|
6
|
+
require "active_support/hash_with_indifferent_access"
|
|
6
7
|
require "json"
|
|
7
8
|
require "globalid"
|
|
8
9
|
require_relative "enums/source"
|
|
9
10
|
require_relative "enums/event"
|
|
11
|
+
require_relative "enums/log_field"
|
|
10
12
|
require_relative "string_scrubber"
|
|
11
13
|
require_relative "log"
|
|
12
14
|
require_relative "param_filters"
|
|
@@ -49,43 +51,60 @@ module LogStruct
|
|
|
49
51
|
StringScrubber.scrub(string)
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
MAX_RECURSION_DEPTH = 20
|
|
55
|
+
|
|
56
|
+
# The mask Rails' parameter filter puts in place of filtered values
|
|
57
|
+
RAILS_FILTERED_MASK = "[FILTERED]"
|
|
58
|
+
|
|
59
|
+
# LogStruct's own fields, which some filters don't apply to at the top of a line
|
|
60
|
+
LOG_FIELD_KEYS = T.let(LogField.values.map(&:serialize).to_set.freeze, T::Set[T.untyped])
|
|
61
|
+
|
|
62
|
+
# path and root are for filters that match a key by its path (see ParamFilters.should_filter_key?).
|
|
63
|
+
# They're only tracked when such filters exist: path is nil otherwise.
|
|
64
|
+
sig do
|
|
65
|
+
params(
|
|
66
|
+
arg: T.untyped,
|
|
67
|
+
recursion_depth: Integer,
|
|
68
|
+
ancestors: T::Hash[T.untyped, T::Boolean],
|
|
69
|
+
path: T.nilable(T::Array[String]),
|
|
70
|
+
root: T.untyped
|
|
71
|
+
).returns(T.untyped)
|
|
72
|
+
end
|
|
73
|
+
def process_values(arg, recursion_depth: 0, ancestors: {}.compare_by_identity, path: nil, root: nil)
|
|
74
|
+
if recursion_depth == 0 && path.nil? && !LogStruct.config.filters.path_matchers.empty?
|
|
75
|
+
path = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Circular references and deeply nested values stop here. Values below this depth
|
|
79
|
+
# would skip filtering and scrubbing, so they're summarized instead of logged.
|
|
80
|
+
return summarize_unprocessed_value(arg) if recursion_depth > MAX_RECURSION_DEPTH || ancestors.key?(arg)
|
|
57
81
|
|
|
58
82
|
case arg
|
|
59
|
-
when Hash
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
# Check if this key should be filtered at any depth
|
|
65
|
-
result[key] = if ParamFilters.should_filter_key?(key, value)
|
|
66
|
-
# Filter the value
|
|
67
|
-
{_filtered: ParamFilters.summarize_json_attribute(key, value)}
|
|
83
|
+
when Hash, Array
|
|
84
|
+
ancestors[arg] = true
|
|
85
|
+
begin
|
|
86
|
+
if arg.is_a?(Hash)
|
|
87
|
+
process_hash(arg, recursion_depth: recursion_depth, ancestors: ancestors, path: path, root: root)
|
|
68
88
|
else
|
|
69
|
-
|
|
70
|
-
process_values(value, recursion_depth: recursion_depth + 1)
|
|
89
|
+
process_array(arg, recursion_depth: recursion_depth, ancestors: ancestors, path: path, root: root)
|
|
71
90
|
end
|
|
91
|
+
ensure
|
|
92
|
+
ancestors.delete(arg)
|
|
72
93
|
end
|
|
73
|
-
|
|
74
|
-
result
|
|
75
|
-
when Array
|
|
76
|
-
process_array(arg, recursion_depth: recursion_depth)
|
|
77
94
|
when GlobalID::Identification
|
|
95
|
+
# Scrubbed like any String: a record's ID can be an email address
|
|
78
96
|
begin
|
|
79
|
-
arg.to_global_id
|
|
97
|
+
scrub_string(arg.to_global_id.to_s)
|
|
80
98
|
rescue
|
|
81
99
|
begin
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
object = T.cast(arg, Object)
|
|
101
|
+
# Active Record is optional
|
|
102
|
+
if defined?(::ActiveRecord::Base) && object.is_a?(::ActiveRecord::Base)
|
|
103
|
+
scrub_string("#{object.class}(##{object.id})")
|
|
85
104
|
else
|
|
86
105
|
# For non-ActiveRecord objects that failed to_global_id, try to get a string representation
|
|
87
106
|
# If this also fails, we want to catch it and return the error placeholder
|
|
88
|
-
String(
|
|
107
|
+
scrub_string(String(object))
|
|
89
108
|
end
|
|
90
109
|
rescue => e
|
|
91
110
|
LogStruct.handle_exception(e, source: Source::Internal)
|
|
@@ -95,14 +114,24 @@ module LogStruct
|
|
|
95
114
|
when Source, Event
|
|
96
115
|
arg.serialize
|
|
97
116
|
when String
|
|
98
|
-
|
|
117
|
+
scrub_limited_string(valid_utf8(arg))
|
|
99
118
|
when Time
|
|
100
119
|
arg.iso8601(3)
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
when Symbol
|
|
121
|
+
StringScrubber.scrub_name(arg)
|
|
122
|
+
when Float
|
|
123
|
+
# JSON has no NaN or Infinity
|
|
124
|
+
arg.finite? ? arg : arg.to_s
|
|
125
|
+
when Numeric, TrueClass, FalseClass, NilClass
|
|
103
126
|
arg
|
|
127
|
+
else
|
|
128
|
+
# Structs, ActionController::Parameters and other objects: filter and scrub what
|
|
129
|
+
# to_json would write
|
|
130
|
+
process_values(arg.as_json, recursion_depth: recursion_depth + 1, ancestors: ancestors, path: path, root: root)
|
|
104
131
|
end
|
|
105
|
-
rescue => e
|
|
132
|
+
rescue SystemStackError, StandardError => e
|
|
133
|
+
# A SystemStackError comes from an object whose as_json refers back to itself
|
|
134
|
+
e = RuntimeError.new("#{e.class}: #{e.message}") unless e.is_a?(StandardError)
|
|
106
135
|
# Report error through LogStruct's framework
|
|
107
136
|
context = {
|
|
108
137
|
processor_method: "process_values",
|
|
@@ -110,7 +139,141 @@ module LogStruct
|
|
|
110
139
|
recursion_depth: recursion_depth
|
|
111
140
|
}
|
|
112
141
|
LogStruct.handle_exception(e, source: Source::Internal, context: context)
|
|
113
|
-
arg
|
|
142
|
+
summarize_unprocessed_value(arg)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
sig do
|
|
146
|
+
params(
|
|
147
|
+
hash: T::Hash[T.untyped, T.untyped],
|
|
148
|
+
recursion_depth: Integer,
|
|
149
|
+
ancestors: T::Hash[T.untyped, T::Boolean],
|
|
150
|
+
path: T.nilable(T::Array[String]),
|
|
151
|
+
root: T.untyped
|
|
152
|
+
).returns(T::Hash[T.untyped, T.untyped])
|
|
153
|
+
end
|
|
154
|
+
def process_hash(hash, recursion_depth:, ancestors:, path: nil, root: nil)
|
|
155
|
+
result = {}
|
|
156
|
+
|
|
157
|
+
# Path matchers' roots are built only if a matcher reads them (most don't)
|
|
158
|
+
line_root = (path && recursion_depth == 0) ? ConfigStruct::LazyRoot.new(hash) : nil
|
|
159
|
+
|
|
160
|
+
max_keys = LogStruct.config.limits.max_hash_keys
|
|
161
|
+
hash.each_with_index do |(key, value), index|
|
|
162
|
+
if index >= max_keys
|
|
163
|
+
result[:_more_keys] = hash.size - max_keys
|
|
164
|
+
break
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
key = valid_utf8_key(key)
|
|
168
|
+
logstruct_field = recursion_depth == 0 && LOG_FIELD_KEYS.include?(key)
|
|
169
|
+
key_path = path && [*path, key.to_s]
|
|
170
|
+
# A top-level key's root is the line; the keys below it get the top-level field's value
|
|
171
|
+
key_root = (recursion_depth == 0) ? line_root : root
|
|
172
|
+
children_root = (path && recursion_depth == 0) ? ConfigStruct::LazyRoot.new(value) : root
|
|
173
|
+
# Check if this key should be filtered at any depth
|
|
174
|
+
filtered = ParamFilters.should_filter_key?(key, value, logstruct_field: logstruct_field, path: key_path, root: key_root)
|
|
175
|
+
# Keys are scrubbed too: an email address can be a key
|
|
176
|
+
result[StringScrubber.scrub_name(key)] = if !filtered
|
|
177
|
+
process_values(value, recursion_depth: recursion_depth + 1, ancestors: ancestors, path: key_path, root: children_root)
|
|
178
|
+
elsif value == RAILS_FILTERED_MASK
|
|
179
|
+
# Rails' parameter filter has already replaced the value
|
|
180
|
+
value
|
|
181
|
+
else
|
|
182
|
+
{_filtered: ParamFilters.summarize_json_attribute(key, value)}
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
result
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The string as valid UTF-8. JSON can't hold other bytes: Puma passes headers as binary
|
|
190
|
+
# strings, and a byte like \xFF in a User-Agent would stop the whole line from being written.
|
|
191
|
+
# Bytes that aren't valid UTF-8 become U+FFFD.
|
|
192
|
+
sig { params(string: String).returns(String) }
|
|
193
|
+
def valid_utf8(string)
|
|
194
|
+
StringScrubber.valid_utf8(string)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# A String or Symbol key as valid UTF-8 (symbolize_keys makes Symbols of binary String keys)
|
|
198
|
+
sig { params(key: T.untyped).returns(T.untyped) }
|
|
199
|
+
def valid_utf8_key(key)
|
|
200
|
+
case key
|
|
201
|
+
when String
|
|
202
|
+
valid_utf8(key)
|
|
203
|
+
when Symbol
|
|
204
|
+
name = key.name
|
|
205
|
+
fixed = valid_utf8(name)
|
|
206
|
+
fixed.equal?(name) ? key : fixed.to_sym
|
|
207
|
+
else
|
|
208
|
+
key
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The scrubbers see a little more than a cut string keeps, so data at the cut is scrubbed whole
|
|
213
|
+
SCRUB_MARGIN_BYTES = 256
|
|
214
|
+
|
|
215
|
+
# A string scrubbed, and cut to limits.max_string_bytes. The scrubbers see its first max_string_bytes plus
|
|
216
|
+
# SCRUB_MARGIN_BYTES: a value running past that was only partly seen, so scrub_cut_end replaces it if it can
|
|
217
|
+
# be a credential.
|
|
218
|
+
sig { params(string: String).returns(String) }
|
|
219
|
+
def scrub_limited_string(string)
|
|
220
|
+
max_bytes = LogStruct.config.limits.max_string_bytes
|
|
221
|
+
return scrub_string(string) if string.bytesize <= max_bytes
|
|
222
|
+
|
|
223
|
+
seen_bytes = max_bytes + SCRUB_MARGIN_BYTES
|
|
224
|
+
scrubbed = scrub_string(string.byteslice(0, seen_bytes).to_s.scrub(""))
|
|
225
|
+
if string.bytesize > seen_bytes
|
|
226
|
+
scrubbed = StringScrubber.scrub_cut_end(scrubbed)
|
|
227
|
+
elsif scrubbed.bytesize <= max_bytes
|
|
228
|
+
# The scrubbers saw all of it, and their replacements made it short enough
|
|
229
|
+
return scrubbed
|
|
230
|
+
end
|
|
231
|
+
kept = scrubbed.byteslice(0, max_bytes).to_s.scrub("")
|
|
232
|
+
"#{kept}... (cut from #{string.bytesize} bytes)"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Like deep_symbolize_keys, but stops at circular references and at the depth where
|
|
236
|
+
# process_values stops, leaving those values for process_values to summarize
|
|
237
|
+
sig { params(value: T.untyped, recursion_depth: Integer, ancestors: T::Hash[T.untyped, T::Boolean]).returns(T.untyped) }
|
|
238
|
+
def symbolize_keys(value, recursion_depth: 0, ancestors: {}.compare_by_identity)
|
|
239
|
+
return value if recursion_depth > MAX_RECURSION_DEPTH || ancestors.key?(value)
|
|
240
|
+
|
|
241
|
+
case value
|
|
242
|
+
when Hash
|
|
243
|
+
ancestors[value] = true
|
|
244
|
+
result = value.each_with_object({}) do |(key, nested_value), symbolized|
|
|
245
|
+
symbol_key = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
246
|
+
symbolized[symbol_key] = symbolize_keys(nested_value, recursion_depth: recursion_depth + 1, ancestors: ancestors)
|
|
247
|
+
end
|
|
248
|
+
ancestors.delete(value)
|
|
249
|
+
result
|
|
250
|
+
when Array
|
|
251
|
+
ancestors[value] = true
|
|
252
|
+
result = value.map { |item| symbolize_keys(item, recursion_depth: recursion_depth + 1, ancestors: ancestors) }
|
|
253
|
+
ancestors.delete(value)
|
|
254
|
+
result
|
|
255
|
+
else
|
|
256
|
+
value
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Describes a value without filtering, scrubbing or serializing it, so that values which
|
|
261
|
+
# couldn't be processed are never logged raw. Hashes and arrays are not serialized because
|
|
262
|
+
# they may contain circular references.
|
|
263
|
+
sig { params(arg: T.untyped).returns(T.untyped) }
|
|
264
|
+
def summarize_unprocessed_value(arg)
|
|
265
|
+
case arg
|
|
266
|
+
when Hash
|
|
267
|
+
{_filtered: {_class: arg.class.name, _keys_count: arg.size}}
|
|
268
|
+
when Array
|
|
269
|
+
{_filtered: {_class: arg.class.name, _count: arg.size}}
|
|
270
|
+
when String
|
|
271
|
+
{_filtered: {_class: arg.class.name}}
|
|
272
|
+
when Numeric, Symbol, TrueClass, FalseClass, NilClass
|
|
273
|
+
arg
|
|
274
|
+
else
|
|
275
|
+
{_filtered: {_class: arg.class.name}}
|
|
276
|
+
end
|
|
114
277
|
end
|
|
115
278
|
|
|
116
279
|
sig { params(log_value: T.untyped, time: Time).returns(T::Hash[Symbol, T.untyped]) }
|
|
@@ -122,11 +285,15 @@ module LogStruct
|
|
|
122
285
|
|
|
123
286
|
when T::Struct
|
|
124
287
|
# Default T::Struct.serialize methods returns a hash with string keys, so convert them to symbols
|
|
125
|
-
log_value.serialize
|
|
288
|
+
symbolize_keys(log_value.serialize)
|
|
126
289
|
|
|
127
290
|
when Hash
|
|
128
291
|
# Use hash as is and convert string keys to symbols
|
|
129
|
-
log_value
|
|
292
|
+
symbolize_keys(log_value)
|
|
293
|
+
|
|
294
|
+
when Exception
|
|
295
|
+
# logger.error(exception): its class, message and backtrace
|
|
296
|
+
Log.from_exception(Source::App, log_value, {}, time).serialize
|
|
130
297
|
|
|
131
298
|
else
|
|
132
299
|
# Create a Plain log with the message as a string and serialize it with symbol keys
|
|
@@ -174,9 +341,22 @@ module LogStruct
|
|
|
174
341
|
end
|
|
175
342
|
end
|
|
176
343
|
|
|
177
|
-
# Serializes Log (or string) into JSON
|
|
178
|
-
|
|
179
|
-
|
|
344
|
+
# Serializes Log (or string) into JSON.
|
|
345
|
+
# request_id: the request's ID (default: the one RequestContext stored for this thread).
|
|
346
|
+
# context: fields for the line's ctx, like the running job's ID and class. The line's own ctx keys win.
|
|
347
|
+
# tags: the logger's tags (logger.tagged("billing")), written as tags unless the line has its own.
|
|
348
|
+
sig do
|
|
349
|
+
params(
|
|
350
|
+
severity: T.any(String, Symbol, Integer),
|
|
351
|
+
time: Time,
|
|
352
|
+
progname: T.nilable(String),
|
|
353
|
+
log_value: T.untyped,
|
|
354
|
+
request_id: T.nilable(String),
|
|
355
|
+
context: T.nilable(T::Hash[T.untyped, T.untyped]),
|
|
356
|
+
tags: T.nilable(T::Array[T.untyped])
|
|
357
|
+
).returns(String)
|
|
358
|
+
end
|
|
359
|
+
def call(severity, time, progname, log_value, request_id: nil, context: nil, tags: nil)
|
|
180
360
|
level_enum = Level.from_severity(severity)
|
|
181
361
|
|
|
182
362
|
data = log_value_to_hash(log_value, time: time)
|
|
@@ -189,13 +369,77 @@ module LogStruct
|
|
|
189
369
|
data[:evt] ||= Event::Log
|
|
190
370
|
data[:ts] ||= time.iso8601(3)
|
|
191
371
|
data[:lvl] = level_enum # Set level from severity parameter
|
|
192
|
-
data[:
|
|
193
|
-
|
|
372
|
+
add_metadata(data, progname: progname, request_id: request_id || Thread.current[:logstruct_request_id], context: context, tags: tags)
|
|
373
|
+
|
|
374
|
+
begin
|
|
375
|
+
generate_json(data)
|
|
376
|
+
rescue SystemStackError, StandardError => e
|
|
377
|
+
unwritable_line_json(data, e)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# A line that can't be written as JSON is replaced by one that says so, instead of being lost
|
|
382
|
+
sig { params(data: T::Hash[T.untyped, T.untyped], error: Exception).returns(String) }
|
|
383
|
+
def unwritable_line_json(data, error)
|
|
384
|
+
level = data[:lvl]
|
|
385
|
+
line = {
|
|
386
|
+
src: enum_or_string(data[:src]) || Source::Internal.serialize,
|
|
387
|
+
evt: enum_or_string(data[:evt]) || Event::Error.serialize,
|
|
388
|
+
ts: data[:ts].is_a?(String) ? data[:ts] : Time.now.iso8601(3),
|
|
389
|
+
lvl: level.is_a?(Level) ? level.serialize : Level::Error.serialize,
|
|
390
|
+
msg: "LogStruct couldn't write this line as JSON",
|
|
391
|
+
error_class: error.class.name
|
|
392
|
+
}
|
|
393
|
+
unless Thread.current[:logstruct_reporting_unwritable_line]
|
|
394
|
+
begin
|
|
395
|
+
Thread.current[:logstruct_reporting_unwritable_line] = true
|
|
396
|
+
reported = error.is_a?(StandardError) ? error : RuntimeError.new("#{error.class}: #{error.message}")
|
|
397
|
+
LogStruct.handle_exception(reported, source: Source::Internal, context: {src: line[:src], evt: line[:evt]})
|
|
398
|
+
ensure
|
|
399
|
+
Thread.current[:logstruct_reporting_unwritable_line] = nil
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
"#{::JSON.generate(line)}\n"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# The program name, request ID and job context, filtered and scrubbed like the rest of the line:
|
|
406
|
+
# an X-Request-Id header can carry anything
|
|
407
|
+
sig do
|
|
408
|
+
params(
|
|
409
|
+
data: T::Hash[T.untyped, T.untyped],
|
|
410
|
+
progname: T.nilable(String),
|
|
411
|
+
request_id: T.untyped,
|
|
412
|
+
context: T.nilable(T::Hash[T.untyped, T.untyped]),
|
|
413
|
+
tags: T.nilable(T::Array[T.untyped])
|
|
414
|
+
).void
|
|
415
|
+
end
|
|
416
|
+
def add_metadata(data, progname:, request_id:, context:, tags:)
|
|
417
|
+
metadata = {}
|
|
418
|
+
metadata[:prog] = progname if progname.present?
|
|
419
|
+
metadata[:tags] = tags if tags.is_a?(Array) && !tags.empty? && !data.key?(:tags)
|
|
194
420
|
if request_id.is_a?(String) && !request_id.empty? && !data.key?(:req_id)
|
|
195
|
-
|
|
421
|
+
metadata[:req_id] = request_id
|
|
196
422
|
end
|
|
423
|
+
metadata[:ctx] = symbolize_keys(context) if context.present?
|
|
424
|
+
return if metadata.empty?
|
|
425
|
+
|
|
426
|
+
processed = process_values(metadata)
|
|
427
|
+
job_context = processed.delete(:ctx)
|
|
428
|
+
data.merge!(processed)
|
|
429
|
+
return if job_context.nil?
|
|
430
|
+
|
|
431
|
+
line_context = data[:ctx]
|
|
432
|
+
# A logged Hash's own ctx keeps its keys and gains the job's
|
|
433
|
+
data[:ctx] = line_context.is_a?(Hash) ? job_context.merge(line_context) : (line_context || job_context)
|
|
434
|
+
end
|
|
197
435
|
|
|
198
|
-
|
|
436
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
437
|
+
def enum_or_string(value)
|
|
438
|
+
if value.is_a?(Source) || value.is_a?(Event)
|
|
439
|
+
value.serialize.to_s
|
|
440
|
+
elsif value.is_a?(String)
|
|
441
|
+
value
|
|
442
|
+
end
|
|
199
443
|
end
|
|
200
444
|
|
|
201
445
|
# Output as JSON with a newline. We mock this method in tests so we can
|
|
@@ -205,26 +449,30 @@ module LogStruct
|
|
|
205
449
|
"#{data.to_json}\n"
|
|
206
450
|
end
|
|
207
451
|
|
|
208
|
-
|
|
209
|
-
|
|
452
|
+
# An array's items keep the array's path, as in Rails (users.name filters each user's name)
|
|
453
|
+
sig do
|
|
454
|
+
params(
|
|
455
|
+
array: T::Array[T.untyped],
|
|
456
|
+
recursion_depth: Integer,
|
|
457
|
+
ancestors: T::Hash[T.untyped, T::Boolean],
|
|
458
|
+
path: T.nilable(T::Array[String]),
|
|
459
|
+
root: T.untyped
|
|
460
|
+
).returns(T::Array[T.untyped])
|
|
461
|
+
end
|
|
462
|
+
def process_array(array, recursion_depth:, ancestors:, path: nil, root: nil)
|
|
210
463
|
return [] if array.empty?
|
|
211
464
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
break if index >= 10
|
|
218
|
-
|
|
219
|
-
processed << process_values(value, recursion_depth: recursion_depth + 1)
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
if array.size > 10
|
|
223
|
-
processed << "... and #{array.size - 10} more items"
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
processed
|
|
465
|
+
limits = LogStruct.config.limits
|
|
466
|
+
backtrace = looks_like_backtrace_array?(array)
|
|
467
|
+
max_items = backtrace ? limits.max_backtrace_lines : limits.max_array_items
|
|
468
|
+
processed = array.first(max_items).map do |value|
|
|
469
|
+
process_values(value, recursion_depth: recursion_depth + 1, ancestors: ancestors, path: path, root: root)
|
|
227
470
|
end
|
|
471
|
+
if array.size > max_items
|
|
472
|
+
processed << "... and #{array.size - max_items} more #{backtrace ? "lines" : "items"}"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
processed
|
|
228
476
|
end
|
|
229
477
|
|
|
230
478
|
# Check if an array looks like a backtrace (array of strings with file:line pattern)
|
|
@@ -1,20 +1,49 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "
|
|
4
|
+
require "openssl"
|
|
5
5
|
|
|
6
6
|
module LogStruct
|
|
7
7
|
# Utility module for hashing sensitive data
|
|
8
8
|
module HashUtils
|
|
9
|
+
# The key when there's neither a configured hash_salt nor a Rails app with a secret_key_base.
|
|
10
|
+
# It's public, so these hashes can be matched to a list of candidate values.
|
|
11
|
+
FALLBACK_KEY = "l0g5t0p"
|
|
12
|
+
|
|
9
13
|
class << self
|
|
10
14
|
extend T::Sig
|
|
11
15
|
|
|
12
|
-
#
|
|
16
|
+
# An HMAC-SHA256 of the value, so lines about the same email can be traced without logging it
|
|
13
17
|
sig { params(value: String).returns(String) }
|
|
14
18
|
def hash_value(value)
|
|
15
|
-
salt = LogStruct.config.filters.hash_salt
|
|
16
19
|
length = LogStruct.config.filters.hash_length
|
|
17
|
-
|
|
20
|
+
OpenSSL::HMAC.hexdigest("SHA256", hash_key, value)[0...length] || "error"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The configured hash_salt, or a key derived from the app's secret_key_base
|
|
24
|
+
sig { returns(String) }
|
|
25
|
+
def hash_key
|
|
26
|
+
LogStruct.config.filters.hash_salt || derived_key || FALLBACK_KEY
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Forget the derived key (for tests that change the Rails app)
|
|
30
|
+
sig { void }
|
|
31
|
+
def reset!
|
|
32
|
+
@derived_key = T.let(nil, T.nilable(String))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
sig { returns(T.nilable(String)) }
|
|
38
|
+
def derived_key
|
|
39
|
+
@derived_key ||= T.let(begin
|
|
40
|
+
application = ::Rails.application if defined?(::Rails) && ::Rails.respond_to?(:application)
|
|
41
|
+
# Rails derives keys like this one for cookies and messages; nil until the app has its secret
|
|
42
|
+
application&.key_generator&.generate_key("LogStruct hashed values", 32)
|
|
43
|
+
rescue
|
|
44
|
+
nil
|
|
45
|
+
end,
|
|
46
|
+
T.nilable(String))
|
|
18
47
|
end
|
|
19
48
|
end
|
|
20
49
|
end
|