errorgap 0.4.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 +28 -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 +72 -23
- data/lib/errorgap/notifier.rb +4 -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,34 @@ 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
|
+
|
|
8
36
|
## [0.4.0] - 2026-07-16
|
|
9
37
|
|
|
10
38
|
### 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
|
|
@@ -111,16 +146,30 @@ module Errorgap
|
|
|
111
146
|
[match[1], match[2].to_i, match[3]]
|
|
112
147
|
end
|
|
113
148
|
|
|
114
|
-
|
|
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)
|
|
115
162
|
root = @configuration.root_directory.to_s
|
|
116
|
-
return
|
|
163
|
+
return false if root.empty?
|
|
117
164
|
|
|
118
|
-
|
|
165
|
+
absolute_path == root || absolute_path.start_with?("#{root}/")
|
|
119
166
|
end
|
|
120
167
|
|
|
121
|
-
def
|
|
168
|
+
def display_path(absolute_path)
|
|
122
169
|
root = @configuration.root_directory.to_s
|
|
123
|
-
|
|
170
|
+
return absolute_path if root.empty?
|
|
171
|
+
|
|
172
|
+
absolute_path.sub(%r{\A#{Regexp.escape(root)}/?}, "")
|
|
124
173
|
end
|
|
125
174
|
|
|
126
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
|
|
@@ -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
|