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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Event
5
+ # Builds the event hash for one exception in the caller's thread, already
6
+ # within the server's size limits (INGEST_API.md). Filtering and
7
+ # before_send happen afterwards, in EventPipeline.
8
+ class Builder
9
+ NOTIFIER = { "name" => NOTIFIER_NAME, "version" => VERSION }.freeze
10
+ MAX_TRANSACTION_LENGTH = 1_000
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ @sanitizer = Sanitizer.new
15
+ @exception_section = build_exception_section(config)
16
+ @request_section = RequestSection.new(config.request_extractor, @sanitizer)
17
+ @runtime = RuntimeContext.new
18
+ end
19
+
20
+ def build(exception, options, scope)
21
+ event = header(options)
22
+ event["exception"] = @exception_section.build(exception)
23
+ event.merge!(scope_sections(options, scope))
24
+ event["context"] = context(exception, options, scope)
25
+ event["fingerprint"] = Fields.fingerprint(options[:fingerprint])
26
+ event["notifier"] = NOTIFIER.dup
27
+ event.compact
28
+ end
29
+
30
+ private
31
+
32
+ def build_exception_section(config)
33
+ paths = Backtrace::Paths.new(config.root)
34
+ code_context = Backtrace::CodeContext.new(Backtrace::LineCache.new(paths))
35
+ ExceptionSection.new(paths: paths, code_context: code_context, send_code_context: config.send_code_context)
36
+ end
37
+
38
+ def header(options)
39
+ {
40
+ "event_id" => Fields.event_id,
41
+ "occurred_at" => Fields.timestamp,
42
+ "level" => Fields.level(options[:level]),
43
+ "release" => @config.release
44
+ }
45
+ end
46
+
47
+ def scope_sections(options, scope)
48
+ {
49
+ "transaction" => Fields.text(options[:transaction] || scope.transaction, MAX_TRANSACTION_LENGTH),
50
+ "request" => @request_section.build(scope),
51
+ "job" => JobSection.build(scope.job, @sanitizer),
52
+ "user" => UserSection.build(options[:user] || scope.user),
53
+ "breadcrumbs" => breadcrumbs(scope),
54
+ "tags" => presence(TagSet.merge(@config.tags, scope.tags, options[:tags]))
55
+ }
56
+ end
57
+
58
+ def presence(hash)
59
+ hash.empty? ? nil : hash
60
+ end
61
+
62
+ def breadcrumbs(scope)
63
+ crumbs = scope.breadcrumbs.to_a.last(100)
64
+ crumbs.empty? ? nil : crumbs.map(&:dup)
65
+ end
66
+
67
+ def context(exception, options, scope)
68
+ context = @runtime.to_h
69
+ causes = CauseChain.new(exception).to_a
70
+ context["cause"] = causes unless causes.empty?
71
+ context.merge!(@sanitizer.sanitize(scope.context)) if scope.context.any?
72
+ context.merge!(@sanitizer.sanitize(options[:context])) if options[:context].is_a?(Hash)
73
+ context
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Event
5
+ # Up to three causes of an exception, as class and message.
6
+ class CauseChain
7
+ MAX_CAUSES = 3
8
+ MAX_MESSAGE_LENGTH = 1_000
9
+
10
+ def initialize(exception)
11
+ @exception = exception
12
+ end
13
+
14
+ def to_a
15
+ causes.map do |cause|
16
+ {
17
+ "class" => ExceptionSection.class_name(cause),
18
+ "message" => ExceptionSection.message(cause, MAX_MESSAGE_LENGTH)
19
+ }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def causes
26
+ seen = {}.compare_by_identity
27
+ seen[@exception] = true
28
+ current = @exception.cause
29
+ list = []
30
+ while current && list.size < MAX_CAUSES && !seen[current]
31
+ seen[current] = true
32
+ list << current
33
+ current = current.cause
34
+ end
35
+ list
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Event
5
+ # The "exception" section: class, message and backtrace frames, with code
6
+ # context for the top in-app frames.
7
+ class ExceptionSection
8
+ MAX_CLASS_LENGTH = 255
9
+ MAX_MESSAGE_LENGTH = 10_000
10
+ MAX_FRAMES = 250
11
+ CONTEXT_FRAMES = 10
12
+
13
+ def initialize(paths:, code_context:, send_code_context:)
14
+ @paths = paths
15
+ @code_context = code_context
16
+ @send_code_context = send_code_context
17
+ @parser = Backtrace::Parser.new
18
+ end
19
+
20
+ def build(exception)
21
+ {
22
+ "class" => self.class.class_name(exception),
23
+ "message" => self.class.message(exception, MAX_MESSAGE_LENGTH),
24
+ "backtrace" => backtrace(exception)
25
+ }
26
+ end
27
+
28
+ def self.class_name(exception)
29
+ Text.limit(exception.class.name || exception.class.to_s, MAX_CLASS_LENGTH)
30
+ end
31
+
32
+ def self.message(exception, max)
33
+ Text.limit(exception.message, max)
34
+ rescue StandardError
35
+ ""
36
+ end
37
+
38
+ private
39
+
40
+ def backtrace(exception)
41
+ remaining = @send_code_context ? CONTEXT_FRAMES : 0
42
+ @parser.frames(exception, limit: MAX_FRAMES).map do |frame|
43
+ hash = frame_hash(frame)
44
+ next hash unless hash["in_app"] && remaining.positive?
45
+
46
+ remaining -= 1
47
+ add_code_context(hash, frame)
48
+ end
49
+ end
50
+
51
+ def frame_hash(frame)
52
+ path = frame.path.to_s
53
+ {
54
+ "file" => Text.limit(@paths.relative(path), 1_024),
55
+ "line" => frame.line,
56
+ "method" => frame.label && Text.limit(frame.label, 255),
57
+ "in_app" => @paths.in_app?(path)
58
+ }.compact
59
+ end
60
+
61
+ def add_code_context(hash, frame)
62
+ context = @code_context.around(frame.path.to_s, frame.line)
63
+ hash["context"] = context if context
64
+ hash
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Event
5
+ # Small normalizers for top-level event fields.
6
+ module Fields
7
+ LEVELS = %w[fatal error warning info].freeze
8
+ LEVEL_ALIASES = { "warn" => "warning", "critical" => "fatal", "debug" => "info" }.freeze
9
+ MAX_FINGERPRINT_PARTS = 10
10
+ MAX_FINGERPRINT_LENGTH = 200
11
+
12
+ module_function
13
+
14
+ def level(value, default = "error")
15
+ name = value.to_s.downcase
16
+ name = LEVEL_ALIASES.fetch(name, name)
17
+ LEVELS.include?(name) ? name : default
18
+ end
19
+
20
+ def fingerprint(value)
21
+ return nil if value.nil?
22
+
23
+ parts = Array(value).select { |part| part.is_a?(String) || part.is_a?(Symbol) || part.is_a?(Numeric) }
24
+ parts = parts.first(MAX_FINGERPRINT_PARTS).map { |part| Text.limit(part.to_s, MAX_FINGERPRINT_LENGTH) }
25
+ parts.empty? ? nil : parts
26
+ end
27
+
28
+ def event_id
29
+ SecureRandom.respond_to?(:uuid_v7) ? SecureRandom.uuid_v7 : SecureRandom.uuid
30
+ end
31
+
32
+ def timestamp(time = Time.now)
33
+ time.utc.iso8601(3)
34
+ end
35
+
36
+ # A job timestamp as ISO 8601: a Time (or Date), epoch seconds, epoch
37
+ # milliseconds (Sidekiq 8) or an already formatted string.
38
+ def iso_time(value)
39
+ case value
40
+ when nil then nil
41
+ when String then Text.limit(value, 64)
42
+ when Time then timestamp(value)
43
+ when Numeric then timestamp(Time.at(epoch_seconds(value)))
44
+ else formatted_time(value)
45
+ end
46
+ end
47
+
48
+ def epoch_seconds(value)
49
+ value > 100_000_000_000 ? value / 1000.0 : value
50
+ end
51
+
52
+ def formatted_time(value)
53
+ value.respond_to?(:iso8601) ? Text.limit(value.iso8601, 64) : nil
54
+ end
55
+
56
+ def text(value, max)
57
+ value.nil? ? nil : Text.limit(value, max)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Event
5
+ # The "job" section (DESIGN.md 5.1): which job ran, where, and with which
6
+ # arguments. Keeps the known fields only; arguments are capped like
7
+ # request params and filtered by EventFilter afterwards.
8
+ module JobSection
9
+ TEXT_FIELDS = %w[
10
+ framework class id provider_job_id queue mailer action enqueued_at scheduled_at
11
+ ].freeze
12
+ INTEGER_FIELDS = %w[priority executions retry_count].freeze
13
+ MAX_TEXT_LENGTH = 255
14
+
15
+ module_function
16
+
17
+ def build(raw, sanitizer)
18
+ return nil unless raw.is_a?(Hash)
19
+
20
+ section = {}
21
+ raw.each { |key, value| add(section, key.to_s, value, sanitizer) }
22
+ section.empty? ? nil : section
23
+ end
24
+
25
+ def add(section, key, value, sanitizer)
26
+ return if value.nil?
27
+
28
+ section[key] = Text.limit(value, MAX_TEXT_LENGTH) if TEXT_FIELDS.include?(key)
29
+ section[key] = value if INTEGER_FIELDS.include?(key) && value.is_a?(Integer)
30
+ section[key] = sanitizer.sanitize(value) if key == "arguments"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Event
5
+ # The "request" section. Integrations either store a ready hash in
6
+ # scope.request, or keep the Rack env in scope.request_env and set
7
+ # config.request_extractor (an object responding to +extract(env)+), so
8
+ # the request is only read when an error is actually reported.
9
+ class RequestSection
10
+ SCALARS = %w[url method format request_id remote_ip].freeze
11
+ NESTED = %w[params headers session].freeze
12
+
13
+ def initialize(extractor, sanitizer)
14
+ @extractor = extractor
15
+ @sanitizer = sanitizer
16
+ end
17
+
18
+ def build(scope)
19
+ raw = scope.request || extract(scope.request_env)
20
+ return nil unless raw.is_a?(Hash)
21
+
22
+ section = {}
23
+ raw.each { |key, value| add(section, key.to_s, value) }
24
+ section.empty? ? nil : section
25
+ end
26
+
27
+ private
28
+
29
+ def extract(env)
30
+ env && @extractor ? @extractor.extract(env) : nil
31
+ end
32
+
33
+ def add(section, key, value)
34
+ return if value.nil?
35
+
36
+ section[key] = Text.limit(value, 4_096) if SCALARS.include?(key)
37
+ section[key] = @sanitizer.sanitize(value) if NESTED.include?(key) && value.is_a?(Hash)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Event
5
+ # Runtime details for the Environment tab: Ruby (and YJIT), Rails, host,
6
+ # process and the RAILS_ENV/RACK_ENV variables only.
7
+ class RuntimeContext
8
+ ENV_NAMES = %w[RAILS_ENV RACK_ENV].freeze
9
+
10
+ def initialize
11
+ @ruby = ruby_label
12
+ @host = hostname
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ "runtime" => { "ruby" => @ruby, "rails" => rails_version }.compact,
18
+ "host" => @host,
19
+ "process" => "pid #{Process.pid} · thread #{thread_label}",
20
+ "env" => env_vars
21
+ }.compact
22
+ end
23
+
24
+ private
25
+
26
+ def env_vars
27
+ vars = ENV.to_h.slice(*ENV_NAMES)
28
+ vars.empty? ? nil : vars
29
+ end
30
+
31
+ def ruby_label
32
+ yjit = defined?(::RubyVM::YJIT) && ::RubyVM::YJIT.respond_to?(:enabled?) && ::RubyVM::YJIT.enabled?
33
+ yjit ? "#{RUBY_VERSION} (YJIT on)" : RUBY_VERSION
34
+ end
35
+
36
+ def rails_version
37
+ ::Rails.version.to_s if defined?(::Rails) && ::Rails.respond_to?(:version)
38
+ end
39
+
40
+ def hostname
41
+ Text.limit(Socket.gethostname, 255)
42
+ rescue StandardError
43
+ nil
44
+ end
45
+
46
+ def thread_label
47
+ thread = Thread.current
48
+ thread.name || thread.native_thread_id || thread.object_id
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Event
5
+ # Merges tag hashes (later ones win) and keeps what the server accepts:
6
+ # up to 25 keys matching KEY with scalar values of at most 200 chars.
7
+ module TagSet
8
+ KEY = /\A[a-z0-9_.-]{1,32}\z/i
9
+ MAX_TAGS = 25
10
+ MAX_VALUE_LENGTH = 200
11
+
12
+ module_function
13
+
14
+ def merge(*hashes)
15
+ merged = {}
16
+ hashes.each { |hash| add(merged, hash) if hash.is_a?(Hash) }
17
+ merged.first(MAX_TAGS).to_h
18
+ end
19
+
20
+ def add(merged, hash)
21
+ hash.each do |key, raw|
22
+ name = key.to_s
23
+ value = scalar(raw)
24
+ merged[name] = value if KEY.match?(name) && !value.nil?
25
+ end
26
+ end
27
+
28
+ def scalar(raw)
29
+ case raw
30
+ when String, Symbol then Text.limit(raw.to_s, MAX_VALUE_LENGTH)
31
+ when Integer, true, false then raw
32
+ when Float then finite_float(raw)
33
+ end
34
+ end
35
+
36
+ def finite_float(raw)
37
+ raw.finite? ? raw : nil
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Event
5
+ # Keeps only the four user fields the server accepts. Reads them from a
6
+ # hash (string or symbol keys) or from an object such as a User model.
7
+ module UserSection
8
+ FIELDS = %w[id email username name].freeze
9
+ MAX_LENGTH = 255
10
+
11
+ module_function
12
+
13
+ def build(user)
14
+ return nil if user.nil?
15
+
16
+ fields = FIELDS.to_h { |field| [field, value(read(user, field))] }.compact
17
+ fields.empty? ? nil : fields
18
+ end
19
+
20
+ def read(user, field)
21
+ return user.fetch(field) { user[field.to_sym] } if user.is_a?(Hash)
22
+
23
+ user.public_send(field) if user.respond_to?(field)
24
+ rescue StandardError
25
+ nil
26
+ end
27
+
28
+ def value(raw)
29
+ return raw if raw.is_a?(Integer)
30
+ return nil if raw.nil?
31
+
32
+ Text.limit(raw, MAX_LENGTH)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Applies the key filter, and any extra filters, to every part of an event
5
+ # that holds free-form data: request params, headers, session and URL query,
6
+ # job arguments, context, tags and breadcrumb data. The user section is left alone on
7
+ # purpose (DESIGN.md 3.4).
8
+ #
9
+ # Extra filters (config.extra_filters) are objects responding to
10
+ # +filter(hash) -> hash+, for example ActiveSupport::ParameterFilter, which
11
+ # the Rails integration adds for Rails.application.config.filter_parameters.
12
+ class EventFilter
13
+ REQUEST_SECTIONS = %w[params headers session].freeze
14
+
15
+ def initialize(key_filter, extra_filters = [])
16
+ @key_filter = key_filter
17
+ @extra_filters = Array(extra_filters)
18
+ @extra_probe = @extra_filters.empty? ? nil : ExtraFilterProbe.new(@extra_filters)
19
+ end
20
+
21
+ def filter_event(event)
22
+ filter_request(event["request"]) if event["request"]
23
+ filter_job(event["job"]) if event["job"]
24
+ filter_section(event, "context")
25
+ filter_section(event, "tags")
26
+ event["breadcrumbs"]&.each { |crumb| filter_section(crumb, "data") }
27
+ event
28
+ end
29
+
30
+ def filter_hash(hash)
31
+ filtered = @key_filter.filter(hash)
32
+ @extra_filters.reduce(filtered) { |result, extra| extra.filter(result) }
33
+ end
34
+
35
+ private
36
+
37
+ def filter_request(request)
38
+ request["url"] = @key_filter.filter_url(request["url"], @extra_probe) if request["url"]
39
+ REQUEST_SECTIONS.each { |name| filter_section(request, name) }
40
+ end
41
+
42
+ # Arguments are often an array, so they are filtered inside a wrapper hash.
43
+ def filter_job(job)
44
+ return unless job.key?("arguments")
45
+
46
+ job["arguments"] = filter_hash({ "arguments" => job["arguments"] })["arguments"]
47
+ end
48
+
49
+ def filter_section(container, name)
50
+ value = container[name]
51
+ container[name] = filter_hash(value) if value.is_a?(Hash)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Builds, filters and passes an event through before_send, in the caller's
5
+ # thread. Returns the event, or nil when before_send drops it (or raises).
6
+ class EventPipeline
7
+ def initialize(config, log)
8
+ @builder = Event::Builder.new(config)
9
+ @filter = EventFilter.new(KeyFilter.new(config.filter_params), config.event_filters)
10
+ @before_send = config.before_send
11
+ @log = log
12
+ end
13
+
14
+ def prepare(exception, options, scope)
15
+ event = @builder.build(exception, options, scope)
16
+ @filter.filter_event(event)
17
+ apply_before_send(event)
18
+ end
19
+
20
+ private
21
+
22
+ def apply_before_send(event)
23
+ return event unless @before_send
24
+
25
+ result = @before_send.call(event)
26
+ return result if result.is_a?(Hash)
27
+
28
+ @log.warn_once(:before_send_type, "before_send returned #{result.class}; event dropped") unless result.nil?
29
+ nil
30
+ rescue StandardError => e
31
+ @log.warn_every(:before_send_error, 60, "before_send raised #{e.class}; event dropped")
32
+ nil
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Serializes an event to JSON, dropping (and logging, by size only) events
5
+ # over the server's 512 KiB limit.
6
+ class EventSerializer
7
+ MAX_BYTES = 524_288
8
+
9
+ def initialize(log)
10
+ @log = log
11
+ end
12
+
13
+ def dump(event)
14
+ json = JSON.generate(event)
15
+ return json if json.bytesize <= MAX_BYTES
16
+
17
+ @log.warn_once(:oversized_event,
18
+ "dropped an event of #{json.bytesize} bytes (limit 512 KiB); later drops are not logged")
19
+ nil
20
+ rescue StandardError => e
21
+ @log.warn_every(:unserializable_event, 300, "dropped an event that could not be serialized (#{e.class})")
22
+ nil
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Turns what was passed to notify into an exception. A message becomes a
5
+ # RuntimeError whose backtrace is the caller's stack (without this gem's
6
+ # own frames).
7
+ module ExceptionCoercion
8
+ GEM_DIR = File.expand_path("..", __dir__)
9
+
10
+ def self.coerce(error)
11
+ return error if error.is_a?(Exception)
12
+
13
+ exception = RuntimeError.new(Text.safe_to_s(error))
14
+ exception.set_backtrace(caller.reject { |line| line.start_with?(GEM_DIR) })
15
+ exception
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Asks the extra filters (such as Rails' filter_parameters) whether they
5
+ # would filter a key path, by filtering a hash that holds one probe value
6
+ # at that path. Used for URL query parameters, which are text, not hashes.
7
+ class ExtraFilterProbe
8
+ PROBE = "goodsorcery-probe"
9
+
10
+ def initialize(extra_filters)
11
+ @extra_filters = Array(extra_filters)
12
+ end
13
+
14
+ # +parts+ is a parameter name split on brackets: "user[pin]" => ["user", "pin"].
15
+ def filters_path?(parts)
16
+ return false if parts.empty?
17
+
18
+ @extra_filters.any? { |extra| !survives?(extra.filter(probe(parts)), parts) }
19
+ end
20
+
21
+ private
22
+
23
+ def probe(parts)
24
+ parts.reverse.reduce(PROBE) { |inner, part| { part => inner } }
25
+ end
26
+
27
+ def survives?(value, parts)
28
+ parts.each { |part| value = value.is_a?(Hash) ? value[part] : nil }
29
+ value == PROBE
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Exception classes that are never reported, by name; subclasses (any
5
+ # ancestor with a listed name) are ignored too.
6
+ class IgnoreList
7
+ def initialize(entries)
8
+ @names = Array(entries).to_h { |entry| [entry.is_a?(Module) ? entry.name : entry.to_s, true] }
9
+ end
10
+
11
+ def ignore?(exception)
12
+ exception.class.ancestors.any? { |ancestor| @names.key?(ancestor.name.to_s) }
13
+ end
14
+ end
15
+ end