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
@@ -51,7 +51,7 @@ module Lapsoss
51
51
 
52
52
  # File system patterns
53
53
  {
54
- pattern: /Errno::(ENOENT|EACCES).*\/tmp\//,
54
+ pattern: %r{Errno::(ENOENT|EACCES).*/tmp/},
55
55
  fingerprint: "tmp-file-error"
56
56
  },
57
57
  {
@@ -121,9 +121,7 @@ module Lapsoss
121
121
  parts << event.message if event.message
122
122
 
123
123
  # Include first few backtrace lines for context
124
- if event.exception&.backtrace
125
- parts.concat(event.exception.backtrace.first(3))
126
- end
124
+ parts.concat(event.exception.backtrace.first(3)) if event.exception&.backtrace
127
125
 
128
126
  parts.compact.join(" ")
129
127
  end
@@ -148,9 +146,7 @@ module Lapsoss
148
146
  end
149
147
 
150
148
  # Include environment if configured
151
- if @include_environment && event.environment
152
- components << event.environment
153
- end
149
+ components << event.environment if @include_environment && event.environment
154
150
 
155
151
  # Generate hash from components
156
152
  content = components.compact.join("|")
@@ -194,18 +190,18 @@ module Lapsoss
194
190
 
195
191
  # Find first non-gem, non-framework line
196
192
  app_line = backtrace.find do |line|
197
- !line.include?("/gems/") &&
198
- !line.include?("/ruby/") &&
199
- !line.include?("(eval)") &&
200
- !line.start_with?("[")
193
+ line.exclude?("/gems/") &&
194
+ line.exclude?("/ruby/") &&
195
+ line.exclude?("(eval)") &&
196
+ !line.start_with?("[")
201
197
  end
202
198
 
203
199
  line_to_use = app_line || backtrace.first
204
200
 
205
201
  if @normalize_paths
206
202
  # Extract just filename:line_number
207
- if line_to_use.match(%r{([^/]+):(\d+)})
208
- "#{$1}:#{$2}"
203
+ if line_to_use =~ %r{([^/]+):(\d+)}
204
+ "#{::Regexp.last_match(1)}:#{::Regexp.last_match(2)}"
209
205
  else
210
206
  line_to_use
211
207
  end
@@ -8,7 +8,7 @@ require "zlib"
8
8
  module Lapsoss
9
9
  # HTTP client wrapper using Faraday with retry logic
10
10
  class HttpClient
11
- USER_AGENT = "lapsoss/#{Lapsoss::VERSION}"
11
+ USER_AGENT = "lapsoss/#{Lapsoss::VERSION}".freeze
12
12
 
13
13
  def initialize(base_url, config = {})
14
14
  @base_url = base_url
@@ -53,26 +53,60 @@ module Lapsoss
53
53
  conn.options.open_timeout = @config[:timeout] || 5
54
54
 
55
55
  # Configure SSL
56
- if @config.key?(:ssl_verify)
57
- conn.ssl.verify = @config[:ssl_verify]
58
- end
56
+ conn.ssl.verify = @config[:ssl_verify] if @config.key?(:ssl_verify)
59
57
 
60
58
  # Set user agent
61
59
  conn.headers["User-Agent"] = USER_AGENT
62
60
 
63
- # Use default adapter
64
- conn.adapter Faraday.default_adapter
61
+ # Auto-detect and use appropriate adapter
62
+ conn.adapter detect_optimal_adapter
65
63
  end
66
64
  end
67
65
 
66
+ def detect_optimal_adapter
67
+ if fiber_scheduler_active? && async_adapter_available? && !force_sync_mode?
68
+ log_adapter_selection(:async)
69
+ :async_http
70
+ else
71
+ log_adapter_selection(:sync)
72
+ Faraday.default_adapter
73
+ end
74
+ end
75
+
76
+ def fiber_scheduler_active?
77
+ Fiber.current_scheduler != nil
78
+ end
79
+
80
+ def async_adapter_available?
81
+ require "async/http/faraday"
82
+ true
83
+ rescue LoadError
84
+ false
85
+ end
86
+
87
+ def force_sync_mode?
88
+ Lapsoss.configuration.force_sync_http
89
+ end
90
+
91
+ def log_adapter_selection(adapter_type)
92
+ return unless Lapsoss.configuration.debug?
93
+
94
+ Lapsoss.configuration.logger&.debug(
95
+ "[Lapsoss::HttpClient] Using #{adapter_type} HTTP adapter " \
96
+ "(fiber_scheduler: #{fiber_scheduler_active?}, " \
97
+ "async_available: #{async_adapter_available?}, " \
98
+ "force_sync: #{force_sync_mode?})"
99
+ )
100
+ end
101
+
68
102
  def retry_options
69
103
  {
70
104
  max: @config[:max_retries] || 3,
71
105
  interval: @config[:initial_backoff] || 1.0,
72
106
  max_interval: @config[:max_backoff] || 64.0,
73
107
  backoff_factor: @config[:backoff_multiplier] || 2.0,
74
- retry_statuses: [408, 429, 500, 502, 503, 504],
75
- methods: [:post]
108
+ retry_statuses: [ 408, 429, 500, 502, 503, 504 ],
109
+ methods: [ :post ]
76
110
  }
77
111
  end
78
112
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ # Performance-optimized scope that provides a merged view without cloning
5
+ class MergedScope
6
+ def initialize(scope_stack, base_scope)
7
+ @scope_stack = scope_stack
8
+ @base_scope = base_scope || Scope.new
9
+ @own_breadcrumbs = []
10
+ end
11
+
12
+ def tags
13
+ @tags ||= merge_hash_contexts(:tags)
14
+ end
15
+
16
+ def user
17
+ @user ||= merge_hash_contexts(:user)
18
+ end
19
+
20
+ def extra
21
+ @extra ||= merge_hash_contexts(:extra)
22
+ end
23
+
24
+ def breadcrumbs
25
+ @breadcrumbs ||= merge_breadcrumbs
26
+ end
27
+
28
+ def add_breadcrumb(message, type: :default, **metadata)
29
+ breadcrumb = {
30
+ message: message,
31
+ type: type,
32
+ metadata: metadata,
33
+ timestamp: Time.now.utc
34
+ }
35
+ @own_breadcrumbs << breadcrumb
36
+ # Keep breadcrumbs to a reasonable limit
37
+ @own_breadcrumbs.shift if @own_breadcrumbs.length > 20
38
+ # Clear cached breadcrumbs to force recomputation
39
+ @breadcrumbs = nil
40
+ end
41
+
42
+ private
43
+
44
+ def merge_hash_contexts(key)
45
+ result = @base_scope.send(key).dup
46
+ @scope_stack.each do |context|
47
+ result.merge!(context[key] || {})
48
+ end
49
+ result
50
+ end
51
+
52
+ def merge_breadcrumbs
53
+ result = @base_scope.breadcrumbs.dup
54
+ @scope_stack.each do |context|
55
+ result.concat(context[:breadcrumbs]) if context[:breadcrumbs]
56
+ end
57
+ # Add our own breadcrumbs
58
+ result.concat(@own_breadcrumbs)
59
+ # Keep breadcrumbs to a reasonable limit
60
+ result.last(20)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
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
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class ConditionalFilter < Base
6
+ def initialize(app, condition)
7
+ super(app)
8
+ @condition = condition
9
+ end
10
+
11
+ def call(event, hint = {})
12
+ return nil unless @condition.call(event, hint)
13
+
14
+ @app.call(event, hint)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class EventEnricher < Base
6
+ def initialize(app, enrichers: [])
7
+ super(app)
8
+ @enrichers = enrichers
9
+ end
10
+
11
+ def call(event, hint = {})
12
+ @enrichers.each do |enricher|
13
+ enricher.call(event, hint)
14
+ end
15
+ @app.call(event, hint)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class EventTransformer < Base
6
+ def initialize(app, transformer)
7
+ super(app)
8
+ @transformer = transformer
9
+ end
10
+
11
+ def call(event, hint = {})
12
+ transformed_event = @transformer.call(event, hint)
13
+ return nil unless transformed_event
14
+
15
+ @app.call(transformed_event, hint)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class ExceptionFilter < Base
6
+ def initialize(app, excluded_exceptions: [], excluded_patterns: [])
7
+ super(app)
8
+ @excluded_exceptions = Array(excluded_exceptions)
9
+ @excluded_patterns = Array(excluded_patterns)
10
+ end
11
+
12
+ def call(event, hint = {})
13
+ return nil if should_exclude?(event)
14
+
15
+ @app.call(event, hint)
16
+ end
17
+
18
+ private
19
+
20
+ def should_exclude?(event)
21
+ return false unless event.exception
22
+
23
+ exception_class = event.exception.class
24
+ exception_message = event.exception.message
25
+
26
+ # Check exact class matches
27
+ return true if @excluded_exceptions.any? { |klass| exception_class <= klass }
28
+
29
+ # Check pattern matches
30
+ @excluded_patterns.any? do |pattern|
31
+ case pattern
32
+ when Regexp
33
+ exception_message&.match?(pattern) || exception_class.name.match?(pattern)
34
+ when String
35
+ exception_message&.include?(pattern) || exception_class.name.include?(pattern)
36
+ else
37
+ false
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class MetricsCollector < Base
6
+ def initialize(app, collector: nil)
7
+ super(app)
8
+ @collector = collector
9
+ @metrics = {
10
+ events_processed: 0,
11
+ events_dropped: 0,
12
+ events_by_type: Hash.new(0),
13
+ events_by_level: Hash.new(0)
14
+ }
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def call(event, hint = {})
19
+ @mutex.synchronize do
20
+ @metrics[:events_processed] += 1
21
+ @metrics[:events_by_type][event.type] += 1
22
+ @metrics[:events_by_level][event.level] += 1
23
+ end
24
+
25
+ result = @app.call(event, hint)
26
+
27
+ if result.nil?
28
+ @mutex.synchronize do
29
+ @metrics[:events_dropped] += 1
30
+ end
31
+ end
32
+
33
+ # Send to external collector if provided
34
+ @collector&.call(@metrics.dup, event, hint)
35
+
36
+ result
37
+ end
38
+
39
+ def metrics
40
+ @mutex.synchronize { @metrics.dup }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class RateLimiter < Base
6
+ def initialize(app, max_events: 100, time_window: 60)
7
+ super(app)
8
+ @max_events = max_events
9
+ @time_window = time_window
10
+ @events = []
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def call(event, hint = {})
15
+ @mutex.synchronize do
16
+ now = Time.zone.now
17
+ # Remove old events outside time window
18
+ @events.reject! { |timestamp| now - timestamp > @time_window }
19
+
20
+ # Check if we're over the limit
21
+ return nil if @events.length >= @max_events
22
+
23
+ # Add current event
24
+ @events << now
25
+ end
26
+
27
+ @app.call(event, hint)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class ReleaseTracker < Base
6
+ def initialize(app, release_provider: nil)
7
+ super(app)
8
+ @release_provider = release_provider
9
+ end
10
+
11
+ def call(event, hint = {})
12
+ add_release_info(event, hint)
13
+ @app.call(event, hint)
14
+ end
15
+
16
+ private
17
+
18
+ def add_release_info(event, hint)
19
+ release_info = @release_provider&.call(event, hint) || auto_detect_release
20
+
21
+ event.context[:release] = release_info if release_info
22
+ end
23
+
24
+ def auto_detect_release
25
+ release_info = {}
26
+
27
+ # Try to detect Git information
28
+ if git_info = detect_git_info
29
+ release_info.merge!(git_info)
30
+ end
31
+
32
+ # Try to detect deployment info
33
+ if deployment_info = detect_deployment_info
34
+ release_info.merge!(deployment_info)
35
+ end
36
+
37
+ release_info.empty? ? nil : release_info
38
+ end
39
+
40
+ def detect_git_info
41
+ return nil unless File.exist?(".git")
42
+
43
+ begin
44
+ # Get current commit SHA
45
+ commit_sha = `git rev-parse HEAD`.strip
46
+ return nil if commit_sha.empty?
47
+
48
+ # Get branch name
49
+ branch = `git rev-parse --abbrev-ref HEAD`.strip
50
+ branch = nil if branch.empty? || branch == "HEAD"
51
+
52
+ # Get commit timestamp
53
+ commit_time = `git log -1 --format=%ct`.strip
54
+ commit_timestamp = commit_time.empty? ? nil : Time.zone.at(commit_time.to_i)
55
+
56
+ # Get tag if on a tag
57
+ tag = `git describe --exact-match --tags HEAD 2>/dev/null`.strip
58
+ tag = nil if tag.empty?
59
+
60
+ {
61
+ commit_sha: commit_sha,
62
+ branch: branch,
63
+ tag: tag,
64
+ commit_timestamp: commit_timestamp
65
+ }.compact
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+
71
+ def detect_deployment_info
72
+ info = {}
73
+
74
+ # Check common deployment environment variables
75
+ info[:deployment_id] = ENV["DEPLOYMENT_ID"] if ENV["DEPLOYMENT_ID"]
76
+ info[:build_number] = ENV["BUILD_NUMBER"] if ENV["BUILD_NUMBER"]
77
+ info[:deployment_time] = parse_deployment_time(ENV["DEPLOYMENT_TIME"]) if ENV["DEPLOYMENT_TIME"]
78
+
79
+ # Check Heroku
80
+ if ENV["HEROKU_APP_NAME"]
81
+ info[:platform] = "heroku"
82
+ info[:app_name] = ENV["HEROKU_APP_NAME"]
83
+ info[:dyno] = ENV.fetch("DYNO", nil)
84
+ info[:slug_commit] = ENV.fetch("HEROKU_SLUG_COMMIT", nil)
85
+ end
86
+
87
+ # Check AWS
88
+ if ENV["AWS_EXECUTION_ENV"]
89
+ info[:platform] = "aws"
90
+ info[:execution_env] = ENV["AWS_EXECUTION_ENV"]
91
+ info[:region] = ENV.fetch("AWS_REGION", nil)
92
+ end
93
+
94
+ # Check Docker
95
+ if ENV["DOCKER_CONTAINER_ID"] || File.exist?("/.dockerenv")
96
+ info[:platform] = "docker"
97
+ info[:container_id] = ENV["DOCKER_CONTAINER_ID"]
98
+ end
99
+
100
+ # Check Kubernetes
101
+ if ENV["KUBERNETES_SERVICE_HOST"]
102
+ info[:platform] = "kubernetes"
103
+ info[:namespace] = ENV.fetch("KUBERNETES_NAMESPACE", nil)
104
+ info[:pod_name] = ENV.fetch("HOSTNAME", nil)
105
+ end
106
+
107
+ info.empty? ? nil : info
108
+ end
109
+
110
+ def parse_deployment_time(time_str)
111
+ Time.zone.parse(time_str)
112
+ rescue StandardError
113
+ nil
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class SampleFilter < Base
6
+ def initialize(app, sample_rate: 1.0, sample_callback: nil)
7
+ super(app)
8
+ @sample_rate = sample_rate
9
+ @sample_callback = sample_callback
10
+ end
11
+
12
+ def call(event, hint = {})
13
+ # Apply custom sampling logic first
14
+ return nil if @sample_callback && !@sample_callback.call(event, hint)
15
+
16
+ # Apply rate-based sampling
17
+ return nil if (@sample_rate < 1.0) && (rand > @sample_rate)
18
+
19
+ @app.call(event, hint)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class SamplingMiddleware < Base
6
+ def initialize(app, sampler)
7
+ super(app)
8
+ @sampler = sampler
9
+ end
10
+
11
+ def call(event, hint = {})
12
+ return nil unless @sampler.sample?(event, hint)
13
+
14
+ @app.call(event, hint)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Middleware
5
+ class UserContextEnhancer < Base
6
+ def initialize(app, user_provider: nil, privacy_mode: false)
7
+ super(app)
8
+ @user_provider = user_provider
9
+ @privacy_mode = privacy_mode
10
+ end
11
+
12
+ def call(event, hint = {})
13
+ enhance_user_context(event, hint)
14
+ @app.call(event, hint)
15
+ end
16
+
17
+ private
18
+
19
+ def enhance_user_context(event, hint)
20
+ # Get user from provider if available
21
+ user_data = @user_provider&.call(event, hint) || {}
22
+
23
+ # Merge with existing user context
24
+ existing_user = event.context[:user] || {}
25
+ enhanced_user = existing_user.merge(user_data)
26
+
27
+ # Apply privacy filtering if enabled
28
+ enhanced_user = apply_privacy_filtering(enhanced_user) if @privacy_mode
29
+
30
+ event.context[:user] = enhanced_user unless enhanced_user.empty?
31
+ end
32
+
33
+ def apply_privacy_filtering(user_data)
34
+ # Remove sensitive fields in privacy mode
35
+ sensitive_fields = %i[email phone address ssn credit_card]
36
+ filtered = user_data.dup
37
+
38
+ sensitive_fields.each do |field|
39
+ filtered[field] = "[FILTERED]" if filtered.key?(field)
40
+ end
41
+
42
+ filtered
43
+ end
44
+ end
45
+ end
46
+ end