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 +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +56 -1
- data/exe/errorgap +17 -1
- data/lib/errorgap/configuration.rb +49 -2
- data/lib/errorgap/delivery.rb +248 -0
- data/lib/errorgap/integrations/active_job.rb +58 -0
- data/lib/errorgap/integrations/delayed_job.rb +75 -0
- data/lib/errorgap/integrations/net_http.rb +50 -0
- data/lib/errorgap/integrations/rake.rb +44 -0
- data/lib/errorgap/integrations/resque.rb +45 -0
- data/lib/errorgap/integrations/sidekiq.rb +56 -0
- data/lib/errorgap/log_delivery.rb +3 -25
- data/lib/errorgap/notice.rb +10 -21
- data/lib/errorgap/notifier.rb +30 -21
- data/lib/errorgap/rack_middleware.rb +48 -5
- data/lib/errorgap/rails/railtie.rb +15 -0
- data/lib/errorgap/release_detector.rb +69 -0
- data/lib/errorgap/remote_settings.rb +99 -0
- data/lib/errorgap/sanitizer.rb +91 -0
- data/lib/errorgap/sign_ins.rb +2 -19
- data/lib/errorgap/transacter.rb +3 -24
- data/lib/errorgap/version.rb +1 -1
- data/lib/errorgap.rb +119 -6
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f52bfa8726abcfbc207b98bc4756db649a619595a56298d4c06b73c63d7b8d08
|
|
4
|
+
data.tar.gz: bf7d11b852e1fffe27e928ccc259fa3589d35997e4d4eeabfa609c19020a16f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
:
|
|
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
|