errorgap 0.8.0 → 0.10.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: f52bfa8726abcfbc207b98bc4756db649a619595a56298d4c06b73c63d7b8d08
4
+ data.tar.gz: bf7d11b852e1fffe27e928ccc259fa3589d35997e4d4eeabfa609c19020a16f6
5
5
  SHA512:
6
- metadata.gz: b8d0f1bae2b1df08c0893e414a8916a4cc7487afa5443eb3c1de0860133baca93d71c29935552c158ec93392b878b44933848e53ed5761aca84fd141014d2702
7
- data.tar.gz: a9ba148ab19bba740b6d877403ee628c43f303a27abfdd20347a5728d7860ea1ec792739b3b23c5b1992066cdd309ac457fd28de60fd9bb7cca4af19bddc49b6
6
+ metadata.gz: 32f5f975ae8555dc20ee6cdde6618ebe5b001ecf89b51bb59e4f4b72cd3a2d64747c5814c60ab590fe26adbfb23f5923dd2c45511c2c22ad4c3b518877917368
7
+ data.tar.gz: 7aec10caf7eef47f0e593266cfe554ef67aab2c2fb32e03320cfd3e7c1fad88db40b9ececff9241dbb136858c3f85830fe895b6dc6442d789b474cf8c19252bd
data/CHANGELOG.md CHANGED
@@ -5,6 +5,76 @@ 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.10.0] - 2026-10-13
9
+
10
+ ### Added
11
+
12
+ - **Remote settings.** The gem follows the project's settings in errorgap
13
+ (Project settings › What SDKs send): when errors, performance data or logs
14
+ are switched off there, running apps stop sending them within ten minutes,
15
+ with no deploy. Read on a background thread from
16
+ `GET /api/projects/:slug/config`; if errorgap cannot be reached everything
17
+ stays on. `config.remote_settings = false` (or
18
+ `ERRORGAP_REMOTE_SETTINGS=false`) turns this off.
19
+ - **Resque.** Failed jobs are reported with their class, queue and arguments,
20
+ alongside Resque's own failure backend.
21
+ - **Delayed Job.** Jobs are timed as `job` transactions and their errors
22
+ reported with the job class, queue, id and attempt count.
23
+ - **Rake.** An error that ends a task is reported with the task's name and
24
+ flushed before Rake exits.
25
+
26
+ In Rails all three are installed for you. Elsewhere:
27
+ `Errorgap::Integrations::Resque.install`, `…::DelayedJob.install`,
28
+ `…::Rake.install`.
29
+
30
+ ## [0.9.0] - 2026-10-13
31
+
32
+ ### Added
33
+
34
+ - **Background jobs.** In Rails, ActiveJob jobs (any adapter) and Sidekiq
35
+ jobs are timed as `job` transactions and their errors are reported with the
36
+ job class, queue, id and arguments, then re-raised so retries work as
37
+ before. Outside Rails: `Errorgap::Integrations::ActiveJob.install` /
38
+ `Errorgap::Integrations::Sidekiq.install`.
39
+ - **`config.before_notify`.** A block that sees each notice before it is sent
40
+ and can change it or return `false` to drop it.
41
+ - **Outbound HTTP spans.** `Net::HTTP` requests made during a transaction are
42
+ recorded as `http` spans (`GET api.stripe.com`).
43
+ - **Signed-in user.** Errors raised in a request carry the Warden / Devise
44
+ user as `session.user_id` and `session.user_email`
45
+ (`config.capture_user = false` to turn off). `Errorgap.set_user(id:,
46
+ email:)` names the user anywhere else.
47
+ - **Release detection.** With `config.release` unset the release is read from
48
+ the platform's commit variable (`SOURCE_VERSION`, `HEROKU_SLUG_COMMIT`,
49
+ `RENDER_GIT_COMMIT`, `KAMAL_VERSION`, …), a Capistrano `REVISION` file or
50
+ the git checkout.
51
+ - **Deploys.** `Errorgap.track_release` and `errorgap deploy` record a release
52
+ with errorgap; `config.track_release = true` records it when the first
53
+ error of a process is reported.
54
+ - `config.timeout` (5 seconds) and `config.queue_size` (100).
55
+
56
+ ### Changed
57
+
58
+ - **Delivery.** Async sends go through one bounded queue and one worker
59
+ thread instead of a thread per notice, transaction and log line. A full
60
+ queue drops its oldest entry. Requests time out after `config.timeout`
61
+ (they previously waited up to 60 seconds). Sends that fail on the network
62
+ or with a 5xx are retried up to three times; a 429 or 503 pauses sending
63
+ for the server's `Retry-After`. Queued payloads are flushed at exit, and
64
+ the worker restarts in forked processes (Puma workers, Sidekiq).
65
+ - The Rack middleware queues an uncaught error instead of sending it inside
66
+ the failing request.
67
+
68
+ ### Fixed
69
+
70
+ - **Nested sensitive params were sent unmasked.** `filter_keys` was only
71
+ applied to top-level keys, so `user[password]` went out in the clear. Keys
72
+ are now masked at any depth, in arrays, in the Rack session and in the
73
+ query string. `filter_keys` also accepts regexps.
74
+ - A param holding invalid UTF-8, a reference cycle or an object that cannot
75
+ be serialized no longer loses the notice; long strings and large
76
+ collections are truncated.
77
+
8
78
  ## [0.8.0] - 2026-10-06
