Skip to content

Commit 21c7c8b

Browse files
committed
Guard blocks handed to the transaction
A transaction runs blocks supplied by user code in a few places: the error block passed to `set_error`, `send_error`, and `report_error`, and the `after_create` and `before_complete` hooks. Any of them can raise. These blocks can run far from where they were defined. In agent mode an error block runs at completion, not when the error is added. So a raise propagated out of whatever drove creation or completion, surfacing the error in unrelated code. In collector mode it also skipped the backend's completion step, which left the transaction's OpenTelemetry context attached to the fiber. That leaked context then became the parent of the next request's root span on the reused thread, merging unrelated requests into one trace. Agent mode never showed the leak because its extension handle is not a per-fiber stack. Run each of these blocks defensively. On failure, log the error and the block's definition site, then carry on so creation and completion always finish. The error is swallowed rather than re-raised, because the block can run far from its caller and re-raising would surface it in code that has nothing to do with the block.
1 parent c220cce commit 21c7c8b

3 files changed

Lines changed: 138 additions & 5 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
bump: patch
3+
type: fix
4+
---
5+
6+
Complete the transaction even when a block handed to it raises. The error block passed to `Appsignal.set_error`, `Appsignal.send_error`, and `Appsignal.report_error`, as well as the internal `after_create` and `before_complete` hooks, are user code that can raise. Until now such an exception propagated out of transaction creation or completion, which surfaced the error in unrelated code and could leave the transaction unfinished. These blocks are now run defensively: the failure is logged, naming the error and where the block was defined, and creation and completion carry on.

lib/appsignal/transaction.rb

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,11 @@ def internal_set_error(error, &block)
751751
# span -- breadcrumbs, nested errors, custom instrumentation -- then
752752
# lands where the error was reported, not on the root span at
753753
# completion.
754-
self.class.with_transaction(self) { block.call(self) } if block
754+
if block
755+
self.class.with_transaction(self) do
756+
call_transaction_block(block, self, :source => "error")
757+
end
758+
end
755759
else
756760
@error_blocks[error] << block
757761
@error_blocks[error].compact!
@@ -760,15 +764,34 @@ def internal_set_error(error, &block)
760764

761765
private
762766

767+
# Run a block handed to the transaction by user code -- an error block from
768+
# `set_error`/`send_error`/`report_error`, or an `after_create`/
769+
# `before_complete` hook -- without letting it break the transaction
770+
# lifecycle. These blocks can run far from where they were defined (an error
771+
# block runs at completion in agent mode), so a failure is logged and
772+
# swallowed rather than raised into whatever drove creation or completion.
773+
# Re-raising would surface the error in unrelated code and, in collector
774+
# mode, leave the OpenTelemetry context attached to the fiber -- leaking it
775+
# into the next request on that thread.
776+
def call_transaction_block(block, *args, source:)
777+
block.call(*args)
778+
rescue => e
779+
location = block.source_location&.join(":") || "unknown location"
780+
Appsignal.internal_logger.error(
781+
"Error in #{source} block defined at #{location}: " \
782+
"#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}"
783+
)
784+
end
785+
763786
def run_after_create_hooks
764787
self.class.after_create.each do |block|
765-
block.call(self)
788+
call_transaction_block(block, self, :source => "after_create hook")
766789
end
767790
end
768791

769792
def run_before_complete_hooks
770793
self.class.before_complete.each do |block|
771-
block.call(self, @error_set)
794+
call_transaction_block(block, self, @error_set, :source => "before_complete hook")
772795
end
773796
end
774797

@@ -799,7 +822,9 @@ def report_errors_as_duplicates
799822
# with a block that calls all the blocks set for that error
800823
# in the original transaction.
801824
transaction.internal_set_error(error) do
802-
@error_blocks[error].each { |block| block.call(transaction) }
825+
@error_blocks[error].each do |block|
826+
call_transaction_block(block, transaction, :source => "error")
827+
end
803828
end
804829

805830
transaction.complete
@@ -810,7 +835,7 @@ def report_errors_as_duplicates
810835

811836
self.class.with_transaction(self) do
812837
@error_blocks[@error_set].each do |block|
813-
block.call(self)
838+
call_transaction_block(block, self, :source => "error")
814839
end
815840
end
816841
end

