errorgap 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a6d206f60530a163bfebf7cbc25d4ef0f889a81d1b15c78f440c2141e4efc3a
4
- data.tar.gz: d9346233cd3aec6e191a13efcec513fdfec9fadececb304549afc1527f4da3a7
3
+ metadata.gz: 75cf1e4a725d5e1c7f484a577814d71403fbdc4e6c6cd4bcec3d0eca30f00478
4
+ data.tar.gz: 98d336466f4eceedc7ad6fc03fff038704d10872e30c6b7f5376528e424dd73b
5
5
  SHA512:
6
- metadata.gz: c35e78949f9cbd4e30e24acacfbd5857f3d3003aa763f6d41c26a1713dde9c3e55a62bfe0bc24caa6941029910423ba1c5b0609ee4f48fed890ff1dbc4364e5a
7
- data.tar.gz: 73a7f26536826709ceb3feb1624f48caa336dc2f6981c558a7d692a2ddbbef93206ff6397f4fa4192cc6f2306a486d8125f80df5750d4ecce168845ed6466e61
6
+ metadata.gz: 44e3afd205bce67d34e40e6b71a5c67b43615dc04acc299f7188f0e01d514844e7b836523a0dcae0e95388b7b38875c76f2ffc012d3a83b316450182b5a2fb36
7
+ data.tar.gz: b8b2d440abe90925b5750cf6526b34449d00d913d5bd47a8d74339ce8ba9c5622abe55949b66465ad1f1f0b0972ee26446c258086a98280c94dcdff36b66a229
data/CHANGELOG.md CHANGED
@@ -5,6 +5,48 @@ 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.6.0] - 2026-10-03
9
+
10
+ ### Added
11
+
12
+ - **Errors link to their request.** Every APM transaction (Rack middleware,
13
+ `track_transaction`, `track_job`) now has an id, sent as the transaction's
14
+ `id`, and errors reported while it runs carry it as
15
+ `context.transaction_id`. Errorgap shows the error a request actually raised
16
+ on its trace instead of matching by route and time, and links each
17
+ occurrence to its trace. The id is fiber-local, so concurrent requests never
18
+ share one, and is sent even when APM sampling drops the transaction.
19
+ `Errorgap.current_transaction_id` and `Errorgap.with_transaction_id` expose
20
+ it for custom instrumentation.
21
+
22
+ ## [0.5.0] - 2026-07-20
23
+
24
+ ### Added
25
+
26
+ - **Nested exception causes.** The `cause` chain of a raised exception is now
27
+ walked and reported: each cause appears under `context.causes`, and every
28
+ link's frames are merged into a single, re-indexed backtrace so the dashboard
29
+ renders the whole chain in one view.
30
+ - **Breadcrumbs.** `Errorgap.add_breadcrumb(message, category:, metadata:)`
31
+ records a diagnostic trail (a fixed-size ring, `config.max_breadcrumbs`,
32
+ default 25) that is attached to subsequent notices as `context.breadcrumbs`.
33
+ `Errorgap.clear_breadcrumbs` empties it.
34
+ - **Structured logs.** `Errorgap.log(message, level:, source:)` delivers log
35
+ lines to the ingestion API. Levels (`trace`/`debug`/`info`/`warn`/`error`/
36
+ `fatal`, plus common aliases) are normalized and ranked; anything below
37
+ `config.minimum_log_level` (default `info`) is dropped locally.
38
+ `config.logs_enabled` toggles delivery.
39
+ - **Manual APM API.** `Errorgap.track_transaction` and `Errorgap.track_job`
40
+ time a block and deliver a transaction, yielding a span recorder for manual
41
+ DB/HTTP spans (`spans.database`, `spans.external`) — automatic
42
+ `sql.active_record` spans recorded during the block are merged in.
43
+ `Errorgap.notify_transaction` delivers a prebuilt transaction. This lets
44
+ non-Rails apps and background jobs report APM data.
45
+ - **Source excerpts for dependency frames.** Backtrace source is now attached to
46
+ any readable frame (bounded by the existing 25-frame cap), not only in-app
47
+ frames, so dependency frames show source in the dashboard.
48
+ - `Errorgap.flush` joins in-flight async delivery threads before process exit.
49
+
8
50
  ## [0.4.0] - 2026-07-16
