errorgap 0.8.0 → 0.9.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: 0b1e9118a26db2870eb0aa90b9a4ef3784138e078d03b2959ce1b064949b53de
4
- data.tar.gz: e3da3e394f5aa7961c5956f8fdfa5346696086324b48eedac330e0d95dd42f59
3
+ metadata.gz: c01da4468e72e4df60f1a720efa5f8c5873357a2681c3c8163b97c83de0489f4
4
+ data.tar.gz: 11274b3058d605600c50fcac50f3e9dc7e9f118d5f8d1ab673d6b754d3e039d6
5
5
  SHA512:
6
- metadata.gz: b8d0f1bae2b1df08c0893e414a8916a4cc7487afa5443eb3c1de0860133baca93d71c29935552c158ec93392b878b44933848e53ed5761aca84fd141014d2702
7
- data.tar.gz: a9ba148ab19bba740b6d877403ee628c43f303a27abfdd20347a5728d7860ea1ec792739b3b23c5b1992066cdd309ac457fd28de60fd9bb7cca4af19bddc49b6
6
+ metadata.gz: 0b438eac927e6e9fe023551f5106b73a4f4e160af91acc90940686fbae4924ba33216ff524d224145c23370cdc398b47bfac644920c28a9edeab8c4e2a29a201
7
+ data.tar.gz: 521300e7615cce7765d24e98563368815b2cd7d6cb8397205988b3bcd2673d378bd14bad26ec5845d87dd20cc639b882fc865e4699efe287dee5dc078f76a93d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.0] - 2026-10-13
9
+
10
+ ### Added
11
+
12
+ - **Background jobs.** In Rails, ActiveJob jobs (any adapter) and Sidekiq
13
+ jobs are timed as `job` transactions and their errors are reported with the
14
+ job class, queue, id and arguments, then re-raised so retries work as
15
+ before. Outside Rails: `Errorgap::Integrations::ActiveJob.install` /
16
+ `Errorgap::Integrations::Sidekiq.install`.
17
+ - **`config.before_notify`.** A block that sees each notice before it is sent
18
+ and can change it or return `false` to drop it.
19
+ - **Outbound HTTP spans.** `Net::HTTP` requests made during a transaction are
20
+ recorded as `http` spans (`GET api.stripe.com`).
21
+ - **Signed-in user.** Errors raised in a request carry the Warden / Devise
22
+ user as `session.user_id` and `session.user_email`
23
+ (`config.capture_user = false` to turn off). `Errorgap.set_user(id:,
24
+ email:)` names the user anywhere else.
25
+ - **Release detection.** With `config.release` unset the release is read from
26
+ the platform's commit variable (`SOURCE_VERSION`, `HEROKU_SLUG_COMMIT`,
27
+ `RENDER_GIT_COMMIT`, `KAMAL_VERSION`, …), a Capistrano `REVISION` file or
28
+ the git checkout.
29
+ - **Deploys.** `Errorgap.track_release` and `errorgap deploy` record a release
30
+ with errorgap; `config.track_release = true` records it when the first
31
+ error of a process is reported.
32
+ - `config.timeout` (5 seconds) and `config.queue_size` (100).
33
+
34
+ ### Changed
35
+
36
+ - **Delivery.** Async sends go through one bounded queue and one worker
37
+ thread instead of a thread per notice, transaction and log line. A full
38
+ queue drops its oldest entry. Requests time out after `config.timeout`
39
+ (they previously waited up to 60 seconds). Sends that fail on the network
40
+ or with a 5xx are retried up to three times; a 429 or 503 pauses sending
41
+ for the server's `Retry-After`. Queued payloads are flushed at exit, and
42
+ the worker restarts in forked processes (Puma workers, Sidekiq).
43
+ - The Rack middleware queues an uncaught error instead of sending it inside
44
+ the failing request.
45
+
46
+ ### Fixed
47
+
48
+ - **Nested sensitive params were sent unmasked.** `filter_keys` was only
49
+ applied to top-level keys, so `user[password]` went out in the clear. Keys
50
+ are now masked at any depth, in arrays, in the Rack session and in the
51
+ query string. `filter_keys` also accepts regexps.
52
+ - A param holding invalid UTF-8, a reference cycle or an object that cannot
53
+ be serialized no longer loses the notice; long strings and large
54
+ collections are truncated.
55
+
8
56
  ## [0.8.0] - 2026-10-06
