lapsoss 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +855 -0
- data/lib/lapsoss/adapters/appsignal_adapter.rb +136 -0
- data/lib/lapsoss/adapters/base.rb +88 -0
- data/lib/lapsoss/adapters/insight_hub_adapter.rb +190 -0
- data/lib/lapsoss/adapters/logger_adapter.rb +67 -0
- data/lib/lapsoss/adapters/rollbar_adapter.rb +157 -0
- data/lib/lapsoss/adapters/sentry_adapter.rb +197 -0
- data/lib/lapsoss/backtrace_frame.rb +258 -0
- data/lib/lapsoss/backtrace_processor.rb +346 -0
- data/lib/lapsoss/client.rb +115 -0
- data/lib/lapsoss/configuration.rb +310 -0
- data/lib/lapsoss/current.rb +9 -0
- data/lib/lapsoss/event.rb +107 -0
- data/lib/lapsoss/exclusions.rb +429 -0
- data/lib/lapsoss/fingerprinter.rb +217 -0
- data/lib/lapsoss/http_client.rb +79 -0
- data/lib/lapsoss/middleware.rb +353 -0
- data/lib/lapsoss/pipeline.rb +131 -0
- data/lib/lapsoss/railtie.rb +72 -0
- data/lib/lapsoss/registry.rb +114 -0
- data/lib/lapsoss/release_tracker.rb +553 -0
- data/lib/lapsoss/router.rb +36 -0
- data/lib/lapsoss/sampling.rb +332 -0
- data/lib/lapsoss/scope.rb +110 -0
- data/lib/lapsoss/scrubber.rb +170 -0
- data/lib/lapsoss/user_context.rb +355 -0
- data/lib/lapsoss/validators.rb +142 -0
- data/lib/lapsoss/version.rb +5 -0
- data/lib/lapsoss.rb +76 -0
- metadata +217 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lapsoss
|
|
4
|
+
# Error exclusion system for filtering exception types
|
|
5
|
+
class ExclusionFilter
|
|
6
|
+
def initialize(configuration = {})
|
|
7
|
+
@excluded_exceptions = configuration[:excluded_exceptions] || []
|
|
8
|
+
@excluded_patterns = configuration[:excluded_patterns] || []
|
|
9
|
+
@excluded_messages = configuration[:excluded_messages] || []
|
|
10
|
+
@excluded_environments = configuration[:excluded_environments] || []
|
|
11
|
+
@custom_filters = configuration[:custom_filters] || []
|
|
12
|
+
@inclusion_overrides = configuration[:inclusion_overrides] || []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def should_exclude?(event)
|
|
16
|
+
# Check inclusion overrides first - these take precedence
|
|
17
|
+
return false if should_include_override?(event)
|
|
18
|
+
|
|
19
|
+
# Apply exclusion filters
|
|
20
|
+
return true if excluded_by_exception_type?(event)
|
|
21
|
+
return true if excluded_by_pattern?(event)
|
|
22
|
+
return true if excluded_by_message?(event)
|
|
23
|
+
return true if excluded_by_environment?(event)
|
|
24
|
+
return true if excluded_by_custom_filter?(event)
|
|
25
|
+
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_exclusion(type, value)
|
|
30
|
+
case type
|
|
31
|
+
when :exception
|
|
32
|
+
@excluded_exceptions << value
|
|
33
|
+
when :pattern
|
|
34
|
+
@excluded_patterns << value
|
|
35
|
+
when :message
|
|
36
|
+
@excluded_messages << value
|
|
37
|
+
when :environment
|
|
38
|
+
@excluded_environments << value
|
|
39
|
+
when :custom
|
|
40
|
+
@custom_filters << value
|
|
41
|
+
else
|
|
42
|
+
raise ArgumentError, "Unknown exclusion type: #{type}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def add_inclusion_override(filter)
|
|
47
|
+
@inclusion_overrides << filter
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear_exclusions(type = nil)
|
|
51
|
+
if type
|
|
52
|
+
case type
|
|
53
|
+
when :exception then @excluded_exceptions.clear
|
|
54
|
+
when :pattern then @excluded_patterns.clear
|
|
55
|
+
when :message then @excluded_messages.clear
|
|
56
|
+
when :environment then @excluded_environments.clear
|
|
57
|
+
when :custom then @custom_filters.clear
|
|
58
|
+
else raise ArgumentError, "Unknown exclusion type: #{type}"
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
@excluded_exceptions.clear
|
|
62
|
+
@excluded_patterns.clear
|
|
63
|
+
@excluded_messages.clear
|
|
64
|
+
@excluded_environments.clear
|
|
65
|
+
@custom_filters.clear
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def exclusion_stats
|
|
70
|
+
{
|
|
71
|
+
excluded_exceptions: @excluded_exceptions.length,
|
|
72
|
+
excluded_patterns: @excluded_patterns.length,
|
|
73
|
+
excluded_messages: @excluded_messages.length,
|
|
74
|
+
excluded_environments: @excluded_environments.length,
|
|
75
|
+
custom_filters: @custom_filters.length,
|
|
76
|
+
inclusion_overrides: @inclusion_overrides.length
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def should_include_override?(event)
|
|
83
|
+
@inclusion_overrides.any? { |filter| filter.call(event) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def excluded_by_exception_type?(event)
|
|
87
|
+
return false unless event.exception
|
|
88
|
+
|
|
89
|
+
exception_class = event.exception.class
|
|
90
|
+
|
|
91
|
+
@excluded_exceptions.any? do |excluded|
|
|
92
|
+
case excluded
|
|
93
|
+
when Class
|
|
94
|
+
exception_class <= excluded
|
|
95
|
+
when String
|
|
96
|
+
exception_class.name == excluded || exception_class.name.include?(excluded)
|
|
97
|
+
when Regexp
|
|
98
|
+
exception_class.name.match?(excluded)
|
|
99
|
+
else
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def excluded_by_pattern?(event)
|
|
106
|
+
return false unless event.exception
|
|
107
|
+
|
|
108
|
+
exception_class = event.exception.class
|
|
109
|
+
exception_message = event.exception.message
|
|
110
|
+
|
|
111
|
+
@excluded_patterns.any? do |pattern|
|
|
112
|
+
case pattern
|
|
113
|
+
when Regexp
|
|
114
|
+
exception_class.name.match?(pattern) || exception_message.match?(pattern)
|
|
115
|
+
when String
|
|
116
|
+
exception_class.name.include?(pattern) || exception_message.include?(pattern)
|
|
117
|
+
else
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def excluded_by_message?(event)
|
|
124
|
+
return false unless event.exception
|
|
125
|
+
|
|
126
|
+
exception_message = event.exception.message
|
|
127
|
+
|
|
128
|
+
@excluded_messages.any? do |excluded_message|
|
|
129
|
+
case excluded_message
|
|
130
|
+
when Regexp
|
|
131
|
+
exception_message.match?(excluded_message)
|
|
132
|
+
when String
|
|
133
|
+
exception_message.include?(excluded_message)
|
|
134
|
+
else
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def excluded_by_environment?(event)
|
|
141
|
+
return false if @excluded_environments.empty?
|
|
142
|
+
|
|
143
|
+
environment = event.context[:environment] ||
|
|
144
|
+
event.context.dig(:tags, :environment) ||
|
|
145
|
+
Lapsoss.configuration.environment
|
|
146
|
+
|
|
147
|
+
return false unless environment
|
|
148
|
+
|
|
149
|
+
@excluded_environments.include?(environment.to_s)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def excluded_by_custom_filter?(event)
|
|
153
|
+
@custom_filters.any? { |filter| filter.call(event) }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Predefined exclusion configurations for common use cases
|
|
158
|
+
class ExclusionPresets
|
|
159
|
+
def self.development
|
|
160
|
+
{
|
|
161
|
+
excluded_exceptions: [
|
|
162
|
+
# Test-related exceptions
|
|
163
|
+
"RSpec::Expectations::ExpectationNotMetError",
|
|
164
|
+
"Minitest::Assertion",
|
|
165
|
+
|
|
166
|
+
# Development tools
|
|
167
|
+
"Pry::CommandError",
|
|
168
|
+
"Byebug::CommandError"
|
|
169
|
+
],
|
|
170
|
+
excluded_patterns: [
|
|
171
|
+
/test/i,
|
|
172
|
+
/spec/i,
|
|
173
|
+
/debug/i,
|
|
174
|
+
/development/i
|
|
175
|
+
],
|
|
176
|
+
excluded_environments: %w[test]
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def self.production
|
|
181
|
+
{
|
|
182
|
+
excluded_exceptions: [
|
|
183
|
+
# Common Rails exceptions that are usually not actionable
|
|
184
|
+
"ActionController::RoutingError",
|
|
185
|
+
"ActionController::UnknownFormat",
|
|
186
|
+
"ActionController::BadRequest",
|
|
187
|
+
"ActionController::ParameterMissing",
|
|
188
|
+
|
|
189
|
+
# ActiveRecord exceptions for common user errors
|
|
190
|
+
"ActiveRecord::RecordNotFound",
|
|
191
|
+
"ActiveRecord::RecordInvalid",
|
|
192
|
+
|
|
193
|
+
# Network timeouts that are expected
|
|
194
|
+
"Net::ReadTimeout",
|
|
195
|
+
"Net::OpenTimeout",
|
|
196
|
+
"Timeout::Error"
|
|
197
|
+
],
|
|
198
|
+
excluded_patterns: [
|
|
199
|
+
# Bot and crawler patterns
|
|
200
|
+
/bot/i,
|
|
201
|
+
/crawler/i,
|
|
202
|
+
/spider/i,
|
|
203
|
+
/scraper/i,
|
|
204
|
+
|
|
205
|
+
# Security scanning patterns
|
|
206
|
+
/sql.*injection/i,
|
|
207
|
+
/xss/i,
|
|
208
|
+
/csrf/i,
|
|
209
|
+
|
|
210
|
+
# Common attack patterns
|
|
211
|
+
/\.php$/i,
|
|
212
|
+
/\.asp$/i,
|
|
213
|
+
/wp-admin/i,
|
|
214
|
+
/wp-login/i
|
|
215
|
+
],
|
|
216
|
+
excluded_messages: [
|
|
217
|
+
# Common spam/attack messages
|
|
218
|
+
"No route matches",
|
|
219
|
+
"Invalid authenticity token",
|
|
220
|
+
"Forbidden",
|
|
221
|
+
"Unauthorized"
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def self.staging
|
|
227
|
+
{
|
|
228
|
+
excluded_exceptions: [
|
|
229
|
+
# Test data related errors
|
|
230
|
+
"ActiveRecord::RecordNotFound",
|
|
231
|
+
"ArgumentError"
|
|
232
|
+
],
|
|
233
|
+
excluded_patterns: [
|
|
234
|
+
/test/i,
|
|
235
|
+
/staging/i,
|
|
236
|
+
/dummy/i,
|
|
237
|
+
/fake/i
|
|
238
|
+
],
|
|
239
|
+
excluded_environments: %w[test development]
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def self.security_focused
|
|
244
|
+
{
|
|
245
|
+
excluded_patterns: [
|
|
246
|
+
# Exclude common security scanning attempts
|
|
247
|
+
/\.php$/i,
|
|
248
|
+
/\.asp$/i,
|
|
249
|
+
/\.jsp$/i,
|
|
250
|
+
/wp-admin/i,
|
|
251
|
+
/wp-login/i,
|
|
252
|
+
/phpmyadmin/i,
|
|
253
|
+
/admin/i,
|
|
254
|
+
/login\.php/i,
|
|
255
|
+
/index\.php/i,
|
|
256
|
+
|
|
257
|
+
# SQL injection attempts
|
|
258
|
+
/union.*select/i,
|
|
259
|
+
/insert.*into/i,
|
|
260
|
+
/drop.*table/i,
|
|
261
|
+
/delete.*from/i,
|
|
262
|
+
|
|
263
|
+
# XSS attempts
|
|
264
|
+
/<script/i,
|
|
265
|
+
/javascript:/i,
|
|
266
|
+
/onclick=/i,
|
|
267
|
+
/onerror=/i
|
|
268
|
+
],
|
|
269
|
+
excluded_messages: [
|
|
270
|
+
"Invalid authenticity token",
|
|
271
|
+
"Forbidden",
|
|
272
|
+
"Unauthorized",
|
|
273
|
+
"Access denied"
|
|
274
|
+
],
|
|
275
|
+
custom_filters: [
|
|
276
|
+
# Exclude requests from known bot user agents
|
|
277
|
+
lambda do |event|
|
|
278
|
+
user_agent = event.context.dig(:request, :headers, "User-Agent")
|
|
279
|
+
return false unless user_agent
|
|
280
|
+
|
|
281
|
+
bot_patterns = [
|
|
282
|
+
/googlebot/i,
|
|
283
|
+
/bingbot/i,
|
|
284
|
+
/slurp/i,
|
|
285
|
+
/crawler/i,
|
|
286
|
+
/spider/i,
|
|
287
|
+
/bot/i
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
bot_patterns.any? { |pattern| user_agent.match?(pattern) }
|
|
291
|
+
end
|
|
292
|
+
]
|
|
293
|
+
}
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def self.performance_focused
|
|
297
|
+
{
|
|
298
|
+
excluded_exceptions: [
|
|
299
|
+
# Timeout exceptions that are expected under load
|
|
300
|
+
"Net::ReadTimeout",
|
|
301
|
+
"Net::OpenTimeout",
|
|
302
|
+
"Timeout::Error",
|
|
303
|
+
"Redis::TimeoutError",
|
|
304
|
+
|
|
305
|
+
# Memory and resource limits
|
|
306
|
+
"NoMemoryError",
|
|
307
|
+
"SystemStackError"
|
|
308
|
+
],
|
|
309
|
+
excluded_patterns: [
|
|
310
|
+
/timeout/i,
|
|
311
|
+
/memory/i,
|
|
312
|
+
/resource/i,
|
|
313
|
+
/limit/i
|
|
314
|
+
],
|
|
315
|
+
custom_filters: [
|
|
316
|
+
# Exclude high-frequency errors during peak times
|
|
317
|
+
lambda do |event|
|
|
318
|
+
now = Time.now
|
|
319
|
+
peak_hours = (9..17).cover?(now.hour) && (1..5).cover?(now.wday)
|
|
320
|
+
|
|
321
|
+
if peak_hours
|
|
322
|
+
# During peak hours, exclude common performance-related errors
|
|
323
|
+
return true if event.exception.is_a?(Timeout::Error)
|
|
324
|
+
return true if event.exception.message.match?(/timeout/i)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
false
|
|
328
|
+
end
|
|
329
|
+
]
|
|
330
|
+
}
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def self.user_error_focused
|
|
334
|
+
{
|
|
335
|
+
excluded_exceptions: [
|
|
336
|
+
# User input validation errors
|
|
337
|
+
"ActiveModel::ValidationError",
|
|
338
|
+
"ActiveRecord::RecordInvalid",
|
|
339
|
+
"ActionController::ParameterMissing",
|
|
340
|
+
"ArgumentError",
|
|
341
|
+
"TypeError"
|
|
342
|
+
],
|
|
343
|
+
excluded_patterns: [
|
|
344
|
+
/validation/i,
|
|
345
|
+
/invalid/i,
|
|
346
|
+
/missing/i,
|
|
347
|
+
/required/i,
|
|
348
|
+
/format/i
|
|
349
|
+
],
|
|
350
|
+
custom_filters: [
|
|
351
|
+
# Exclude errors from invalid user input
|
|
352
|
+
lambda do |event|
|
|
353
|
+
return false unless event.exception
|
|
354
|
+
|
|
355
|
+
# Check if error is from user input validation
|
|
356
|
+
message = event.exception.message.downcase
|
|
357
|
+
validation_keywords = %w[invalid required missing format validation]
|
|
358
|
+
|
|
359
|
+
validation_keywords.any? { |keyword| message.include?(keyword) }
|
|
360
|
+
end
|
|
361
|
+
]
|
|
362
|
+
}
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def self.combined(presets)
|
|
366
|
+
combined_config = {
|
|
367
|
+
excluded_exceptions: [],
|
|
368
|
+
excluded_patterns: [],
|
|
369
|
+
excluded_messages: [],
|
|
370
|
+
excluded_environments: [],
|
|
371
|
+
custom_filters: []
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
presets.each do |preset|
|
|
375
|
+
config = case preset
|
|
376
|
+
when :development then development
|
|
377
|
+
when :production then production
|
|
378
|
+
when :staging then staging
|
|
379
|
+
when :security_focused then security_focused
|
|
380
|
+
when :performance_focused then performance_focused
|
|
381
|
+
when :user_error_focused then user_error_focused
|
|
382
|
+
when Hash then preset
|
|
383
|
+
else raise ArgumentError, "Unknown preset: #{preset}"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
combined_config[:excluded_exceptions].concat(config[:excluded_exceptions] || [])
|
|
387
|
+
combined_config[:excluded_patterns].concat(config[:excluded_patterns] || [])
|
|
388
|
+
combined_config[:excluded_messages].concat(config[:excluded_messages] || [])
|
|
389
|
+
combined_config[:excluded_environments].concat(config[:excluded_environments] || [])
|
|
390
|
+
combined_config[:custom_filters].concat(config[:custom_filters] || [])
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Remove duplicates
|
|
394
|
+
combined_config[:excluded_exceptions].uniq!
|
|
395
|
+
combined_config[:excluded_patterns].uniq!
|
|
396
|
+
combined_config[:excluded_messages].uniq!
|
|
397
|
+
combined_config[:excluded_environments].uniq!
|
|
398
|
+
|
|
399
|
+
combined_config
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Configuration helper for exclusions
|
|
404
|
+
module ExclusionConfiguration
|
|
405
|
+
def self.configure_exclusions(config, preset: nil, **custom_config)
|
|
406
|
+
exclusion_config = if preset
|
|
407
|
+
case preset
|
|
408
|
+
when Array
|
|
409
|
+
ExclusionPresets.combined(preset)
|
|
410
|
+
else
|
|
411
|
+
ExclusionPresets.send(preset)
|
|
412
|
+
end
|
|
413
|
+
else
|
|
414
|
+
{}
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Merge custom configuration
|
|
418
|
+
exclusion_config.merge!(custom_config)
|
|
419
|
+
|
|
420
|
+
# Create exclusion filter
|
|
421
|
+
exclusion_filter = ExclusionFilter.new(exclusion_config)
|
|
422
|
+
|
|
423
|
+
# Add to configuration
|
|
424
|
+
config.exclusion_filter = exclusion_filter
|
|
425
|
+
|
|
426
|
+
exclusion_filter
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Lapsoss
|
|
6
|
+
class Fingerprinter
|
|
7
|
+
DEFAULT_PATTERNS = [
|
|
8
|
+
# User/ID normalization patterns
|
|
9
|
+
{
|
|
10
|
+
pattern: /User \d+ (not found|invalid|missing)/i,
|
|
11
|
+
fingerprint: "user-lookup-error"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
pattern: /Record \d+ (not found|invalid|missing)/i,
|
|
15
|
+
fingerprint: "record-lookup-error"
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
# URL/Path normalization patterns
|
|
19
|
+
{
|
|
20
|
+
pattern: %r{/users/\d+(/.*)?},
|
|
21
|
+
fingerprint: "users-id-endpoint"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
pattern: %r{/api/v\d+/.*},
|
|
25
|
+
fingerprint: "api-endpoint"
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
# Database error patterns
|
|
29
|
+
{
|
|
30
|
+
pattern: /PG::ConnectionBad|Mysql2::Error|SQLite3::BusyException/,
|
|
31
|
+
fingerprint: "database-connection-error"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
pattern: /ActiveRecord::RecordNotFound/,
|
|
35
|
+
fingerprint: "record-not-found"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
pattern: /ActiveRecord::StatementInvalid.*timeout/i,
|
|
39
|
+
fingerprint: "database-timeout"
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
# Network error patterns
|
|
43
|
+
{
|
|
44
|
+
pattern: /Net::(TimeoutError|ReadTimeout|OpenTimeout)/,
|
|
45
|
+
fingerprint: "network-timeout"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
pattern: /Errno::(ECONNREFUSED|ECONNRESET|EHOSTUNREACH)/,
|
|
49
|
+
fingerprint: "network-connection-error"
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
# File system patterns
|
|
53
|
+
{
|
|
54
|
+
pattern: /Errno::(ENOENT|EACCES).*\/tmp\//,
|
|
55
|
+
fingerprint: "tmp-file-error"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
pattern: /No such file or directory.*\.log/,
|
|
59
|
+
fingerprint: "log-file-missing"
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
# Memory/Resource patterns
|
|
63
|
+
{
|
|
64
|
+
pattern: /NoMemoryError|SystemStackError/,
|
|
65
|
+
fingerprint: "memory-resource-error"
|
|
66
|
+
}
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
def initialize(config = {})
|
|
70
|
+
@custom_callback = config[:custom_callback]
|
|
71
|
+
@patterns = config[:patterns] || DEFAULT_PATTERNS
|
|
72
|
+
@normalize_paths = config.fetch(:normalize_paths, true)
|
|
73
|
+
@normalize_ids = config.fetch(:normalize_ids, true)
|
|
74
|
+
@include_environment = config.fetch(:include_environment, false)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def generate_fingerprint(event)
|
|
78
|
+
# Try custom callback first
|
|
79
|
+
if @custom_callback
|
|
80
|
+
custom_result = @custom_callback.call(event)
|
|
81
|
+
return custom_result if custom_result
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Try pattern matching
|
|
85
|
+
pattern_result = match_patterns(event)
|
|
86
|
+
return pattern_result if pattern_result
|
|
87
|
+
|
|
88
|
+
# Fall back to default fingerprinting
|
|
89
|
+
generate_default_fingerprint(event)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def match_patterns(event)
|
|
95
|
+
full_error_text = build_error_text(event)
|
|
96
|
+
|
|
97
|
+
@patterns.each do |pattern_config|
|
|
98
|
+
pattern = pattern_config[:pattern]
|
|
99
|
+
fingerprint = pattern_config[:fingerprint]
|
|
100
|
+
|
|
101
|
+
if pattern.is_a?(Regexp) && full_error_text.match?(pattern)
|
|
102
|
+
return fingerprint
|
|
103
|
+
elsif pattern.is_a?(String) && full_error_text.include?(pattern)
|
|
104
|
+
return fingerprint
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_error_text(event)
|
|
112
|
+
parts = []
|
|
113
|
+
|
|
114
|
+
# Include exception class
|
|
115
|
+
if event.exception
|
|
116
|
+
parts << event.exception.class.name
|
|
117
|
+
parts << event.exception.message if event.exception.message
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Include event message
|
|
121
|
+
parts << event.message if event.message
|
|
122
|
+
|
|
123
|
+
# Include first few backtrace lines for context
|
|
124
|
+
if event.exception&.backtrace
|
|
125
|
+
parts.concat(event.exception.backtrace.first(3))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
parts.compact.join(" ")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def generate_default_fingerprint(event)
|
|
132
|
+
components = []
|
|
133
|
+
|
|
134
|
+
# Exception type
|
|
135
|
+
if event.exception
|
|
136
|
+
components << event.exception.class.name
|
|
137
|
+
|
|
138
|
+
# Normalized message
|
|
139
|
+
message = normalize_message(event.exception.message)
|
|
140
|
+
components << message if message
|
|
141
|
+
|
|
142
|
+
# Primary stack frame location
|
|
143
|
+
primary_location = extract_primary_location(event.exception.backtrace)
|
|
144
|
+
components << primary_location if primary_location
|
|
145
|
+
elsif event.message
|
|
146
|
+
components << "message"
|
|
147
|
+
components << normalize_message(event.message)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Include environment if configured
|
|
151
|
+
if @include_environment && event.environment
|
|
152
|
+
components << event.environment
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Generate hash from components
|
|
156
|
+
content = components.compact.join("|")
|
|
157
|
+
Digest::SHA256.hexdigest(content)[0, 16] # Use first 16 chars for readability
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def normalize_message(message)
|
|
161
|
+
return nil unless message
|
|
162
|
+
|
|
163
|
+
normalized = message.dup
|
|
164
|
+
|
|
165
|
+
if @normalize_ids
|
|
166
|
+
# Replace UUIDs first (before numeric IDs)
|
|
167
|
+
normalized.gsub!(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, ":uuid")
|
|
168
|
+
|
|
169
|
+
# Replace hex hashes with placeholder
|
|
170
|
+
normalized.gsub!(/\b[0-9a-f]{32,}\b/i, ":hash")
|
|
171
|
+
|
|
172
|
+
# Replace numeric IDs with placeholder (after UUIDs and hashes)
|
|
173
|
+
normalized.gsub!(/\b\d{3,}\b/, ":id")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
if @normalize_paths
|
|
177
|
+
# Replace absolute file paths with placeholder
|
|
178
|
+
normalized.gsub!(%r{/[^/\s]+(?:/[^/\s]+)*\.[a-zA-Z0-9]+}, ":filepath")
|
|
179
|
+
normalized.gsub!(%r{/[^/\s]+(?:/[^/\s]+)+(?:/)?}, ":dirpath")
|
|
180
|
+
|
|
181
|
+
# Replace timestamps
|
|
182
|
+
normalized.gsub!(/\b\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}/, ":timestamp")
|
|
183
|
+
|
|
184
|
+
# Replace URLs with placeholder
|
|
185
|
+
normalized.gsub!(%r{https?://[^\s]+}, ":url")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Clean up extra whitespace
|
|
189
|
+
normalized.strip.squeeze(" ")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def extract_primary_location(backtrace)
|
|
193
|
+
return nil unless backtrace&.any?
|
|
194
|
+
|
|
195
|
+
# Find first non-gem, non-framework line
|
|
196
|
+
app_line = backtrace.find do |line|
|
|
197
|
+
!line.include?("/gems/") &&
|
|
198
|
+
!line.include?("/ruby/") &&
|
|
199
|
+
!line.include?("(eval)") &&
|
|
200
|
+
!line.start_with?("[")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
line_to_use = app_line || backtrace.first
|
|
204
|
+
|
|
205
|
+
if @normalize_paths
|
|
206
|
+
# Extract just filename:line_number
|
|
207
|
+
if line_to_use.match(%r{([^/]+):(\d+)})
|
|
208
|
+
"#{$1}:#{$2}"
|
|
209
|
+
else
|
|
210
|
+
line_to_use
|
|
211
|
+
end
|
|
212
|
+
else
|
|
213
|
+
line_to_use
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|