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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/LICENSE +21 -0
  4. data/README.md +299 -0
  5. data/lib/generators/goodsorcery/install/credentials_api_key.rb +64 -0
  6. data/lib/generators/goodsorcery/install/install_generator.rb +64 -0
  7. data/lib/generators/goodsorcery/install/templates/initializer.rb +54 -0
  8. data/lib/goodsorcery/backtrace/code_context.rb +27 -0
  9. data/lib/goodsorcery/backtrace/line_cache.rb +57 -0
  10. data/lib/goodsorcery/backtrace/parser.rb +38 -0
  11. data/lib/goodsorcery/backtrace/paths.rb +42 -0
  12. data/lib/goodsorcery/bundler_plugin.rb +117 -0
  13. data/lib/goodsorcery/client.rb +180 -0
  14. data/lib/goodsorcery/configuration/validation.rb +59 -0
  15. data/lib/goodsorcery/configuration.rb +204 -0
  16. data/lib/goodsorcery/defaults.rb +40 -0
  17. data/lib/goodsorcery/delivery/active_job_queue.rb +71 -0
  18. data/lib/goodsorcery/delivery/async.rb +122 -0
  19. data/lib/goodsorcery/delivery/backoff.rb +20 -0
  20. data/lib/goodsorcery/delivery/custom.rb +27 -0
  21. data/lib/goodsorcery/delivery/event_queue.rb +124 -0
  22. data/lib/goodsorcery/delivery/sync.rb +27 -0
  23. data/lib/goodsorcery/delivery/test.rb +30 -0
  24. data/lib/goodsorcery/delivery/worker.rb +71 -0
  25. data/lib/goodsorcery/delivery.rb +39 -0
  26. data/lib/goodsorcery/delivery_job.rb +26 -0
  27. data/lib/goodsorcery/env_flag.rb +24 -0
  28. data/lib/goodsorcery/environment_gate.rb +61 -0
  29. data/lib/goodsorcery/errors.rb +14 -0
  30. data/lib/goodsorcery/event/breadcrumb.rb +32 -0
  31. data/lib/goodsorcery/event/builder.rb +77 -0
  32. data/lib/goodsorcery/event/cause_chain.rb +39 -0
  33. data/lib/goodsorcery/event/exception_section.rb +68 -0
  34. data/lib/goodsorcery/event/fields.rb +61 -0
  35. data/lib/goodsorcery/event/job_section.rb +34 -0
  36. data/lib/goodsorcery/event/request_section.rb +41 -0
  37. data/lib/goodsorcery/event/runtime_context.rb +52 -0
  38. data/lib/goodsorcery/event/tag_set.rb +41 -0
  39. data/lib/goodsorcery/event/user_section.rb +36 -0
  40. data/lib/goodsorcery/event_filter.rb +54 -0
  41. data/lib/goodsorcery/event_pipeline.rb +35 -0
  42. data/lib/goodsorcery/event_serializer.rb +25 -0
  43. data/lib/goodsorcery/exception_coercion.rb +18 -0
  44. data/lib/goodsorcery/extra_filter_probe.rb +32 -0
  45. data/lib/goodsorcery/ignore_list.rb +15 -0
  46. data/lib/goodsorcery/install_check.rb +58 -0
  47. data/lib/goodsorcery/integrations/action_cable.rb +75 -0
  48. data/lib/goodsorcery/integrations/active_job/job_details.rb +90 -0
  49. data/lib/goodsorcery/integrations/active_job.rb +39 -0
  50. data/lib/goodsorcery/integrations/rails/controller_action.rb +40 -0
  51. data/lib/goodsorcery/integrations/rails/controller_enricher.rb +27 -0
  52. data/lib/goodsorcery/integrations/rails/error_subscriber.rb +40 -0
  53. data/lib/goodsorcery/integrations/rails/notification_breadcrumbs.rb +82 -0
  54. data/lib/goodsorcery/integrations/rails/sql_statement.rb +31 -0
  55. data/lib/goodsorcery/integrations/rails/user_lookup.rb +39 -0
  56. data/lib/goodsorcery/integrations/rails.rb +56 -0
  57. data/lib/goodsorcery/integrations/rake.rb +68 -0
  58. data/lib/goodsorcery/integrations/sidekiq/job_details.rb +54 -0
  59. data/lib/goodsorcery/integrations/sidekiq.rb +55 -0
  60. data/lib/goodsorcery/integrations.rb +27 -0
  61. data/lib/goodsorcery/job_arguments.rb +77 -0
  62. data/lib/goodsorcery/key_filter.rb +99 -0
  63. data/lib/goodsorcery/log.rb +55 -0
  64. data/lib/goodsorcery/masked_key.rb +29 -0
  65. data/lib/goodsorcery/rack/middleware.rb +57 -0
  66. data/lib/goodsorcery/rack/request_details.rb +120 -0
  67. data/lib/goodsorcery/rack/request_enricher.rb +20 -0
  68. data/lib/goodsorcery/rails_filter_parameters.rb +24 -0
  69. data/lib/goodsorcery/railtie.rb +29 -0
  70. data/lib/goodsorcery/release_detector.rb +42 -0
  71. data/lib/goodsorcery/reported_mark.rb +19 -0
  72. data/lib/goodsorcery/sanitizer.rb +71 -0
  73. data/lib/goodsorcery/scope.rb +106 -0
  74. data/lib/goodsorcery/status_report.rb +50 -0
  75. data/lib/goodsorcery/tasks.rb +46 -0
  76. data/lib/goodsorcery/test_event.rb +46 -0
  77. data/lib/goodsorcery/text.rb +35 -0
  78. data/lib/goodsorcery/transport/batch_chunks.rb +40 -0
  79. data/lib/goodsorcery/transport/connection.rb +87 -0
  80. data/lib/goodsorcery/transport/result.rb +103 -0
  81. data/lib/goodsorcery/transport.rb +172 -0
  82. data/lib/goodsorcery/verification.rb +47 -0
  83. data/lib/goodsorcery/version.rb +6 -0
  84. data/lib/goodsorcery.rb +176 -0
  85. data/plugins.rb +6 -0
  86. 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