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.
@@ -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