9
57
 
10
58
  ### Added
data/README.md CHANGED
@@ -34,10 +34,53 @@ Errorgap.configure do |config|
34
34
 
35
35
  # The deployed version or commit (defaults to ERRORGAP_RELEASE). Errors carry
36
36
  # it, so errorgap ties each one to the deploy that shipped it.
37
+ # Unset, it is detected: the platform's commit variable, a Capistrano
38
+ # REVISION file, then the git checkout.
37
39
  config.release = ENV["GIT_SHA"]
40
+
41
+ # Change a notice before it is sent, or return false to drop it.
42
+ config.before_notify do |notice|
43
+ false if notice[:errors].first[:type] == "ActionController::RoutingError"
44
+ end
38
45
  end
39
46
  ```
40
47
 
48
+ `filter_keys` (substrings or regexps, case-insensitive) are masked at any
49
+ depth in params, the session and the query string. `config.timeout` (5 s) and
50
+ `config.queue_size` (100) bound delivery: notices are queued and sent by one
51
+ background thread, retried after a network failure or 5xx, and paused when the
52
+ server answers 429.
53
+
54
+ ## Background jobs
55
+
56
+ In Rails, ActiveJob (any adapter) and Sidekiq jobs need no setup: each job is
57
+ timed as a `job` transaction when APM is on, and an error it raises is reported
58
+ with the job class, queue, id and arguments, then re-raised. Outside Rails:
59
+
60
+ ```ruby
61
+ Errorgap::Integrations::ActiveJob.install
62
+ Errorgap::Integrations::Sidekiq.install
63
+ ```
64
+
65
+ ## Users
66
+
67
+ Errors raised in a request carry the signed-in Warden / Devise user's id and
68
+ email (`config.capture_user = false` to turn off). Anywhere else:
69
+
70
+ ```ruby
71
+ Errorgap.set_user(id: account.id, email: account.email)
72
+ ```
73
+
74
+ ## Deploys
75
+
76
+ ```sh
77
+ bundle exec errorgap deploy --sha "$GIT_SHA" --branch main
78
+ ```
79
+
80
+ or `Errorgap.track_release(sha:, branch:)` from a deploy script, or
81
+ `config.track_release = true` to record the release when a process reports its
82
+ first error.
83
+
41
84
  When the errorgap browser SDK is on the page, its API calls send an
42
85
  `x-errorgap-trace` header; the Rack middleware records it on the request's
43
86
  transaction, so the browser's Performance view links each call to the server
@@ -123,7 +166,7 @@ app or background job — use the block API:
123
166
  ```ruby
124
167
  Errorgap.track_transaction(method: "GET", path: "/orders/{id}", path_raw: "/orders/7", status_code: 200) do |spans|
125
168
  spans.database("SELECT * FROM orders WHERE id = 7", 4.2, fn_name: "Repo.load")
126
- spans.external(30.0, fn_name: "Gateway.fetch")
169
+ spans.external(30.0, fn_name: "Gateway.fetch") # Net::HTTP calls are recorded for you
127
170
  end
128
171
 
129
172
  Errorgap.track_job("ReceiptJob", queue: "mailers") do |spans|
data/exe/errorgap CHANGED
@@ -9,7 +9,23 @@ case command
9
9
  when "test"
10
10
  Errorgap.notify(RuntimeError.new("Errorgap test exception"), sync: true)
11
11
  puts "Sent test exception to Errorgap."
