lapsoss 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +153 -733
  3. data/lib/lapsoss/adapters/appsignal_adapter.rb +7 -8
  4. data/lib/lapsoss/adapters/base.rb +0 -3
  5. data/lib/lapsoss/adapters/bugsnag_adapter.rb +12 -0
  6. data/lib/lapsoss/adapters/insight_hub_adapter.rb +102 -101
  7. data/lib/lapsoss/adapters/logger_adapter.rb +7 -7
  8. data/lib/lapsoss/adapters/rollbar_adapter.rb +93 -54
  9. data/lib/lapsoss/adapters/sentry_adapter.rb +11 -17
  10. data/lib/lapsoss/backtrace_frame.rb +35 -214
  11. data/lib/lapsoss/backtrace_frame_factory.rb +228 -0
  12. data/lib/lapsoss/backtrace_processor.rb +37 -37
  13. data/lib/lapsoss/client.rb +2 -6
  14. data/lib/lapsoss/configuration.rb +25 -22
  15. data/lib/lapsoss/current.rb +9 -1
  16. data/lib/lapsoss/event.rb +30 -6
  17. data/lib/lapsoss/exception_backtrace_frame.rb +39 -0
  18. data/lib/lapsoss/exclusion_configuration.rb +30 -0
  19. data/lib/lapsoss/exclusion_filter.rb +156 -0
  20. data/lib/lapsoss/{exclusions.rb → exclusion_presets.rb} +1 -181
  21. data/lib/lapsoss/fingerprinter.rb +9 -13
  22. data/lib/lapsoss/http_client.rb +42 -8
  23. data/lib/lapsoss/merged_scope.rb +63 -0
  24. data/lib/lapsoss/middleware/base.rb +15 -0
  25. data/lib/lapsoss/middleware/conditional_filter.rb +18 -0
  26. data/lib/lapsoss/middleware/event_enricher.rb +19 -0
  27. data/lib/lapsoss/middleware/event_transformer.rb +19 -0
  28. data/lib/lapsoss/middleware/exception_filter.rb +43 -0
  29. data/lib/lapsoss/middleware/metrics_collector.rb +44 -0
  30. data/lib/lapsoss/middleware/rate_limiter.rb +31 -0
  31. data/lib/lapsoss/middleware/release_tracker.rb +117 -0
  32. data/lib/lapsoss/middleware/sample_filter.rb +23 -0
  33. data/lib/lapsoss/middleware/sampling_middleware.rb +18 -0
  34. data/lib/lapsoss/middleware/user_context_enhancer.rb +46 -0
  35. data/lib/lapsoss/middleware.rb +0 -347
  36. data/lib/lapsoss/pipeline.rb +1 -73
  37. data/lib/lapsoss/pipeline_builder.rb +69 -0
  38. data/lib/lapsoss/rails_error_subscriber.rb +42 -0
  39. data/lib/lapsoss/rails_middleware.rb +78 -0
  40. data/lib/lapsoss/railtie.rb +22 -50
  41. data/lib/lapsoss/registry.rb +34 -20
  42. data/lib/lapsoss/release_providers.rb +110 -0
  43. data/lib/lapsoss/release_tracker.rb +112 -207
  44. data/lib/lapsoss/router.rb +3 -5
  45. data/lib/lapsoss/sampling/adaptive_sampler.rb +46 -0
  46. data/lib/lapsoss/sampling/base.rb +11 -0
  47. data/lib/lapsoss/sampling/composite_sampler.rb +26 -0
  48. data/lib/lapsoss/sampling/consistent_hash_sampler.rb +30 -0
  49. data/lib/lapsoss/sampling/exception_type_sampler.rb +44 -0
  50. data/lib/lapsoss/sampling/health_based_sampler.rb +19 -0
  51. data/lib/lapsoss/sampling/rate_limiter.rb +32 -0
  52. data/lib/lapsoss/sampling/sampling_factory.rb +69 -0
  53. data/lib/lapsoss/sampling/time_based_sampler.rb +44 -0
  54. data/lib/lapsoss/sampling/uniform_sampler.rb +15 -0
  55. data/lib/lapsoss/sampling/user_based_sampler.rb +42 -0
  56. data/lib/lapsoss/sampling.rb +0 -326
  57. data/lib/lapsoss/scope.rb +17 -57
  58. data/lib/lapsoss/scrubber.rb +16 -18
  59. data/lib/lapsoss/user_context.rb +18 -198
  60. data/lib/lapsoss/user_context_integrations.rb +39 -0
  61. data/lib/lapsoss/user_context_middleware.rb +50 -0
  62. data/lib/lapsoss/user_context_provider.rb +93 -0
  63. data/lib/lapsoss/utils.rb +13 -0
  64. data/lib/lapsoss/validators.rb +14 -27
  65. data/lib/lapsoss/version.rb +1 -1
  66. data/lib/lapsoss.rb +12 -25
  67. metadata +106 -21
