Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changesets/complete-transaction-when-a-block-raises.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
bump: patch
type: fix
---

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.
35 changes: 30 additions & 5 deletions lib/appsignal/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,11 @@ def internal_set_error(error, &block)
# span -- breadcrumbs, nested errors, custom instrumentation -- then
# lands where the error was reported, not on the root span at
# completion.
self.class.with_transaction(self) { block.call(self) } if block
if block
self.class.with_transaction(self) do
call_transaction_block(block, self, :source => "error")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you want to differentiate between the three different handlers that now have the source set to 'error'?

Like so for example:

Suggested change
call_transaction_block(block, self, :source => "error")
call_transaction_block(block, self, :source => "internal_set_error")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like for it to be meaningful to the customer -- so send_error/set_error/report_error, but that information is lost at this point in the call tree. I could try and pass it down, or I could try to show all three names for readability.

As far as we're concerned, in terms of our ability to debug it, we should get which one of internal_set_error or report_errors_as_duplicates it was that caused it from the backtrace itself, so that information is never lost. The method name in the error is for the customer, for them to be able to debug what it is they're doing in a block that caused it to error.

What does concern me, though, is that you mentioned it's called three different times. I think some of those are redundant with each other -- if internal_set_error calls its block with call_transaction_block, then report_errors_as_duplicates does not need to call its block with call_transaction_block from inside a block passed to internal_set_error. Thank you for flagging that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on this: I went with wrapping at the entry point.

Each error block is now wrapped once, where user code hands it in, instead of at every place it later runs. That fixed the redundancy I mentioned. The duplicate-transaction path no longer wraps a block from inside a block passed to internal_set_error. It calls the already-wrapped block directly.

It also recovered the customer-facing name. send_error and report_error pass their own name when they wrap the block, so the log names the call the customer made rather than an internal method. set_error is left out on purpose, because its block runs inline at the call site, so a failure there already surfaces to the caller.

The after_create and before_complete hooks are the one exception. They can be registered either with a block or by pushing a method onto the set, so there is no single entry point to wrap them at. They keep their guard where they run.

end
end
else
@error_blocks[error] << block
@error_blocks[error].compact!
Expand All @@ -760,15 +764,34 @@ def internal_set_error(error, &block)

private

# Run a block handed to the transaction by user code -- an error block from
# `set_error`/`send_error`/`report_error`, or an `after_create`/
# `before_complete` hook -- without letting it break the transaction
# lifecycle. These blocks can run far from where they were defined (an error
# block runs at completion in agent mode), so a failure is logged and
# swallowed rather than raised into whatever drove creation or completion.
# Re-raising would surface the error in unrelated code and, in collector
# mode, leave the OpenTelemetry context attached to the fiber -- leaking it
# into the next request on that thread.
def call_transaction_block(block, *args, source:)
block.call(*args)
rescue => e
location = block.source_location&.join(":") || "unknown location"
Appsignal.internal_logger.error(
"Error in #{source} block defined at #{location}: " \
"#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}"
)
end

def run_after_create_hooks
self.class.after_create.each do |block|
block.call(self)
call_transaction_block(block, self, :source => "after_create hook")
end
end

def run_before_complete_hooks
self.class.before_complete.each do |block|
block.call(self, @error_set)
call_transaction_block(block, self, @error_set, :source => "before_complete hook")
end
end

Expand Down Expand Up @@ -799,7 +822,9 @@ def report_errors_as_duplicates
# with a block that calls all the blocks set for that error
# in the original transaction.
transaction.internal_set_error(error) do
@error_blocks[error].each { |block| block.call(transaction) }
@error_blocks[error].each do |block|
call_transaction_block(block, transaction, :source => "error")
end
end

transaction.complete
Expand All @@ -810,7 +835,7 @@ def report_errors_as_duplicates

self.class.with_transaction(self) do
@error_blocks[@error_set].each do |block|
block.call(self)
call_transaction_block(block, self, :source => "error")
end
end
end
Expand Down
102 changes: 102 additions & 0 deletions spec/lib/appsignal/transaction_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,108 @@
end
end

# A block handed to AppSignal (an error block, or an after_create/
# before_complete hook) is user code that can raise. Because these blocks
# can run far from where they were defined -- an error block runs at
# completion in agent mode -- a failure must be logged and swallowed, never
# raised into whatever drove creation or completion. Otherwise, in collector
# mode, the transaction's OpenTelemetry context is left attached to the
# fiber and leaks into the next request on that thread.
describe "when a block handed to the transaction raises" do
describe "an error block" do
it_in_both_modes "completes the transaction and logs, without raising" do
transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST)

logs = capture_logs do
expect do
transaction.add_error(ExampleStandardError.new("boom")) do
raise ExampleStandardError, "error block boom"
end
Appsignal::Transaction.complete_current!
end.to_not raise_error
end

expect(transaction).to be_completed
expect(logs).to contains_log(:error, /Error in error block defined at .+error block boom/)
end

it "detaches the OpenTelemetry context", :collector_mode do
start_collector_agent
transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST)

expect do
transaction.add_error(ExampleStandardError.new("boom")) do
raise ExampleStandardError, "error block boom"
end
Appsignal::Transaction.complete_current!
end.to_not raise_error

expect(::OpenTelemetry::Trace.current_span)
.to eq(::OpenTelemetry::Trace::Span::INVALID)
end
end

describe "a before_complete hook" do
it_in_both_modes "completes the transaction and logs, without raising" do
Appsignal::Transaction.before_complete do |_transaction, _error|
raise ExampleStandardError, "before_complete boom"
end
transaction = create_transaction(Appsignal::Transaction::HTTP_REQUEST)

logs = capture_logs do
expect { Appsignal::Transaction.complete_current! }.to_not raise_error
end

expect(transaction).to be_completed
expect(logs).to contains_log(
:error, /Error in before_complete hook block defined at .+before_complete boom/
)
end

it "detaches the OpenTelemetry context", :collector_mode do
start_collector_agent
Appsignal::Transaction.before_complete do |_transaction, _error|
raise ExampleStandardError, "before_complete boom"
end
create_transaction(Appsignal::Transaction::HTTP_REQUEST)

expect { Appsignal::Transaction.complete_current! }.to_not raise_error
expect(::OpenTelemetry::Trace.current_span)
.to eq(::OpenTelemetry::Trace::Span::INVALID)
end
end

describe "an after_create hook" do
it_in_both_modes "creates the transaction and logs, without raising" do
Appsignal::Transaction.after_create do |_transaction|
raise ExampleStandardError, "after_create boom"
end

logs = capture_logs do
expect { create_transaction(Appsignal::Transaction::HTTP_REQUEST) }
.to_not raise_error
end

expect(logs).to contains_log(
:error, /Error in after_create hook block defined at .+after_create boom/
)
end

it "detaches the OpenTelemetry context on the next completion", :collector_mode do
start_collector_agent
Appsignal::Transaction.after_create do |_transaction|
raise ExampleStandardError, "after_create boom"
end

create_transaction(Appsignal::Transaction::HTTP_REQUEST)
Appsignal::Transaction.complete_current!

expect(::OpenTelemetry::Trace.current_span)
.to eq(::OpenTelemetry::Trace::Span::INVALID)
end
end
end

describe ".current" do
context "when there is a current transaction" do
let!(:transaction) { create_transaction }
Expand Down
Loading