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,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
|