9
79
 
10
80
  ### Added
data/README.md CHANGED
@@ -34,10 +34,65 @@ 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
+ Resque, Delayed Job and Rake tasks are covered the same way (in Rails with no
66
+ setup; elsewhere `Errorgap::Integrations::Resque.install`,
67
+ `Errorgap::Integrations::DelayedJob.install`,
68
+ `Errorgap::Integrations::Rake.install`).
69
+
70
+ ## Remote settings
71
+
72
+ Errors, performance data and logs can each be switched off for a project in
73
+ errorgap (Project settings › What SDKs send). Running apps follow within ten
74
+ minutes, with no deploy. If errorgap cannot be reached, everything stays on.
75
+ `config.remote_settings = false` ignores the project's switches.
76
+
77
+ ## Users
78
+
79
+ Errors raised in a request carry the signed-in Warden / Devise user's id and
80
+ email (`config.capture_user = false` to turn off). Anywhere else:
81
+
82
+ ```ruby
83
+ Errorgap.set_user(id: account.id, email: account.email)
84
+ ```
85
+
86
+ ## Deploys
87
+
88
+ ```sh
89
+ bundle exec errorgap deploy --sha "$GIT_SHA" --branch main
90
+ ```
91
+
92
+ or `Errorgap.track_release(sha:, branch:)` from a deploy script, or
93
+ `config.track_release = true` to record the release when a process reports its
94
+ first error.
95
+
41
96
  When the errorgap browser SDK is on the page, its API calls send an
42
97
  `x-errorgap-trace` header; the Rack middleware records it on the request's
43
98
  transaction, so the browser's Performance view links each call to the server