9
51
 
10
52
  ### Fixed
data/README.md CHANGED
@@ -45,6 +45,58 @@ 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
+
93
+ Every transaction gets an id, and errors reported while it runs — from the
94
+ middleware, `Errorgap.notify`, or inside `track_transaction`/`track_job` —
95
+ carry it as `context.transaction_id`. Errorgap then shows the error a request
96
+ actually raised on its trace, and links each occurrence to its request.
97
+ `Errorgap.current_transaction_id` returns the id in effect (fiber-local), and
98
+ `Errorgap.with_transaction_id { |id| ... }` scopes one around custom work.
99
+
48
100
  ## Rack
49
101
 
50
102
  ```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
@@ -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
- Array(@error.backtrace).map.with_index do |line, index|
66
- file, line_number, function = parse_backtrace_line(line)
67
- frame = {
68
- file: relative_file(file),
69
- line: line_number,
70
- function: function,
71
- in_app: in_app?(file),
72
- index: index
73
- }
74
- if in_app?(file) && source_frames < MAX_SOURCE_FRAMES &&
75
- (source = source_excerpt(file, line_number))
76
- frame[:source] = source
77
- source_frames += 1
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
- def relative_file(file)
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 file if root.empty?
163
+ return false if root.empty?
117
164
 
118
- file.to_s.sub(%r{\A#{Regexp.escape(root)}/?}, "")
165
+ absolute_path == root || absolute_path.start_with?("#{root}/")
119
166
  end
120
167
 
121
- def in_app?(file)
168
+ def display_path(absolute_path)
122
169
  root = @configuration.root_directory.to_s
123
- !root.empty? && file.to_s.start_with?(root)
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)
@@ -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
@@ -7,30 +7,42 @@ module Errorgap
7
7
  end
8
8
 
9
9
  def call(env)
10
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
- SpanCollector.start if apm_enabled?
10
+ return call_without_transaction(env) unless apm_enabled?
12
11
 
13
- status, headers, body = @app.call(env)
14
- [status, headers, body]
15
- rescue Exception => exception # rubocop:disable Lint/RescueException
16
- notify_once(env, exception)
17
- raise
18
- ensure
19
- if apm_enabled?
20
- elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
21
- record_transaction(env, status || 500, elapsed_ms)
12
+ # Errors raised while this request runs carry its transaction id.
13
+ Errorgap.with_transaction_id do |transaction_id|
14
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ SpanCollector.start
16
+ begin
17
+ status, headers, body = @app.call(env)
18
+ [status, headers, body]
19
+ rescue Exception => exception # rubocop:disable Lint/RescueException
20
+ notify_once(env, exception)
21
+ raise
22
+ ensure
23
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
24
+ record_transaction(env, status || 500, elapsed_ms, transaction_id)
25
+ end
22
26
  end
23
27
  end
24
28
 
25
29
  private
26
30
 
31
+ def call_without_transaction(env)
32
+ @app.call(env)
33
+ rescue Exception => exception # rubocop:disable Lint/RescueException
34
+ notify_once(env, exception)
35
+ raise
36
+ end
37
+
27
38
  def apm_enabled?
28
39
  Errorgap.configuration.apm_enabled
29
40
  end
30
41
 
31
- def record_transaction(env, status_code, elapsed_ms)
42
+ def record_transaction(env, status_code, elapsed_ms, transaction_id)
32
43
  spans = SpanCollector.flush
33
44
  txn = Transaction.new(
45
+ id: transaction_id,
34
46
  kind: "web",
35
47
  method: env["REQUEST_METHOD"],
36
48
  path: route_pattern(env),
@@ -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
@@ -18,7 +18,7 @@ module Errorgap
18
18
  return if @configuration.ignored_environment?
19
19
  return unless should_sample?
20
20
 
21
- Thread.new { deliver(transaction) }
21
+ Errorgap.register_thread(Thread.new { deliver(transaction) })
22
22
  end
23
23
 
24
24
  def deliver(transaction)
@@ -17,13 +17,14 @@ module Errorgap
17
17
  end
18
18
 
19
19
  Transaction = Struct.new(
20
- :kind, :method, :path, :path_raw, :status_code,
20
+ :id, :kind, :method, :path, :path_raw, :status_code,
21
21
  :duration_ms, :environment, :occurred_at, :spans,
22
22
  :job_class, :queue,
23
23
  keyword_init: true
24
24
  ) do
25
25
  def to_h
26
26
  {
27
+ id: id,
27
28
  kind: kind,
28
29
  method: method,
29
30
  path: path,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Errorgap
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/errorgap.rb CHANGED
@@ -1,10 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  require_relative "errorgap/configuration"
6
+ require_relative "errorgap/breadcrumbs"
4
7
  require_relative "errorgap/notifier"
5
8
  require_relative "errorgap/notice"
9
+ require_relative "errorgap/log_delivery"
6
10
  require_relative "errorgap/transaction"
7
11
  require_relative "errorgap/span_collector"
12
+ require_relative "errorgap/span_recorder"
8
13
  require_relative "errorgap/transacter"
9
14
  require_relative "errorgap/rack_middleware"
10
15
  require_relative "errorgap/version"
@@ -15,6 +20,8 @@ module Errorgap
15
20
  yield(configuration)
16
21
  notifier.configure(configuration)
17
22
  transacter.configure(configuration)
23
+ log_delivery.configure(configuration)
24
+ @breadcrumbs = Breadcrumbs.new(configuration.max_breadcrumbs)
18
25
  end
19
26
 
20
27
  def configuration
@@ -29,16 +36,164 @@ module Errorgap
29
36
  @transacter ||= Transacter.new(configuration)
30
37
  end
31
38
 
39
+ def log_delivery
40
+ @log_delivery ||= LogDelivery.new(configuration)
41
+ end
42
+
43
+ def breadcrumbs
44
+ @breadcrumbs ||= Breadcrumbs.new(configuration.max_breadcrumbs)
45
+ end
46
+
32
47
  def notify(error, context: {}, environment: {}, session: {}, params: {}, sync: false)
48
+ # The request or job this error was raised in, so errorgap links the two.
49
+ if (transaction_id = current_transaction_id) && !context.key?(:transaction_id) && !context.key?("transaction_id")
50
+ context = context.merge(transaction_id: transaction_id)
51
+ end
33
52
  notifier.notify(
34
53
  error,
35
54
  context: context,
36
55
  environment: environment,
37
56
  session: session,
38
57
  params: params,
58
+ breadcrumbs: breadcrumbs.to_a,
39
59
  sync: sync
40
60
  )
41
61
  end
62
+
63
+ # Record a diagnostic breadcrumb attached to subsequent notices.
64
+ def add_breadcrumb(message, category: nil, metadata: nil)
65
+ breadcrumbs.add(message, category: category, metadata: metadata)
66
+ end
67
+
68
+ def clear_breadcrumbs
69
+ breadcrumbs.clear
70
+ end
71
+
72
+ # Deliver a structured log line at the given level.
73
+ def log(message, level: "info", source: nil, environment: nil, occurred_at: nil, sync: false)
74
+ log_delivery.log(
75
+ message,
76
+ level: level,
77
+ source: source,
78
+ environment: environment,
79
+ occurred_at: occurred_at,
80
+ sync: sync
81
+ )
82
+ end
83
+
84
+ TRANSACTION_ID_KEY = :errorgap_transaction_id
85
+
86
+ # The id of the APM transaction running on this fiber, if any. Notices
87
+ # reported while it is set carry it as `context.transaction_id`.
88
+ def current_transaction_id
89
+ Thread.current[TRANSACTION_ID_KEY]
90
+ end
91
+
92
+ # Run the block as one transaction: a new id is current for its duration
93
+ # (fiber-local, so concurrent requests never share one) and the previous
94
+ # one is restored after. Yields the id.
95
+ def with_transaction_id
96
+ previous = Thread.current[TRANSACTION_ID_KEY]
97
+ id = SecureRandom.uuid
98
+ Thread.current[TRANSACTION_ID_KEY] = id
99
+ yield id
100
+ ensure
101
+ Thread.current[TRANSACTION_ID_KEY] = previous
102
+ end
103
+
104
+ # Deliver a prebuilt APM transaction, honoring apm_enabled and sampling.
105
+ def notify_transaction(transaction, sync: false)
106
+ return unless configuration.apm_enabled
107
+ return if configuration.ignored_environment?
108
+ return unless sample_apm?
109
+
110
+ if sync || !configuration.async
111
+ transacter.deliver(transaction)
112
+ else
113
+ register_thread(Thread.new { transacter.deliver(transaction) })
114
+ end
115
+ end
116
+
117
+ # Time an HTTP interaction and deliver it as a transaction. The block
118
+ # receives a SpanRecorder for manual DB/HTTP spans; any automatic
119
+ # `sql.active_record` spans recorded during the block are merged in.
120
+ def track_transaction(method: nil, path: nil, path_raw: nil, status_code: nil, kind: "web", environment: nil, sync: false)
121
+ with_transaction_id do |transaction_id|
122
+ SpanCollector.start
123
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
+ occurred = Time.now
125
+ begin
126
+ yield(SpanRecorder.new) if block_given?
127
+ ensure
128
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
129
+ transaction = Transaction.new(
130
+ id: transaction_id,
131
+ kind: kind, method: method, path: path, path_raw: path_raw,
132
+ status_code: status_code, duration_ms: elapsed_ms.round(2),
133
+ environment: environment || configuration.environment,
134
+ occurred_at: occurred, spans: SpanCollector.flush
135
+ )
136
+ notify_transaction(transaction, sync: sync)
137
+ end
138
+ end
139
+ end
140
+
141
+ # Time a background job and deliver it as a `job` transaction.
142
+ def track_job(job_class, queue: "default", environment: nil, sync: false)
143
+ with_transaction_id do |transaction_id|
144
+ SpanCollector.start
145
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
146
+ occurred = Time.now
147
+ begin
148
+ yield(SpanRecorder.new) if block_given?
149
+ ensure
150
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
151
+ transaction = Transaction.new(
152
+ id: transaction_id,
153
+ kind: "job", job_class: job_class, queue: queue,
154
+ duration_ms: elapsed_ms.round(2),
155
+ environment: environment || configuration.environment,
156
+ occurred_at: occurred, spans: SpanCollector.flush
157
+ )
158
+ notify_transaction(transaction, sync: sync)
159
+ end
160
+ end
161
+ end
162
+
163
+ # Join any in-flight async delivery threads. Call before process exit to
164
+ # avoid dropping queued notices/transactions/logs.
165
+ def flush
166
+ threads = thread_mutex.synchronize do
167
+ pending = delivery_threads.dup
168
+ delivery_threads.clear
169
+ pending
170
+ end
171
+ threads.each { |thread| thread.join(5) }
172
+ nil
173
+ end
174
+
175
+ def register_thread(thread)
176
+ thread_mutex.synchronize { delivery_threads << thread }
177
+ thread
178
+ end
179
+
180
+ private
181
+
182
+ def sample_apm?
183
+ rate = configuration.apm_sample_rate.to_f
184
+ return true if rate >= 1.0
185
+ return false if rate <= 0.0
186
+
187
+ rand < rate
188
+ end
189
+
190
+ def delivery_threads
191
+ @delivery_threads ||= []
192
+ end
193
+
194
+ def thread_mutex
195
+ @thread_mutex ||= Mutex.new
196
+ end
42
197
  end
43
198
  end
44
199
 
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.0
4
+ version: 0.6.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-17 00:00:00.000000000 Z
11
+ date: 2026-10-03 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