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,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Lapsoss
6
+ 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
+ end
332
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lapsoss
4
+ class Scope
5
+ attr_reader :breadcrumbs, :tags, :user, :extra
6
+
7
+ def initialize
8
+ @breadcrumbs = []
9
+ @tags = {}
10
+ @user = {}
11
+ @extra = {}
12
+ end
13
+
14
+ def add_breadcrumb(message, type: :default, **metadata)
15
+ breadcrumb = {
16
+ message: message,
17
+ type: type,
18
+ metadata: metadata,
19
+ timestamp: Time.now.utc
20
+ }
21
+ @breadcrumbs << breadcrumb
22
+ # Keep breadcrumbs to a reasonable limit
23
+ @breadcrumbs.shift if @breadcrumbs.length > 20
24
+ end
25
+
26
+ def apply_context(context)
27
+ @tags.merge!(context[:tags] || {})
28
+ @user.merge!(context[:user] || {})
29
+ @extra.merge!(context[:extra] || {})
30
+
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
39
+ end
40
+
41
+ def clear
42
+ @breadcrumbs.clear
43
+ @tags.clear
44
+ @user.clear
45
+ @extra.clear
46
+ end
47
+ end
48
+
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 = []
55
+ end
56
+
57
+ def tags
58
+ @tags ||= merge_hash_contexts(:tags)
59
+ end
60
+
61
+ def user
62
+ @user ||= merge_hash_contexts(:user)
63
+ end
64
+
65
+ def extra
66
+ @extra ||= merge_hash_contexts(:extra)
67
+ end
68
+
69
+ def breadcrumbs
70
+ @breadcrumbs ||= merge_breadcrumbs
71
+ end
72
+
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)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/parameter_filter"
4
+
5
+ module Lapsoss
6
+ class Scrubber
7
+ DEFAULT_SCRUB_FIELDS = %w[
8
+ password passwd pwd secret token key api_key access_token
9
+ authorization auth_token session_token csrf_token
10
+ credit_card cc_number card_number ssn social_security_number
11
+ phone mobile email_address
12
+ ].freeze
13
+
14
+ PROTECTED_EVENT_FIELDS = %w[
15
+ type timestamp level message exception environment context
16
+ ].freeze
17
+
18
+ ATTACHMENT_CLASSES = %w[
19
+ ActionDispatch::Http::UploadedFile
20
+ Rack::Multipart::UploadedFile
21
+ Tempfile
22
+ ].freeze
23
+
24
+ def initialize(config = {})
25
+ @rails_parameter_filter = rails_parameter_filter
26
+
27
+ # Only use custom scrubbing if Rails parameter filter is not available
28
+ unless @rails_parameter_filter
29
+ @scrub_fields = Array(config[:scrub_fields] || DEFAULT_SCRUB_FIELDS)
30
+ @scrub_all = config[:scrub_all] || false
31
+ @whitelist_fields = Array(config[:whitelist_fields] || [])
32
+ @randomize_scrub_length = config[:randomize_scrub_length] || false
33
+ @scrub_value = config[:scrub_value] || "**SCRUBBED**"
34
+ end
35
+ end
36
+
37
+ def scrub(data)
38
+ return data if data.nil?
39
+
40
+ # If Rails parameter filter is available, use it exclusively
41
+ if @rails_parameter_filter
42
+ return @rails_parameter_filter.filter(data)
43
+ end
44
+
45
+ # Fallback to custom scrubbing logic only if Rails filter is not available
46
+ @scrubbed_objects = {}.compare_by_identity
47
+ scrub_recursive(data)
48
+ end
49
+
50
+ private
51
+
52
+ def scrub_recursive(data)
53
+ return data if @scrubbed_objects.key?(data)
54
+
55
+ @scrubbed_objects[data] = true
56
+
57
+ case data
58
+ when Hash
59
+ scrub_hash(data)
60
+ when Array
61
+ scrub_array(data)
62
+ else
63
+ scrub_value(data)
64
+ end
65
+ end
66
+
67
+ def scrub_hash(hash)
68
+ hash.each_with_object({}) do |(key, value), result|
69
+ key_string = key.to_s.downcase
70
+
71
+ if should_scrub_field?(key_string)
72
+ result[key] = generate_scrub_value(value)
73
+ elsif value.is_a?(Hash)
74
+ result[key] = scrub_recursive(value)
75
+ elsif value.is_a?(Array)
76
+ result[key] = scrub_array(value)
77
+ else
78
+ result[key] = scrub_value(value)
79
+ end
80
+ end
81
+ end
82
+
83
+ def scrub_array(array)
84
+ array.map do |item|
85
+ scrub_recursive(item)
86
+ end
87
+ end
88
+
89
+ def scrub_value(value)
90
+ if attachment_value?(value)
91
+ scrub_attachment(value)
92
+ else
93
+ value
94
+ end
95
+ end
96
+
97
+ def should_scrub_field?(field_name)
98
+ return false if whitelisted_field?(field_name)
99
+ return false if protected_event_field?(field_name)
100
+ return true if @scrub_all
101
+
102
+ @scrub_fields.any? { |pattern| field_matches_pattern?(field_name, pattern) }
103
+ end
104
+
105
+ def field_matches_pattern?(field_name, pattern)
106
+ if pattern.is_a?(Regexp)
107
+ pattern.match?(field_name)
108
+ else
109
+ field_name.include?(pattern.to_s.downcase)
110
+ end
111
+ end
112
+
113
+ def whitelisted_field?(field_name)
114
+ @whitelist_fields.any? { |pattern| field_matches_pattern?(field_name, pattern) }
115
+ end
116
+
117
+ def protected_event_field?(field_name)
118
+ PROTECTED_EVENT_FIELDS.include?(field_name.to_s)
119
+ end
120
+
121
+ def whitelisted_value?(value)
122
+ # Basic implementation - could be extended
123
+ false
124
+ end
125
+
126
+ def attachment_value?(value)
127
+ return false unless value.respond_to?(:class)
128
+
129
+ ATTACHMENT_CLASSES.include?(value.class.name)
130
+ end
131
+
132
+ def scrub_attachment(attachment)
133
+ {
134
+ __attachment__: true,
135
+ content_type: safe_call(attachment, :content_type),
136
+ original_filename: safe_call(attachment, :original_filename),
137
+ size: safe_call(attachment, :size) || safe_call(attachment, :tempfile, :size)
138
+ }
139
+ rescue => e
140
+ { __attachment__: true, error: "Failed to extract attachment info: #{e.message}" }
141
+ end
142
+
143
+ def safe_call(object, *methods)
144
+ methods.reduce(object) do |obj, method|
145
+ obj.respond_to?(method) ? obj.public_send(method) : nil
146
+ end
147
+ end
148
+
149
+ def generate_scrub_value(original_value)
150
+ if @randomize_scrub_length
151
+ "*" * rand(6..12)
152
+ else
153
+ @scrub_value
154
+ end
155
+ end
156
+
157
+ def rails_parameter_filter
158
+ return nil unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
159
+ return nil unless defined?(ActiveSupport::ParameterFilter)
160
+
161
+ filter_params = Rails.application.config.filter_parameters
162
+ return nil if filter_params.empty?
163
+
164
+ ActiveSupport::ParameterFilter.new(filter_params)
165
+ rescue StandardError
166
+ # Fallback silently if Rails config is not available
167
+ nil
168
+ end
169
+ end
170
+ end