raises 0.1.0 → 0.3.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: 826d161b194d0bb9f3d3b1e00f3326dc986da4efb420e9ae1c8ff0dccf59708e
4
+ data.tar.gz: b0b9b1dbe0e03571e0585c6982e59091495337e14118765a0ff1274427a1b956
5
5
  SHA512:
6
- metadata.gz: 3ca5937ee49e66bafd8c7dc6d25ca55999d35ebc2f336f75d9206f3a1dccc4570b70c62f4d3169021b305d790941586df216ade5d4257c090b8c9223b09a7f1f
7
- data.tar.gz: 95e0bac19718d9f1f0ab67856425c31180f7207c72e0e2fc1cb64d0727c3c446a1cf7dcbaf9adfa33c7c00f8c446b64f7563d58a1cb70af899008b1d90cb256f
6
+ metadata.gz: ee447f476885abbda2516cbe5b92600b824b879507954f23c8f276225adb718f78652f2cc53bf79e1bf55fe11a6203bad9519b4adffbbcb64e4eae20ca9b653a
7
+ data.tar.gz: 15d056b1826db28175a104359215f1a3eebd7497358a8c260057b0811e5a3758bef330c9412372393f7de077a81ea7741bcd8a82a20a9e0f2a0e0898c14c6098
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Add `Raises.notify` for informational, warning, and error-level events.
6
+ - Preserve event and exception routes in the optional disk spool.
7
+ - Keep informational events separate from exception grouping and GitHub issues.
8
+
9
+ ## 0.2.0
10
+
11
+ - Add opt-in, bounded disk spooling through `RAISES_SPOOL_DIR`.
12
+ - Retry transient delivery failures safely across Rails processes and restarts.
13
+ - Keep ingestion credentials out of spool files.
14
+
3
15
  ## 0.1.0
4
16
 
5
17
  - Initial Rails 7.1+ `Rails.error` subscriber.
data/README.md CHANGED
@@ -8,6 +8,21 @@ gem "raises"
8
8
 
9
9
  Set `RAISES_TOKEN` to the ingestion credential created for the project. Production errors are reported to `https://raises.dev` automatically through `Rails.error`. Set `RAISES_REPORT=1` to exercise the integration outside production.
10
10
 
11
+ Send a one-off operational notice without raising an exception:
12
+
13
+ ```ruby
14
+ Raises.notify(
15
+ "Import finished",
16
+ level: :info,
17
+ source: "nightly-import",
18
+ context: { imported: 412, skipped: 3 }
19
+ )
20
+ ```
21
+
22
+ Valid levels are `:info`, `:warning`, and `:error`. Notices are retained and delivered to configured outbound integrations, but they do not create error groups, acknowledgements, or GitHub issues.
23
+
11
24
  Optional settings are `RAISES_URL`, `RAISES_ENV`, `RAISES_REVISION`, `RAISES_OPEN_TIMEOUT`, and `RAISES_READ_TIMEOUT`.
12
25
 