12
+ when "deploy"
13
+ # errorgap deploy [--version V] [--sha SHA] [--branch B] [--environment E]
14
+ options = {}
15
+ ARGV.each_slice(2) do |flag, value|
16
+ key = flag.to_s.sub(/\A--/, "").to_sym
17
+ abort "Unknown option #{flag}" unless %i[version sha branch environment].include?(key)
18
+ abort "Missing value for #{flag}" if value.nil?
19
+ options[key] = value
20
+ end
21
+ result = Errorgap.track_release(**options)
22
+ if result&.success?
23
+ puts "Recorded release with Errorgap."
24
+ else
25
+ warn "Release not recorded#{result ? " (#{result.error || result.status})" : ": no version or sha"}"
26
+ exit 1
27
+ end
12
28
  else
13
- warn "Usage: errorgap test"
29
+ warn "Usage: errorgap test | errorgap deploy [--version V] [--sha SHA] [--branch B] [--environment E]"
14
30
  exit 1
15
31
  end
@@ -17,10 +17,17 @@ module Errorgap
17
17
  :logs_enabled,
18
18
  :minimum_log_level,
19
19
  :max_breadcrumbs,
20
- :release,
20
+ :timeout,
21
+ :queue_size,
22
+ :capture_user,
23
+ :track_release,
24
+ :release_sha,
25
+ :release_branch,
21
26
  :auth_events,
22
27
  :auth_user,
23
28
  :app_name
29
+ attr_writer :release
30
+ attr_reader :before_notify_hooks
24
31
 
25
32
  def initialize
26
33
  @endpoint = ENV.fetch("ERRORGAP_ENDPOINT", "http://127.0.0.1:3030")
@@ -37,9 +44,24 @@ module Errorgap
37
44
  @logs_enabled = true
38
45
  @minimum_log_level = "info"
39
46
  @max_breadcrumbs = 25
47
+ # Seconds allowed for each request to errorgap.
48
+ @timeout = 5
49
+ # Payloads held for the background sender; the oldest is dropped when
50
+ # the queue is full.
51
+ @queue_size = 100
40
52
  # The deployed version or commit: errors are attributed to the deploy
41
- # that shipped them.
53
+ # that shipped them. Unset, it is read from the platform's commit
54
+ # variable, a REVISION file or the git checkout.
42
55
  @release = ENV["ERRORGAP_RELEASE"].to_s.strip.empty? ? nil : ENV["ERRORGAP_RELEASE"].strip
56
+ @release_sha = nil
57
+ @release_branch = nil
58
+ # Record the release with errorgap (Deploys) when the first error of a
59
+ # process is reported.
60
+ @track_release = false
61
+ # Attach the signed-in user's id and email to errors raised in a
62
+ # request (Warden / Devise).
63
+ @capture_user = true
64
+ @before_notify_hooks = []
43
65
  # Sign-ins to this app (Security › Logins). Off until you opt in: they
44
66
  # carry user names and IPs.
45
67
  @auth_events = false
@@ -50,6 +72,26 @@ module Errorgap
50
72
  @app_name = nil
51
73
  end
52
74
 
75
+ def release
76
+ return @release if @release
77
+
78
+ unless defined?(@detected_release) && @detected_root == root_directory
79
+ @detected_root = root_directory
80
+ @detected_release = ReleaseDetector.detect(root_directory)
81
+ end
82
+ @detected_release
83
+ end
84
+
85
+ # Runs before a notice is sent, with the notice as a Hash that can be
86
+ # changed in place. Return `false` to drop it.
87
+ #
88
+ # config.before_notify { |notice| false if notice[:errors][0][:type] == "Timeout::Error" }
89
+ def before_notify(callable = nil, &block)
90
+ hook = callable || block
91
+ @before_notify_hooks << hook if hook.respond_to?(:call)
92
+ hook
93
+ end
94
+
53
95
  def validate!
54
96
  raise ArgumentError, "Errorgap project_slug is required" if blank?(project_slug)
55
97
 
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Errorgap
8
+ # Sends every payload (notices, transactions, logs, sign-ins, releases) to
9
+ # the ingestion API. Async sends go through one bounded queue drained by a
10
+ # single worker thread, so a burst of errors cannot spawn a thread each or
11
+ # hold the host's memory. A send that fails on the network or with a 5xx is
12
+ # retried later; a 429 or 503 pauses all sending for the server's
13
+ # `Retry-After`.
14
+ class Delivery
15
+ Result = Struct.new(:status, :body, :error, keyword_init: true) do
16
+ def success?
17
+ error.nil? && status.to_i.between?(200, 299)
18
+ end
19
+ end
20
+
21
+ class RateLimited < StandardError; end
22
+
23
+ MAX_ATTEMPTS = 3
24
+ RETRY_DELAY = 30
25
+ DEFAULT_RETRY_AFTER = 60
26
+ MAX_RETRY_AFTER = 300
27
+ BACKLOG_SIZE = 100
28
+ FLUSH_TIMEOUT = 5
29
+
30
+ Item = Struct.new(:resource, :payload, :attempt, :due_at)
31
+
32
+ def initialize(configuration)
33
+ @configuration = configuration
34
+ @mutex = Mutex.new
35
+ @work = ConditionVariable.new
36
+ @idle = ConditionVariable.new
37
+ @queue = []
38
+ @backlog = []
39
+ @in_flight = 0
40
+ @paused_until = nil
41
+ @worker = nil
42
+ @pid = nil
43
+ end
44
+
45
+ def configure(configuration)
46
+ @configuration = configuration
47
+ end
48
+
49
+ # Queue a payload and return at once. When the queue is full the oldest
50
+ # entry is dropped, so the newest errors survive a burst.
51
+ def enqueue(resource, payload)
52
+ @mutex.synchronize do
53
+ ensure_worker
54
+ if @queue.length >= queue_size
55
+ @queue.shift
56
+ log("queue full, dropped the oldest payload")
57
+ end
58
+ @queue << Item.new(resource, payload, 1, nil)
59
+ @work.signal
60
+ end
61
+ Result.new(status: 202, body: "queued")
62
+ end
63
+
64
+ # Send now, on the calling thread.
65
+ def post(resource, payload)
66
+ if paused?
67
+ return Result.new(error: RateLimited.new("rate limited by the server, not sent"))
68
+ end
69
+
70
+ uri = URI.join(base_url, "api/projects/#{@configuration.project_slug}/#{resource}")
71
+ request = Net::HTTP::Post.new(uri)
72
+ request["Content-Type"] = "application/json"
73
+ request["User-Agent"] = "errorgap-ruby/#{Errorgap::VERSION}"
74
+ request["X-Errorgap-Project-Key"] = @configuration.api_key if present?(@configuration.api_key)
75
+ request.body = JSON.generate(payload)
76
+
77
+ timeout = http_timeout
78
+ response = with_own_request do
79
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https",
80
+ open_timeout: timeout, read_timeout: timeout,
81
+ write_timeout: timeout) do |http|
82
+ http.request(request)
83
+ end
84
+ end
85
+ status = response.code.to_i
86
+ pause_for(response["Retry-After"]) if [429, 503].include?(status)
87
+ Result.new(status: status, body: response.body)
88
+ rescue StandardError => exception
89
+ log("#{resource} delivery error: #{exception.class}: #{exception.message}")
90
+ Result.new(error: exception)
91
+ end
92
+
93
+ # Wait for queued payloads to be sent. Payloads waiting for a retry are
94
+ # not waited for.
95
+ def flush(timeout = FLUSH_TIMEOUT)
96
+ deadline = monotonic + timeout
97
+ @mutex.synchronize do
98
+ until @queue.empty? && @in_flight.zero?
99
+ remaining = deadline - monotonic
100
+ break if remaining <= 0 || !@worker&.alive?
101
+
102
+ @idle.wait(@mutex, remaining)
103
+ end
104
+ end
105
+ nil
106
+ end
107
+
108
+ def paused?
109
+ until_time = @paused_until
110
+ !until_time.nil? && monotonic < until_time
111
+ end
112
+
113
+ # True while this thread is sending to errorgap, so instrumentation can
114
+ # leave the SDK's own requests out of the app's spans.
115
+ def self.sending?
116
+ Thread.current[:errorgap_sending] == true
117
+ end
118
+
119
+ private
120
+
121
+ def with_own_request
122
+ previous = Thread.current[:errorgap_sending]
123
+ Thread.current[:errorgap_sending] = true
124
+ yield
125
+ ensure
126
+ Thread.current[:errorgap_sending] = previous
127
+ end
128
+
129
+ # Called with the mutex held. A forked child (Puma workers, Sidekiq,
130
+ # Passenger) has the parent's queue but not its thread: start clean.
131
+ def ensure_worker
132
+ if @pid != Process.pid
133
+ @pid = Process.pid
134
+ @queue = []
135
+ @backlog = []
136
+ @in_flight = 0
137
+ @worker = nil
138
+ install_exit_flush
139
+ end
140
+ return if @worker&.alive?
141
+
142
+ @worker = Thread.new { run }
143
+ @worker.name = "errorgap-delivery" if @worker.respond_to?(:name=)
144
+ @worker.report_on_exception = false if @worker.respond_to?(:report_on_exception=)
145
+ end
146
+
147
+ def install_exit_flush
148
+ pid = Process.pid
149
+ at_exit { flush if Process.pid == pid }
150
+ end
151
+
152
+ def run
153
+ loop do
154
+ item = next_item
155
+ result = post(item.resource, item.payload)
156
+ @mutex.synchronize do
157
+ retry_later(item) if retryable?(result)
158
+ @in_flight -= 1
159
+ @idle.broadcast if @queue.empty? && @in_flight.zero?
160
+ end
161
+ end
162
+ rescue StandardError => exception
163
+ log("delivery worker stopped: #{exception.class}: #{exception.message}")
164
+ end
165
+
166
+ def next_item
167
+ @mutex.synchronize do
168
+ loop do
169
+ promote_due
170
+ unless @queue.empty?
171
+ @in_flight += 1
172
+ return @queue.shift
173
+ end
174
+ @idle.broadcast
175
+ @work.wait(@mutex, next_due_in)
176
+ end
177
+ end
178
+ end
179
+
180
+ def promote_due
181
+ return if @backlog.empty?
182
+
183
+ now = monotonic
184
+ due, @backlog = @backlog.partition { |item| item.due_at <= now }
185
+ @queue.concat(due)
186
+ end
187
+
188
+ def next_due_in
189
+ return nil if @backlog.empty?
190
+
191
+ [@backlog.map(&:due_at).min - monotonic, 0.05].max
192
+ end
193
+
194
+ # Network failures and 5xx responses are worth another try. A 4xx is not:
195
+ # the server understood and refused, and after a 429 the payload is
196
+ # dropped so a limited project is not sent the same burst again.
197
+ def retryable?(result)
198
+ return false if result.error.is_a?(RateLimited)
199
+ return true if result.error
200
+
201
+ result.status.to_i >= 500
202
+ end
203
+
204
+ def retry_later(item)
205
+ return if item.attempt >= MAX_ATTEMPTS
206
+ return if @backlog.length >= BACKLOG_SIZE
207
+
208
+ @backlog << Item.new(item.resource, item.payload, item.attempt + 1,
209
+ monotonic + (RETRY_DELAY * item.attempt))
210
+ end
211
+
212
+ def pause_for(header)
213
+ seconds = header.to_s.strip.match?(/\A\d+\z/) ? header.to_i : DEFAULT_RETRY_AFTER
214
+ seconds = [[seconds, 1].max, MAX_RETRY_AFTER].min
215
+ @paused_until = monotonic + seconds
216
+ log("rate limited, pausing delivery for #{seconds}s")
217
+ end
218
+
219
+ def base_url
220
+ endpoint = @configuration.endpoint.to_s
221
+ endpoint.end_with?("/") ? endpoint : "#{endpoint}/"
222
+ end
223
+
224
+ def http_timeout
225
+ value = @configuration.timeout.to_f
226
+ value.positive? ? value : 5
227
+ end
228
+
229
+ def queue_size
230
+ size = @configuration.queue_size.to_i
231
+ size.positive? ? size : 100
232
+ end
233
+
234
+ def monotonic
235
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
236
+ end
237
+
238
+ def log(message)
239
+ @configuration.logger&.warn("[errorgap] #{message}")
240
+ rescue StandardError
241
+ nil
242
+ end
243
+
244
+ def present?(value)
245
+ !value.nil? && !value.to_s.empty?
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errorgap
4
+ module Integrations
5
+ # ActiveJob, whatever the queue adapter: every job runs as a `job`
6
+ # transaction and an error it raises is reported with the job's class,
7
+ # queue, id and arguments, then re-raised so `retry_on` / `discard_on` and
8
+ # the adapter's own retries work as before. The Rails railtie installs it.
9
+ module ActiveJob
10
+ class << self
11
+ def install
12
+ return false unless defined?(::ActiveSupport) && ::ActiveSupport.respond_to?(:on_load)
13
+ return true if @installed
14
+
15
+ ::ActiveSupport.on_load(:active_job) do
16
+ around_perform { |job, block| Errorgap::Integrations::ActiveJob.perform(job, &block) }
17
+ end
18
+ @installed = true
19
+ end
20
+
21
+ def installed?
22
+ @installed == true
23
+ end
24
+
25
+ def perform(job, &block)
26
+ Errorgap.track_job(job.class.name.to_s, queue: job.queue_name.to_s) do
27
+ run(job, &block)
28
+ end
29
+ ensure
30
+ Errorgap.clear_user
31
+ end
32
+
33
+ private
34
+
35
+ def run(job)
36
+ yield
37
+ rescue Exception => exception # rubocop:disable Lint/RescueException
38
+ Errorgap.notify_once(
39
+ exception,
40
+ context: { component: "active_job", action: job.class.name.to_s },
41
+ environment: { queue: job.queue_name.to_s, job_id: job.job_id, executions: job.executions }.compact,
42
+ params: { "arguments" => arguments(job) }
43
+ )
44
+ raise
45
+ end
46
+
47
+ # Records and other GlobalID arguments are named, not serialized.
48
+ def arguments(job)
49
+ Array(job.arguments).map do |argument|
50
+ argument.respond_to?(:to_global_id) ? argument.to_global_id.to_s : argument
51
+ end
52
+ rescue StandardError
53
+ []
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errorgap
4
+ module Integrations
5
+ # Times outbound `Net::HTTP` requests made during a transaction and
6
+ # records each as an `http` span named by method and host. Most Ruby HTTP
7
+ # clients (Faraday's default adapter, HTTParty, RestClient) use Net::HTTP
8
+ # underneath. Requests errorgap makes itself are left out.
9
+ module NetHTTP
10
+ class << self
11
+ def install
12
+ return false unless defined?(::Net::HTTP)
13
+ return true if @installed
14
+
15
+ ::Net::HTTP.prepend(Patch)
16
+ @installed = true
17
+ end
18
+ end
19
+
20
+ module Patch
21
+ def request(req, body = nil, &block)
22
+ # `request` calls itself once when the connection is not started.
23
+ unless started? && Thread.current[SpanCollector::THREAD_KEY] && !Errorgap::Delivery.sending?
24
+ return super
25
+ end
26
+
27
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ begin
29
+ super
30
+ ensure
31
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
32
+ Errorgap::Integrations::NetHTTP.record(req, address, elapsed_ms)
33
+ end
34
+ end
35
+ end
36
+
37
+ def self.record(request, host, elapsed_ms)
38
+ file, line, = SpanCollector.app_call_site
39
+ SpanCollector.store << Span.new(
40
+ kind: "http",
41
+ fn_name: "#{request.method} #{host}",
42
+ file: file, line: line,
43
+ duration_ms: elapsed_ms.round(3)
44
+ )
45
+ rescue StandardError
46
+ nil
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errorgap
4
+ module Integrations
5
+ # Sidekiq server middleware: every job runs as a `job` transaction and an
6
+ # error it raises is reported with the job's class, queue, id and
7
+ # arguments, then re-raised so Sidekiq's retries work as before.
8
+ #
9
+ # Installed automatically when Sidekiq is loaded before errorgap is
10
+ # configured; otherwise:
11
+ #
12
+ # Sidekiq.configure_server do |config|
13
+ # config.server_middleware { |chain| chain.add Errorgap::Integrations::Sidekiq }
14
+ # end
15
+ class Sidekiq
16
+ class << self
17
+ def install
18
+ return false unless defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
19
+ return true if @installed
20
+
21
+ ::Sidekiq.configure_server do |config|
22
+ config.server_middleware { |chain| chain.add Errorgap::Integrations::Sidekiq }
23
+ end
24
+ @installed = true
25
+ end
26
+ end
27
+
28
+ def call(_worker, job, queue, &block)
29
+ # An ActiveJob run by Sidekiq is covered by the ActiveJob integration,
30
+ # which knows the real job class and its arguments.
31
+ return yield if job["wrapped"] && Errorgap::Integrations::ActiveJob.installed?
32
+
33
+ job_class = (job["wrapped"] || job["class"]).to_s
34
+ Errorgap.track_job(job_class, queue: queue.to_s) do
35
+ run(job, job_class, queue, &block)
36
+ end
37
+ ensure
38
+ Errorgap.clear_user
39
+ end
40
+
41
+ private
42
+
43
+ def run(job, job_class, queue)
44
+ yield
45
+ rescue Exception => exception # rubocop:disable Lint/RescueException
46
+ Errorgap.notify_once(
47
+ exception,
48
+ context: { component: "sidekiq", action: job_class },
49
+ environment: { queue: queue.to_s, job_id: job["jid"], retry_count: job["retry_count"] }.compact,
50
+ params: { "arguments" => job["args"] }
51
+ )
52
+ raise
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "net/http"
5
- require "uri"
6
3
  require "time"