@@ -2,352 +2,5 @@
2
2
 
3
3
  module Lapsoss
4
4
  module Middleware
5
- class Base
6
- def initialize(app)
7
- @app = app
8
- end
9
-
10
- def call(event, hint = {})
11
- @app.call(event, hint)
12
- end
13
- end
14
-
15
- # Built-in middleware for common functionality
16
- class SampleFilter < Base
17
- def initialize(app, sample_rate: 1.0, sample_callback: nil)
18
- super(app)
19
- @sample_rate = sample_rate
20
- @sample_callback = sample_callback
21
- end
22
-
23
- def call(event, hint = {})
24
- # Apply custom sampling logic first
25
- if @sample_callback
26
- return nil unless @sample_callback.call(event, hint)
27
- end
28
-
29
- # Apply rate-based sampling
30
- if @sample_rate < 1.0
31
- return nil if rand > @sample_rate
32
- end
33
-
34
- @app.call(event, hint)
35
- end
36
- end
37
-
38
- class ExceptionFilter < Base
39
- def initialize(app, excluded_exceptions: [], excluded_patterns: [])
40
- super(app)
41
- @excluded_exceptions = Array(excluded_exceptions)
42
- @excluded_patterns = Array(excluded_patterns)
43
- end
44
-
45
- def call(event, hint = {})
46
- return nil if should_exclude?(event)
47
- @app.call(event, hint)
48
- end
49
-
50
- private
51
-
52
- def should_exclude?(event)
53
- return false unless event.exception
54
-
55
- exception_class = event.exception.class
56
- exception_message = event.exception.message
57
-
58
- # Check exact class matches
59
- return true if @excluded_exceptions.any? { |klass| exception_class <= klass }
60
-
61
- # Check pattern matches
62
- @excluded_patterns.any? do |pattern|
63
- case pattern
64
- when Regexp
65
- exception_message&.match?(pattern) || exception_class.name.match?(pattern)
66
- when String
67
- exception_message&.include?(pattern) || exception_class.name.include?(pattern)
68
- else
69
- false
70
- end
71
- end
72
- end
73
- end
74
-
75
- class UserContextEnhancer < Base
76
- def initialize(app, user_provider: nil, privacy_mode: false)
77
- super(app)
78
- @user_provider = user_provider
79
- @privacy_mode = privacy_mode
80
- end
81
-
82
- def call(event, hint = {})
83
- enhance_user_context(event, hint)
84
- @app.call(event, hint)
85
- end
86
-
87
- private
88
-
89
- def enhance_user_context(event, hint)
90
- # Get user from provider if available
91
- user_data = @user_provider&.call(event, hint) || {}
92
-
93
- # Merge with existing user context
94
- existing_user = event.context[:user] || {}
95
- enhanced_user = existing_user.merge(user_data)
96
-
97
- # Apply privacy filtering if enabled
98
- if @privacy_mode
99
- enhanced_user = apply_privacy_filtering(enhanced_user)
100
- end
101
-
102
- event.context[:user] = enhanced_user unless enhanced_user.empty?
103
- end
104
-
105
- def apply_privacy_filtering(user_data)
106
- # Remove sensitive fields in privacy mode
107
- sensitive_fields = [:email, :phone, :address, :ssn, :credit_card]
108
- filtered = user_data.dup
109
-
110
- sensitive_fields.each do |field|
111
- if filtered.key?(field)
112
- filtered[field] = "[FILTERED]"
113
- end
114
- end
115
-
116
- filtered
117
- end
118
- end
119
-
120
- class ReleaseTracker < Base
121
- def initialize(app, release_provider: nil)
122
- super(app)
123
- @release_provider = release_provider
124
- end
125
-
126
- def call(event, hint = {})
127
- add_release_info(event, hint)
128
- @app.call(event, hint)
129
- end
130
-
131
- private
132
-
133
- def add_release_info(event, hint)
134
- release_info = @release_provider&.call(event, hint) || auto_detect_release
135
-
136
- if release_info
137
- event.context[:release] = release_info
138
- end
139
- end
140
-
141
- def auto_detect_release
142
- release_info = {}
143
-
144
- # Try to detect Git information
145
- if git_info = detect_git_info
146
- release_info.merge!(git_info)
147
- end
148
-
149
- # Try to detect deployment info
150
- if deployment_info = detect_deployment_info
151
- release_info.merge!(deployment_info)
152
- end
153
-
154
- release_info.empty? ? nil : release_info
155
- end
156
-
157
- def detect_git_info
158
- return nil unless File.exist?(".git")
159
-
160
- begin
161
- # Get current commit SHA
162
- commit_sha = `git rev-parse HEAD`.strip
163
- return nil if commit_sha.empty?
164
-
165
- # Get branch name
166
- branch = `git rev-parse --abbrev-ref HEAD`.strip
167
- branch = nil if branch.empty? || branch == "HEAD"
168
-
169
- # Get commit timestamp
170
- commit_time = `git log -1 --format=%ct`.strip
171
- commit_timestamp = commit_time.empty? ? nil : Time.at(commit_time.to_i)
172
-
173
- # Get tag if on a tag
174
- tag = `git describe --exact-match --tags HEAD 2>/dev/null`.strip
175
- tag = nil if tag.empty?
176
-
177
- {
178
- commit_sha: commit_sha,
179
- branch: branch,
180
- tag: tag,
181
- commit_timestamp: commit_timestamp
182
- }.compact
183
- rescue StandardError
184
- nil
185
- end
186
- end
187
-
188
- def detect_deployment_info
189
- info = {}
190
-
191
- # Check common deployment environment variables
192
- info[:deployment_id] = ENV["DEPLOYMENT_ID"] if ENV["DEPLOYMENT_ID"]
193
- info[:build_number] = ENV["BUILD_NUMBER"] if ENV["BUILD_NUMBER"]
194
- info[:deployment_time] = parse_deployment_time(ENV["DEPLOYMENT_TIME"]) if ENV["DEPLOYMENT_TIME"]
195
-
196
- # Check Heroku
197
- if ENV["HEROKU_APP_NAME"]
198
- info[:platform] = "heroku"
199
- info[:app_name] = ENV["HEROKU_APP_NAME"]
200
- info[:dyno] = ENV["DYNO"]
201
- info[:slug_commit] = ENV["HEROKU_SLUG_COMMIT"]
202
- end
203
-
204
- # Check AWS
205
- if ENV["AWS_EXECUTION_ENV"]
206
- info[:platform] = "aws"
207
- info[:execution_env] = ENV["AWS_EXECUTION_ENV"]
208
- info[:region] = ENV["AWS_REGION"]
209
- end
210
-
211
- # Check Docker
212
- if ENV["DOCKER_CONTAINER_ID"] || File.exist?("/.dockerenv")
213
- info[:platform] = "docker"
214
- info[:container_id] = ENV["DOCKER_CONTAINER_ID"]
215
- end
216
-
217
- # Check Kubernetes
218
- if ENV["KUBERNETES_SERVICE_HOST"]
219
- info[:platform] = "kubernetes"
220
- info[:namespace] = ENV["KUBERNETES_NAMESPACE"]
221
- info[:pod_name] = ENV["HOSTNAME"]
222
- end
223
-
224
- info.empty? ? nil : info
225
- end
226
-
227
- def parse_deployment_time(time_str)
228
- Time.parse(time_str)
229
- rescue StandardError
230
- nil
231
- end
232
- end
233
-
234
- class EventEnricher < Base
235
- def initialize(app, enrichers: [])
236
- super(app)
237
- @enrichers = enrichers
238
- end
239
-
240
- def call(event, hint = {})
241
- @enrichers.each do |enricher|
242
- enricher.call(event, hint)
243
- end
244
- @app.call(event, hint)
245
- end
246
- end
247
-
248
- class ConditionalFilter < Base
249
- def initialize(app, condition)
250
- super(app)
251
- @condition = condition
252
- end
253
-
254
- def call(event, hint = {})
255
- return nil unless @condition.call(event, hint)
256
- @app.call(event, hint)
257
- end
258
- end
259
-
260
- class EventTransformer < Base
261
- def initialize(app, transformer)
262
- super(app)
263
- @transformer = transformer
264
- end
265
-
266
- def call(event, hint = {})
267
- transformed_event = @transformer.call(event, hint)
268
- return nil unless transformed_event
269
- @app.call(transformed_event, hint)
270
- end
271
- end
272
-
273
- class RateLimiter < Base
274
- def initialize(app, max_events: 100, time_window: 60)
275
- super(app)
276
- @max_events = max_events
277
- @time_window = time_window
278
- @events = []
279
- @mutex = Mutex.new
280
- end
281
-
282
- def call(event, hint = {})
283
- @mutex.synchronize do
284
- now = Time.now
285
- # Remove old events outside time window
286
- @events.reject! { |timestamp| now - timestamp > @time_window }
287
-
288
- # Check if we're over the limit
289
- if @events.length >= @max_events
290
- return nil
291
- end
292
-
293
- # Add current event
294
- @events << now
295
- end
296
-
297
- @app.call(event, hint)
298
- end
299
- end
300
-
301
- class MetricsCollector < Base
302
- def initialize(app, collector: nil)
303
- super(app)
304
- @collector = collector
305
- @metrics = {
306
- events_processed: 0,
307
- events_dropped: 0,
308
- events_by_type: Hash.new(0),
309
- events_by_level: Hash.new(0)
310
- }
311
- @mutex = Mutex.new
312
- end
313
-
314
- def call(event, hint = {})
315
- @mutex.synchronize do
316
- @metrics[:events_processed] += 1
317
- @metrics[:events_by_type][event.type] += 1
318
- @metrics[:events_by_level][event.level] += 1
319
- end
320
-
321
- result = @app.call(event, hint)
322
-
323
- if result.nil?
324
- @mutex.synchronize do
325
- @metrics[:events_dropped] += 1
326
- end
327
- end
328
-
329
- # Send to external collector if provided
330
- @collector&.call(@metrics.dup, event, hint)
331
-
332
- result
333
- end
334
-
335
- def metrics
336
- @mutex.synchronize { @metrics.dup }
337
- end
338
- end
339
-
340
- # Middleware wrapper for sampling strategies
341
- class SamplingMiddleware < Base
342
- def initialize(app, sampler)
343
- super(app)
344
- @sampler = sampler
345
- end
346
-
347
- def call(event, hint = {})
348
- return nil unless @sampler.sample?(event, hint)
349
- @app.call(event, hint)
350
- end
351
- end
352
5
  end