26
+ Set `RAISES_SPOOL_DIR` to add restart-safe, disk-backed delivery retries. The directory must be on persistent storage if payloads should survive a deploy. Raises stores event or exception 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 payloads or 100 MB. Leave it unset to preserve synchronous best-effort delivery.
27
+
13
28
  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,50 @@
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(payload, path: "v1/notices")
14
+ item = { "path" => path, "payload" => payload }
15
+ response = @post.call(path, payload)
16
+ return true unless response.respond_to?(:code)
17
+ return true if response.is_a?(Net::HTTPSuccess) || response.code.to_i.between?(200, 299)
18
+
19
+ response_accepted?(item, response.code.to_i)
20
+ rescue StandardError => e
21
+ exception_accepted?(item || { "path" => path, "payload" => payload }, e)
22
+ end
23
+
24
+ private
25
+
26
+ def response_accepted?(item, status)
27
+ if retryable_status?(status) && @spool&.enqueue(item)
28
+ @warn.call("raises queued notice after HTTP #{status}")
29
+ true
30
+ else
31
+ @warn.call("raises rejected notice: HTTP #{status}")
32
+ false
33
+ end
34
+ end
35
+
36
+ def exception_accepted?(item, error)
37
+ if @spool&.enqueue(item)
38
+ @warn.call("raises queued notice after #{error.class}")
39
+ true
40
+ else
41
+ @warn.call("raises subscriber failed: #{error.class}: #{error.message}")
42
+ false
43
+ end
44
+ end
45
+
46
+ def retryable_status?(status)
47
+ status == 408 || status == 429 || status >= 500
48
+ end
49
+ end
50
+ 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)
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(item)
42
+ accepted = @storage.store(item) == :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("path"), envelope.fetch("payload"))
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,140 @@
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(item)
20
+ prepare
21
+ raw = JSON.generate(
22
+ "version" => 2,
23
+ "attempts" => 0,
24
+ "next_attempt_at" => @now.call.to_f,
25
+ "path" => item.fetch("path"),
26
+ "payload" => item.fetch("payload")
27
+ )
28
+ with_directory_lock do
29
+ return :full unless capacity_for?(raw)
30
+
31
+ atomic_write(File.join(@directory, filename), raw)
32
+ end
33
+ :stored
34
+ end
35
+
36
+ def each_due(limit:)
37
+ processed = 0
38
+ files.each do |path|
39
+ break if processed >= limit
40
+
41
+ File.open(path, File::RDWR) do |file|
42
+ next unless file.flock(File::LOCK_EX | File::LOCK_NB)
43
+
44
+ envelope = parse(file.read, path)
45
+ next unless envelope
46
+ next if envelope.fetch("next_attempt_at", 0).to_f > @now.call.to_f
47
+
48
+ processed += 1
49
+ yield path, envelope
50
+ end
51
+ rescue Errno::ENOENT
52
+ next
53
+ end
54
+ processed
55
+ end
56
+
57
+ def delete(path)
58
+ File.delete(path)
59
+ rescue Errno::ENOENT
60
+ nil
61
+ end
62
+
63
+ def rewrite(path, envelope)
64
+ atomic_write(path, JSON.generate(envelope))
65
+ end
66
+
67
+ private
68
+
69
+ def prepare
70
+ FileUtils.mkdir_p(@directory, mode: 0o700)
71
+ File.chmod(0o700, @directory)
72
+ end
73
+
74
+ def files
75
+ Dir.glob(File.join(@directory, "*.json"))
76
+ end
77
+
78
+ def capacity_for?(raw)
79
+ paths = files
80
+ return false if paths.length >= MAX_NOTICES
81
+
82
+ paths.sum { |path| file_size(path) } + raw.bytesize <= MAX_BYTES
83
+ end
84
+
85
+ def file_size(path)
86
+ File.size(path)
87
+ rescue Errno::ENOENT
88
+ 0
89
+ end
90
+
91
+ def filename
92
+ format("%<time>020d-%<random>s.json", time: (@now.call.to_f * 1_000_000).to_i, random: SecureRandom.hex(8))
93
+ end
94
+
95
+ def with_directory_lock
96
+ File.open(File.join(@directory, ".lock"), File::WRONLY | File::CREAT, 0o600) do |lock|
97
+ lock.flock(File::LOCK_EX)
98
+ yield
99
+ end
100
+ end
101
+
102
+ def atomic_write(path, raw)
103
+ temporary = "#{path}.tmp-#{Process.pid}-#{SecureRandom.hex(4)}"
104
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
105
+ file.write(raw)
106
+ file.flush
107
+ file.fsync
108
+ end
109
+ File.rename(temporary, path)
110
+ ensure
111
+ File.delete(temporary) if defined?(temporary) && File.exist?(temporary)
112
+ end
113
+
114
+ def parse(raw, path)
115
+ envelope = JSON.parse(raw)
116
+ return envelope if envelope.is_a?(Hash) && envelope["path"].is_a?(String) && envelope["payload"].is_a?(Hash)
117
+
118
+ if envelope.is_a?(Hash) && envelope["notice"].is_a?(Hash)
119
+ envelope["version"] = 2
120
+ envelope["path"] = "v1/notices"
121
+ envelope["payload"] = envelope.delete("notice")
122
+ return envelope
123
+ end
124
+
125
+ raise JSON::ParserError, "invalid envelope"
126
+ rescue JSON::ParserError, TypeError => e
127
+ quarantine(path)
128
+ @warn.call("raises quarantined corrupt spool entry: #{e.message}")
129
+ nil
130
+ end
131
+
132
+ def quarantine(path)
133
+ target = path.sub(/\.json\z/, ".corrupt")
134
+ File.rename(path, target)
135
+ File.chmod(0o600, target)
136
+ rescue Errno::ENOENT
137
+ nil
138
+ end
139
+ end
140
+ end
@@ -5,10 +5,21 @@ require "net/http"
5
5
  require "uri"