@@ -123,7 +178,7 @@ app or background job — use the block API:
123
178
  ```ruby
124
179
  Errorgap.track_transaction(method: "GET", path: "/orders/{id}", path_raw: "/orders/7", status_code: 200) do |spans|
125
180
  spans.database("SELECT * FROM orders WHERE id = 7", 4.2, fn_name: "Repo.load")
126
- spans.external(30.0, fn_name: "Gateway.fetch")
181
+ spans.external(30.0, fn_name: "Gateway.fetch") # Net::HTTP calls are recorded for you
127
182
  end
128
183
 
129
184
  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,18 @@ 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,
26
+ :remote_settings,
21
27
  :auth_events,
22
28
  :auth_user,
23
29
  :app_name
30
+ attr_writer :release
31
+ attr_reader :before_notify_hooks
24
32
 
25
33
  def initialize
26
34
  @endpoint = ENV.fetch("ERRORGAP_ENDPOINT", "http://127.0.0.1:3030")
@@ -37,9 +45,28 @@ module Errorgap
37
45
  @logs_enabled = true
38
46
  @minimum_log_level = "info"
39
47
  @max_breadcrumbs = 25
48
+ # Seconds allowed for each request to errorgap.
49
+ @timeout = 5
50
+ # Payloads held for the background sender; the oldest is dropped when
51
+ # the queue is full.
52
+ @queue_size = 100
40
53
  # The deployed version or commit: errors are attributed to the deploy
41
- # that shipped them.
54
+ # that shipped them. Unset, it is read from the platform's commit
55
+ # variable, a REVISION file or the git checkout.
42
56
  @release = ENV["ERRORGAP_RELEASE"].to_s.strip.empty? ? nil : ENV["ERRORGAP_RELEASE"].strip
57
+ @release_sha = nil
58
+ @release_branch = nil
59
+ # Record the release with errorgap (Deploys) when the first error of a
60
+ # process is reported.
61
+ @track_release = false
62
+ # Attach the signed-in user's id and email to errors raised in a
63
+ # request (Warden / Devise).
64
+ @capture_user = true
65
+ @before_notify_hooks = []
66
+ # Follow the project's settings in errorgap (Project settings › What
67
+ # SDKs send): stop sending errors, performance data or logs when they
68
+ # are switched off there.
69
+ @remote_settings = ENV["ERRORGAP_REMOTE_SETTINGS"].to_s.strip.downcase != "false"
43
70
  # Sign-ins to this app (Security › Logins). Off until you opt in: they
44
71
  # carry user names and IPs.
45
72
  @auth_events = false
@@ -50,6 +77,26 @@ module Errorgap
50
77
  @app_name = nil
51
78
  end
52
79
 
80
+ def release
81
+ return @release if @release
82
+
83
+ unless defined?(@detected_release) && @detected_root == root_directory
84
+ @detected_root = root_directory
85
+ @detected_release = ReleaseDetector.detect(root_directory)
86
+ end
87
+ @detected_release
88
+ end
89
+
90
+ # Runs before a notice is sent, with the notice as a Hash that can be
91
+ # changed in place. Return `false` to drop it.
92
+ #
93
+ # config.before_notify { |notice| false if notice[:errors][0][:type] == "Timeout::Error" }
94
+ def before_notify(callable = nil, &block)
95
+ hook = callable || block
96
+ @before_notify_hooks << hook if hook.respond_to?(:call)
97
+ hook
98
+ end
99
+
53
100
  def validate!
54
101
  raise ArgumentError, "Errorgap project_slug is required" if blank?(project_slug)
55
102
 
@@ -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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errorgap
4
+ module Integrations
5
+ # Delayed Job: every job runs as a `job` transaction and an error it
6
+ # raises is reported with the job's class, queue, id and attempt count,
7
+ # then re-raised so Delayed Job's retries work as before.
8
+ #
9
+ # Errorgap::Integrations::DelayedJob.install # after Delayed Job is loaded
10
+ module DelayedJob
11
+ ACTIVE_JOB_WRAPPER = "ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper"
12
+
13
+ class << self
14
+ def install
15
+ return false unless defined?(::Delayed::Plugin) && defined?(::Delayed::Worker)
16
+ return true if @installed
17
+
18
+ ::Delayed::Worker.plugins << plugin
19
+ @installed = true
20
+ end
21
+
22
+ def plugin
23
+ @plugin ||= Class.new(::Delayed::Plugin) do
24
+ callbacks do |lifecycle|
25
+ lifecycle.around(:invoke_job) do |job, *args, &block|
26
+ Errorgap::Integrations::DelayedJob.invoke(job) { block.call(job, *args) }
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ def invoke(job, &block)
33
+ job_class = job_name(job)
34
+ # An ActiveJob run by Delayed Job is covered by the ActiveJob
35
+ # integration, which knows the real job class and its arguments.
36
+ return yield if job_class == ACTIVE_JOB_WRAPPER && ActiveJob.installed?
37
+
38
+ Errorgap.track_job(job_class, queue: (job.respond_to?(:queue) && job.queue) || "default") do
39
+ run(job, job_class, &block)
40
+ end
41
+ ensure
42
+ Errorgap.clear_user
43
+ end
44
+
45
+ private
46
+
47
+ def run(job, job_class)
48
+ yield
49
+ rescue Exception => exception # rubocop:disable Lint/RescueException
50
+ Errorgap.notify_once(
51
+ exception,
52
+ context: { component: "delayed_job", action: job_class },
53
+ environment: {
54
+ queue: job.respond_to?(:queue) ? job.queue : nil,
55
+ job_id: job.respond_to?(:id) ? job.id : nil,
56
+ attempts: job.respond_to?(:attempts) ? job.attempts : nil
57
+ }.compact
58
+ )
59
+ raise
60
+ end
61
+
62
+ # `Mailer.delay.welcome(user)` is a PerformableMethod: name it by
63
+ # what it calls rather than by the wrapper.
64
+ def job_name(job)
65
+ payload = job.payload_object
66
+ return payload.display_name.to_s if payload.respond_to?(:display_name)
67
+
68
+ payload.class.name.to_s
69
+ rescue StandardError
70
+ "Delayed::Job"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end