7
4
 
8
5
  module Errorgap
@@ -39,27 +36,13 @@ module Errorgap
39
36
  if sync || !@configuration.async
40
37
  deliver(payload)
41
38
  else
42
- Errorgap.register_thread(Thread.new { deliver(payload) })
39
+ Errorgap.delivery.enqueue("logs", payload)
43
40
  nil
44
41
  end
45
42
  end
46
43
 
47
44
  def deliver(payload)
48
- uri = URI.join(
49
- @configuration.endpoint.end_with?("/") ? @configuration.endpoint : "#{@configuration.endpoint}/",
50
- "api/projects/#{@configuration.project_slug}/logs"
51
- )
52
- request = Net::HTTP::Post.new(uri)
53
- request["Content-Type"] = "application/json"
54
- request["User-Agent"] = "errorgap-ruby/#{Errorgap::VERSION}"
55
- request["X-Errorgap-Project-Key"] = @configuration.api_key if present?(@configuration.api_key)
56
- request.body = JSON.generate(payload)
57
-
58
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
59
- http.request(request)
60
- end
61
- rescue StandardError => exception
62
- @configuration.logger&.warn("[errorgap] log delivery error: #{exception.class}: #{exception.message}")
45
+ Errorgap.delivery.post("logs", payload)
63
46
  end
64
47
 
65
48
  def self.normalize_level(level)
@@ -71,11 +54,5 @@ module Errorgap
71
54
  def self.rank(level)
72
55
  LEVELS.index(level) || LEVELS.index("info")
73
56
  end
74
-
75
- private
76
-
77
- def present?(value)
78
- !value.nil? && !value.to_s.empty?
79
- end
80
57
  end
81
58
  end