debugbundle 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/Gemfile +17 -0
- data/Makefile +43 -0
- data/README.md +168 -0
- data/debugbundle.gemspec +30 -0
- data/lib/debugbundle/client.rb +724 -0
- data/lib/debugbundle/config.rb +144 -0
- data/lib/debugbundle/logging.rb +77 -0
- data/lib/debugbundle/rack/middleware.rb +94 -0
- data/lib/debugbundle/rack/relay_middleware.rb +37 -0
- data/lib/debugbundle/rails/railtie.rb +35 -0
- data/lib/debugbundle/rails/relay_endpoint.rb +100 -0
- data/lib/debugbundle/rails.rb +10 -0
- data/lib/debugbundle/redaction.rb +151 -0
- data/lib/debugbundle/relay/handler.rb +231 -0
- data/lib/debugbundle/relay.rb +4 -0
- data/lib/debugbundle/remote_config.rb +153 -0
- data/lib/debugbundle/runtime.rb +22 -0
- data/lib/debugbundle/sidekiq/server_middleware.rb +34 -0
- data/lib/debugbundle/suppression.rb +121 -0
- data/lib/debugbundle/transport.rb +190 -0
- data/lib/debugbundle/trigger_token.rb +122 -0
- data/lib/debugbundle/version.rb +5 -0
- data/lib/debugbundle.rb +93 -0
- data/spec/client_spec.rb +236 -0
- data/spec/debugbundle_spec.rb +54 -0
- data/spec/file_transport_spec.rb +54 -0
- data/spec/logger_integration_spec.rb +118 -0
- data/spec/rack_integration_spec.rb +44 -0
- data/spec/rack_middleware_spec.rb +206 -0
- data/spec/rails_railtie_spec.rb +96 -0
- data/spec/rails_relay_spec.rb +121 -0
- data/spec/redaction_spec.rb +42 -0
- data/spec/relay_spec.rb +178 -0
- data/spec/remote_config_spec.rb +402 -0
- data/spec/sidekiq_integration_spec.rb +66 -0
- data/spec/sidekiq_middleware_spec.rb +50 -0
- data/spec/spec_helper.rb +20 -0
- data/spec/suppression_spec.rb +16 -0
- metadata +113 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
class Config
|
|
5
|
+
DEFAULT_ENDPOINT = 'https://api.debugbundle.com/v1/events'
|
|
6
|
+
DEFAULT_PROJECT_MODE = :connected
|
|
7
|
+
DEFAULT_BATCH_SIZE = 25
|
|
8
|
+
DEFAULT_FLUSH_INTERVAL = 5
|
|
9
|
+
DEFAULT_SAMPLE_RATE = 1.0
|
|
10
|
+
DEFAULT_LOG_LEVEL = :warning
|
|
11
|
+
DEFAULT_LOCAL_EVENTS_DIR = '.debugbundle/local/events'
|
|
12
|
+
DEFAULT_SPOOL_DIR = '.debugbundle/local/browser-relay-spool'
|
|
13
|
+
DEFAULT_RELAY_RATE_LIMIT_PER_MINUTE = 60
|
|
14
|
+
DEFAULT_MAX_PROBE_LABELS = 50
|
|
15
|
+
DEFAULT_MAX_PROBE_ENTRIES_PER_LABEL = 10
|
|
16
|
+
DEFAULT_PROBE_FLUSH_ON_ERROR = true
|
|
17
|
+
DEFAULT_PROBES_POLL_INTERVAL = 60
|
|
18
|
+
DEFAULT_REDACT_FIELDS = [].freeze
|
|
19
|
+
|
|
20
|
+
VALID_PROJECT_MODES = %i[connected local_only].freeze
|
|
21
|
+
VALID_STATUSES = %i[healthy degraded disconnected].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :project_token,
|
|
24
|
+
:enabled,
|
|
25
|
+
:environment,
|
|
26
|
+
:service,
|
|
27
|
+
:endpoint,
|
|
28
|
+
:project_mode,
|
|
29
|
+
:local_events_dir,
|
|
30
|
+
:spool_dir,
|
|
31
|
+
:batch_size,
|
|
32
|
+
:flush_interval,
|
|
33
|
+
:sample_rate,
|
|
34
|
+
:log_level,
|
|
35
|
+
:relay_enabled,
|
|
36
|
+
:relay_rate_limit_per_minute,
|
|
37
|
+
:relay_durable_write,
|
|
38
|
+
:redact_fields,
|
|
39
|
+
:max_probe_labels,
|
|
40
|
+
:max_probe_entries_per_label,
|
|
41
|
+
:probe_flush_on_error,
|
|
42
|
+
:probes_poll_interval
|
|
43
|
+
|
|
44
|
+
def initialize(
|
|
45
|
+
project_token: nil,
|
|
46
|
+
enabled: true,
|
|
47
|
+
environment: nil,
|
|
48
|
+
service: nil,
|
|
49
|
+
endpoint: DEFAULT_ENDPOINT,
|
|
50
|
+
project_mode: DEFAULT_PROJECT_MODE,
|
|
51
|
+
local_events_dir: DEFAULT_LOCAL_EVENTS_DIR,
|
|
52
|
+
spool_dir: DEFAULT_SPOOL_DIR,
|
|
53
|
+
batch_size: DEFAULT_BATCH_SIZE,
|
|
54
|
+
flush_interval: DEFAULT_FLUSH_INTERVAL,
|
|
55
|
+
sample_rate: DEFAULT_SAMPLE_RATE,
|
|
56
|
+
log_level: DEFAULT_LOG_LEVEL,
|
|
57
|
+
relay_enabled: true,
|
|
58
|
+
relay_rate_limit_per_minute: DEFAULT_RELAY_RATE_LIMIT_PER_MINUTE,
|
|
59
|
+
relay_durable_write: true,
|
|
60
|
+
redact_fields: DEFAULT_REDACT_FIELDS,
|
|
61
|
+
max_probe_labels: DEFAULT_MAX_PROBE_LABELS,
|
|
62
|
+
max_probe_entries_per_label: DEFAULT_MAX_PROBE_ENTRIES_PER_LABEL,
|
|
63
|
+
probe_flush_on_error: DEFAULT_PROBE_FLUSH_ON_ERROR,
|
|
64
|
+
probes_poll_interval: DEFAULT_PROBES_POLL_INTERVAL
|
|
65
|
+
)
|
|
66
|
+
@project_token = project_token
|
|
67
|
+
@enabled = enabled
|
|
68
|
+
@environment = environment
|
|
69
|
+
@service = service
|
|
70
|
+
@endpoint = endpoint
|
|
71
|
+
@project_mode = normalize_project_mode(project_mode)
|
|
72
|
+
@local_events_dir = local_events_dir
|
|
73
|
+
@spool_dir = spool_dir
|
|
74
|
+
@batch_size = normalize_positive_integer(batch_size, DEFAULT_BATCH_SIZE)
|
|
75
|
+
@flush_interval = normalize_positive_number(flush_interval, DEFAULT_FLUSH_INTERVAL)
|
|
76
|
+
@sample_rate = normalize_sample_rate(sample_rate)
|
|
77
|
+
@log_level = log_level
|
|
78
|
+
@relay_enabled = relay_enabled
|
|
79
|
+
@relay_rate_limit_per_minute = normalize_positive_integer(
|
|
80
|
+
relay_rate_limit_per_minute,
|
|
81
|
+
DEFAULT_RELAY_RATE_LIMIT_PER_MINUTE
|
|
82
|
+
)
|
|
83
|
+
@relay_durable_write = relay_durable_write
|
|
84
|
+
@redact_fields = normalize_redact_fields(redact_fields)
|
|
85
|
+
@max_probe_labels = normalize_positive_integer(max_probe_labels, DEFAULT_MAX_PROBE_LABELS)
|
|
86
|
+
@max_probe_entries_per_label = normalize_positive_integer(
|
|
87
|
+
max_probe_entries_per_label,
|
|
88
|
+
DEFAULT_MAX_PROBE_ENTRIES_PER_LABEL
|
|
89
|
+
)
|
|
90
|
+
@probe_flush_on_error = probe_flush_on_error
|
|
91
|
+
@probes_poll_interval = normalize_positive_number(probes_poll_interval, DEFAULT_PROBES_POLL_INTERVAL)
|
|
92
|
+
freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def enabled?
|
|
96
|
+
enabled
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def configured?
|
|
100
|
+
!project_token.to_s.empty?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def normalize_project_mode(value)
|
|
106
|
+
normalized = value.to_s.strip.downcase.tr('-', '_').to_sym
|
|
107
|
+
return normalized if VALID_PROJECT_MODES.include?(normalized)
|
|
108
|
+
|
|
109
|
+
DEFAULT_PROJECT_MODE
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def normalize_positive_integer(value, fallback)
|
|
113
|
+
integer = Integer(value)
|
|
114
|
+
integer.positive? ? integer : fallback
|
|
115
|
+
rescue ArgumentError, TypeError
|
|
116
|
+
fallback
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def normalize_positive_number(value, fallback)
|
|
120
|
+
number = Float(value)
|
|
121
|
+
number.positive? ? number : fallback
|
|
122
|
+
rescue ArgumentError, TypeError
|
|
123
|
+
fallback
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def normalize_sample_rate(value)
|
|
127
|
+
number = Float(value)
|
|
128
|
+
number.clamp(0.0, 1.0)
|
|
129
|
+
rescue ArgumentError, TypeError
|
|
130
|
+
DEFAULT_SAMPLE_RATE
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def normalize_redact_fields(value)
|
|
134
|
+
Array(value).filter_map do |entry|
|
|
135
|
+
case entry
|
|
136
|
+
when String, Symbol
|
|
137
|
+
entry.to_s
|
|
138
|
+
when Regexp
|
|
139
|
+
entry.source
|
|
140
|
+
end
|
|
141
|
+
end.freeze
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module DebugBundle
|
|
6
|
+
module Logging
|
|
7
|
+
RECURSION_GUARD_KEY = :__debugbundle_logger_capture_active__
|
|
8
|
+
LOGGER_LEVEL_NAMES = {
|
|
9
|
+
::Logger::DEBUG => :debug,
|
|
10
|
+
::Logger::INFO => :info,
|
|
11
|
+
::Logger::WARN => :warning,
|
|
12
|
+
::Logger::ERROR => :error,
|
|
13
|
+
::Logger::FATAL => :fatal,
|
|
14
|
+
::Logger::UNKNOWN => :critical
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.install_stdlib_logger(logger, client:)
|
|
18
|
+
interceptor = Module.new do
|
|
19
|
+
define_method(:add) do |severity, message = nil, progname = nil, &block|
|
|
20
|
+
was_capturing = Thread.current[RECURSION_GUARD_KEY]
|
|
21
|
+
unless was_capturing
|
|
22
|
+
Thread.current[RECURSION_GUARD_KEY] = true
|
|
23
|
+
resolved_message = message
|
|
24
|
+
resolved_message = block.call if resolved_message.nil? && block
|
|
25
|
+
resolved_message = progname if resolved_message.nil?
|
|
26
|
+
|
|
27
|
+
client.capture_log(
|
|
28
|
+
resolved_message.to_s,
|
|
29
|
+
level: LOGGER_LEVEL_NAMES.fetch(severity || ::Logger::UNKNOWN, :warning),
|
|
30
|
+
context: { logger_name: logger.progname }
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
super(severity, message, progname, &block)
|
|
35
|
+
ensure
|
|
36
|
+
Thread.current[RECURSION_GUARD_KEY] = was_capturing
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
logger.singleton_class.prepend(interceptor)
|
|
41
|
+
interceptor
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.install_semantic_logger(client:)
|
|
45
|
+
return nil unless defined?(::SemanticLogger)
|
|
46
|
+
return nil unless ::SemanticLogger.respond_to?(:add_appender)
|
|
47
|
+
|
|
48
|
+
appender = SemanticLoggerAppender.new(client: client)
|
|
49
|
+
::SemanticLogger.add_appender(appender: appender)
|
|
50
|
+
appender
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class SemanticLoggerAppender
|
|
54
|
+
def initialize(client: DebugBundle.client)
|
|
55
|
+
@client = client
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def log(log)
|
|
59
|
+
was_capturing = Thread.current[RECURSION_GUARD_KEY]
|
|
60
|
+
return if was_capturing
|
|
61
|
+
|
|
62
|
+
Thread.current[RECURSION_GUARD_KEY] = true
|
|
63
|
+
@client.capture_log(
|
|
64
|
+
log.message,
|
|
65
|
+
level: log.level || :info,
|
|
66
|
+
context: {
|
|
67
|
+
logger_name: log.name,
|
|
68
|
+
payload: log.payload,
|
|
69
|
+
tags: log.tags
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
ensure
|
|
73
|
+
Thread.current[RECURSION_GUARD_KEY] = was_capturing
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module DebugBundle
|
|
6
|
+
module Rack
|
|
7
|
+
class Middleware
|
|
8
|
+
def initialize(app, client: DebugBundle.client)
|
|
9
|
+
@app = app
|
|
10
|
+
@client = client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
request_context = build_request_context(env)
|
|
15
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
16
|
+
@client.with_request_trigger(request_context.fetch(:request)) do
|
|
17
|
+
status, headers, body = @app.call(env)
|
|
18
|
+
duration_ms = elapsed_ms(started_at)
|
|
19
|
+
|
|
20
|
+
@client.capture_request(
|
|
21
|
+
request_context.fetch(:request),
|
|
22
|
+
{ status_code: status, headers: headers.to_h },
|
|
23
|
+
context: request_context.merge(duration_ms: duration_ms, route_template: route_template(env))
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
[status, headers, body]
|
|
27
|
+
end
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
duration_ms = elapsed_ms(started_at)
|
|
30
|
+
@client.capture_exception(
|
|
31
|
+
e,
|
|
32
|
+
context: request_context.merge(
|
|
33
|
+
response: { status_code: 500, headers: {} },
|
|
34
|
+
duration_ms: duration_ms,
|
|
35
|
+
route_template: route_template(env)
|
|
36
|
+
),
|
|
37
|
+
handled: false
|
|
38
|
+
)
|
|
39
|
+
raise
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def build_request_context(env)
|
|
45
|
+
{
|
|
46
|
+
request: {
|
|
47
|
+
method: env['REQUEST_METHOD'],
|
|
48
|
+
path: env['PATH_INFO'],
|
|
49
|
+
query: parse_query(env['QUERY_STRING']),
|
|
50
|
+
headers: request_headers(env),
|
|
51
|
+
body: {}
|
|
52
|
+
},
|
|
53
|
+
request_id: env['action_dispatch.request_id'] || env['HTTP_X_REQUEST_ID'],
|
|
54
|
+
trace_id: env['HTTP_X_DEBUGBUNDLE_TRACE_ID']
|
|
55
|
+
}.merge(rails_metadata(env))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def request_headers(env)
|
|
59
|
+
env.each_with_object({}) do |(key, value), headers|
|
|
60
|
+
next unless key.start_with?('HTTP_') || %w[CONTENT_TYPE ACCEPT].include?(key)
|
|
61
|
+
|
|
62
|
+
normalized_key = key.sub(/^HTTP_/, '').downcase.tr('_', '-')
|
|
63
|
+
headers[normalized_key] = value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_query(query_string)
|
|
68
|
+
CGI.parse(query_string.to_s).transform_values do |values|
|
|
69
|
+
values.length == 1 ? values.first : values
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def route_template(env)
|
|
74
|
+
env['debugbundle.route_template'] || env['action_dispatch.route_uri_pattern']
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rails_metadata(env)
|
|
78
|
+
parameters = env['action_dispatch.request.parameters'] || {}
|
|
79
|
+
metadata = {}
|
|
80
|
+
if env.key?('action_dispatch.request_id') || env.key?('action_dispatch.route_uri_pattern')
|
|
81
|
+
metadata[:framework] = 'rails'
|
|
82
|
+
end
|
|
83
|
+
metadata[:route_template] = route_template(env) if route_template(env)
|
|
84
|
+
metadata[:controller] = parameters['controller'] if parameters['controller']
|
|
85
|
+
metadata[:action] = parameters['action'] if parameters['action']
|
|
86
|
+
metadata
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def elapsed_ms(started_at)
|
|
90
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
5
|
+
module DebugBundle
|
|
6
|
+
module Rack
|
|
7
|
+
class RelayMiddleware
|
|
8
|
+
def initialize(app = nil, handler: DebugBundle::Relay::Handler.new)
|
|
9
|
+
@app = app
|
|
10
|
+
@handler = handler
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
response = @handler.handle(
|
|
15
|
+
method: env['REQUEST_METHOD'],
|
|
16
|
+
headers: relay_headers(env),
|
|
17
|
+
body: env.fetch('rack.input', StringIO.new).read,
|
|
18
|
+
ip_address: env['REMOTE_ADDR']
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
body = response.body ? JSON.generate(response.body) : ''
|
|
22
|
+
[response.status, { 'Content-Type' => 'application/json' }, [body]]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def relay_headers(env)
|
|
28
|
+
env.each_with_object({}) do |(key, value), headers|
|
|
29
|
+
next unless key.start_with?('HTTP_') || %w[CONTENT_TYPE HOST REFERER].include?(key)
|
|
30
|
+
|
|
31
|
+
normalized_key = key.sub(/^HTTP_/, '').downcase.tr('_', '-')
|
|
32
|
+
headers[normalized_key] = value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if defined?(Rails::Railtie)
|
|
4
|
+
module DebugBundle
|
|
5
|
+
module Rails
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
config.debugbundle = ActiveSupport::OrderedOptions.new
|
|
8
|
+
|
|
9
|
+
initializer 'debugbundle.configure' do |app|
|
|
10
|
+
options = app.config.debugbundle
|
|
11
|
+
next if options.respond_to?(:enabled) && options.enabled == false
|
|
12
|
+
|
|
13
|
+
client = DebugBundle.init(
|
|
14
|
+
project_token: options.project_token || ENV.fetch('DEBUGBUNDLE_TOKEN', nil),
|
|
15
|
+
service: options.service || app.class.module_parent_name.underscore.tr('_', '-'),
|
|
16
|
+
environment: options.environment || ::Rails.env,
|
|
17
|
+
project_mode: options.project_mode || :connected,
|
|
18
|
+
local_events_dir: options.local_events_dir || DebugBundle::Config::DEFAULT_LOCAL_EVENTS_DIR,
|
|
19
|
+
endpoint: options.endpoint || DebugBundle::Config::DEFAULT_ENDPOINT,
|
|
20
|
+
redact_fields: Array(options.redact_fields) + Array(app.config.filter_parameters)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app.middleware.use(DebugBundle::Rack::Middleware, client: client)
|
|
24
|
+
if DebugBundle::Rails.relay_route_enabled?(app)
|
|
25
|
+
app.routes.append do
|
|
26
|
+
post DebugBundle::Rails.relay_path(app), to: DebugBundle::Rails::RelayEndpoint.new(app: app)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
DebugBundle.capture_logger(::Rails.logger) if ::Rails.logger
|
|
30
|
+
DebugBundle.capture_semantic_logger if defined?(::SemanticLogger)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
module Rails
|
|
5
|
+
class RelayEndpoint
|
|
6
|
+
def initialize(app:, handler: nil)
|
|
7
|
+
@app = app
|
|
8
|
+
@handler = handler
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(env)
|
|
12
|
+
middleware.call(env)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def middleware
|
|
18
|
+
@middleware ||= DebugBundle::Rack::RelayMiddleware.new(nil, handler: @handler || DebugBundle::Rails.build_relay_handler(@app))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.build_relay_handler(app)
|
|
23
|
+
options = relay_options(app)
|
|
24
|
+
return options.relay_handler if relay_option_present?(options, :relay_handler)
|
|
25
|
+
|
|
26
|
+
Relay::Handler.new(
|
|
27
|
+
project_mode: relay_option(options, :project_mode, :connected),
|
|
28
|
+
project_token: relay_option(options, :project_token, ENV.fetch('DEBUGBUNDLE_TOKEN', nil)),
|
|
29
|
+
endpoint: relay_option(options, :endpoint, DebugBundle::Config::DEFAULT_ENDPOINT),
|
|
30
|
+
local_events_dir: relay_option(options, :local_events_dir, DebugBundle::Config::DEFAULT_LOCAL_EVENTS_DIR),
|
|
31
|
+
spool_dir: relay_option(options, :spool_dir, DebugBundle::Config::DEFAULT_SPOOL_DIR),
|
|
32
|
+
durable_write: relay_durable_write(options),
|
|
33
|
+
service: relay_service_name(app, options),
|
|
34
|
+
environment: relay_environment_name(options),
|
|
35
|
+
allowed_origins: relay_option(options, :relay_allowed_origins, nil),
|
|
36
|
+
max_body_bytes: relay_option(options, :relay_max_body_bytes, Relay::DEFAULT_MAX_BODY_BYTES),
|
|
37
|
+
rate_limit_per_minute: relay_rate_limit(options),
|
|
38
|
+
rate_limit_store: relay_option(options, :relay_rate_limit_store, nil),
|
|
39
|
+
forward_transport: relay_option(options, :relay_forward_transport, nil)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.relay_route_enabled?(app)
|
|
44
|
+
options = relay_options(app)
|
|
45
|
+
relay_option(options, :relay_enabled, true) != false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.relay_path(app)
|
|
49
|
+
options = relay_options(app)
|
|
50
|
+
path = relay_option(options, :relay_path, '').to_s
|
|
51
|
+
path.empty? ? '/debugbundle/browser' : path
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.relay_options(app)
|
|
55
|
+
return nil unless app.respond_to?(:config)
|
|
56
|
+
return nil unless app.config.respond_to?(:debugbundle)
|
|
57
|
+
|
|
58
|
+
app.config.debugbundle
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.relay_durable_write(options)
|
|
62
|
+
relay_option(options, :relay_durable_write, true) != false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.relay_rate_limit(options)
|
|
66
|
+
relay_option(options, :relay_rate_limit_per_minute, Relay::DEFAULT_RATE_LIMIT_PER_MINUTE)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.relay_service_name(app, options)
|
|
70
|
+
service_name = relay_option(options, :service, nil)
|
|
71
|
+
return service_name if service_name && !service_name.to_s.empty?
|
|
72
|
+
|
|
73
|
+
if app.class.respond_to?(:module_parent_name)
|
|
74
|
+
app_name = app.class.module_parent_name.to_s
|
|
75
|
+
return app_name.underscore.tr('_', '-') unless app_name.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Client::DEFAULT_SERVICE_NAME
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.relay_environment_name(options)
|
|
82
|
+
environment_name = relay_option(options, :environment, nil)
|
|
83
|
+
return environment_name if environment_name && !environment_name.to_s.empty?
|
|
84
|
+
return ::Rails.env if defined?(::Rails)
|
|
85
|
+
|
|
86
|
+
Client::DEFAULT_ENVIRONMENT
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.relay_option(options, name, default)
|
|
90
|
+
return default if options.nil? || !options.respond_to?(name)
|
|
91
|
+
|
|
92
|
+
value = options.public_send(name)
|
|
93
|
+
value.nil? ? default : value
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.relay_option_present?(options, name)
|
|
97
|
+
!options.nil? && options.respond_to?(name) && !options.public_send(name).nil?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module DebugBundle
|
|
6
|
+
module Redaction
|
|
7
|
+
REDACTED_VALUE = '[REDACTED]'
|
|
8
|
+
CIRCULAR_VALUE = '[Circular]'
|
|
9
|
+
TRUNCATED_DEPTH_VALUE = '[Truncated:depth]'
|
|
10
|
+
TRUNCATED_COLLECTION_VALUE = '[Truncated:collection]'
|
|
11
|
+
DEFAULT_MAX_DEPTH = 5
|
|
12
|
+
DEFAULT_MAX_STRING_LENGTH = 1_024
|
|
13
|
+
DEFAULT_MAX_ARRAY_LENGTH = 50
|
|
14
|
+
DEFAULT_MAX_HASH_KEYS = 50
|
|
15
|
+
DEFAULT_SENSITIVE_FIELDS = %w[
|
|
16
|
+
password
|
|
17
|
+
secret
|
|
18
|
+
token
|
|
19
|
+
api_key
|
|
20
|
+
apikey
|
|
21
|
+
access_token
|
|
22
|
+
refresh_token
|
|
23
|
+
private_key
|
|
24
|
+
passwd
|
|
25
|
+
card_number
|
|
26
|
+
credit_card
|
|
27
|
+
cvv
|
|
28
|
+
cvc
|
|
29
|
+
pin
|
|
30
|
+
expiry
|
|
31
|
+
phone
|
|
32
|
+
bearer
|
|
33
|
+
session_id
|
|
34
|
+
otp
|
|
35
|
+
verification_code
|
|
36
|
+
authorization
|
|
37
|
+
cookie
|
|
38
|
+
ssn
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
class Redactor
|
|
42
|
+
def initialize(
|
|
43
|
+
sensitive_fields: DEFAULT_SENSITIVE_FIELDS,
|
|
44
|
+
max_depth: DEFAULT_MAX_DEPTH,
|
|
45
|
+
max_string_length: DEFAULT_MAX_STRING_LENGTH,
|
|
46
|
+
max_array_length: DEFAULT_MAX_ARRAY_LENGTH,
|
|
47
|
+
max_hash_keys: DEFAULT_MAX_HASH_KEYS
|
|
48
|
+
)
|
|
49
|
+
@sensitive_terms = sensitive_fields.map { |field| compile_sensitive_term(field) }
|
|
50
|
+
@max_depth = max_depth
|
|
51
|
+
@max_string_length = max_string_length
|
|
52
|
+
@max_array_length = max_array_length
|
|
53
|
+
@max_hash_keys = max_hash_keys
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def redact_value(value)
|
|
57
|
+
sanitize(value, depth: 0, seen: {}.compare_by_identity)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def sanitize(value, depth:, seen:)
|
|
63
|
+
return TRUNCATED_DEPTH_VALUE if depth >= @max_depth
|
|
64
|
+
|
|
65
|
+
case value
|
|
66
|
+
when NilClass, TrueClass, FalseClass, Numeric
|
|
67
|
+
value
|
|
68
|
+
when String
|
|
69
|
+
truncate_string(value)
|
|
70
|
+
when Symbol
|
|
71
|
+
truncate_string(value.to_s)
|
|
72
|
+
when Time, DateTime
|
|
73
|
+
value.iso8601
|
|
74
|
+
when Array
|
|
75
|
+
return CIRCULAR_VALUE if circular?(value, seen)
|
|
76
|
+
|
|
77
|
+
mark_seen(value, seen)
|
|
78
|
+
value.first(@max_array_length).map { |item| sanitize(item, depth: depth + 1, seen: seen) }.tap do |items|
|
|
79
|
+
items << TRUNCATED_COLLECTION_VALUE if value.length > @max_array_length
|
|
80
|
+
end
|
|
81
|
+
when Hash
|
|
82
|
+
return CIRCULAR_VALUE if circular?(value, seen)
|
|
83
|
+
|
|
84
|
+
mark_seen(value, seen)
|
|
85
|
+
sanitize_hash(value, depth: depth + 1, seen: seen)
|
|
86
|
+
else
|
|
87
|
+
if value.respond_to?(:to_h)
|
|
88
|
+
sanitize(value.to_h, depth: depth + 1, seen: seen)
|
|
89
|
+
elsif value.respond_to?(:to_hash)
|
|
90
|
+
sanitize(value.to_hash, depth: depth + 1, seen: seen)
|
|
91
|
+
else
|
|
92
|
+
truncate_string(value.to_s)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def sanitize_hash(value, depth:, seen:)
|
|
98
|
+
value.each_with_index.with_object({}) do |((key, nested_value), index), result|
|
|
99
|
+
break result if index >= @max_hash_keys
|
|
100
|
+
|
|
101
|
+
key_string = key.to_s
|
|
102
|
+
result[key_string] =
|
|
103
|
+
sensitive_key?(key_string) ? REDACTED_VALUE : sanitize(nested_value, depth: depth, seen: seen)
|
|
104
|
+
end.tap do |result|
|
|
105
|
+
result['__truncated__'] = TRUNCATED_COLLECTION_VALUE if value.size > @max_hash_keys
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def truncate_string(value)
|
|
110
|
+
return value if value.length <= @max_string_length
|
|
111
|
+
|
|
112
|
+
value[0, @max_string_length] + TRUNCATED_COLLECTION_VALUE
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def sensitive_key?(key)
|
|
116
|
+
segments, joined = normalize_key(key)
|
|
117
|
+
|
|
118
|
+
@sensitive_terms.any? do |term|
|
|
119
|
+
joined == term[:joined] || contains_contiguous_segments?(segments, term[:segments])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def contains_contiguous_segments?(segments, target_segments)
|
|
124
|
+
return false if target_segments.empty? || segments.empty? || target_segments.length > segments.length
|
|
125
|
+
|
|
126
|
+
segments.each_index.any? do |index|
|
|
127
|
+
segments[index, target_segments.length] == target_segments
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def normalize_key(key)
|
|
132
|
+
underscored = key.to_s.gsub(/([a-z\d])([A-Z])/, '\\1_\\2').downcase
|
|
133
|
+
segments = underscored.split(/[^a-z0-9]+/).reject(&:empty?)
|
|
134
|
+
[segments, segments.join]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def compile_sensitive_term(term)
|
|
138
|
+
segments, joined = normalize_key(term)
|
|
139
|
+
{ segments: segments, joined: joined }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def circular?(value, seen)
|
|
143
|
+
seen.key?(value)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def mark_seen(value, seen)
|
|
147
|
+
seen[value] = true
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|