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 +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +44 -1
- data/exe/errorgap +17 -1
- data/lib/errorgap/configuration.rb +44 -2
- data/lib/errorgap/delivery.rb +248 -0
- data/lib/errorgap/integrations/active_job.rb +58 -0
- data/lib/errorgap/integrations/net_http.rb +50 -0
- data/lib/errorgap/integrations/sidekiq.rb +56 -0
- data/lib/errorgap/log_delivery.rb +2 -25
- data/lib/errorgap/notice.rb +10 -21
- data/lib/errorgap/notifier.rb +27 -21
- data/lib/errorgap/rack_middleware.rb +48 -5
- data/lib/errorgap/rails/railtie.rb +8 -0
- data/lib/errorgap/release_detector.rb +69 -0
- data/lib/errorgap/sanitizer.rb +91 -0
- data/lib/errorgap/sign_ins.rb +2 -19
- data/lib/errorgap/transacter.rb +2 -24
- data/lib/errorgap/version.rb +1 -1
- data/lib/errorgap.rb +108 -6
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c01da4468e72e4df60f1a720efa5f8c5873357a2681c3c8163b97c83de0489f4
|
|
4
|
+
data.tar.gz: 11274b3058d605600c50fcac50f3e9dc7e9f118d5f8d1ab673d6b754d3e039d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
:
|
|
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.
|
|
39
|
+
Errorgap.delivery.enqueue("logs", payload)
|
|
43
40
|
nil
|
|
44
41
|
end
|
|
45
42
|
end
|
|
46
43
|
|
|
47
44
|
def deliver(payload)
|
|
48
|
-
|
|
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
|