353
6
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "middleware"
4
-
5
3
  module Lapsoss
6
4
  class Pipeline
7
5
  def initialize
@@ -46,7 +44,7 @@ module Lapsoss
46
44
 
47
45
  def build_chain
48
46
  # The final app just returns the event
49
- final_app = lambda { |event, hint| event }
47
+ final_app = ->(event, _hint) { event }
50
48
 
51
49
  # Build middleware chain from right to left
52
50
  @middlewares.reverse.reduce(final_app) do |app, middleware_config|
@@ -58,74 +56,4 @@ module Lapsoss
58
56
  end
59
57
  end
60
58
  end
61
-
62
- class PipelineBuilder
63
- def initialize
64
- @pipeline = Pipeline.new
65
- end
66
-
67
- def sample(rate: 1.0, &block)
68
- @pipeline.use(Middleware::SampleFilter, sample_rate: rate, sample_callback: block)
69
- self
70
- end
71
-
72
- def exclude_exceptions(*exception_classes, patterns: [])
73
- @pipeline.use(Middleware::ExceptionFilter,
74
- excluded_exceptions: exception_classes,
75
- excluded_patterns: patterns)
76
- self
77
- end
78
-
79
- def enhance_user_context(provider: nil, privacy_mode: false)
80
- @pipeline.use(Middleware::UserContextEnhancer,
81
- user_provider: provider,
82
- privacy_mode: privacy_mode)
83
- self
84
- end
85
-
86
- def track_releases(provider: nil)
87
- @pipeline.use(Middleware::ReleaseTracker, release_provider: provider)
88
- self
89
- end
90
-
91
- def rate_limit(max_events: 100, time_window: 60)
92
- @pipeline.use(Middleware::RateLimiter,
93
- max_events: max_events,
94
- time_window: time_window)
95
- self
96
- end
97
-
98
- def collect_metrics(collector: nil)
99
- @pipeline.use(Middleware::MetricsCollector, collector: collector)
100
- self
101
- end
102
-
103
- def enrich_events(*enrichers)
104
- @pipeline.use(Middleware::EventEnricher, enrichers: enrichers)
105
- self
106
- end
107
-
108
- def filter_if(&condition)
109
- @pipeline.use(Middleware::ConditionalFilter, condition)
110
- self
111
- end
112
-
113
- def transform_events(&transformer)
114
- @pipeline.use(Middleware::EventTransformer, transformer)
115
- self
116
- end
117
-
118
- def use_middleware(middleware_class, *args, **kwargs)
119
- @pipeline.use(middleware_class, *args, **kwargs)
120
- self
121
- end
122
-
123
- def build
124
- @pipeline.build
125
- end
126
-
127
- def pipeline
128
- @pipeline
129
- end
130
- end
131
59
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ class PipelineBuilder
5
+ def initialize
6
+ @pipeline = Pipeline.new
7
+ end
8
+
9
+ def sample(rate: 1.0, &block)
10
+ @pipeline.use(Middleware::SampleFilter, sample_rate: rate, sample_callback: block)
11
+ self
12
+ end
13
+
14
+ def exclude_exceptions(*exception_classes, patterns: [])
15
+ @pipeline.use(Middleware::ExceptionFilter,
16
+ excluded_exceptions: exception_classes,
17
+ excluded_patterns: patterns)
18
+ self
19
+ end
20
+
21
+ def enhance_user_context(provider: nil, privacy_mode: false)
22
+ @pipeline.use(Middleware::UserContextEnhancer,
23
+ user_provider: provider,
24
+ privacy_mode: privacy_mode)
25
+ self
26
+ end
27
+
28
+ def track_releases(provider: nil)
29
+ @pipeline.use(Middleware::ReleaseTracker, release_provider: provider)
30
+ self
31
+ end
32
+
33
+ def rate_limit(max_events: 100, time_window: 60)
34
+ @pipeline.use(Middleware::RateLimiter,
35
+ max_events: max_events,
36
+ time_window: time_window)
37
+ self
38
+ end
39
+
40
+ def collect_metrics(collector: nil)
41
+ @pipeline.use(Middleware::MetricsCollector, collector: collector)
42
+ self
43
+ end
44
+
45
+ def enrich_events(*enrichers)
46
+ @pipeline.use(Middleware::EventEnricher, enrichers: enrichers)
47
+ self
48
+ end
49
+
50
+ def filter_if(&condition)
51
+ @pipeline.use(Middleware::ConditionalFilter, condition)
52
+ self
53
+ end
54
+
55
+ def transform_events(&transformer)
56
+ @pipeline.use(Middleware::EventTransformer, transformer)
57
+ self
58
+ end
59
+
60
+ def use_middleware(middleware_class, *, **)
61
+ @pipeline.use(middleware_class, *, **)
62
+ self
63
+ end
64
+
65
+ delegate :build, to: :@pipeline
66
+
67
+ attr_reader :pipeline
68
+ end
69
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ class RailsErrorSubscriber
5
+ def report(error, handled:, severity:, context:, source: nil)
6
+ # Skip certain framework errors
7
+ return if skip_error?(error, source)
8
+
9
+ level = map_severity(severity)
10
+
11
+ Lapsoss.capture_exception(
12
+ error,
13
+ level: level,
14
+ tags: {
15
+ handled: handled,
16
+ source: source || "rails"
17
+ },
18
+ context: context
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def skip_error?(error, source)
25
+ # Skip cache-related Redis errors if configured to do so
26
+ if Lapsoss.configuration.skip_rails_cache_errors && source&.include?("cache") && error.is_a?(Redis::CannotConnectError)
27
+ return true
28
+ end
29
+
30
+ false
31
+ end
32
+
33
+ def map_severity(severity)
34
+ case severity
35
+ when :error then :error
36
+ when :warning then :warning
37
+ when :info then :info
38
+ else :error
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ class RailsMiddleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ Lapsoss::Current.with_clean_scope do
11
+ # Add request context to current scope
12
+ if Lapsoss.configuration.capture_request_context
13
+ Rails.logger.debug "[Lapsoss] Adding request context" if Rails.env.test?
14
+ add_request_context(env)
15
+ end
16
+
17
+ begin
18
+ @app.call(env)
19
+ rescue Exception => e
20
+ Rails.logger.debug { "[Lapsoss] Capturing exception: #{e.class} - #{e.message}" } if Rails.env.test?
21
+ # Capture the exception
22
+ Lapsoss.capture_exception(e)
23
+ # Re-raise the exception to maintain Rails error handling
24
+ raise
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def add_request_context(env)
32
+ request = Rack::Request.new(env)
33
+
34
+ return unless Lapsoss::Current.scope
35
+
36
+ Lapsoss::Current.scope.set_context("request", {
37
+ method: request.request_method,
38
+ url: request.url,
39
+ path: request.path,
40
+ query_string: request.query_string,
41
+ headers: extract_headers(env),
42
+ ip: request.ip,
43
+ user_agent: request.user_agent,
44
+ referer: request.referer,
45
+ request_id: env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
46
+ })
47
+
48
+ # Add user context if available
49
+ return unless env["warden"]&.user
50
+
51
+ user = env["warden"].user
52
+ Lapsoss::Current.scope.set_user(
53
+ id: user.id,
54
+ email: user.respond_to?(:email) ? user.email : nil
55
+ )
56
+ end
57
+
58
+ def extract_headers(env)
59
+ headers = {}
60
+
61
+ env.each do |key, value|
62
+ if key.start_with?("HTTP_") && FILTERED_HEADERS.exclude?(key)
63
+ header_name = key.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-")
64
+ headers[header_name] = value
65
+ end
66
+ end
67
+
68
+ headers
69
+ end
70
+
71
+ FILTERED_HEADERS = %w[
72
+ HTTP_AUTHORIZATION
73
+ HTTP_COOKIE
74
+ HTTP_X_API_KEY
75
+ HTTP_X_AUTH_TOKEN
76
+ ].freeze
77
+ end
78
+ end