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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf7a8c31ce5ed40e110b57671dee77d76cc712059ba386c64fed29f3e9da31ad
4
- data.tar.gz: 12e7e56ce32e4c3cbeea11912f7de8e765ee0cf44636a99bd963e8b2796c510f
3
+ metadata.gz: cd2351828d8eeb04e0af96c88322c369dcd2ecd158384d7e80543cede4153646
4
+ data.tar.gz: 85e43d1fa7d393159c8bb54fc99898863fc5afd7369f680486e8947d74412c1a
5
5
  SHA512:
6
- metadata.gz: 3ca5937ee49e66bafd8c7dc6d25ca55999d35ebc2f336f75d9206f3a1dccc4570b70c62f4d3169021b305d790941586df216ade5d4257c090b8c9223b09a7f1f
7
- data.tar.gz: 95e0bac19718d9f1f0ab67856425c31180f7207c72e0e2fc1cb64d0727c3c446a1cf7dcbaf9adfa33c7c00f8c446b64f7563d58a1cb70af899008b1d90cb256f
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
@@ -3,7 +3,7 @@
3
3
  module Raises
4
4
  class Railtie < Rails::Railtie
5
5
  initializer "raises.subscribe" do
6
- Rails.error.subscribe(Raises::Subscriber.new)
6
+ Rails.error.subscribe(Raises::Subscriber.new.start)
7
7
  end
8
8
  end
9
9
  end
@@ -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
@@ -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
- post(notice)
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
- response = http.request(request)
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raises
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/raises.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "raises/version"
4
+ require "raises/spool_storage"
5
+ require "raises/spool"
6
+ require "raises/delivery"
4
7
  require "raises/subscriber"
5
8
  require "raises/railtie" if defined?(Rails::Railtie)
6
9
 
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.1.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