raises 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +2 -0
- data/lib/raises/delivery.rb +47 -0
- data/lib/raises/railtie.rb +1 -1
- data/lib/raises/spool.rb +133 -0
- data/lib/raises/spool_storage.rb +127 -0
- data/lib/raises/subscriber.rb +21 -4
- data/lib/raises/version.rb +1 -1
- data/lib/raises.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd2351828d8eeb04e0af96c88322c369dcd2ecd158384d7e80543cede4153646
|
|
4
|
+
data.tar.gz: 85e43d1fa7d393159c8bb54fc99898863fc5afd7369f680486e8947d74412c1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f10b1aa6748ccf6848f9b2ceccb479c1d4a6182b44350318cffd93bab4e70f152613fe66520c1c6cc9d600e912b36eb0abc4ba4b5e0072cca6356e6cdf8676ba
|
|
7
|
+
data.tar.gz: 5ba3f6610cce1de92879f3599180d630e38b8183a5ea357ad1e1b9e8d918d7199fdf48bda4fedf9152f93492fec65c576db6404779160d85d2e8cba1413bb532
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Add opt-in, bounded disk spooling through `RAISES_SPOOL_DIR`.
|
|
6
|
+
- Retry transient delivery failures safely across Rails processes and restarts.
|
|
7
|
+
- Keep ingestion credentials out of spool files.
|
|
8
|
+
|
|
3
9
|
## 0.1.0
|
|
4
10
|
|
|
5
11
|
- Initial Rails 7.1+ `Rails.error` subscriber.
|
data/README.md
CHANGED
|
@@ -10,4 +10,6 @@ Set `RAISES_TOKEN` to the ingestion credential created for the project. Producti
|
|
|
10
10
|
|
|
11
11
|
Optional settings are `RAISES_URL`, `RAISES_ENV`, `RAISES_REVISION`, `RAISES_OPEN_TIMEOUT`, and `RAISES_READ_TIMEOUT`.
|
|
12
12
|
|
|
13
|
+
Set `RAISES_SPOOL_DIR` to add restart-safe, disk-backed delivery retries. The directory must be on persistent storage if notices should survive a deploy. Raises stores notice JSON there with private permissions, never stores the ingestion token, retries network errors, HTTP 408/429, and 5xx responses, and bounds the spool at 1,000 notices or 100 MB. Leave it unset to preserve synchronous best-effort delivery.
|
|
14
|
+
|
|
13
15
|
The reporter fails open: network or serialization failures never replace the application exception. Rails filtered parameters are included; headers, raw bodies, query strings, and the request object are not.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module Raises
|
|
6
|
+
class Delivery
|
|
7
|
+
def initialize(post:, spool:, warn:)
|
|
8
|
+
@post = post
|
|
9
|
+
@spool = spool
|
|
10
|
+
@warn = warn
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(notice)
|
|
14
|
+
response = @post.call(notice)
|
|
15
|
+
return response unless response.respond_to?(:code)
|
|
16
|
+
return response if response.is_a?(Net::HTTPSuccess)
|
|
17
|
+
|
|
18
|
+
handle_response(notice, response.code.to_i)
|
|
19
|
+
response
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
handle_exception(notice, e)
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def handle_response(notice, status)
|
|
28
|
+
if retryable_status?(status) && @spool&.enqueue(notice)
|
|
29
|
+
@warn.call("raises queued notice after HTTP #{status}")
|
|
30
|
+
else
|
|
31
|
+
@warn.call("raises rejected notice: HTTP #{status}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def handle_exception(notice, error)
|
|
36
|
+
if @spool&.enqueue(notice)
|
|
37
|
+
@warn.call("raises queued notice after #{error.class}")
|
|
38
|
+
else
|
|
39
|
+
@warn.call("raises subscriber failed: #{error.class}: #{error.message}")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def retryable_status?(status)
|
|
44
|
+
status == 408 || status == 429 || status >= 500
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/raises/railtie.rb
CHANGED
data/lib/raises/spool.rb
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "spool_storage"
|
|
4
|
+
|
|
5
|
+
module Raises
|
|
6
|
+
class Spool
|
|
7
|
+
RETRYABLE_STATUS = [408, 429].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(directory, deliver:, warn:, **options)
|
|
10
|
+
@deliver = deliver
|
|
11
|
+
@warn = warn
|
|
12
|
+
@now = options.fetch(:now, -> { Time.now })
|
|
13
|
+
@random = options.fetch(:random, Random.new)
|
|
14
|
+
@start_on_enqueue = options.fetch(:start_on_enqueue, true)
|
|
15
|
+
@storage = SpoolStorage.new(directory, warn: warn, now: @now)
|
|
16
|
+
reset_process_state
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
reset_process_state if @pid != Process.pid
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
return if @thread&.alive?
|
|
23
|
+
|
|
24
|
+
@thread = Thread.new { run }
|
|
25
|
+
@thread.name = "raises-spool" if @thread.respond_to?(:name=)
|
|
26
|
+
@thread.report_on_exception = false
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stop
|
|
31
|
+
return if @pid != Process.pid
|
|
32
|
+
|
|
33
|
+
thread = @mutex.synchronize do
|
|
34
|
+
@stopping = true
|
|
35
|
+
@condition.broadcast
|
|
36
|
+
@thread
|
|
37
|
+
end
|
|
38
|
+
thread&.join(1)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def enqueue(notice)
|
|
42
|
+
accepted = @storage.store(notice) == :stored
|
|
43
|
+
if accepted
|
|
44
|
+
start if @start_on_enqueue
|
|
45
|
+
wake if @start_on_enqueue
|
|
46
|
+
else
|
|
47
|
+
@warn.call("raises spool is full; notice was not queued")
|
|
48
|
+
end
|
|
49
|
+
accepted
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
@warn.call("raises could not queue notice: #{e.class}: #{e.message}")
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def drain_once(limit: 20)
|
|
56
|
+
@storage.each_due(limit: limit) do |path, envelope|
|
|
57
|
+
deliver(path, envelope)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def reset_process_state
|
|
64
|
+
@pid = Process.pid
|
|
65
|
+
@mutex = Mutex.new
|
|
66
|
+
@condition = ConditionVariable.new
|
|
67
|
+
@thread = nil
|
|
68
|
+
@stopping = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def deliver(path, envelope)
|
|
72
|
+
response = @deliver.call(envelope.fetch("notice"))
|
|
73
|
+
status = response_code(response)
|
|
74
|
+
if status.between?(200, 299)
|
|
75
|
+
@storage.delete(path)
|
|
76
|
+
elsif retryable_status?(status)
|
|
77
|
+
reschedule(path, envelope, retry_after(response))
|
|
78
|
+
else
|
|
79
|
+
@storage.delete(path)
|
|
80
|
+
@warn.call("raises rejected queued notice: HTTP #{status}")
|
|
81
|
+
end
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
reschedule(path, envelope)
|
|
84
|
+
@warn.call("raises queued notice retry failed: #{e.class}: #{e.message}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def response_code(response)
|
|
88
|
+
Integer(response.respond_to?(:code) ? response.code : response)
|
|
89
|
+
rescue ArgumentError, TypeError
|
|
90
|
+
500
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def retryable_status?(status)
|
|
94
|
+
RETRYABLE_STATUS.include?(status) || status >= 500
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def retry_after(response)
|
|
98
|
+
return unless response.respond_to?(:[])
|
|
99
|
+
|
|
100
|
+
seconds = Integer(response["Retry-After"], exception: false)
|
|
101
|
+
seconds&.clamp(1, 3_600)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def reschedule(path, envelope, requested_delay = nil)
|
|
105
|
+
attempts = envelope.fetch("attempts", 0).to_i + 1
|
|
106
|
+
base = [2**[attempts, 8].min, 300].min
|
|
107
|
+
delay = requested_delay || (base + (@random.rand * base * 0.2))
|
|
108
|
+
envelope["attempts"] = attempts
|
|
109
|
+
envelope["next_attempt_at"] = @now.call.to_f + delay
|
|
110
|
+
@storage.rewrite(path, envelope)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def run
|
|
114
|
+
loop do
|
|
115
|
+
drain_once
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
break if @stopping
|
|
118
|
+
|
|
119
|
+
@condition.wait(@mutex, 5)
|
|
120
|
+
break if @stopping
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
@warn.call("raises spool worker stopped: #{e.class}: #{e.message}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def wake
|
|
128
|
+
return if @pid != Process.pid
|
|
129
|
+
|
|
130
|
+
@mutex.synchronize { @condition.broadcast }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Raises
|
|
8
|
+
class SpoolStorage
|
|
9
|
+
MAX_NOTICES = 1_000
|
|
10
|
+
MAX_BYTES = 100 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
def initialize(directory, warn:, now:)
|
|
13
|
+
@directory = File.expand_path(directory)
|
|
14
|
+
@warn = warn
|
|
15
|
+
@now = now
|
|
16
|
+
prepare
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def store(notice)
|
|
20
|
+
prepare
|
|
21
|
+
raw = JSON.generate("version" => 1, "attempts" => 0, "next_attempt_at" => @now.call.to_f, "notice" => notice)
|
|
22
|
+
with_directory_lock do
|
|
23
|
+
return :full unless capacity_for?(raw)
|
|
24
|
+
|
|
25
|
+
atomic_write(File.join(@directory, filename), raw)
|
|
26
|
+
end
|
|
27
|
+
:stored
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def each_due(limit:)
|
|
31
|
+
processed = 0
|
|
32
|
+
files.each do |path|
|
|
33
|
+
break if processed >= limit
|
|
34
|
+
|
|
35
|
+
File.open(path, File::RDWR) do |file|
|
|
36
|
+
next unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
37
|
+
|
|
38
|
+
envelope = parse(file.read, path)
|
|
39
|
+
next unless envelope
|
|
40
|
+
next if envelope.fetch("next_attempt_at", 0).to_f > @now.call.to_f
|
|
41
|
+
|
|
42
|
+
processed += 1
|
|
43
|
+
yield path, envelope
|
|
44
|
+
end
|
|
45
|
+
rescue Errno::ENOENT
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
processed
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delete(path)
|
|
52
|
+
File.delete(path)
|
|
53
|
+
rescue Errno::ENOENT
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def rewrite(path, envelope)
|
|
58
|
+
atomic_write(path, JSON.generate(envelope))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def prepare
|
|
64
|
+
FileUtils.mkdir_p(@directory, mode: 0o700)
|
|
65
|
+
File.chmod(0o700, @directory)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def files
|
|
69
|
+
Dir.glob(File.join(@directory, "*.json"))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def capacity_for?(raw)
|
|
73
|
+
paths = files
|
|
74
|
+
return false if paths.length >= MAX_NOTICES
|
|
75
|
+
|
|
76
|
+
paths.sum { |path| file_size(path) } + raw.bytesize <= MAX_BYTES
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def file_size(path)
|
|
80
|
+
File.size(path)
|
|
81
|
+
rescue Errno::ENOENT
|
|
82
|
+
0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def filename
|
|
86
|
+
format("%<time>020d-%<random>s.json", time: (@now.call.to_f * 1_000_000).to_i, random: SecureRandom.hex(8))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def with_directory_lock
|
|
90
|
+
File.open(File.join(@directory, ".lock"), File::WRONLY | File::CREAT, 0o600) do |lock|
|
|
91
|
+
lock.flock(File::LOCK_EX)
|
|
92
|
+
yield
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def atomic_write(path, raw)
|
|
97
|
+
temporary = "#{path}.tmp-#{Process.pid}-#{SecureRandom.hex(4)}"
|
|
98
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
99
|
+
file.write(raw)
|
|
100
|
+
file.flush
|
|
101
|
+
file.fsync
|
|
102
|
+
end
|
|
103
|
+
File.rename(temporary, path)
|
|
104
|
+
ensure
|
|
105
|
+
File.delete(temporary) if defined?(temporary) && File.exist?(temporary)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse(raw, path)
|
|
109
|
+
envelope = JSON.parse(raw)
|
|
110
|
+
return envelope if envelope.is_a?(Hash) && envelope["notice"].is_a?(Hash)
|
|
111
|
+
|
|
112
|
+
raise JSON::ParserError, "invalid envelope"
|
|
113
|
+
rescue JSON::ParserError, TypeError => e
|
|
114
|
+
quarantine(path)
|
|
115
|
+
@warn.call("raises quarantined corrupt spool entry: #{e.message}")
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def quarantine(path)
|
|
120
|
+
target = path.sub(/\.json\z/, ".corrupt")
|
|
121
|
+
File.rename(path, target)
|
|
122
|
+
File.chmod(0o600, target)
|
|
123
|
+
rescue Errno::ENOENT
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/raises/subscriber.rb
CHANGED
|
@@ -6,9 +6,18 @@ require "uri"
|
|
|
6
6
|
|
|
7
7
|
module Raises
|
|
8
8
|
class Subscriber
|
|
9
|
+
def initialize
|
|
10
|
+
@spool = build_spool
|
|
11
|
+
@delivery = Delivery.new(post: method(:post), spool: @spool, warn: ->(message) { warn(message) })
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def start = tap { @spool&.start }
|
|
15
|
+
|
|
9
16
|
def report(error, handled:, severity:, context:, source: nil)
|
|
10
17
|
return unless report?
|
|
11
18
|
|
|
19
|
+
start
|
|
20
|
+
|
|
12
21
|
notice = {
|
|
13
22
|
env: env_name,
|
|
14
23
|
revision: revision,
|
|
@@ -22,13 +31,23 @@ module Raises
|
|
|
22
31
|
request: request_from(context)
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
|
|
34
|
+
@delivery.call(notice)
|
|
26
35
|
rescue StandardError => e
|
|
27
36
|
warn("raises subscriber failed: #{e.class}: #{e.message}")
|
|
28
37
|
end
|
|
29
38
|
|
|
30
39
|
private
|
|
31
40
|
|
|
41
|
+
def build_spool
|
|
42
|
+
directory = ENV["RAISES_SPOOL_DIR"].to_s
|
|
43
|
+
return if directory.empty?
|
|
44
|
+
|
|
45
|
+
Spool.new(directory, deliver: method(:post), warn: ->(message) { warn(message) })
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
warn("raises spool unavailable: #{e.class}: #{e.message}")
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
32
51
|
def report?
|
|
33
52
|
return false if url.to_s.empty? || token.to_s.empty?
|
|
34
53
|
return true if ENV["RAISES_REPORT"] == "1"
|
|
@@ -124,9 +143,7 @@ module Raises
|
|
|
124
143
|
request["Content-Type"] = "application/json"
|
|
125
144
|
request["User-Agent"] = "raises-ruby/#{Raises::VERSION}"
|
|
126
145
|
request.body = JSON.generate(notice)
|
|
127
|
-
|
|
128
|
-
warn("raises rejected notice: HTTP #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
|
129
|
-
response
|
|
146
|
+
http.request(request)
|
|
130
147
|
end
|
|
131
148
|
|
|
132
149
|
def timeout(name, fallback)
|
data/lib/raises/version.rb
CHANGED
data/lib/raises.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: raises
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Clayton Lengel-Zigich
|
|
@@ -32,7 +32,10 @@ files:
|
|
|
32
32
|
- LICENSE.txt
|
|
33
33
|
- README.md
|
|
34
34
|
- lib/raises.rb
|
|
35
|
+
- lib/raises/delivery.rb
|
|
35
36
|
- lib/raises/railtie.rb
|
|
37
|
+
- lib/raises/spool.rb
|
|
38
|
+
- lib/raises/spool_storage.rb
|
|
36
39
|
- lib/raises/subscriber.rb
|
|
37
40
|
- lib/raises/version.rb
|
|
38
41
|
homepage: https://raises.dev
|