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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +855 -0
- data/lib/lapsoss/adapters/appsignal_adapter.rb +136 -0
- data/lib/lapsoss/adapters/base.rb +88 -0
- data/lib/lapsoss/adapters/insight_hub_adapter.rb +190 -0
- data/lib/lapsoss/adapters/logger_adapter.rb +67 -0
- data/lib/lapsoss/adapters/rollbar_adapter.rb +157 -0
- data/lib/lapsoss/adapters/sentry_adapter.rb +197 -0
- data/lib/lapsoss/backtrace_frame.rb +258 -0
- data/lib/lapsoss/backtrace_processor.rb +346 -0
- data/lib/lapsoss/client.rb +115 -0
- data/lib/lapsoss/configuration.rb +310 -0
- data/lib/lapsoss/current.rb +9 -0
- data/lib/lapsoss/event.rb +107 -0
- data/lib/lapsoss/exclusions.rb +429 -0
- data/lib/lapsoss/fingerprinter.rb +217 -0
- data/lib/lapsoss/http_client.rb +79 -0
- data/lib/lapsoss/middleware.rb +353 -0
- data/lib/lapsoss/pipeline.rb +131 -0
- data/lib/lapsoss/railtie.rb +72 -0
- data/lib/lapsoss/registry.rb +114 -0
- data/lib/lapsoss/release_tracker.rb +553 -0
- data/lib/lapsoss/router.rb +36 -0
- data/lib/lapsoss/sampling.rb +332 -0
- data/lib/lapsoss/scope.rb +110 -0
- data/lib/lapsoss/scrubber.rb +170 -0
- data/lib/lapsoss/user_context.rb +355 -0
- data/lib/lapsoss/validators.rb +142 -0
- data/lib/lapsoss/version.rb +5 -0
- data/lib/lapsoss.rb +76 -0
- metadata +217 -0
|
@@ -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
|