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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Delivery
5
+ # Enqueues GoodSorcery::DeliveryJob with the filtered event as a JSON
6
+ # string. Falls back to the background thread when ActiveJob is not loaded
7
+ # or enqueueing raises. Note: the event is stored in the job backend.
8
+ class ActiveJobQueue
9
+ def initialize(serializer:, queue_name:, log:, &fallback_builder)
10
+ @serializer = serializer
11
+ @queue_name = queue_name
12
+ @log = log
13
+ @fallback_builder = fallback_builder
14
+ @fallback = nil
15
+ @lock = Mutex.new
16
+ end
17
+
18
+ def deliver(event)
19
+ job = DeliveryJobLoader.job_class
20
+ return deliver_in_background(event, "ActiveJob is not loaded") unless job
21
+
22
+ json = @serializer.dump(event)
23
+ return false unless json
24
+
25
+ return true if enqueued?(job.set(queue: @queue_name).perform_later(json))
26
+
27
+ deliver_in_background(event, "GoodSorcery::DeliveryJob was not enqueued")
28
+ rescue StandardError => e
29
+ deliver_in_background(event, "enqueueing GoodSorcery::DeliveryJob failed (#{e.class})")
30
+ end
31
+
32
+ def flush(timeout = 5)
33
+ @fallback ? @fallback.flush(timeout) : true
34
+ end
35
+
36
+ def shutdown(timeout = 5)
37
+ @fallback ? @fallback.shutdown(timeout) : true
38
+ end
39
+
40
+ private
41
+
42
+ # perform_later returns false when a callback aborted the enqueue or
43
+ # the adapter raised ActiveJob::EnqueueError.
44
+ def enqueued?(job)
45
+ return false unless job
46
+
47
+ job.respond_to?(:successfully_enqueued?) ? job.successfully_enqueued? != false : true
48
+ end
49
+
50
+ def deliver_in_background(event, reason)
51
+ @log.warn_every(:active_job_fallback, 60, "#{reason}; delivering in the background thread instead")
52
+ fallback.deliver(event)
53
+ end
54
+
55
+ def fallback
56
+ @fallback || @lock.synchronize { @fallback ||= @fallback_builder.call }
57
+ end
58
+ end
59
+
60
+ # Defines GoodSorcery::DeliveryJob on first use, once ActiveJob is loaded.
61
+ module DeliveryJobLoader
62
+ def self.job_class
63
+ return GoodSorcery::DeliveryJob if GoodSorcery.const_defined?(:DeliveryJob, false)
64
+ return nil unless defined?(::ActiveJob::Base)
65
+
66
+ require_relative "../delivery_job"
67
+ GoodSorcery::DeliveryJob
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Delivery
5
+ # The default delivery: a bounded in-memory queue drained in batches by
6
+ # one background thread ("goodsorcery-worker"). The thread starts on the
7
+ # first event and restarts in a forked child, which starts with an empty
8
+ # queue (the parent still owns the events it queued).
9
+ class Async
10
+ DEFAULTS = {
11
+ batch_size: 25, flush_interval: 2.0, max_queue_size: 1_000,
12
+ max_retries: 3, shutdown_timeout: 5, backoff: nil, sleeper: nil
13
+ }.freeze
14
+
15
+ def initialize(transport:, serializer:, log:, **settings)
16
+ @transport = transport
17
+ @serializer = serializer
18
+ @log = log
19
+ @settings = DEFAULTS.merge(settings)
20
+ @settings[:backoff] ||= Backoff.new
21
+ @lock = Mutex.new
22
+ @pid = Process.pid
23
+ @queue = new_queue
24
+ @thread = nil
25
+ end
26
+
27
+ def deliver(event)
28
+ ensure_worker
29
+ return true if @queue.push(event)
30
+
31
+ @log.warn_every(:queue_full, 60, "queue full (max_queue_size #{@settings[:max_queue_size]}); " \
32
+ "#{@queue.dropped} event(s) dropped so far")
33
+ false
34
+ end
35
+
36
+ # Waits until every queued event has been handled. => true when drained.
37
+ def flush(timeout = 5)
38
+ return true unless running?
39
+
40
+ @queue.wait_until_idle(timeout)
41
+ end
42
+
43
+ def shutdown(timeout = @settings[:shutdown_timeout])
44
+ thread, queue = @lock.synchronize { [@thread, @queue] }
45
+ return true unless thread&.alive? && @pid == Process.pid
46
+
47
+ drained = queue.wait_until_idle(timeout)
48
+ undelivered = queue.size
49
+ queue.close
50
+ thread.join(drained ? 1 : 0.1)
51
+ log_undelivered(undelivered) unless drained
52
+ drained
53
+ end
54
+
55
+ def dropped_count
56
+ @queue.dropped
57
+ end
58
+
59
+ def queue_size
60
+ @queue.size
61
+ end
62
+
63
+ def running?
64
+ @pid == Process.pid && @thread&.alive? ? true : false
65
+ end
66
+
67
+ private
68
+
69
+ def ensure_worker
70
+ return if running?
71
+
72
+ @lock.synchronize { start_worker unless running? }
73
+ end
74
+
75
+ def start_worker
76
+ if @pid != Process.pid
77
+ @pid = Process.pid
78
+ @queue = new_queue
79
+ end
80
+ @queue = new_queue if @queue.closed?
81
+ @thread = spawn_thread(build_worker)
82
+ ExitHook.install
83
+ end
84
+
85
+ def spawn_thread(worker)
86
+ thread = Thread.new do
87
+ Thread.current.report_on_exception = false
88
+ worker.run
89
+ end
90
+ thread.name = "goodsorcery-worker"
91
+ thread
92
+ end
93
+
94
+ def build_worker
95
+ Worker.new(queue: @queue, transport: @transport, serializer: @serializer, log: @log,
96
+ settings: @settings.slice(:flush_interval, :max_retries, :backoff, :sleeper))
97
+ end
98
+
99
+ def new_queue
100
+ EventQueue.new(max_size: @settings[:max_queue_size], batch_size: @settings[:batch_size])
101
+ end
102
+
103
+ # Counted before the queue closes: once closed, the worker drops what is left.
104
+ def log_undelivered(count)
105
+ @log.warn("shutdown timed out with #{count} event(s) undelivered")
106
+ end
107
+ end
108
+
109
+ # Drains the queue when the process exits (registered once, on the first
110
+ # background delivery).
111
+ module ExitHook
112
+ @installed = false
113
+
114
+ def self.install
115
+ return if @installed
116
+
117
+ @installed = true
118
+ at_exit { GoodSorcery.shutdown }
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Delivery
5
+ # Exponential backoff with jitter: about 1, 2, 4, 8 … seconds, capped at
6
+ # 60 s, each multiplied by a random factor between 0.5 and 1.
7
+ class Backoff
8
+ BASE = 1.0
9
+ MAX = 60.0
10
+
11
+ def initialize(random: Random.new)
12
+ @random = random
13
+ end
14
+
15
+ def delay(attempt)
16
+ [BASE * (2**attempt), MAX].min * (0.5 + (@random.rand * 0.5))
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Delivery
5
+ # Wraps an app-supplied delivery object. It must respond to
6
+ # deliver(event_hash) and may respond to flush(timeout) and shutdown(timeout).
7
+ class Custom
8
+ attr_reader :target
9
+
10
+ def initialize(target)
11
+ @target = target
12
+ end
13
+
14
+ def deliver(event)
15
+ @target.deliver(event)
16
+ end
17
+
18
+ def flush(timeout = 5)
19
+ @target.respond_to?(:flush) ? @target.flush(timeout) : true
20
+ end
21
+
22
+ def shutdown(timeout = 5)
23
+ @target.respond_to?(:shutdown) ? @target.shutdown(timeout) : true
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Delivery
5
+ # A bounded queue between the app's threads and the delivery worker.
6
+ # push never blocks: when the queue is full the event is dropped and
7
+ # counted. The worker takes batches and reports when each is done, so
8
+ # wait_until_idle can tell when everything queued has been handled.
9
+ class EventQueue
10
+ attr_reader :dropped
11
+
12
+ def initialize(max_size:, batch_size:)
13
+ @max_size = max_size
14
+ @batch_size = batch_size
15
+ @items = []
16
+ @in_flight = 0
17
+ @dropped = 0
18
+ @closed = false
19
+ @flushing = false
20
+ @mutex = Mutex.new
21
+ @arrived = ConditionVariable.new
22
+ @idle = ConditionVariable.new
23
+ @wakeup = ConditionVariable.new
24
+ end
25
+
26
+ def push(event)
27
+ @mutex.synchronize do
28
+ next reject if @closed || @items.size >= @max_size
29
+
30
+ @items << event
31
+ @arrived.signal if @items.size == 1 || @items.size >= @batch_size
32
+ true
33
+ end
34
+ end
35
+
36
+ # Waits until a batch is due: batch_size events queued, +interval+
37
+ # seconds since the first one arrived, a flush, or close. Returns []
38
+ # once the queue is closed and empty.
39
+ def take_batch(interval)
40
+ @mutex.synchronize do
41
+ @arrived.wait(@mutex) while @items.empty? && !@closed
42
+ wait_for_full_batch(interval)
43
+ batch = @items.shift(@batch_size)
44
+ @in_flight = batch.size
45
+ batch
46
+ end
47
+ end
48
+
49
+ def batch_done
50
+ @mutex.synchronize do
51
+ @in_flight = 0
52
+ @flushing = false if @items.empty?
53
+ @idle.broadcast if idle?
54
+ end
55
+ end
56
+
57
+ def wait_until_idle(timeout)
58
+ deadline = Clock.now + timeout
59
+ @mutex.synchronize do
60
+ @flushing = true
61
+ @arrived.signal
62
+ wait_on(@idle, deadline) until idle? || Clock.now >= deadline
63
+ @flushing = false if idle?
64
+ idle?
65
+ end
66
+ end
67
+
68
+ # Sleeps for +seconds+ unless the queue is closed first.
69
+ def pause(seconds)
70
+ deadline = Clock.now + seconds
71
+ @mutex.synchronize do
72
+ wait_on(@wakeup, deadline) until @closed || Clock.now >= deadline
73
+ end
74
+ end
75
+
76
+ def close
77
+ @mutex.synchronize do
78
+ @closed = true
79
+ [@arrived, @wakeup, @idle].each(&:broadcast)
80
+ end
81
+ end
82
+
83
+ def closed?
84
+ @mutex.synchronize { @closed }
85
+ end
86
+
87
+ def size
88
+ @mutex.synchronize { @items.size + @in_flight }
89
+ end
90
+
91
+ private
92
+
93
+ def reject
94
+ @dropped += 1
95
+ false
96
+ end
97
+
98
+ def idle?
99
+ @items.empty? && @in_flight.zero?
100
+ end
101
+
102
+ def wait_for_full_batch(interval)
103
+ deadline = Clock.now + interval
104
+ wait_on(@arrived, deadline) until batch_due? || Clock.now >= deadline
105
+ end
106
+
107
+ def batch_due?
108
+ @items.size >= @batch_size || @closed || @flushing
109
+ end
110
+
111
+ def wait_on(condition, deadline)
112
+ remaining = deadline - Clock.now
113
+ condition.wait(@mutex, remaining) if remaining.positive?
114
+ end
115
+ end
116
+
117
+ # Monotonic seconds.
118
+ module Clock
119
+ def self.now
120
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Delivery
5
+ # Sends each event immediately, in the calling thread. For scripts, rake
6
+ # tasks, serverless functions and debugging.
7
+ class Sync
8
+ def initialize(transport:, serializer:, log:)
9
+ @transport = transport
10
+ @serializer = serializer
11
+ @log = log
12
+ end
13
+
14
+ def deliver(event)
15
+ json = @serializer.dump(event)
16
+ return false unless json
17
+
18
+ result = @transport.post_event(json)
19
+ @log.warn_every(:sync_failed, 60, "could not deliver an event (#{result.description})") if result.retry_later?
20
+ result.success?
21
+ end
22
+
23
+ def flush(_timeout = nil) = true
24
+ def shutdown(_timeout = nil) = true
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Delivery
5
+ # Keeps events in memory for app test suites (GoodSorcery.test_events).
6
+ # Never touches the network.
7
+ class Test
8
+ def initialize
9
+ @events = []
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def deliver(event)
14
+ @mutex.synchronize { @events << event }
15
+ true
16
+ end
17
+
18
+ def events
19
+ @mutex.synchronize { @events.dup }
20
+ end
21
+
22
+ def clear
23
+ @mutex.synchronize { @events.clear }
24
+ end
25
+
26
+ def flush(_timeout = nil) = true
27
+ def shutdown(_timeout = nil) = true
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Delivery
5
+ # The loop run by the goodsorcery-worker thread: take a batch, serialize
6
+ # it, send it, retry 5xx/network failures with backoff and wait out 429s
7
+ # without spending a retry. It never raises and never reports its own
8
+ # errors as events.
9
+ class Worker
10
+ def initialize(queue:, transport:, serializer:, log:, settings:)
11
+ @queue = queue
12
+ @transport = transport
13
+ @serializer = serializer
14
+ @log = log
15
+ @flush_interval = settings.fetch(:flush_interval)
16
+ @max_retries = settings.fetch(:max_retries)
17
+ @backoff = settings.fetch(:backoff)
18
+ @sleeper = settings.fetch(:sleeper) || queue
19
+ end
20
+
21
+ def run
22
+ loop do
23
+ batch = @queue.take_batch(@flush_interval)
24
+ break if batch.empty?
25
+
26
+ deliver_batch(batch)
27
+ end
28
+ end
29
+
30
+ def deliver_batch(events)
31
+ jsons = events.filter_map { |event| @serializer.dump(event) }
32
+ send_with_retries(jsons)
33
+ rescue StandardError => e
34
+ @log.error("the delivery worker hit #{e.class}; #{events.size} event(s) dropped")
35
+ ensure
36
+ @queue.batch_done
37
+ end
38
+
39
+ private
40
+
41
+ def send_with_retries(jsons)
42
+ attempt = 0
43
+ until jsons.empty?
44
+ result = @transport.post_batch(jsons)
45
+ jsons = result.pending
46
+ attempt = wait_before_retry(result, attempt, jsons.size) unless jsons.empty?
47
+ break if attempt.nil?
48
+ end
49
+ end
50
+
51
+ # Returns the next attempt number, or nil to give up.
52
+ def wait_before_retry(result, attempt, count)
53
+ return give_up(result, count) if @queue.closed?
54
+
55
+ if result.rate_limited?
56
+ @sleeper.pause(result.retry_after)
57
+ return attempt
58
+ end
59
+ return give_up(result, count) if attempt >= @max_retries
60
+
61
+ @sleeper.pause(@backoff.delay(attempt))
62
+ attempt + 1
63
+ end
64
+
65
+ def give_up(result, count)
66
+ @log.warn_every(:delivery_failed, 60, "could not deliver #{count} event(s) (#{result.description}); dropped")
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "delivery/event_queue"
4
+ require_relative "delivery/backoff"
5
+ require_relative "delivery/worker"
6
+ require_relative "delivery/async"
7
+ require_relative "delivery/sync"
8
+ require_relative "delivery/test"
9
+ require_relative "delivery/custom"
10
+ require_relative "delivery/active_job_queue"
11
+
12
+ module GoodSorcery
13
+ # Delivery strategies (config.delivery). Each exposes deliver(event),
14
+ # flush(timeout) and shutdown(timeout).
15
+ module Delivery
16
+ def self.build(config, client)
17
+ case config.delivery
18
+ when :async then async(config, client.transport, client.serializer, client.log)
19
+ when :sync then Sync.new(transport: client.transport, serializer: client.serializer, log: client.log)
20
+ when :test then Test.new
21
+ when :active_job then active_job(config, client)
22
+ else Custom.new(config.delivery)
23
+ end
24
+ end
25
+
26
+ def self.async(config, transport, serializer, log)
27
+ Async.new(transport: transport, serializer: serializer, log: log,
28
+ batch_size: config.batch_size, flush_interval: config.flush_interval,
29
+ max_queue_size: config.max_queue_size, max_retries: config.max_retries,
30
+ shutdown_timeout: config.shutdown_timeout)
31
+ end
32
+
33
+ def self.active_job(config, client)
34
+ ActiveJobQueue.new(serializer: client.serializer, queue_name: config.active_job_queue, log: client.log) do
35
+ async(config, client.transport, client.serializer, client.log)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Sends one already-filtered event (a JSON string) synchronously. Retries
5
+ # 429/5xx/network failures with ActiveJob's retry_on. Any exception raised
6
+ # here is marked as reported first, so no integration reports it back to
7
+ # Good Sorcery (no recursion). The argument is captured error data, so
8
+ # Active Job never writes it to the log.
9
+ class DeliveryJob < ::ActiveJob::Base
10
+ WAIT = ::ActiveJob.gem_version >= Gem::Version.new("7.1") ? :polynomially_longer : :exponentially_longer
11
+
12
+ self.log_arguments = false
13
+ queue_as { GoodSorcery.configuration.active_job_queue }
14
+ retry_on GoodSorcery::RetryableDeliveryError, wait: WAIT, attempts: 5
15
+
16
+ def perform(event_json)
17
+ result = GoodSorcery.client.transport.post_event(event_json)
18
+ return unless result.retry_later?
19
+
20
+ raise GoodSorcery::RetryableDeliveryError, "event delivery failed (#{result.description})"
21
+ rescue Exception => e # rubocop:disable Lint/RescueException
22
+ ReportedMark.mark(e)
23
+ raise
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Reads boolean environment variables such as GOODSORCERY_ENABLED.
5
+ module EnvFlag
6
+ TRUE_VALUES = %w[1 true yes on].freeze
7
+ FALSE_VALUES = %w[0 false no off].freeze
8
+
9
+ # => true, false, or nil when the value is unset or unrecognised.
10
+ def self.parse(value)
11
+ normalized = value.to_s.strip.downcase
12
+ return true if TRUE_VALUES.include?(normalized)
13
+ return false if FALSE_VALUES.include?(normalized)
14
+
15
+ nil
16
+ end
17
+
18
+ # ENV[name] unless it is unset or blank.
19
+ def self.present(name)
20
+ value = ENV.fetch(name, nil)
21
+ value unless value.nil? || value.strip.empty?
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Decides whether reporting is on for the configured environment
5
+ # (DESIGN.md 3.2) and explains why.
6
+ class EnvironmentGate
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ # Checked on every report and breadcrumb, so it builds no strings.
12
+ def enabled?
13
+ explicit = @config.enabled
14
+ return explicit unless explicit.nil?
15
+
16
+ allow_list = @config.enabled_environments
17
+ return listed?(allow_list) if allow_list
18
+
19
+ !listed?(@config.disabled_environments)
20
+ end
21
+
22
+ def reason
23
+ decision.last
24
+ end
25
+
26
+ private
27
+
28
+ def decision
29
+ explicit = @config.enabled
30
+ return [explicit, "config.enabled (or GOODSORCERY_ENABLED) is #{explicit}"] unless explicit.nil?
31
+ return allow_list_decision if @config.enabled_environments
32
+
33
+ deny_list_decision
34
+ end
35
+
36
+ def allow_list_decision
37
+ included = names(@config.enabled_environments).include?(environment)
38
+ verb = included ? "is" : "is not"
39
+ [included, "environment #{environment.inspect} #{verb} in enabled_environments"]
40
+ end
41
+
42
+ def deny_list_decision
43
+ excluded = names(@config.disabled_environments).include?(environment)
44
+ verb = excluded ? "is" : "is not"
45
+ [!excluded, "environment #{environment.inspect} #{verb} in disabled_environments"]
46
+ end
47
+
48
+ def environment
49
+ @config.environment.to_s
50
+ end
51
+
52
+ def names(list)
53
+ Array(list).map(&:to_s)
54
+ end
55
+
56
+ def listed?(list)
57
+ name = environment
58
+ Array(list).any? { |entry| entry.to_s == name }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ # Base class for errors raised by the gem itself.
5
+ class Error < StandardError; end
6
+
7
+ # Raised (and rescued) by `goodsorcery:test` and the install check to send a
8
+ # realistic test exception.
9
+ class TestError < StandardError; end
10
+
11
+ # Raised by GoodSorcery::DeliveryJob so ActiveJob retries a delivery that
12
+ # failed with a 429, a 5xx or a network error.
13
+ class RetryableDeliveryError < Error; end
14
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Event
5
+ # Builds one breadcrumb, copying and capping its data when it is recorded
6
+ # so the scope never holds on to app objects.
7
+ module Breadcrumb
8
+ CATEGORIES = %w[request controller sql http cache log job custom].freeze
9
+ LEVELS = %w[debug info warning error].freeze
10
+ MAX_MESSAGE_LENGTH = 1_000
11
+ DATA_SANITIZER = Sanitizer.new(max_depth: 4, max_items: 20, max_keys: 20, max_string: 1_000)
12
+
13
+ module_function
14
+
15
+ def build(message, category: "custom", level: "info", data: nil, time: Time.now)
16
+ crumb = {
17
+ "timestamp" => Fields.timestamp(time),
18
+ "category" => choice(category, CATEGORIES, "custom"),
19
+ "level" => choice(Fields::LEVEL_ALIASES.fetch(level.to_s, level.to_s), LEVELS, "info"),
20
+ "message" => Text.limit(message, MAX_MESSAGE_LENGTH)
21
+ }
22
+ crumb["data"] = DATA_SANITIZER.sanitize(data) if data.is_a?(Hash) && !data.empty?
23
+ crumb
24
+ end
25
+
26
+ def choice(value, allowed, default)
27
+ name = value.to_s
28
+ allowed.include?(name) ? name : default
29
+ end
30
+ end
31
+ end
32
+ end