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
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Sampling
5
+ class HealthBasedSampler < Base
6
+ def initialize(health_check:, high_rate: 1.0, low_rate: 0.1)
7
+ @health_check = health_check
8
+ @high_rate = high_rate
9
+ @low_rate = low_rate
10
+ end
11
+
12
+ def sample?(_event, _hint = {})
13
+ healthy = @health_check.call
14
+ rate = healthy ? @high_rate : @low_rate
15
+ rate > rand
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Sampling
5
+ class RateLimiter < Base
6
+ def initialize(max_events_per_second: 10)
7
+ @max_events_per_second = max_events_per_second
8
+ @tokens = max_events_per_second
9
+ @last_refill = Time.zone.now
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def sample?(_event, _hint = {})
14
+ @mutex.synchronize do
15
+ now = Time.zone.now
16
+ time_passed = now - @last_refill
17
+
18
+ # Refill tokens based on time passed
19
+ @tokens = [ @tokens + (time_passed * @max_events_per_second), @max_events_per_second ].min
20
+ @last_refill = now
21
+
22
+ if @tokens >= 1
23
+ @tokens -= 1
24
+ true
25
+ else
26
+ false
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Sampling
5
+ # Factory for creating common sampling configurations
6
+ class SamplingFactory
7
+ def self.create_production_sampling
8
+ CompositeSampler.new(
9
+ samplers: [
10
+ # Rate limit to prevent overwhelming
11
+ RateLimiter.new(max_events_per_second: 50),
12
+
13
+ # Different rates for different exception types
14
+ ExceptionTypeSampler.new(rates: {
15
+ # Critical errors - always sample
16
+ SecurityError => 1.0,
17
+ SystemStackError => 1.0,
18
+ NoMemoryError => 1.0,
19
+
20
+ # Common errors - sample less
21
+ ArgumentError => 0.1,
22
+ TypeError => 0.1,
23
+
24
+ # Network errors - medium sampling
25
+ /timeout/i => 0.3,
26
+ /connection/i => 0.3,
27
+
28
+ # Default for unknown errors
29
+ default: 0.5
30
+ }),
31
+
32
+ # Lower sampling during business hours
33
+ TimeBasedSampler.new(schedule: {
34
+ business_hours: 0.3,
35
+ weekends: 0.8,
36
+ default: 0.5
37
+ })
38
+ ],
39
+ strategy: :all
40
+ )
41
+ end
42
+
43
+ def self.create_development_sampling
44
+ UniformSampler.new(1.0) # Sample everything in development
45
+ end
46
+
47
+ def self.create_user_focused_sampling
48
+ CompositeSampler.new(
49
+ samplers: [
50
+ # Higher sampling for internal users
51
+ UserBasedSampler.new(rates: {
52
+ internal: 1.0,
53
+ premium: 0.8,
54
+ beta: 0.9,
55
+ default: 0.1
56
+ }),
57
+
58
+ # Consistent sampling based on user ID
59
+ ConsistentHashSampler.new(
60
+ rate: 0.1,
61
+ key_extractor: ->(event, _hint) { event.context.dig(:user, :id) }
62
+ )
63
+ ],
64
+ strategy: :any
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Sampling
5
+ class TimeBasedSampler < Base
6
+ def initialize(schedule: {})
7
+ @schedule = schedule
8
+ @default_rate = schedule.fetch(:default, 1.0)
9
+ end
10
+
11
+ def sample?(_event, _hint = {})
12
+ now = Time.zone.now
13
+ rate = find_rate_for_time(now)
14
+ rate > rand
15
+ end
16
+
17
+ private
18
+
19
+ def find_rate_for_time(time)
20
+ hour = time.hour
21
+ day_of_week = time.wday # 0 = Sunday
22
+
23
+ # Check specific hour
24
+ hour_key = :"hour_#{hour}"
25
+ return @schedule[hour_key] if @schedule.key?(hour_key)
26
+
27
+ # Check day of week
28
+ day_names = %i[sunday monday tuesday wednesday thursday friday saturday]
29
+ day_key = day_names[day_of_week]
30
+ return @schedule[day_key] if @schedule.key?(day_key)
31
+
32
+ # Check business hours
33
+ if @schedule.key?(:business_hours) && (9..17).cover?(hour) && (1..5).cover?(day_of_week)
34
+ return @schedule[:business_hours]
35
+ end
36
+
37
+ # Check weekends
38
+ return @schedule[:weekends] if @schedule.key?(:weekends) && [ 0, 6 ].include?(day_of_week)
39
+
40
+ @default_rate
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Sampling
5
+ class UniformSampler < Base
6
+ def initialize(rate)
7
+ @rate = rate
8
+ end
9
+
10
+ def sample?(_event, _hint = {})
11
+ rand < @rate
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ module Sampling
5
+ class UserBasedSampler < Base
6
+ def initialize(rates: {})
7
+ @rates = rates
8
+ @default_rate = rates.fetch(:default, 1.0)
9
+ end
10
+
11
+ def sample?(event, _hint = {})
12
+ user = event.context[:user]
13
+ return @default_rate > rand unless user
14
+
15
+ rate = find_rate_for_user(user)
16
+ rate > rand
17
+ end
18
+
19
+ private
20
+
21
+ def find_rate_for_user(user)
22
+ # Check specific user ID
23
+ user_id = user[:id] || user["id"]
24
+ return @rates[user_id] if user_id && @rates.key?(user_id)
25
+
26
+ # Check user segments
27
+ @rates.each do |segment, rate|
28
+ case segment
29
+ when :internal, "internal"
30
+ return rate if user[:internal] || user["internal"]
31
+ when :premium, "premium"
32
+ return rate if user[:premium] || user["premium"]
33
+ when :beta, "beta"
34
+ return rate if user[:beta] || user["beta"]
35
+ end
36
+ end
37
+
38
+ @default_rate
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,332 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
-
5
3
  module Lapsoss
6
4
  module Sampling
7
- class Base
8
- def sample?(event, hint = {})
9
- true
10
- end
11
- end
12
-
13
- class UniformSampler < Base
14
- def initialize(rate)
15
- @rate = rate
16
- end
17
-
18
- def sample?(event, hint = {})
19
- rand < @rate
20
- end
21
- end
22
-
23
- class RateLimiter < Base
24
- def initialize(max_events_per_second: 10)
25
- @max_events_per_second = max_events_per_second
26
- @tokens = max_events_per_second
27
- @last_refill = Time.now
28
- @mutex = Mutex.new
29
- end
30
-
31
- def sample?(event, hint = {})
32
- @mutex.synchronize do
33
- now = Time.now
34
- time_passed = now - @last_refill
35
-
36
- # Refill tokens based on time passed
37
- @tokens = [@tokens + (time_passed * @max_events_per_second), @max_events_per_second].min
38
- @last_refill = now
39
-
40
- if @tokens >= 1
41
- @tokens -= 1
42
- true
43
- else
44
- false
45
- end
46
- end
47
- end
48
- end
49
-
50
- class ExceptionTypeSampler < Base
51
- def initialize(rates: {})
52
- @rates = rates
53
- @default_rate = rates.fetch(:default, 1.0)
54
- end
55
-
56
- def sample?(event, hint = {})
57
- return @default_rate > rand unless event.exception
58
-
59
- exception_class = event.exception.class
60
- rate = find_rate_for_exception(exception_class)
61
- rate > rand
62
- end
63
-
64
- private
65
-
66
- def find_rate_for_exception(exception_class)
67
- # Check exact class match first
68
- return @rates[exception_class] if @rates.key?(exception_class)
69
-
70
- # Check inheritance hierarchy
71
- @rates.each do |klass, rate|
72
- return rate if klass.is_a?(Class) && exception_class <= klass
73
- end
74
-
75
- # Check string/regex patterns
76
- @rates.each do |pattern, rate|
77
- case pattern
78
- when String
79
- return rate if exception_class.name.include?(pattern)
80
- when Regexp
81
- return rate if exception_class.name.match?(pattern)
82
- end
83
- end
84
-
85
- @default_rate
86
- end
87
- end
88
-
89
- class UserBasedSampler < Base
90
- def initialize(rates: {})
91
- @rates = rates
92
- @default_rate = rates.fetch(:default, 1.0)
93
- end
94
-
95
- def sample?(event, hint = {})
96
- user = event.context[:user]
97
- return @default_rate > rand unless user
98
-
99
- rate = find_rate_for_user(user)
100
- rate > rand
101
- end
102
-
103
- private
104
-
105
- def find_rate_for_user(user)
106
- # Check specific user ID
107
- user_id = user[:id] || user["id"]
108
- return @rates[user_id] if user_id && @rates.key?(user_id)
109
-
110
- # Check user segments
111
- @rates.each do |segment, rate|
112
- case segment
113
- when :internal, "internal"
114
- return rate if user[:internal] || user["internal"]
115
- when :premium, "premium"
116
- return rate if user[:premium] || user["premium"]
117
- when :beta, "beta"
118
- return rate if user[:beta] || user["beta"]
119
- end
120
- end
121
-
122
- @default_rate
123
- end
124
- end
125
-
126
- class ConsistentHashSampler < Base
127
- def initialize(rate:, key_extractor: nil)
128
- @rate = rate
129
- @key_extractor = key_extractor || method(:default_key_extractor)
130
- @threshold = (rate * 0xFFFFFFFF).to_i
131
- end
132
-
133
- def sample?(event, hint = {})
134
- key = @key_extractor.call(event, hint)
135
- return @rate > rand unless key
136
-
137
- hash_value = Digest::MD5.hexdigest(key.to_s)[0, 8].to_i(16)
138
- hash_value <= @threshold
139
- end
140
-
141
- private
142
-
143
- def default_key_extractor(event, hint)
144
- # Use fingerprint for consistent sampling
145
- event.fingerprint
146
- end
147
- end
148
-
149
- class TimeBasedSampler < Base
150
- def initialize(schedule: {})
151
- @schedule = schedule
152
- @default_rate = schedule.fetch(:default, 1.0)
153
- end
154
-
155
- def sample?(event, hint = {})
156
- now = Time.now
157
- rate = find_rate_for_time(now)
158
- rate > rand
159
- end
160
-
161
- private
162
-
163
- def find_rate_for_time(time)
164
- hour = time.hour
165
- day_of_week = time.wday # 0 = Sunday
166
-
167
- # Check specific hour
168
- hour_key = "hour_#{hour}".to_sym
169
- return @schedule[hour_key] if @schedule.key?(hour_key)
170
-
171
- # Check day of week
172
- day_names = [:sunday, :monday, :tuesday, :wednesday, :thursday, :friday, :saturday]
173
- day_key = day_names[day_of_week]
174
- return @schedule[day_key] if @schedule.key?(day_key)
175
-
176
- # Check business hours
177
- if @schedule.key?(:business_hours) && (9..17).cover?(hour) && (1..5).cover?(day_of_week)
178
- return @schedule[:business_hours]
179
- end
180
-
181
- # Check weekends
182
- if @schedule.key?(:weekends) && [0, 6].include?(day_of_week)
183
- return @schedule[:weekends]
184
- end
185
-
186
- @default_rate
187
- end
188
- end
189
-
190
- class CompositeSampler < Base
191
- def initialize(app = nil, samplers: [], strategy: :all)
192
- @app = app
193
- @samplers = samplers
194
- @strategy = strategy
195
- end
196
-
197
- def sample?(event, hint = {})
198
- case @strategy
199
- when :all
200
- @samplers.all? { |sampler| sampler.sample?(event, hint) }
201
- when :any
202
- @samplers.any? { |sampler| sampler.sample?(event, hint) }
203
- when :first
204
- @samplers.first&.sample?(event, hint) || true
205
- else
206
- raise ArgumentError, "Unknown strategy: #{@strategy}"
207
- end
208
- end
209
- end
210
-
211
- class AdaptiveSampler < Base
212
- def initialize(target_rate: 1.0, adjustment_period: 60)
213
- @target_rate = target_rate
214
- @adjustment_period = adjustment_period
215
- @current_rate = target_rate
216
- @events_count = 0
217
- @last_adjustment = Time.now
218
- @mutex = Mutex.new
219
- end
220
-
221
- def sample?(event, hint = {})
222
- @mutex.synchronize do
223
- @events_count += 1
224
-
225
- # Adjust rate periodically
226
- now = Time.now
227
- if now - @last_adjustment > @adjustment_period
228
- adjust_rate
229
- @last_adjustment = now
230
- @events_count = 0
231
- end
232
- end
233
-
234
- @current_rate > rand
235
- end
236
-
237
- def current_rate
238
- @current_rate
239
- end
240
-
241
- private
242
-
243
- def adjust_rate
244
- # Simple adaptive logic - can be enhanced based on system metrics
245
- # For now, just ensure we don't drift too far from target
246
- if @events_count > 100 # High volume
247
- @current_rate = [@current_rate * 0.9, @target_rate * 0.1].max
248
- elsif @events_count < 10 # Low volume
249
- @current_rate = [@current_rate * 1.1, @target_rate].min
250
- end
251
- end
252
- end
253
-
254
- class HealthBasedSampler < Base
255
- def initialize(health_check:, high_rate: 1.0, low_rate: 0.1)
256
- @health_check = health_check
257
- @high_rate = high_rate
258
- @low_rate = low_rate
259
- end
260
-
261
- def sample?(event, hint = {})
262
- healthy = @health_check.call
263
- rate = healthy ? @high_rate : @low_rate
264
- rate > rand
265
- end
266
- end
267
-
268
- # Factory for creating common sampling configurations
269
- class SamplingFactory
270
- def self.create_production_sampling
271
- CompositeSampler.new(
272
- samplers: [
273
- # Rate limit to prevent overwhelming
274
- RateLimiter.new(max_events_per_second: 50),
275
-
276
- # Different rates for different exception types
277
- ExceptionTypeSampler.new(rates: {
278
- # Critical errors - always sample
279
- SecurityError => 1.0,
280
- SystemStackError => 1.0,
281
- NoMemoryError => 1.0,
282
-
283
- # Common errors - sample less
284
- ArgumentError => 0.1,
285
- TypeError => 0.1,
286
-
287
- # Network errors - medium sampling
288
- /timeout/i => 0.3,
289
- /connection/i => 0.3,
290
-
291
- # Default for unknown errors
292
- default: 0.5
293
- }),
294
-
295
- # Lower sampling during business hours
296
- TimeBasedSampler.new(schedule: {
297
- business_hours: 0.3,
298
- weekends: 0.8,
299
- default: 0.5
300
- })
301
- ],
302
- strategy: :all
303
- )
304
- end
305
-
306
- def self.create_development_sampling
307
- UniformSampler.new(1.0) # Sample everything in development
308
- end
309
-
310
- def self.create_user_focused_sampling
311
- CompositeSampler.new(
312
- samplers: [
313
- # Higher sampling for internal users
314
- UserBasedSampler.new(rates: {
315
- internal: 1.0,
316
- premium: 0.8,
317
- beta: 0.9,
318
- default: 0.1
319
- }),
320
-
321
- # Consistent sampling based on user ID
322
- ConsistentHashSampler.new(
323
- rate: 0.1,
324
- key_extractor: ->(event, hint) { event.context.dig(:user, :id) }
325
- )
326
- ],
327
- strategy: :any
328
- )
329
- end
330
- end
331
5
  end
332
6
  end
data/lib/lapsoss/scope.rb CHANGED
@@ -29,13 +29,11 @@ module Lapsoss
29
29
  @extra.merge!(context[:extra] || {})
30
30
 
31
31
  # Handle breadcrumbs if provided
32
- if context[:breadcrumbs]
33
- @breadcrumbs.concat(context[:breadcrumbs])
34
- # Keep breadcrumbs to a reasonable limit
35
- while @breadcrumbs.length > 20
36
- @breadcrumbs.shift
37
- end
38
- end
32
+ return unless context[:breadcrumbs]
33
+
34
+ @breadcrumbs.concat(context[:breadcrumbs])
35
+ # Keep breadcrumbs to a reasonable limit
36
+ @breadcrumbs.shift while @breadcrumbs.length > 20
39
37
  end
40
38
 
41
39
  def clear
@@ -44,67 +42,29 @@ module Lapsoss
44
42
  @user.clear
45
43
  @extra.clear
46
44
  end
47
- end
48
45
 
49
- # Performance-optimized scope that provides a merged view without cloning
50
- class MergedScope
51
- def initialize(scope_stack, base_scope)
52
- @scope_stack = scope_stack
53
- @base_scope = base_scope || Scope.new
54
- @own_breadcrumbs = []
46
+ def set_context(key, value)
47
+ @extra[key] = value
55
48
  end
56
49
 
57
- def tags
58
- @tags ||= merge_hash_contexts(:tags)
50
+ def set_user(user_info)
51
+ @user.merge!(user_info)
59
52
  end
60
53
 
61
- def user
62
- @user ||= merge_hash_contexts(:user)
54
+ def set_tag(key, value)
55
+ @tags[key] = value
63
56
  end
64
57
 
65
- def extra
66
- @extra ||= merge_hash_contexts(:extra)
58
+ def set_tags(tags)
59
+ @tags.merge!(tags)
67
60
  end
68
61
 
69
- def breadcrumbs
70
- @breadcrumbs ||= merge_breadcrumbs
62
+ def set_extra(key, value)
63
+ @extra[key] = value
71
64
  end
72
65
 
73
- def add_breadcrumb(message, type: :default, **metadata)
74
- breadcrumb = {
75
- message: message,
76
- type: type,
77
- metadata: metadata,
78
- timestamp: Time.now.utc
79
- }
80
- @own_breadcrumbs << breadcrumb
81
- # Keep breadcrumbs to a reasonable limit
82
- @own_breadcrumbs.shift if @own_breadcrumbs.length > 20
83
- # Clear cached breadcrumbs to force recomputation
84
- @breadcrumbs = nil
85
- end
86
-
87
- private
88
-
89
- def merge_hash_contexts(key)
90
- result = @base_scope.send(key).dup
91
- @scope_stack.each do |context|
92
- result.merge!(context[key] || {})
93
- end
94
- result
95
- end
96
-
97
- def merge_breadcrumbs
98
- result = @base_scope.breadcrumbs.dup
99
- @scope_stack.each do |context|
100
- if context[:breadcrumbs]
101
- result.concat(context[:breadcrumbs])
102
- end
103
- end
104
- # Add our own breadcrumbs
105
- result.concat(@own_breadcrumbs)
106
- # Keep breadcrumbs to a reasonable limit
107
- result.last(20)
66
+ def set_extras(extras)
67
+ @extra.merge!(extras)
108
68
  end
109
69
  end
110
70
  end