Skip to content

Commit fa79a1c

Browse files
committed
Run error blocks eagerly in collector mode
In collector mode each error is recorded when it is added, but its block was still deferred to completion. Deferring only makes sense in agent mode, where blocks run against fabricated duplicate transactions. Run the block when the error is added instead. Side effects that target the current span -- breadcrumbs, nested errors, custom instrumentation -- then land where the error was reported rather than on the root span at completion. None of those belong in an error block, but this puts them in the right place if they are used.
1 parent f62db5f commit fa79a1c

7 files changed

Lines changed: 79 additions & 50 deletions

File tree

lib/appsignal/transaction.rb

Lines changed: 43 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,16 @@ def initialize(namespace, id: SecureRandom.uuid, backend: nil, opentelemetry_con
174174
@completed = false
175175
@tags = {}
176176
@store = Hash.new { |hash, key| hash[key] = {} }
177+
# The distinct errors added to this transaction, in add order. Drives
178+
# `last_errors`, the dedup check and the `ERRORS_LIMIT`, in both modes.
179+
# A Set, so membership is by object identity (`eql?`/`hash`), matching how
180+
# these errors used to be deduplicated as Hash keys. `Exception#==` would
181+
# instead collapse distinct errors with the same class, message and
182+
# backtrace, which we don't want.
183+
@errors = Set.new
184+
# Blocks per error, only populated in agent mode, where they run against
185+
# the duplicate transactions at completion. Collector mode runs blocks
186+
# when the error is added and never touches this.
177187
@error_blocks = Hash.new { |hash, key| hash[key] = [] }
178188
@is_duplicate = false
179189
@error_set = nil
@@ -236,7 +246,7 @@ def complete
236246
should_sample = true
237247

238248
unless duplicate?
239-
self.class.last_errors = @error_blocks.keys
249+
self.class.last_errors = @errors.to_a
240250
should_sample = @backend.finish
241251
end
242252

@@ -627,7 +637,7 @@ def add_error(error, &block)
627637
return unless error
628638
return unless Appsignal.active?
629639

630-
if error.instance_variable_get(:@__appsignal_error_reported) && !@error_blocks.include?(error)
640+
if error.instance_variable_get(:@__appsignal_error_reported) && !@errors.include?(error)
631641
return
632642
end
633643

@@ -715,26 +725,37 @@ def to_h
715725

716726
# @!visibility private
717727
def internal_set_error(error, &block)
718-
is_new_error = !@error_blocks.include?(error)
728+
is_new_error = !@errors.include?(error)
719729

720-
if is_new_error && @error_blocks.length >= ERRORS_LIMIT
730+
if is_new_error && @errors.length >= ERRORS_LIMIT
721731
Appsignal.internal_logger.warn "Appsignal::Transaction#add_error: Transaction has more " \
722732
"than #{ERRORS_LIMIT} distinct errors. Only the first " \
723733
"#{ERRORS_LIMIT} distinct errors will be reported."
724734
return
725735
end
726736

727-
if @error_blocks.empty?
737+
if @errors.empty?
728738
_set_error(error)
729-
elsif is_new_error && @backend.records_errors_eagerly?
739+
elsif is_new_error && @backend.supports_multiple_errors?
730740
# Record additional errors immediately so each exception event lands on
731741
# the span current now, not the root span at completion. The agent
732742
# backend instead reports extras as duplicate transactions.
733743
_send_error_to_backend(error)
734744
end
735745

736-
@error_blocks[error] << block
737-
@error_blocks[error].compact!
746+
@errors.add(error)
747+
748+
if @backend.supports_multiple_errors?
749+
# Collector mode: the error is already recorded, so run its block now
750+
# rather than at completion. Anything a block attaches to the current
751+
# span -- breadcrumbs, nested errors, custom instrumentation -- then
752+
# lands where the error was reported, not on the root span at
753+
# completion.
754+
self.class.with_transaction(self) { block.call(self) } if block
755+
else
756+
@error_blocks[error] << block
757+
@error_blocks[error].compact!
758+
end
738759
end
739760

740761
private
@@ -751,40 +772,25 @@ def run_before_complete_hooks
751772
end
752773
end
753774

754-
# Reports the errors stored on the transaction at completion, in one of two
755-
# ways depending on the backend:
775+
# Reports the errors stored on the transaction at completion.
756776
#
757-
# - eager (collector): each error was already recorded as its own exception
758-
# event when added, on the span current at that moment; here we only run
759-
# the error blocks.
760-
# - deferred (agent): the extension holds a single error, so the primary
761-
# error's blocks run on this transaction and every additional error is
762-
# reported as a duplicate transaction.
777+
# In eager (collector) mode nothing is left to do: each error was recorded
778+
# and its block run when the error was added. In deferred (agent) mode the
779+
# extension holds a single error, so the primary error's blocks run on this
780+
# transaction and every additional error is reported as a duplicate
781+
# transaction.
763782
def report_errors
764-
if @backend.records_errors_eagerly?
765-
run_error_blocks
766-
else
767-
report_errors_as_duplicates
768-
end
769-
end
783+
return if @backend.supports_multiple_errors?
770784

771-
# Eager mode: the errors are already recorded, so just run their blocks.
772-
# Blocks run in add-order, so a later error's block wins on a shared key, and
773-
# all block-set metadata merges onto the root span. (Per-error metadata
774-
# isolation is deferred -- the processor/UI does not read per-event
775-
# attributes yet.)
776-
def run_error_blocks
777-
@error_blocks.each_value do |blocks|
778-
self.class.with_transaction(self) do
779-
blocks.each { |block| block.call(self) }
780-
end
781-
end
785+
report_errors_as_duplicates
782786
end
783787

784-
# Agent mode: the extension transaction holds a single error, so report each
785-
# additional error as a duplicate transaction.
788+
# Agent-only legacy path. The extension transaction holds a single error, so
789+
# extra errors are reported as duplicate transactions. This whole method
790+
# disappears once agent mode is dropped: collector mode records every error
791+
# eagerly and leaves nothing to do at completion.
786792
def report_errors_as_duplicates
787-
@error_blocks.each do |error, blocks|
793+
@errors.each do |error|
788794
# Ignore the error that is already set in this transaction.
789795
next if error == @error_set
790796

@@ -793,7 +799,7 @@ def report_errors_as_duplicates
793799
# with a block that calls all the blocks set for that error
794800
# in the original transaction.
795801
transaction.internal_set_error(error) do
796-
blocks.each { |block| block.call(transaction) }
802+
@error_blocks[error].each { |block| block.call(transaction) }
797803
end
798804

799805
transaction.complete

lib/appsignal/transaction/base_backend.rb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ def set_error(_class_name, _message, _backtrace, _causes, _root_cause_missing)
5252
raise NotImplementedError
5353
end
5454

55-
# Whether the backend records each error eagerly onto one trace, or relies
56-
# on the Transaction duplicating itself per error.
57-
def records_errors_eagerly?
55+
# Whether the backend can hold more than one error on a single
56+
# transaction. When it can't (agent mode), the Transaction reports the
57+
# extra errors as duplicate transactions instead.
58+
def supports_multiple_errors?
5859
raise NotImplementedError
5960
end
6061

@@ -71,8 +72,9 @@ def discard
7172
raise NotImplementedError
7273
end
7374

74-
# Only used when `records_errors_eagerly?` is false (agent mode). Backends
75-
# that record eagerly never duplicate and leave this unimplemented.
75+
# Only used when `supports_multiple_errors?` is false (agent mode).
76+
# Backends that support multiple errors never duplicate and leave this
77+
# unimplemented.
7678
def duplicate(_new_transaction_id)
7779
raise NotImplementedError
7880
end

lib/appsignal/transaction/extension_backend.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def discard
109109

110110
# The extension transaction holds a single error, so the Transaction
111111
# reports additional errors as duplicate transactions instead.
112-
def records_errors_eagerly?
112+
def supports_multiple_errors?
113113
false
114114
end
115115

lib/appsignal/transaction/opentelemetry_backend.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,10 @@ def discard
268268
end
269269

270270
# Each error is recorded eagerly as its own `exception` event on the span
271-
# current when it was added, so the Transaction never duplicates itself --
272-
# which is why `duplicate` is left unimplemented (see BaseBackend).
273-
def records_errors_eagerly?
271+
# current when it was added, so a trace holds many errors and the
272+
# Transaction never duplicates itself -- which is why `duplicate` is left
273+
# unimplemented (see BaseBackend).
274+
def supports_multiple_errors?
274275
true
275276
end
276277

spec/lib/appsignal/transaction/extension_backend_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,9 @@
218218
end
219219
end
220220

221-
describe "#records_errors_eagerly?" do
221+
describe "#supports_multiple_errors?" do
222222
it "returns false (extra errors are reported as duplicate transactions)" do
223-
expect(backend.records_errors_eagerly?).to eq(false)
223+
expect(backend.supports_multiple_errors?).to eq(false)
224224
end
225225
end
226226
end

spec/lib/appsignal/transaction/opentelemetry_backend_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,9 +475,9 @@ def exception_event(backend)
475475
end
476476
end
477477

478-
describe "#records_errors_eagerly?" do
478+
describe "#supports_multiple_errors?" do
479479
it "returns true (multiple exception events on one span)" do
480-
expect(create_backend.records_errors_eagerly?).to eq(true)
480+
expect(create_backend.supports_multiple_errors?).to eq(true)
481481
end
482482
end
483483

spec/lib/appsignal/transaction_spec.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,26 @@
203203
expect(event_span.events.map(&:name)).to include("exception", "appsignal.breadcrumb")
204204
expect(Array(foreign_span.events).map(&:name)).to be_empty
205205
end
206+
207+
it "runs an error block on the span current when the error was added", :collector_mode do
208+
start_collector_agent
209+
transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST)
210+
211+
# The block runs when the error is added, while the event span is still
212+
# current -- not deferred to completion, when the root span would be
213+
# current. A breadcrumb added from the block lands on the event span.
214+
transaction.start_event
215+
transaction.add_error(ExampleStandardError.new("boom")) do |t|
216+
t.add_breadcrumb("network", "GET /", "ok", { "code" => "200" })
217+
end
218+
transaction.finish_event("sql.query", "Query", "SELECT 1",
219+
Appsignal::EventFormatter::DEFAULT)
220+
Appsignal::Transaction.complete_current!
221+
222+
event_span = event_spans.find { |s| s.attributes["appsignal.category"] == "sql.query" }
223+
expect(event_span.events.map(&:name)).to include("exception", "appsignal.breadcrumb")
224+
expect(Array(root_span.events).map(&:name)).to_not include("appsignal.breadcrumb")
225+
end
206226
end
207227
end
208228

0 commit comments

Comments
 (0)