6
6
 
7
7
  module Raises
8
+ # Rails.error integration and explicit application notices share one delivery path.
9
+ # rubocop:disable Metrics/ClassLength
8
10
  class Subscriber
11
+ def initialize
12
+ @spool = build_spool
13
+ @delivery = Delivery.new(post: method(:post), spool: @spool, warn: ->(message) { warn(message) })
14
+ end
15
+
16
+ def start = tap { @spool&.start }
17
+
9
18
  def report(error, handled:, severity:, context:, source: nil)
10
19
  return unless report?
11
20
 
21
+ start
22
+
12
23
  notice = {
13
24
  env: env_name,
14
25
  revision: revision,
@@ -22,13 +33,55 @@ module Raises
22
33
  request: request_from(context)
23
34
  }
24
35
 
25
- post(notice)
36
+ @delivery.call(notice, path: "v1/notices")
26
37
  rescue StandardError => e
27
38
  warn("raises subscriber failed: #{e.class}: #{e.message}")
39
+ false
40
+ end
41
+
42
+ def notify(message, level: :info, context: {}, source: nil)
43
+ message = message.to_s.strip
44
+ level = level.to_s
45
+ validate_event!(message, level, context, source)
46
+ return false unless report?
47
+
48
+ start
49
+ event = {
50
+ env: env_name,
51
+ revision: revision,
52
+ level: level,
53
+ message: message,
54
+ source: source,
55
+ context: json_safe(context)
56
+ }
57
+ @delivery.call(event, path: "v1/events")
58
+ rescue ArgumentError
59
+ raise
60
+ rescue StandardError => e
61
+ warn("raises notify failed: #{e.class}: #{e.message}")
62
+ false
28
63
  end
29
64
 
30
65
  private
31
66
 
67
+ def validate_event!(message, level, context, source)
68
+ raise ArgumentError, "message is required" if message.empty?
69
+ raise ArgumentError, "message is too long" if message.length > 2_000
70
+ raise ArgumentError, "level must be info, warning, or error" unless %w[info warning error].include?(level)
71
+ raise ArgumentError, "source is too long" if source.to_s.length > 120
72
+ raise ArgumentError, "context must be a hash" unless context.respond_to?(:each_pair)
73
+ end
74
+
75
+ def build_spool
76
+ directory = ENV["RAISES_SPOOL_DIR"].to_s
77
+ return if directory.empty?
78
+
79
+ Spool.new(directory, deliver: method(:post), warn: ->(message) { warn(message) })
80
+ rescue StandardError => e
81
+ warn("raises spool unavailable: #{e.class}: #{e.message}")
82
+ nil
83
+ end
84
+
32
85
  def report?
33
86
  return false if url.to_s.empty? || token.to_s.empty?
34
87
  return true if ENV["RAISES_REPORT"] == "1"
@@ -113,8 +166,8 @@ module Raises
113
166
  end
114
167
  end
115
168
 
116
- def post(notice)
117
- uri = URI.join(url.end_with?("/") ? url : "#{url}/", "v1/notices")
169
+ def post(path, payload)
170
+ uri = URI.join(url.end_with?("/") ? url : "#{url}/", path)
118
171
  http = Net::HTTP.new(uri.host, uri.port)
119
172
  http.use_ssl = uri.scheme == "https"
120
173
  http.open_timeout = timeout("RAISES_OPEN_TIMEOUT", 1)
@@ -123,10 +176,8 @@ module Raises
123
176
  request["Authorization"] = "Bearer #{token}"
124
177
  request["Content-Type"] = "application/json"
125
178
  request["User-Agent"] = "raises-ruby/#{Raises::VERSION}"
126
- 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
179
+ request.body = JSON.generate(payload)
180
+ http.request(request)
130
181
  end
131
182
 
132
183
  def timeout(name, fallback)
@@ -136,4 +187,5 @@ module Raises
136
187
  fallback
137
188
  end
138
189
  end
190
+ # rubocop:enable Metrics/ClassLength
139
191
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raises
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/raises.rb CHANGED
@@ -1,8 +1,20 @@
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
 
7
10
  module Raises
11
+ class << self
12
+ def notify(message, level: :info, context: {}, source: nil)
13
+ subscriber.notify(message, level: level, context: context, source: source)
14
+ end
15
+
16
+ def subscriber
17
+ @subscriber ||= Subscriber.new.start
18
+ end
19
+ end
8
20
  end
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.3.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