goodsorcery 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 +17 -0
- data/LICENSE +21 -0
- data/README.md +299 -0
- data/lib/generators/goodsorcery/install/credentials_api_key.rb +64 -0
- data/lib/generators/goodsorcery/install/install_generator.rb +64 -0
- data/lib/generators/goodsorcery/install/templates/initializer.rb +54 -0
- data/lib/goodsorcery/backtrace/code_context.rb +27 -0
- data/lib/goodsorcery/backtrace/line_cache.rb +57 -0
- data/lib/goodsorcery/backtrace/parser.rb +38 -0
- data/lib/goodsorcery/backtrace/paths.rb +42 -0
- data/lib/goodsorcery/bundler_plugin.rb +117 -0
- data/lib/goodsorcery/client.rb +180 -0
- data/lib/goodsorcery/configuration/validation.rb +59 -0
- data/lib/goodsorcery/configuration.rb +204 -0
- data/lib/goodsorcery/defaults.rb +40 -0
- data/lib/goodsorcery/delivery/active_job_queue.rb +71 -0
- data/lib/goodsorcery/delivery/async.rb +122 -0
- data/lib/goodsorcery/delivery/backoff.rb +20 -0
- data/lib/goodsorcery/delivery/custom.rb +27 -0
- data/lib/goodsorcery/delivery/event_queue.rb +124 -0
- data/lib/goodsorcery/delivery/sync.rb +27 -0
- data/lib/goodsorcery/delivery/test.rb +30 -0
- data/lib/goodsorcery/delivery/worker.rb +71 -0
- data/lib/goodsorcery/delivery.rb +39 -0
- data/lib/goodsorcery/delivery_job.rb +26 -0
- data/lib/goodsorcery/env_flag.rb +24 -0
- data/lib/goodsorcery/environment_gate.rb +61 -0
- data/lib/goodsorcery/errors.rb +14 -0
- data/lib/goodsorcery/event/breadcrumb.rb +32 -0
- data/lib/goodsorcery/event/builder.rb +77 -0
- data/lib/goodsorcery/event/cause_chain.rb +39 -0
- data/lib/goodsorcery/event/exception_section.rb +68 -0
- data/lib/goodsorcery/event/fields.rb +61 -0
- data/lib/goodsorcery/event/job_section.rb +34 -0
- data/lib/goodsorcery/event/request_section.rb +41 -0
- data/lib/goodsorcery/event/runtime_context.rb +52 -0
- data/lib/goodsorcery/event/tag_set.rb +41 -0
- data/lib/goodsorcery/event/user_section.rb +36 -0
- data/lib/goodsorcery/event_filter.rb +54 -0
- data/lib/goodsorcery/event_pipeline.rb +35 -0
- data/lib/goodsorcery/event_serializer.rb +25 -0
- data/lib/goodsorcery/exception_coercion.rb +18 -0
- data/lib/goodsorcery/extra_filter_probe.rb +32 -0
- data/lib/goodsorcery/ignore_list.rb +15 -0
- data/lib/goodsorcery/install_check.rb +58 -0
- data/lib/goodsorcery/integrations/action_cable.rb +75 -0
- data/lib/goodsorcery/integrations/active_job/job_details.rb +90 -0
- data/lib/goodsorcery/integrations/active_job.rb +39 -0
- data/lib/goodsorcery/integrations/rails/controller_action.rb +40 -0
- data/lib/goodsorcery/integrations/rails/controller_enricher.rb +27 -0
- data/lib/goodsorcery/integrations/rails/error_subscriber.rb +40 -0
- data/lib/goodsorcery/integrations/rails/notification_breadcrumbs.rb +82 -0
- data/lib/goodsorcery/integrations/rails/sql_statement.rb +31 -0
- data/lib/goodsorcery/integrations/rails/user_lookup.rb +39 -0
- data/lib/goodsorcery/integrations/rails.rb +56 -0
- data/lib/goodsorcery/integrations/rake.rb +68 -0
- data/lib/goodsorcery/integrations/sidekiq/job_details.rb +54 -0
- data/lib/goodsorcery/integrations/sidekiq.rb +55 -0
- data/lib/goodsorcery/integrations.rb +27 -0
- data/lib/goodsorcery/job_arguments.rb +77 -0
- data/lib/goodsorcery/key_filter.rb +99 -0
- data/lib/goodsorcery/log.rb +55 -0
- data/lib/goodsorcery/masked_key.rb +29 -0
- data/lib/goodsorcery/rack/middleware.rb +57 -0
- data/lib/goodsorcery/rack/request_details.rb +120 -0
- data/lib/goodsorcery/rack/request_enricher.rb +20 -0
- data/lib/goodsorcery/rails_filter_parameters.rb +24 -0
- data/lib/goodsorcery/railtie.rb +29 -0
- data/lib/goodsorcery/release_detector.rb +42 -0
- data/lib/goodsorcery/reported_mark.rb +19 -0
- data/lib/goodsorcery/sanitizer.rb +71 -0
- data/lib/goodsorcery/scope.rb +106 -0
- data/lib/goodsorcery/status_report.rb +50 -0
- data/lib/goodsorcery/tasks.rb +46 -0
- data/lib/goodsorcery/test_event.rb +46 -0
- data/lib/goodsorcery/text.rb +35 -0
- data/lib/goodsorcery/transport/batch_chunks.rb +40 -0
- data/lib/goodsorcery/transport/connection.rb +87 -0
- data/lib/goodsorcery/transport/result.rb +103 -0
- data/lib/goodsorcery/transport.rb +172 -0
- data/lib/goodsorcery/verification.rb +47 -0
- data/lib/goodsorcery/version.rb +6 -0
- data/lib/goodsorcery.rb +176 -0
- data/plugins.rb +6 -0
- metadata +130 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Backtrace
|
|
5
|
+
# Turns absolute paths under the app root into app-relative ones and
|
|
6
|
+
# decides whether a frame is the app's own code.
|
|
7
|
+
class Paths
|
|
8
|
+
THIRD_PARTY = %r{(?:\A|/)(?:vendor|bundle|\.bundle|gems|node_modules)/}
|
|
9
|
+
|
|
10
|
+
attr_reader :root
|
|
11
|
+
|
|
12
|
+
def initialize(root)
|
|
13
|
+
expanded = root ? File.expand_path(root.to_s) : nil
|
|
14
|
+
@root = expanded && "#{expanded.chomp("/")}/"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def relative(path)
|
|
18
|
+
return path unless @root && path.start_with?(@root)
|
|
19
|
+
|
|
20
|
+
path.delete_prefix(@root)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def in_app?(path)
|
|
24
|
+
relative_path = app_relative(path)
|
|
25
|
+
relative_path ? !THIRD_PARTY.match?(relative_path) : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def under_root?(path)
|
|
29
|
+
@root ? path.start_with?(@root) : false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def app_relative(path)
|
|
35
|
+
return nil if path.empty? || path.start_with?("<", "(")
|
|
36
|
+
return path unless path.start_with?("/")
|
|
37
|
+
|
|
38
|
+
under_root?(path) ? path.delete_prefix(@root) : nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# The Bundler plugin behind `plugin "goodsorcery"` in a Gemfile. Bundler
|
|
5
|
+
# loads plugins.rb from its own copy of the gem (.bundle/plugin), so this
|
|
6
|
+
# file must stay self-contained: no other gem files, no dependencies.
|
|
7
|
+
#
|
|
8
|
+
# After `bundle install` it runs the install check in a subprocess
|
|
9
|
+
# (`bundle exec ruby -e ...` in the app root, 30 s timeout), but only in
|
|
10
|
+
# development, outside CI, unless GOODSORCERY_SKIP_INSTALL_CHECK is set,
|
|
11
|
+
# and only when the Gemfile depends on goodsorcery.
|
|
12
|
+
module BundlerPlugin
|
|
13
|
+
GEM_NAME = "goodsorcery"
|
|
14
|
+
PREFIX = "[goodsorcery]"
|
|
15
|
+
TIMEOUT = 30
|
|
16
|
+
SCRIPT = 'require "goodsorcery/install_check"; GoodSorcery::InstallCheck.new.run'
|
|
17
|
+
|
|
18
|
+
def self.register
|
|
19
|
+
::Bundler::Plugin.add_hook(::Bundler::Plugin::Events::GEM_AFTER_INSTALL_ALL) do |dependencies|
|
|
20
|
+
after_install(dependencies)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.after_install(dependencies, env: ENV, root: nil)
|
|
25
|
+
return unless Gate.new(env, dependencies).open?
|
|
26
|
+
|
|
27
|
+
CheckProcess.new(root: root || ::Bundler.root.to_s).run
|
|
28
|
+
rescue StandardError, ScriptError => e
|
|
29
|
+
warn("#{PREFIX} install check skipped: #{e.class}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Decides whether the check runs at all.
|
|
33
|
+
class Gate
|
|
34
|
+
ENV_NAMES = %w[RAILS_ENV RACK_ENV APP_ENV].freeze
|
|
35
|
+
|
|
36
|
+
def initialize(env, dependencies)
|
|
37
|
+
@env = env
|
|
38
|
+
@dependencies = Array(dependencies)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def open?
|
|
42
|
+
development? && !set?("CI") && !set?("GOODSORCERY_SKIP_INSTALL_CHECK") && depends_on_gem?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def development?
|
|
48
|
+
ENV_NAMES.all? { |name| !set?(name) || @env[name] == "development" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def set?(name)
|
|
52
|
+
value = @env[name]
|
|
53
|
+
!(value.nil? || value.strip.empty?)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def depends_on_gem?
|
|
57
|
+
@dependencies.any? { |dependency| dependency_name(dependency) == GEM_NAME }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def dependency_name(dependency)
|
|
61
|
+
dependency.respond_to?(:name) ? dependency.name.to_s : dependency.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Runs the check command and kills it when it outlives the timeout.
|
|
66
|
+
class CheckProcess
|
|
67
|
+
POLL_SECONDS = 0.05
|
|
68
|
+
|
|
69
|
+
def initialize(root:, timeout: TIMEOUT, command: ["bundle", "exec", "ruby", "-e", SCRIPT], out: $stdout)
|
|
70
|
+
@root = root
|
|
71
|
+
@timeout = timeout
|
|
72
|
+
@command = command
|
|
73
|
+
@out = out
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# => true when the check finished in time.
|
|
77
|
+
def run
|
|
78
|
+
pid = spawn_check
|
|
79
|
+
return true if finished?(pid)
|
|
80
|
+
|
|
81
|
+
terminate(pid)
|
|
82
|
+
@out.puts("#{PREFIX} install check timed out after #{@timeout} s and was stopped")
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def spawn_check
|
|
89
|
+
options = { chdir: @root, in: File::NULL }
|
|
90
|
+
return Process.spawn(*@command, **options) unless bundler_loaded?
|
|
91
|
+
|
|
92
|
+
::Bundler.with_original_env { Process.spawn(*@command, **options) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def bundler_loaded?
|
|
96
|
+
defined?(::Bundler) && ::Bundler.respond_to?(:with_original_env)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def finished?(pid)
|
|
100
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
101
|
+
until Process.wait(pid, Process::WNOHANG)
|
|
102
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
103
|
+
|
|
104
|
+
sleep(POLL_SECONDS)
|
|
105
|
+
end
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def terminate(pid)
|
|
110
|
+
Process.kill("KILL", pid)
|
|
111
|
+
Process.wait(pid)
|
|
112
|
+
rescue SystemCallError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Wires configuration to the event pipeline and the delivery. One client
|
|
5
|
+
# per process, built lazily; GoodSorcery.configure rebuilds its parts.
|
|
6
|
+
class Client
|
|
7
|
+
NETWORK_DELIVERIES = %i[async sync active_job].freeze
|
|
8
|
+
REPORTING_KEY = :__goodsorcery_reporting
|
|
9
|
+
|
|
10
|
+
attr_reader :config, :log
|
|
11
|
+
|
|
12
|
+
def initialize(config, log)
|
|
13
|
+
@config = config
|
|
14
|
+
@log = log
|
|
15
|
+
@lock = Monitor.new
|
|
16
|
+
@gate = EnvironmentGate.new(config)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# => the event id, or nil when nothing was sent. Never raises. An error
|
|
20
|
+
# reported while a report is being built on the same thread (from
|
|
21
|
+
# before_send, the user lookup or a logger that reports errors) is
|
|
22
|
+
# dropped instead of recursing.
|
|
23
|
+
def notify(error, **options)
|
|
24
|
+
return nil unless reporting? && !Thread.current[REPORTING_KEY]
|
|
25
|
+
|
|
26
|
+
while_reporting { report(error, options) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Builds, filters and runs before_send, without any gating.
|
|
30
|
+
def prepare_event(exception, options = {})
|
|
31
|
+
scope = Scope.current
|
|
32
|
+
enrich(scope)
|
|
33
|
+
pipeline.prepare(exception, options, scope)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reporting?
|
|
37
|
+
return true if test_delivery?
|
|
38
|
+
|
|
39
|
+
@gate.enabled? && credentials_present?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def enabled?
|
|
43
|
+
@gate.enabled?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def disabled_reason
|
|
47
|
+
@gate.reason
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_breadcrumb(message, category: "custom", level: "info", data: nil)
|
|
51
|
+
return nil unless collecting_breadcrumbs?
|
|
52
|
+
|
|
53
|
+
crumb = Event::Breadcrumb.build(message, category: category, level: level, data: data)
|
|
54
|
+
Scope.current.add_breadcrumb(crumb, config.max_breadcrumbs)
|
|
55
|
+
nil
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
log.warn_every(:breadcrumb_error, 60, "could not record a breadcrumb (#{e.class})")
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether breadcrumbs are recorded at all, so integrations can skip
|
|
62
|
+
# building them.
|
|
63
|
+
def collecting_breadcrumbs?
|
|
64
|
+
config.breadcrumbs && config.max_breadcrumbs.positive? && (test_delivery? || @gate.enabled?)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def flush(timeout = 5)
|
|
68
|
+
@delivery ? @delivery.flush(timeout) : true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def shutdown(timeout = config.shutdown_timeout)
|
|
72
|
+
@delivery ? @delivery.shutdown(timeout) : true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def verify
|
|
76
|
+
Verification.run(config, transport)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_events
|
|
80
|
+
delivery.is_a?(Delivery::Test) ? delivery.events : []
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def clear_test_events
|
|
84
|
+
delivery.clear if delivery.is_a?(Delivery::Test)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The replaced delivery drains first, then its transport closes, both
|
|
88
|
+
# outside the lock so reporting threads never wait on the network.
|
|
89
|
+
def reconfigure(rebuild_delivery:)
|
|
90
|
+
old_delivery, old_transport = @lock.synchronize { reset_components(rebuild_delivery) }
|
|
91
|
+
old_delivery&.shutdown(config.shutdown_timeout)
|
|
92
|
+
old_transport&.close
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def delivery
|
|
96
|
+
@delivery || @lock.synchronize do
|
|
97
|
+
@delivery ||= Delivery.build(config, self)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def transport
|
|
102
|
+
@transport || @lock.synchronize { @transport ||= build_transport }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def serializer
|
|
106
|
+
@serializer ||= EventSerializer.new(log)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def while_reporting
|
|
112
|
+
Thread.current[REPORTING_KEY] = true
|
|
113
|
+
yield
|
|
114
|
+
ensure
|
|
115
|
+
Thread.current[REPORTING_KEY] = nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def report(error, options)
|
|
119
|
+
exception = ExceptionCoercion.coerce(error)
|
|
120
|
+
return nil if skip?(exception)
|
|
121
|
+
|
|
122
|
+
ReportedMark.mark(exception)
|
|
123
|
+
event = prepare_event(exception, options)
|
|
124
|
+
return nil unless event
|
|
125
|
+
|
|
126
|
+
delivery.deliver(event) == false ? nil : event["event_id"]
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
log.error("notify failed with #{e.class}; the error was not reported")
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def pipeline
|
|
133
|
+
@pipeline || @lock.synchronize { @pipeline ||= EventPipeline.new(config, log) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def ignore_list
|
|
137
|
+
@ignore_list || @lock.synchronize { @ignore_list ||= IgnoreList.new(config.ignore_exceptions) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def enrich(scope)
|
|
141
|
+
scope.enrich
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
log.warn_every(:enrich_error, 60, "could not read request or job details (#{e.class})")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def skip?(exception)
|
|
147
|
+
ReportedMark.reported?(exception) || exception.is_a?(RetryableDeliveryError) || ignore_list.ignore?(exception)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def test_delivery?
|
|
151
|
+
config.delivery == :test
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def credentials_present?
|
|
155
|
+
return true unless NETWORK_DELIVERIES.include?(config.delivery)
|
|
156
|
+
return true unless config.api_key.to_s.empty?
|
|
157
|
+
|
|
158
|
+
log.warn_once(:missing_api_key,
|
|
159
|
+
"no API key configured (GOODSORCERY_API_KEY or config.api_key); errors are not sent")
|
|
160
|
+
false
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def build_transport
|
|
164
|
+
Transport.new(endpoint: config.endpoint, api_key: config.api_key, open_timeout: config.open_timeout,
|
|
165
|
+
read_timeout: config.read_timeout, log: log)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Returns the delivery and transport to retire, if they are being replaced.
|
|
169
|
+
def reset_components(rebuild_delivery)
|
|
170
|
+
@pipeline = nil
|
|
171
|
+
@ignore_list = nil
|
|
172
|
+
return [] unless rebuild_delivery
|
|
173
|
+
|
|
174
|
+
replaced = [@delivery, @transport]
|
|
175
|
+
@delivery = nil
|
|
176
|
+
@transport = nil
|
|
177
|
+
replaced
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
class Configuration
|
|
5
|
+
# Class macros that define setters which reject invalid values with a clear
|
|
6
|
+
# ArgumentError. Messages show the rejected value only for plain settings,
|
|
7
|
+
# never for credentials.
|
|
8
|
+
module Validation
|
|
9
|
+
def integer_setting(name, range)
|
|
10
|
+
define_method(:"#{name}=") do |value|
|
|
11
|
+
unless value.is_a?(Integer) && range.cover?(value)
|
|
12
|
+
raise ArgumentError, "#{name} must be an Integer in #{range} (got #{value.inspect})"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
instance_variable_set(:"@#{name}", value)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def seconds_setting(name)
|
|
20
|
+
define_method(:"#{name}=") do |value|
|
|
21
|
+
unless value.is_a?(Numeric) && value.positive?
|
|
22
|
+
raise ArgumentError, "#{name} must be a positive number of seconds (got #{value.inspect})"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
instance_variable_set(:"@#{name}", value)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The message never echoes the value: an endpoint URL can carry credentials.
|
|
30
|
+
def endpoint_setting
|
|
31
|
+
define_method(:endpoint=) do |value|
|
|
32
|
+
unless value.nil? || Validation.http_url?(value)
|
|
33
|
+
raise ArgumentError, "endpoint must be an http:// or https:// URL with a host and no user:password part"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@endpoint = value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.http_url?(value)
|
|
41
|
+
uri = URI(value.to_s)
|
|
42
|
+
%w[http https].include?(uri.scheme) && !uri.host.to_s.empty? && uri.userinfo.nil?
|
|
43
|
+
rescue URI::InvalidURIError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def choice_setting(name, choices)
|
|
48
|
+
define_method(:"#{name}=") do |value|
|
|
49
|
+
normalized = value.is_a?(String) ? value.to_sym : value
|
|
50
|
+
unless choices.include?(normalized)
|
|
51
|
+
raise ArgumentError, "#{name} must be one of #{choices.map(&:inspect).join(", ")} (got #{value.inspect})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
instance_variable_set(:"@#{name}", normalized)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration/validation"
|
|
4
|
+
|
|
5
|
+
module GoodSorcery
|
|
6
|
+
# Every setting the gem reads (DESIGN.md 2 and 3). Unset credentials,
|
|
7
|
+
# endpoint, environment, release and the enabled flag fall back to
|
|
8
|
+
# environment variables (and Rails) when they are read.
|
|
9
|
+
class Configuration
|
|
10
|
+
extend Validation
|
|
11
|
+
|
|
12
|
+
DELIVERIES = %i[async sync active_job test].freeze
|
|
13
|
+
DELIVERY_SETTINGS = %i[
|
|
14
|
+
delivery api_key endpoint batch_size flush_interval max_queue_size shutdown_timeout
|
|
15
|
+
max_retries open_timeout read_timeout active_job_queue
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
attr_writer :api_key, :environment, :release, :logger, :root
|
|
19
|
+
attr_accessor :disabled_environments, :enabled_environments, :filter_params,
|
|
20
|
+
:use_rails_filter_parameters, :extra_filters, :ignore_exceptions, :tags,
|
|
21
|
+
:user_method, :send_code_context, :breadcrumbs,
|
|
22
|
+
:report_handled_errors, :active_job_queue, :request_extractor,
|
|
23
|
+
:send_request_params, :send_request_headers, :send_session, :send_job_arguments
|
|
24
|
+
attr_reader :delivery, :batch_size, :flush_interval, :max_queue_size,
|
|
25
|
+
:shutdown_timeout, :max_retries, :open_timeout, :read_timeout,
|
|
26
|
+
:max_breadcrumbs, :install_check, :before_send
|
|
27
|
+
|
|
28
|
+
endpoint_setting
|
|
29
|
+
choice_setting :install_check, [:verify, :test_event, false]
|
|
30
|
+
integer_setting :batch_size, 1..100
|
|
31
|
+
integer_setting :max_queue_size, 1..1_000_000
|
|
32
|
+
integer_setting :max_retries, 0..20
|
|
33
|
+
integer_setting :max_breadcrumbs, 0..100
|
|
34
|
+
seconds_setting :flush_interval
|
|
35
|
+
seconds_setting :shutdown_timeout
|
|
36
|
+
seconds_setting :open_timeout
|
|
37
|
+
seconds_setting :read_timeout
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
assign_list_defaults
|
|
41
|
+
assign_delivery_defaults
|
|
42
|
+
assign_behaviour_defaults
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def api_key
|
|
46
|
+
@api_key || EnvFlag.present("GOODSORCERY_API_KEY") || rails_credentials_api_key
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def endpoint
|
|
50
|
+
(@endpoint || EnvFlag.present("GOODSORCERY_ENDPOINT") || Defaults::ENDPOINT).to_s.chomp("/")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The endpoint without any user:password part, for logs and reports.
|
|
54
|
+
def endpoint_label
|
|
55
|
+
endpoint.sub(%r{(?<=://)[^/@]*@}, "")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def environment
|
|
59
|
+
(@environment || EnvFlag.present("GOODSORCERY_ENV") || rails_env ||
|
|
60
|
+
EnvFlag.present("RACK_ENV") || EnvFlag.present("APP_ENV") || "development").to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def release
|
|
64
|
+
return @release.to_s[0, ReleaseDetector::MAX_LENGTH] if @release
|
|
65
|
+
|
|
66
|
+
@detected_release = ReleaseDetector.new(root).release unless defined?(@detected_release)
|
|
67
|
+
@detected_release
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Rails.root in Rails apps, else the working directory.
|
|
71
|
+
def root
|
|
72
|
+
(@root || rails_root || Dir.pwd).to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Rails.logger in Rails apps, else WARN and above to $stderr.
|
|
76
|
+
def logger
|
|
77
|
+
@logger || rails_logger || (@default_logger ||= ::Logger.new($stderr, level: ::Logger::WARN))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The key filter's companions: Rails' filter_parameters (unless turned
|
|
81
|
+
# off), then config.extra_filters.
|
|
82
|
+
def event_filters
|
|
83
|
+
rails = use_rails_filter_parameters ? RailsFilterParameters.build : nil
|
|
84
|
+
[rails, *extra_filters].compact
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def enabled
|
|
88
|
+
@enabled.nil? ? EnvFlag.parse(ENV.fetch("GOODSORCERY_ENABLED", nil)) : @enabled
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def enabled=(value)
|
|
92
|
+
unless [true, false, nil].include?(value)
|
|
93
|
+
raise ArgumentError, "enabled must be true, false or nil (got #{value.inspect})"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@enabled = value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def delivery=(value)
|
|
100
|
+
@delivery = DeliverySetting.normalize(value)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def before_send=(callable)
|
|
104
|
+
unless callable.nil? || callable.respond_to?(:call)
|
|
105
|
+
raise ArgumentError, "before_send must respond to #call (or be nil)"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@before_send = callable
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def masked_api_key
|
|
112
|
+
MaskedKey.new(api_key).to_s
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def delivery_settings
|
|
116
|
+
DELIVERY_SETTINGS.to_h { |name| [name, public_send(name)] }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Never shows the API key (or endpoint credentials): consoles, error
|
|
120
|
+
# pages and loggers print objects with #inspect.
|
|
121
|
+
def inspect
|
|
122
|
+
delivery_name = delivery.is_a?(Symbol) ? delivery.inspect : delivery.class.name
|
|
123
|
+
"#<#{self.class.name} api_key=#{masked_api_key} endpoint=#{endpoint_label} " \
|
|
124
|
+
"environment=#{environment} delivery=#{delivery_name}>"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def assign_list_defaults
|
|
130
|
+
@disabled_environments = Defaults::DISABLED_ENVIRONMENTS.dup
|
|
131
|
+
@enabled_environments = nil
|
|
132
|
+
@filter_params = Defaults::FILTER_PARAMS.dup
|
|
133
|
+
@ignore_exceptions = Defaults::IGNORE_EXCEPTIONS.dup
|
|
134
|
+
@extra_filters = []
|
|
135
|
+
@tags = {}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def assign_delivery_defaults
|
|
139
|
+
@delivery = :async
|
|
140
|
+
@batch_size = 25
|
|
141
|
+
@flush_interval = 2.0
|
|
142
|
+
@max_queue_size = 1_000
|
|
143
|
+
@shutdown_timeout = 5
|
|
144
|
+
@max_retries = 3
|
|
145
|
+
@open_timeout = 2
|
|
146
|
+
@read_timeout = 5
|
|
147
|
+
@active_job_queue = "default"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def assign_behaviour_defaults
|
|
151
|
+
@use_rails_filter_parameters = true
|
|
152
|
+
@user_method = :current_user
|
|
153
|
+
@send_code_context = true
|
|
154
|
+
@breadcrumbs = true
|
|
155
|
+
@max_breadcrumbs = 40
|
|
156
|
+
@install_check = :verify
|
|
157
|
+
@report_handled_errors = true
|
|
158
|
+
assign_capture_defaults
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def assign_capture_defaults
|
|
162
|
+
@send_request_params = true
|
|
163
|
+
@send_request_headers = true
|
|
164
|
+
@send_session = true
|
|
165
|
+
@send_job_arguments = true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def rails_app?
|
|
169
|
+
defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def rails_root
|
|
173
|
+
::Rails.root if rails_app? && ::Rails.respond_to?(:root)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def rails_logger
|
|
177
|
+
::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def rails_env
|
|
181
|
+
::Rails.env.to_s if defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def rails_credentials_api_key
|
|
185
|
+
return unless rails_app?
|
|
186
|
+
|
|
187
|
+
::Rails.application.credentials.dig(:goodsorcery, :api_key)
|
|
188
|
+
rescue StandardError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Normalizes and validates config.delivery.
|
|
194
|
+
module DeliverySetting
|
|
195
|
+
def self.normalize(value)
|
|
196
|
+
symbol = value.is_a?(String) ? value.to_sym : value
|
|
197
|
+
return symbol if Configuration::DELIVERIES.include?(symbol)
|
|
198
|
+
return value if !value.is_a?(Symbol) && value.respond_to?(:deliver)
|
|
199
|
+
|
|
200
|
+
raise ArgumentError, "delivery must be one of #{Configuration::DELIVERIES.map(&:inspect).join(", ")} " \
|
|
201
|
+
"or an object responding to #deliver (got a #{value.class})"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Default lists. Configuration copies them, so apps can add with +=, remove
|
|
5
|
+
# with -= or replace them entirely.
|
|
6
|
+
module Defaults
|
|
7
|
+
# Mirrors Rails' generated filter_parameters plus the server's scrub list.
|
|
8
|
+
FILTER_PARAMS = [
|
|
9
|
+
/passw/i, /secret/i, /token/i, /_key/i, /api-?key/i, /crypt/i, /salt/i, /certificate/i,
|
|
10
|
+
/otp/i, /ssn/i, /cvv/i, /cvc/i, /authorization/i, /cookie/i, /csrf/i, /xsrf/i,
|
|
11
|
+
/session[-_]?id/i, /credit[-_]?card/i, /card[-_]?number/i, /signature/i, /email/i
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
# Class names (subclasses match too) that are never reported.
|
|
15
|
+
IGNORE_EXCEPTIONS = %w[
|
|
16
|
+
AbstractController::ActionNotFound
|
|
17
|
+
ActionController::BadRequest
|
|
18
|
+
ActionController::InvalidAuthenticityToken
|
|
19
|
+
ActionController::InvalidCrossOriginRequest
|
|
20
|
+
ActionController::MethodNotAllowed
|
|
21
|
+
ActionController::NotImplemented
|
|
22
|
+
ActionController::ParameterMissing
|
|
23
|
+
ActionController::RoutingError
|
|
24
|
+
ActionController::UnknownFormat
|
|
25
|
+
ActionController::UnknownHttpMethod
|
|
26
|
+
ActionDispatch::Http::MimeNegotiation::InvalidType
|
|
27
|
+
ActionDispatch::Http::Parameters::ParseError
|
|
28
|
+
ActiveRecord::RecordNotFound
|
|
29
|
+
CGI::Session::CookieStore::TamperedWithCookie
|
|
30
|
+
Rack::QueryParser::InvalidParameterError
|
|
31
|
+
Rack::QueryParser::ParameterTypeError
|
|
32
|
+
Sinatra::NotFound
|
|
33
|
+
SignalException
|
|
34
|
+
SystemExit
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
DISABLED_ENVIRONMENTS = %w[development test].freeze
|
|
38
|
+
ENDPOINT = "https://app.goodsorcery.com"
|
|
39
|
+
end
|
|
40
|
+
end
|