spec/lib/appsignal/transaction_spec.rb

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,108 @@
226226
end
227227
end
228228

229+
# A block handed to AppSignal (an error block, or an after_create/
230+
# before_complete hook) is user code that can raise. Because these blocks
231+
# can run far from where they were defined -- an error block runs at
232+
# completion in agent mode -- a failure must be logged and swallowed, never
233+
# raised into whatever drove creation or completion. Otherwise, in collector
234+
# mode, the transaction's OpenTelemetry context is left attached to the
235+
# fiber and leaks into the next request on that thread.
236+
describe "when a block handed to the transaction raises" do
237+
describe "an error block" do
238+
it_in_both_modes "completes the transaction and logs, without raising" do
239+
transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST)
240+
241+
logs = capture_logs do
242+
expect do
243+
transaction.add_error(ExampleStandardError.new("boom")) do
244+
raise ExampleStandardError, "error block boom"
245+
end
246+
Appsignal::Transaction.complete_current!
247+
end.to_not raise_error
248+
end
249+
250+
expect(transaction).to be_completed
251+
expect(logs).to contains_log(:error, /Error in error block defined at .+error block boom/)
252+
end
253+
254+
it "detaches the OpenTelemetry context", :collector_mode do
255+
start_collector_agent
256+
transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST)
257+
258+
expect do
259+
transaction.add_error(ExampleStandardError.new("boom")) do
260+
raise ExampleStandardError, "error block boom"
261+
end
262+
Appsignal::Transaction.complete_current!
263+
end.to_not raise_error
264+
265+
expect(::OpenTelemetry::Trace.current_span)
266+
.to eq(::OpenTelemetry::Trace::Span::INVALID)
267+
end
268+
end
269+
270+
describe "a before_complete hook" do
271+
it_in_both_modes "completes the transaction and logs, without raising" do
272+
Appsignal::Transaction.before_complete do |_transaction, _error|
273+
raise ExampleStandardError, "before_complete boom"
274+
end
275+
transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST)
276+
277+
logs = capture_logs do
278+
expect { Appsignal::Transaction.complete_current! }.to_not raise_error
279+
end
280+
281+
expect(transaction).to be_completed
282+
expect(logs).to contains_log(
283+
:error, /Error in before_complete hook block defined at .+before_complete boom/
284+
)
285+
end
286+
287+
it "detaches the OpenTelemetry context", :collector_mode do
288+
start_collector_agent
289+
Appsignal::Transaction.before_complete do |_transaction, _error|
290+
raise ExampleStandardError, "before_complete boom"
291+
end
292+
create_transaction(Appsignal::Transaction::HTTP_REQUEST)
293+
294+
expect { Appsignal::Transaction.complete_current! }.to_not raise_error
295+
expect(::OpenTelemetry::Trace.current_span)
296+
.to eq(::OpenTelemetry::Trace::Span::INVALID)
297+
end
298+
end
299+
300+
describe "an after_create hook" do
301+
it_in_both_modes "creates the transaction and logs, without raising" do
302+
Appsignal::Transaction.after_create do |_transaction|
303+
raise ExampleStandardError, "after_create boom"
304+
end
305+
306+
logs = capture_logs do
307+
expect { create_transaction(Appsignal::Transaction::HTTP_REQUEST) }
308+
.to_not raise_error
309+
end
310+
311+
expect(logs).to contains_log(
312+
:error, /Error in after_create hook block defined at .+after_create boom/
313+
)
314+
end
315+
316+
it "detaches the OpenTelemetry context on the next completion", :collector_mode do
317+
start_collector_agent
318+
Appsignal::Transaction.after_create do |_transaction|
319+
raise ExampleStandardError, "after_create boom"
320+
end
321+
322+
create_transaction(Appsignal::Transaction::HTTP_REQUEST)
323+
Appsignal::Transaction.complete_current!
324+
325+
expect(::OpenTelemetry::Trace.current_span)
326+
.to eq(::OpenTelemetry::Trace::Span::INVALID)
327+
end
328+
end
329+
end
330+
229331
describe ".current" do
230332
context "when there is a current transaction" do
231333
let!(:transaction) { create_transaction }

0 commit comments

Comments
 (0)