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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cabe7a082d767651c4e56f067d77fa21486056cde995842eb4ef7f8531adbf00
4
- data.tar.gz: 10cc33121d252ce987eb92ccd2e57301ea4399bafa42c2b8cada4ab0dd4d1f63
3
+ metadata.gz: a962a442c79c4d80123b06eb6cd8afb1e41cdce3b944101e8d9b6112343f2279
4
+ data.tar.gz: 61cd50f45d49df099b0ecd54cf18442c19cc31d837b275a0f7903322fbc57c1f
5
5
  SHA512:
6
- metadata.gz: 283fcdf5b95bcbf34a175b2e9249c5e2a5b4c16f46bedfe9852f088214ada0e6aa022d84f816a4c692b36291c9a3d8a0f099ee0f18beb15cf5c856d014ecdb14
7
- data.tar.gz: a66f945ca3d16ee996aeec660dc24351baa3799e7b7404ce209202e98a3c10e6df7eac940c4bdea82e4b7ca86391c98ed3b718c174c0cfe5499485cfb14bd1b2
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
@@ -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
@@ -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
- 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)
113
162
  root = @configuration.root_directory.to_s
114
- return file if root.empty?
163
+ return false if root.empty?
115
164
 
116
- file.to_s.sub(%r{\A#{Regexp.escape(root)}/?}, "")
165
+ absolute_path == root || absolute_path.start_with?("#{root}/")
117
166
  end
118
167
 
119
- def in_app?(file)
168
+ def display_path(absolute_path)
120
169
  root = @configuration.root_directory.to_s
121
- !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)}/?}, "")
122
173
  end
123
174
 
124
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
@@ -25,15 +25,22 @@ module Errorgap
25
25
 
26
26
  @installed = true
27
27
 
28
- ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
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 = payload[:duration] || 0.0
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(kind: "db", sql: sql, duration_ms: duration_ms.round(3))
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
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Errorgap
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
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.3.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-16 00:00:00.000000000 Z
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