lapsoss 0.2.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.
- checksums.yaml +4 -4
- data/README.md +153 -733
- data/lib/lapsoss/adapters/appsignal_adapter.rb +22 -22
- data/lib/lapsoss/adapters/base.rb +0 -3
- data/lib/lapsoss/adapters/insight_hub_adapter.rb +108 -104
- data/lib/lapsoss/adapters/logger_adapter.rb +1 -1
- data/lib/lapsoss/adapters/rollbar_adapter.rb +108 -68
- data/lib/lapsoss/adapters/sentry_adapter.rb +24 -24
- data/lib/lapsoss/backtrace_frame.rb +37 -206
- data/lib/lapsoss/backtrace_frame_factory.rb +228 -0
- data/lib/lapsoss/backtrace_processor.rb +26 -23
- data/lib/lapsoss/client.rb +2 -4
- data/lib/lapsoss/configuration.rb +28 -32
- data/lib/lapsoss/current.rb +10 -2
- data/lib/lapsoss/event.rb +28 -5
- data/lib/lapsoss/exception_backtrace_frame.rb +39 -0
- data/lib/lapsoss/exclusion_configuration.rb +30 -0
- data/lib/lapsoss/exclusion_filter.rb +0 -273
- data/lib/lapsoss/exclusion_presets.rb +249 -0
- data/lib/lapsoss/fingerprinter.rb +28 -28
- data/lib/lapsoss/http_client.rb +8 -8
- data/lib/lapsoss/merged_scope.rb +63 -0
- data/lib/lapsoss/middleware/base.rb +15 -0
- data/lib/lapsoss/middleware/conditional_filter.rb +18 -0
- data/lib/lapsoss/middleware/event_enricher.rb +19 -0
- data/lib/lapsoss/middleware/event_transformer.rb +19 -0
- data/lib/lapsoss/middleware/exception_filter.rb +43 -0
- data/lib/lapsoss/middleware/metrics_collector.rb +44 -0
- data/lib/lapsoss/middleware/rate_limiter.rb +31 -0
- data/lib/lapsoss/middleware/release_tracker.rb +117 -0
- data/lib/lapsoss/middleware/sample_filter.rb +23 -0
- data/lib/lapsoss/middleware/sampling_middleware.rb +18 -0
- data/lib/lapsoss/middleware/user_context_enhancer.rb +46 -0
- data/lib/lapsoss/middleware.rb +0 -339
- data/lib/lapsoss/pipeline.rb +0 -68
- data/lib/lapsoss/pipeline_builder.rb +69 -0
- data/lib/lapsoss/rails_error_subscriber.rb +42 -0
- data/lib/lapsoss/rails_middleware.rb +78 -0
- data/lib/lapsoss/railtie.rb +22 -50
- data/lib/lapsoss/registry.rb +18 -5
- data/lib/lapsoss/release_providers.rb +110 -0
- data/lib/lapsoss/release_tracker.rb +159 -232
- data/lib/lapsoss/sampling/adaptive_sampler.rb +46 -0
- data/lib/lapsoss/sampling/base.rb +11 -0
- data/lib/lapsoss/sampling/composite_sampler.rb +26 -0
- data/lib/lapsoss/sampling/consistent_hash_sampler.rb +30 -0
- data/lib/lapsoss/sampling/exception_type_sampler.rb +44 -0
- data/lib/lapsoss/sampling/health_based_sampler.rb +19 -0
- data/lib/lapsoss/sampling/rate_limiter.rb +32 -0
- data/lib/lapsoss/sampling/sampling_factory.rb +69 -0
- data/lib/lapsoss/sampling/time_based_sampler.rb +44 -0
- data/lib/lapsoss/sampling/uniform_sampler.rb +15 -0
- data/lib/lapsoss/sampling/user_based_sampler.rb +42 -0
- data/lib/lapsoss/sampling.rb +0 -322
- data/lib/lapsoss/scope.rb +12 -48
- data/lib/lapsoss/scrubber.rb +7 -7
- data/lib/lapsoss/user_context.rb +30 -203
- data/lib/lapsoss/user_context_integrations.rb +39 -0
- data/lib/lapsoss/user_context_middleware.rb +50 -0
- data/lib/lapsoss/user_context_provider.rb +93 -0
- data/lib/lapsoss/utils.rb +13 -0
- data/lib/lapsoss/validators.rb +15 -15
- data/lib/lapsoss/version.rb +1 -1
- data/lib/lapsoss.rb +3 -3
- metadata +54 -5
data/lib/lapsoss/http_client.rb
CHANGED
@@ -1,9 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
6
|
-
require
|
3
|
+
require "faraday"
|
4
|
+
require "faraday/retry"
|
5
|
+
require "json"
|
6
|
+
require "zlib"
|
7
7
|
|
8
8
|
module Lapsoss
|
9
9
|
# HTTP client wrapper using Faraday with retry logic
|
@@ -56,7 +56,7 @@ module Lapsoss
|
|
56
56
|
conn.ssl.verify = @config[:ssl_verify] if @config.key?(:ssl_verify)
|
57
57
|
|
58
58
|
# Set user agent
|
59
|
-
conn.headers[
|
59
|
+
conn.headers["User-Agent"] = USER_AGENT
|
60
60
|
|
61
61
|
# Auto-detect and use appropriate adapter
|
62
62
|
conn.adapter detect_optimal_adapter
|
@@ -78,7 +78,7 @@ module Lapsoss
|
|
78
78
|
end
|
79
79
|
|
80
80
|
def async_adapter_available?
|
81
|
-
require
|
81
|
+
require "async/http/faraday"
|
82
82
|
true
|
83
83
|
rescue LoadError
|
84
84
|
false
|
@@ -105,8 +105,8 @@ module Lapsoss
|
|
105
105
|
interval: @config[:initial_backoff] || 1.0,
|
106
106
|
max_interval: @config[:max_backoff] || 64.0,
|
107
107
|
backoff_factor: @config[:backoff_multiplier] || 2.0,
|
108
|
-
retry_statuses: [408, 429, 500, 502, 503, 504],
|
109
|
-
methods: [:post]
|
108
|
+
retry_statuses: [ 408, 429, 500, 502, 503, 504 ],
|
109
|
+
methods: [ :post ]
|
110
110
|
}
|
111
111
|
end
|
112
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,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
|