errorgap 0.3.0 → 0.5.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 +50 -0
- data/README.md +45 -0
- data/lib/errorgap/breadcrumbs.rb +37 -0
- data/lib/errorgap/configuration.rb +7 -1
- data/lib/errorgap/log_delivery.rb +81 -0
- data/lib/errorgap/notice.rb +75 -24
- data/lib/errorgap/notifier.rb +4 -3
- data/lib/errorgap/span_collector.rb +35 -3
- data/lib/errorgap/span_recorder.rb +31 -0
- data/lib/errorgap/transacter.rb +1 -1
- data/lib/errorgap/version.rb +1 -1
- data/lib/errorgap.rb +123 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a962a442c79c4d80123b06eb6cd8afb1e41cdce3b944101e8d9b6112343f2279
|
|
4
|
+
data.tar.gz: 61cd50f45d49df099b0ecd54cf18442c19cc31d837b275a0f7903322fbc57c1f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e93b57a2558bde0c050d4c806ecb62c57d87ef41af342a8a3ede7071d12c3f71033d008c84dcfa250e5b5314f60ae93d4bf6b02ea546954a291b65f6f7516906
|
|
7
|
+
data.tar.gz: a456b31419bec30d07d25d3b0fd597d638008e6994c51e2e66f92a67df9bf78ad1e9ab27655ef4ee8abd2e83176b3be681796cf399d77b24bebcf24704093365
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,56 @@ 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.5.0] - 2026-07-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Nested exception causes.** The `cause` chain of a raised exception is now
|
|
13
|
+
walked and reported: each cause appears under `context.causes`, and every
|
|
14
|
+
link's frames are merged into a single, re-indexed backtrace so the dashboard
|
|
15
|
+
renders the whole chain in one view.
|
|
16
|
+
- **Breadcrumbs.** `Errorgap.add_breadcrumb(message, category:, metadata:)`
|
|
17
|
+
records a diagnostic trail (a fixed-size ring, `config.max_breadcrumbs`,
|
|
18
|
+
default 25) that is attached to subsequent notices as `context.breadcrumbs`.
|
|
19
|
+
`Errorgap.clear_breadcrumbs` empties it.
|
|
20
|
+
- **Structured logs.** `Errorgap.log(message, level:, source:)` delivers log
|
|
21
|
+
lines to the ingestion API. Levels (`trace`/`debug`/`info`/`warn`/`error`/
|
|
22
|
+
`fatal`, plus common aliases) are normalized and ranked; anything below
|
|
23
|
+
`config.minimum_log_level` (default `info`) is dropped locally.
|
|
24
|
+
`config.logs_enabled` toggles delivery.
|
|
25
|
+
- **Manual APM API.** `Errorgap.track_transaction` and `Errorgap.track_job`
|
|
26
|
+
time a block and deliver a transaction, yielding a span recorder for manual
|
|
27
|
+
DB/HTTP spans (`spans.database`, `spans.external`) — automatic
|
|
28
|
+
`sql.active_record` spans recorded during the block are merged in.
|
|
29
|
+
`Errorgap.notify_transaction` delivers a prebuilt transaction. This lets
|
|
30
|
+
non-Rails apps and background jobs report APM data.
|
|
31
|
+
- **Source excerpts for dependency frames.** Backtrace source is now attached to
|
|
32
|
+
any readable frame (bounded by the existing 25-frame cap), not only in-app
|
|
33
|
+
frames, so dependency frames show source in the dashboard.
|
|
34
|
+
- `Errorgap.flush` joins in-flight async delivery threads before process exit.
|
|
35
|
+
|
|
36
|
+
## [0.4.0] - 2026-07-16
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- Backtrace frames are now parsed on Ruby 3.4+, which formats frames as
|
|
41
|
+
`file.rb:12:in 'Class#method'` (straight quote) instead of the pre-3.4
|
|
42
|
+
`` file.rb:12:in `method' `` (backtick). Unparsed frames carried no line
|
|
43
|
+
number or function, which also prevented source excerpts from being
|
|
44
|
+
attached — the UI showed "Source is not available for this frame yet."
|
|
45
|
+
for every frame.
|
|
46
|
+
|
|
47
|
+
- DB span durations are now computed from the notification event's
|
|
48
|
+
start/finish timestamps. The `sql.active_record` payload carries no
|
|
49
|
+
`:duration` key, so every query previously shipped `0.0` — showing as
|
|
50
|
+
0.0ms 50th/95th percentiles on the Performance > Queries page.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- DB spans now carry the application call site (`file`, `line`, `fn_name`),
|
|
55
|
+
captured from the first non-gem backtrace frame, so Performance > Queries
|
|
56
|
+
can attribute each query to app code.
|
|
57
|
+
|
|
8
58
|
## [0.3.0] - 2026-07-16
|
|
9
59
|
|
|
10
60
|
### Fixed
|
data/README.md
CHANGED
|
@@ -45,6 +45,51 @@ rescue => error
|
|
|
45
45
|
end
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
`Errorgap.notify` walks the exception's `cause` chain: each cause is reported
|
|
49
|
+
under `context.causes` and its frames are merged into one backtrace. Source
|
|
50
|
+
excerpts are attached to readable app **and** dependency frames.
|
|
51
|
+
|
|
52
|
+
## Breadcrumbs
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
Errorgap.add_breadcrumb("received request", category: "http")
|
|
56
|
+
Errorgap.add_breadcrumb("loaded order", category: "db", metadata: { id: 7 })
|
|
57
|
+
# ...later notices include the trail above
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The buffer keeps the most recent `config.max_breadcrumbs` entries (default 25);
|
|
61
|
+
`Errorgap.clear_breadcrumbs` empties it.
|
|
62
|
+
|
|
63
|
+
## Structured logs
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
Errorgap.log("payment captured", level: "info", source: "payments")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Levels are `trace < debug < info < warn < error < fatal` (with aliases like
|
|
70
|
+
`warning`/`critical`); anything below `config.minimum_log_level` (default
|
|
71
|
+
`info`) is dropped locally. Set `config.logs_enabled = false` to disable.
|
|
72
|
+
|
|
73
|
+
## APM
|
|
74
|
+
|
|
75
|
+
The Rack middleware records a web transaction per request automatically (with
|
|
76
|
+
`sql.active_record` and view spans). To time work manually — or in a plain Ruby
|
|
77
|
+
app or background job — use the block API:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
Errorgap.track_transaction(method: "GET", path: "/orders/{id}", path_raw: "/orders/7", status_code: 200) do |spans|
|
|
81
|
+
spans.database("SELECT * FROM orders WHERE id = 7", 4.2, fn_name: "Repo.load")
|
|
82
|
+
spans.external(30.0, fn_name: "Gateway.fetch")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Errorgap.track_job("ReceiptJob", queue: "mailers") do |spans|
|
|
86
|
+
spans.database("SELECT total FROM receipts WHERE id = 1", 3.1)
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Enable with `config.apm_enabled = true` (and optionally `config.apm_sample_rate`).
|
|
91
|
+
Call `Errorgap.flush` before process exit to drain async deliveries.
|
|
92
|
+
|
|
48
93
|
## Rack
|
|
49
94
|
|
|
50
95
|
```ruby
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Errorgap
|
|
6
|
+
# Fixed-size ring of recent application events (requests, queries, jobs)
|
|
7
|
+
# attached to every notice as context.breadcrumbs. Thread-safe so it can be
|
|
8
|
+
# written from request threads and read at notify time.
|
|
9
|
+
class Breadcrumbs
|
|
10
|
+
def initialize(capacity)
|
|
11
|
+
@capacity = capacity.to_i
|
|
12
|
+
@crumbs = []
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add(message, category: nil, metadata: nil)
|
|
17
|
+
return if @capacity <= 0
|
|
18
|
+
|
|
19
|
+
crumb = { message: message.to_s, timestamp: Time.now.utc.iso8601(3) }
|
|
20
|
+
crumb[:category] = category.to_s if category
|
|
21
|
+
crumb[:metadata] = metadata if metadata
|
|
22
|
+
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
@crumbs << crumb
|
|
25
|
+
@crumbs.shift(@crumbs.length - @capacity) if @crumbs.length > @capacity
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def clear
|
|
30
|
+
@mutex.synchronize { @crumbs = [] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_a
|
|
34
|
+
@mutex.synchronize { @crumbs.dup }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -13,7 +13,10 @@ module Errorgap
|
|
|
13
13
|
:filter_keys,
|
|
14
14
|
:ignore_environments,
|
|
15
15
|
:apm_enabled,
|
|
16
|
-
:apm_sample_rate
|
|
16
|
+
:apm_sample_rate,
|
|
17
|
+
:logs_enabled,
|
|
18
|
+
:minimum_log_level,
|
|
19
|
+
:max_breadcrumbs
|
|
17
20
|
|
|
18
21
|
def initialize
|
|
19
22
|
@endpoint = ENV.fetch("ERRORGAP_ENDPOINT", "http://127.0.0.1:3030")
|
|
@@ -27,6 +30,9 @@ module Errorgap
|
|
|
27
30
|
@ignore_environments = ENV.fetch("ERRORGAP_IGNORE_ENVIRONMENTS", "").split(",").map(&:strip).reject(&:empty?)
|
|
28
31
|
@apm_enabled = false
|
|
29
32
|
@apm_sample_rate = 1.0
|
|
33
|
+
@logs_enabled = true
|
|
34
|
+
@minimum_log_level = "info"
|
|
35
|
+
@max_breadcrumbs = 25
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
def validate!
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Errorgap
|
|
9
|
+
# Delivers structured log lines to the ingestion API. Levels are ranked so a
|
|
10
|
+
# configurable minimum threshold can drop low-severity logs before any
|
|
11
|
+
# request is made.
|
|
12
|
+
class LogDelivery
|
|
13
|
+
LEVELS = %w[trace debug info warn error fatal].freeze
|
|
14
|
+
LEVEL_ALIASES = { "warning" => "warn", "err" => "error", "critical" => "fatal", "panic" => "fatal" }.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(configuration)
|
|
17
|
+
configure(configuration)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def configure(configuration)
|
|
21
|
+
@configuration = configuration
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def log(message, level: "info", source: nil, environment: nil, occurred_at: nil, sync: false)
|
|
25
|
+
return unless @configuration.logs_enabled
|
|
26
|
+
return if @configuration.ignored_environment?
|
|
27
|
+
|
|
28
|
+
normalized = self.class.normalize_level(level)
|
|
29
|
+
return if self.class.rank(normalized) < self.class.rank(self.class.normalize_level(@configuration.minimum_log_level))
|
|
30
|
+
|
|
31
|
+
payload = {
|
|
32
|
+
message: message.to_s,
|
|
33
|
+
level: normalized,
|
|
34
|
+
environment: environment || @configuration.environment,
|
|
35
|
+
occurred_at: (occurred_at || Time.now).utc.iso8601(3)
|
|
36
|
+
}
|
|
37
|
+
payload[:source] = source.to_s if source
|
|
38
|
+
|
|
39
|
+
if sync || !@configuration.async
|
|
40
|
+
deliver(payload)
|
|
41
|
+
else
|
|
42
|
+
Errorgap.register_thread(Thread.new { deliver(payload) })
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
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}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.normalize_level(level)
|
|
66
|
+
value = level.to_s.strip.downcase
|
|
67
|
+
value = LEVEL_ALIASES.fetch(value, value)
|
|
68
|
+
LEVELS.include?(value) ? value : "info"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.rank(level)
|
|
72
|
+
LEVELS.index(level) || LEVELS.index("info")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def present?(value)
|
|
78
|
+
!value.nil? && !value.to_s.empty?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/errorgap/notice.rb
CHANGED
|
@@ -10,25 +10,28 @@ module Errorgap
|
|
|
10
10
|
SOURCE_RADIUS = 6
|
|
11
11
|
MAX_SOURCE_FRAMES = 25
|
|
12
12
|
MAX_SOURCE_LINE_LENGTH = 400
|
|
13
|
+
MAX_CAUSE_DEPTH = 10
|
|
13
14
|
|
|
14
|
-
def self.from_exception(error, configuration:, context: {}, environment: {}, session: {}, params: {})
|
|
15
|
+
def self.from_exception(error, configuration:, context: {}, environment: {}, session: {}, params: {}, breadcrumbs: [])
|
|
15
16
|
new(
|
|
16
17
|
error: error,
|
|
17
18
|
configuration: configuration,
|
|
18
19
|
context: context,
|
|
19
20
|
environment: environment,
|
|
20
21
|
session: session,
|
|
21
|
-
params: params
|
|
22
|
+
params: params,
|
|
23
|
+
breadcrumbs: breadcrumbs
|
|
22
24
|
)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
def initialize(error:, configuration:, context:, environment:, session:, params:)
|
|
27
|
+
def initialize(error:, configuration:, context:, environment:, session:, params:, breadcrumbs: [])
|
|
26
28
|
@error = error
|
|
27
29
|
@configuration = configuration
|
|
28
30
|
@context = context || {}
|
|
29
31
|
@environment = environment || {}
|
|
30
32
|
@session = session || {}
|
|
31
33
|
@params = params || {}
|
|
34
|
+
@breadcrumbs = Array(breadcrumbs)
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
def to_h
|
|
@@ -52,32 +55,64 @@ module Errorgap
|
|
|
52
55
|
private
|
|
53
56
|
|
|
54
57
|
def default_context
|
|
55
|
-
{
|
|
58
|
+
context = {
|
|
56
59
|
notifier: "errorgap-ruby",
|
|
57
60
|
notifier_version: Errorgap::VERSION,
|
|
58
61
|
environment: @configuration.environment,
|
|
59
62
|
root_directory: @configuration.root_directory
|
|
60
63
|
}
|
|
64
|
+
causes = collect_causes
|
|
65
|
+
context[:causes] = causes unless causes.empty?
|
|
66
|
+
context[:breadcrumbs] = @breadcrumbs unless @breadcrumbs.empty?
|
|
67
|
+
context
|
|
61
68
|
end
|
|
62
69
|
|
|
70
|
+
# Walks the exception's `cause` chain (Ruby's nested exceptions) and merges
|
|
71
|
+
# every link's frames into one backtrace, re-indexed, so the dashboard
|
|
72
|
+
# renders the full chain in a single view.
|
|
63
73
|
def backtrace_frames
|
|
64
74
|
source_frames = 0
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
index = 0
|
|
76
|
+
frames = []
|
|
77
|
+
error_chain.each do |link|
|
|
78
|
+
Array(link.backtrace).each do |line|
|
|
79
|
+
file, line_number, function = parse_backtrace_line(line)
|
|
80
|
+
absolute = absolute_frame_path(file)
|
|
81
|
+
frame = {
|
|
82
|
+
file: display_path(absolute),
|
|
83
|
+
line: line_number,
|
|
84
|
+
function: function,
|
|
85
|
+
in_app: within_root?(absolute),
|
|
86
|
+
index: index
|
|
87
|
+
}
|
|
88
|
+
if source_frames < MAX_SOURCE_FRAMES && (source = source_excerpt(absolute, line_number))
|
|
89
|
+
frame[:source] = source
|
|
90
|
+
source_frames += 1
|
|
91
|
+
end
|
|
92
|
+
frames << frame.compact
|
|
93
|
+
index += 1
|
|
78
94
|
end
|
|
79
|
-
frame.compact
|
|
80
95
|
end
|
|
96
|
+
frames
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The causes beyond the root error, as {type, message} pairs.
|
|
100
|
+
def collect_causes
|
|
101
|
+
error_chain.drop(1).map do |link|
|
|
102
|
+
{ type: link.class.name, message: link.message.to_s }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def error_chain
|
|
107
|
+
chain = []
|
|
108
|
+
seen = {}
|
|
109
|
+
current = @error
|
|
110
|
+
while current.is_a?(Exception) && !seen[current.object_id] && chain.length < MAX_CAUSE_DEPTH
|
|
111
|
+
seen[current.object_id] = true
|
|
112
|
+
chain << current
|
|
113
|
+
current = current.cause
|
|
114
|
+
end
|
|
115
|
+
chain
|
|
81
116
|
end
|
|
82
117
|
|
|
83
118
|
# Reads the lines around the failing line so the server can show source
|
|
@@ -102,23 +137,39 @@ module Errorgap
|
|
|
102
137
|
nil
|
|
103
138
|
end
|
|
104
139
|
|
|
140
|
+
# Ruby <= 3.3 formats frames as `file.rb:42:in `method'` (backtick),
|
|
141
|
+
# Ruby >= 3.4 as `file.rb:42:in 'Class#method'` (straight quote).
|
|
105
142
|
def parse_backtrace_line(line)
|
|
106
|
-
match = line.match(/\A(.+?):(\d+)(?::in `(.*)')?\z/)
|
|
143
|
+
match = line.match(/\A(.+?):(\d+)(?::in [`'](.*)')?\z/)
|
|
107
144
|
return [line, nil, nil] unless match
|
|
108
145
|
|
|
109
146
|
[match[1], match[2].to_i, match[3]]
|
|
110
147
|
end
|
|
111
148
|
|
|
112
|
-
|
|
149
|
+
# Ruby reports the entry script by the (possibly relative) path it was
|
|
150
|
+
# invoked with, while `require`d files use absolute paths. Resolve relative
|
|
151
|
+
# frames against the configured root so the entry point — and framework
|
|
152
|
+
# frames reported relative to the app root — classify as in-app too.
|
|
153
|
+
def absolute_frame_path(file)
|
|
154
|
+
path = file.to_s
|
|
155
|
+
return path if path.start_with?("/") || path.match?(%r{\A[A-Za-z]:[\\/]})
|
|
156
|
+
|
|
157
|
+
root = @configuration.root_directory.to_s
|
|
158
|
+
root.empty? ? path : File.expand_path(path, root)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def within_root?(absolute_path)
|
|
113
162
|
root = @configuration.root_directory.to_s
|
|
114
|
-
return
|
|
163
|
+
return false if root.empty?
|
|
115
164
|
|
|
116
|
-
|
|
165
|
+
absolute_path == root || absolute_path.start_with?("#{root}/")
|
|
117
166
|
end
|
|
118
167
|
|
|
119
|
-
def
|
|
168
|
+
def display_path(absolute_path)
|
|
120
169
|
root = @configuration.root_directory.to_s
|
|
121
|
-
|
|
170
|
+
return absolute_path if root.empty?
|
|
171
|
+
|
|
172
|
+
absolute_path.sub(%r{\A#{Regexp.escape(root)}/?}, "")
|
|
122
173
|
end
|
|
123
174
|
|
|
124
175
|
def filter_hash(hash)
|
data/lib/errorgap/notifier.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Errorgap
|
|
|
20
20
|
@configuration = configuration
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def notify(error, context: {}, environment: {}, session: {}, params: {}, sync: false)
|
|
23
|
+
def notify(error, context: {}, environment: {}, session: {}, params: {}, breadcrumbs: [], sync: false)
|
|
24
24
|
@configuration.validate!
|
|
25
25
|
return Response.new(status: 202, body: "ignored environment") if @configuration.ignored_environment?
|
|
26
26
|
|
|
@@ -30,13 +30,14 @@ module Errorgap
|
|
|
30
30
|
context: context,
|
|
31
31
|
environment: environment,
|
|
32
32
|
session: session,
|
|
33
|
-
params: params
|
|
33
|
+
params: params,
|
|
34
|
+
breadcrumbs: breadcrumbs
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
if sync || !@configuration.async
|
|
37
38
|
deliver(notice)
|
|
38
39
|
else
|
|
39
|
-
Thread.new { deliver(notice) }
|
|
40
|
+
Errorgap.register_thread(Thread.new { deliver(notice) })
|
|
40
41
|
Response.new(status: 202, body: "queued")
|
|
41
42
|
end
|
|
42
43
|
rescue StandardError => exception
|
|
@@ -25,15 +25,22 @@ module Errorgap
|
|
|
25
25
|
|
|
26
26
|
@installed = true
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# The 5-arg block form receives the event's start/finish times; the
|
|
29
|
+
# sql.active_record payload itself carries no duration key.
|
|
30
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, started, finished, _id, payload|
|
|
29
31
|
next unless Thread.current[THREAD_KEY]
|
|
30
32
|
next if SKIP_NAMES.any? { |n| payload[:name]&.start_with?(n) }
|
|
31
33
|
next if payload[:cached]
|
|
32
34
|
|
|
33
|
-
duration_ms =
|
|
35
|
+
duration_ms = ((finished - started) * 1000.0).to_f
|
|
34
36
|
sql = normalize_sql(payload[:sql].to_s)
|
|
37
|
+
file, line, fn_name = app_call_site
|
|
35
38
|
|
|
36
|
-
store << Span.new(
|
|
39
|
+
store << Span.new(
|
|
40
|
+
kind: "db", sql: sql,
|
|
41
|
+
file: file, line: line, fn_name: fn_name,
|
|
42
|
+
duration_ms: duration_ms.round(3)
|
|
43
|
+
)
|
|
37
44
|
end
|
|
38
45
|
|
|
39
46
|
# Rails reports view_runtime with database time already subtracted, so
|
|
@@ -65,6 +72,31 @@ module Errorgap
|
|
|
65
72
|
def normalize_sql(sql)
|
|
66
73
|
sql.gsub(NORMALIZE_PATTERN, "?").gsub(/\s+/, " ").strip
|
|
67
74
|
end
|
|
75
|
+
|
|
76
|
+
# The first backtrace frame belonging to the application — skipping this
|
|
77
|
+
# gem, other gems, and the Ruby standard library — so a query can be
|
|
78
|
+
# attributed to the app code that ran it. Returns [file, line, fn_name]
|
|
79
|
+
# with the file relative to Rails.root when available, or nils when no
|
|
80
|
+
# app frame is present (e.g. queries run from a console or gem).
|
|
81
|
+
def app_call_site
|
|
82
|
+
root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.to_s : nil
|
|
83
|
+
location = caller_locations(1, 60)&.find do |loc|
|
|
84
|
+
path = loc.absolute_path || loc.path
|
|
85
|
+
next false unless path
|
|
86
|
+
next false if path.start_with?(LIB_ROOT)
|
|
87
|
+
next false if GEM_PATH_MARKERS.any? { |marker| path.include?(marker) }
|
|
88
|
+
|
|
89
|
+
root ? path.start_with?(root) : true
|
|
90
|
+
end
|
|
91
|
+
return [nil, nil, nil] unless location
|
|
92
|
+
|
|
93
|
+
path = location.absolute_path || location.path
|
|
94
|
+
path = path.delete_prefix("#{root}/") if root
|
|
95
|
+
[path, location.lineno, location.label]
|
|
96
|
+
end
|
|
68
97
|
end
|
|
98
|
+
|
|
99
|
+
LIB_ROOT = File.expand_path("..", __dir__)
|
|
100
|
+
GEM_PATH_MARKERS = ["/gems/", "/rubygems/", "/lib/ruby/", "/bundler/"].freeze
|
|
69
101
|
end
|
|
70
102
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Errorgap
|
|
4
|
+
# Yielded to `Errorgap.track_transaction` / `track_job` blocks so callers can
|
|
5
|
+
# record spans manually (outside Rails' automatic `sql.active_record`
|
|
6
|
+
# instrumentation) — e.g. outbound HTTP calls or queries in a plain Ruby
|
|
7
|
+
# service. Spans are appended to the same thread-local store the automatic
|
|
8
|
+
# collector uses, so manual and automatic spans merge into one transaction.
|
|
9
|
+
class SpanRecorder
|
|
10
|
+
def database(sql, duration_ms, file: nil, line: nil, fn_name: nil)
|
|
11
|
+
SpanCollector.store << Span.new(
|
|
12
|
+
kind: "db",
|
|
13
|
+
sql: SpanCollector.normalize_sql(sql.to_s),
|
|
14
|
+
file: file, line: line, fn_name: fn_name,
|
|
15
|
+
duration_ms: duration_ms.to_f.round(3)
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def external(duration_ms, file: nil, line: nil, fn_name: nil)
|
|
20
|
+
SpanCollector.store << Span.new(
|
|
21
|
+
kind: "http",
|
|
22
|
+
file: file, line: line, fn_name: fn_name,
|
|
23
|
+
duration_ms: duration_ms.to_f.round(3)
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add(span)
|
|
28
|
+
SpanCollector.store << span
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/errorgap/transacter.rb
CHANGED
data/lib/errorgap/version.rb
CHANGED
data/lib/errorgap.rb
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errorgap/configuration"
|
|
4
|
+
require_relative "errorgap/breadcrumbs"
|
|
4
5
|
require_relative "errorgap/notifier"
|
|
5
6
|
require_relative "errorgap/notice"
|
|
7
|
+
require_relative "errorgap/log_delivery"
|
|
6
8
|
require_relative "errorgap/transaction"
|
|
7
9
|
require_relative "errorgap/span_collector"
|
|
10
|
+
require_relative "errorgap/span_recorder"
|
|
8
11
|
require_relative "errorgap/transacter"
|
|
9
12
|
require_relative "errorgap/rack_middleware"
|
|
10
13
|
require_relative "errorgap/version"
|
|
@@ -15,6 +18,8 @@ module Errorgap
|
|
|
15
18
|
yield(configuration)
|
|
16
19
|
notifier.configure(configuration)
|
|
17
20
|
transacter.configure(configuration)
|
|
21
|
+
log_delivery.configure(configuration)
|
|
22
|
+
@breadcrumbs = Breadcrumbs.new(configuration.max_breadcrumbs)
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def configuration
|
|
@@ -29,6 +34,14 @@ module Errorgap
|
|
|
29
34
|
@transacter ||= Transacter.new(configuration)
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
def log_delivery
|
|
38
|
+
@log_delivery ||= LogDelivery.new(configuration)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def breadcrumbs
|
|
42
|
+
@breadcrumbs ||= Breadcrumbs.new(configuration.max_breadcrumbs)
|
|
43
|
+
end
|
|
44
|
+
|
|
32
45
|
def notify(error, context: {}, environment: {}, session: {}, params: {}, sync: false)
|
|
33
46
|
notifier.notify(
|
|
34
47
|
error,
|
|
@@ -36,9 +49,119 @@ module Errorgap
|
|
|
36
49
|
environment: environment,
|
|
37
50
|
session: session,
|
|
38
51
|
params: params,
|
|
52
|
+
breadcrumbs: breadcrumbs.to_a,
|
|
53
|
+
sync: sync
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Record a diagnostic breadcrumb attached to subsequent notices.
|
|
58
|
+
def add_breadcrumb(message, category: nil, metadata: nil)
|
|
59
|
+
breadcrumbs.add(message, category: category, metadata: metadata)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear_breadcrumbs
|
|
63
|
+
breadcrumbs.clear
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Deliver a structured log line at the given level.
|
|
67
|
+
def log(message, level: "info", source: nil, environment: nil, occurred_at: nil, sync: false)
|
|
68
|
+
log_delivery.log(
|
|
69
|
+
message,
|
|
70
|
+
level: level,
|
|
71
|
+
source: source,
|
|
72
|
+
environment: environment,
|
|
73
|
+
occurred_at: occurred_at,
|
|
39
74
|
sync: sync
|
|
40
75
|
)
|
|
41
76
|
end
|
|
77
|
+
|
|
78
|
+
# Deliver a prebuilt APM transaction, honoring apm_enabled and sampling.
|
|
79
|
+
def notify_transaction(transaction, sync: false)
|
|
80
|
+
return unless configuration.apm_enabled
|
|
81
|
+
return if configuration.ignored_environment?
|
|
82
|
+
return unless sample_apm?
|
|
83
|
+
|
|
84
|
+
if sync || !configuration.async
|
|
85
|
+
transacter.deliver(transaction)
|
|
86
|
+
else
|
|
87
|
+
register_thread(Thread.new { transacter.deliver(transaction) })
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Time an HTTP interaction and deliver it as a transaction. The block
|
|
92
|
+
# receives a SpanRecorder for manual DB/HTTP spans; any automatic
|
|
93
|
+
# `sql.active_record` spans recorded during the block are merged in.
|
|
94
|
+
def track_transaction(method: nil, path: nil, path_raw: nil, status_code: nil, kind: "web", environment: nil, sync: false)
|
|
95
|
+
SpanCollector.start
|
|
96
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
+
occurred = Time.now
|
|
98
|
+
begin
|
|
99
|
+
yield(SpanRecorder.new) if block_given?
|
|
100
|
+
ensure
|
|
101
|
+
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
102
|
+
transaction = Transaction.new(
|
|
103
|
+
kind: kind, method: method, path: path, path_raw: path_raw,
|
|
104
|
+
status_code: status_code, duration_ms: elapsed_ms.round(2),
|
|
105
|
+
environment: environment || configuration.environment,
|
|
106
|
+
occurred_at: occurred, spans: SpanCollector.flush
|
|
107
|
+
)
|
|
108
|
+
notify_transaction(transaction, sync: sync)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Time a background job and deliver it as a `job` transaction.
|
|
113
|
+
def track_job(job_class, queue: "default", environment: nil, sync: false)
|
|
114
|
+
SpanCollector.start
|
|
115
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
116
|
+
occurred = Time.now
|
|
117
|
+
begin
|
|
118
|
+
yield(SpanRecorder.new) if block_given?
|
|
119
|
+
ensure
|
|
120
|
+
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
121
|
+
transaction = Transaction.new(
|
|
122
|
+
kind: "job", job_class: job_class, queue: queue,
|
|
123
|
+
duration_ms: elapsed_ms.round(2),
|
|
124
|
+
environment: environment || configuration.environment,
|
|
125
|
+
occurred_at: occurred, spans: SpanCollector.flush
|
|
126
|
+
)
|
|
127
|
+
notify_transaction(transaction, sync: sync)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Join any in-flight async delivery threads. Call before process exit to
|
|
132
|
+
# avoid dropping queued notices/transactions/logs.
|
|
133
|
+
def flush
|
|
134
|
+
threads = thread_mutex.synchronize do
|
|
135
|
+
pending = delivery_threads.dup
|
|
136
|
+
delivery_threads.clear
|
|
137
|
+
pending
|
|
138
|
+
end
|
|
139
|
+
threads.each { |thread| thread.join(5) }
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def register_thread(thread)
|
|
144
|
+
thread_mutex.synchronize { delivery_threads << thread }
|
|
145
|
+
thread
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def sample_apm?
|
|
151
|
+
rate = configuration.apm_sample_rate.to_f
|
|
152
|
+
return true if rate >= 1.0
|
|
153
|
+
return false if rate <= 0.0
|
|
154
|
+
|
|
155
|
+
rand < rate
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def delivery_threads
|
|
159
|
+
@delivery_threads ||= []
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def thread_mutex
|
|
163
|
+
@thread_mutex ||= Mutex.new
|
|
164
|
+
end
|
|
42
165
|
end
|
|
43
166
|
end
|
|
44
167
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: errorgap
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Errorgap
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|
|
@@ -65,12 +65,15 @@ files:
|
|
|
65
65
|
- README.md
|
|
66
66
|
- exe/errorgap
|
|
67
67
|
- lib/errorgap.rb
|
|
68
|
+
- lib/errorgap/breadcrumbs.rb
|
|
68
69
|
- lib/errorgap/configuration.rb
|
|
70
|
+
- lib/errorgap/log_delivery.rb
|
|
69
71
|
- lib/errorgap/notice.rb
|
|
70
72
|
- lib/errorgap/notifier.rb
|
|
71
73
|
- lib/errorgap/rack_middleware.rb
|
|
72
74
|
- lib/errorgap/rails/railtie.rb
|
|
73
75
|
- lib/errorgap/span_collector.rb
|
|
76
|
+
- lib/errorgap/span_recorder.rb
|
|
74
77
|
- lib/errorgap/transacter.rb
|
|
75
78
|
- lib/errorgap/transaction.rb
|
|
76
79
|
- lib/errorgap/version.rb
|