Logging failures without rescuing Dry::Monads::Do::Halt

How would you log a failure without rescuing Dry::Monads::Do::Halt? This is what my team is currently doing and I find it quite disturbing that we’re messing with the internals of Dry::Monads.

class MyOperation
  def call
    result1 = yield private_operation_1
    result2 = yield private_operation_2
    result3 = yield private_operation_3
    result4 = yield private_operation_4
  rescue Dry::Monads::Do::Halt => error
    log_failure(error.failure)
  end
end

Another option is to log the error on the client code:

class ClientOfMyOperation
  def call
    result = MyOperation.new.call
    log_failure(...) if result.failure?
  end
end

But that seems to add extra responsibilities to the client and the log_failure may not have all the context to log the failure properly.

I’m not sure this is the right way, but the way I typically manage it is to delegate to a private method that executes the optimistic pipeline and resolves the failure in call, like so:

class MyOperation
  def call
    pipeline.value_or { |e| raise "some error #{e.message} : #{e.trace}" }
    # or, if the error is recoverable:
    pipeline.or { |failed| RecoveryOperation.call(failed.point_of_failure) } 
    # or, just pass the result up to the caller
    pipeline.to_result(:my_operation_failed)
  end

  private
  
  def pipeline
    result1 = yield private_operation_1
    result2 = yield private_operation_2
    result3 = yield private_operation_3
    result4 = yield private_operation_4
  end
end

You can retain the failure point with trace: dry-rb - dry-monads v1.3 - Tracing failures

And most monads have a way to instrument the Left branch with a failure reason:

for example:

None().to_result(:error) # => Failure(:error)

Does that answer your question?

2 Likes

:bulb: In case it helps, I generally pattern match since I’m going to do something interesting with the result via the caller. Example:

class MyOperation
  def call
    yield private_operation_1
    yield private_operation_2
    yield private_operation_3
    yield private_operation_4
  end
end

class ClientOfMyOperation
  def initialize operation: MyOperation.new
    @operation = operation
  end

  def call
    case operation.call
      in Success(result) then puts result
      in Failure(error) then log_failure error
      else fail StandardError, "Unable to parse operation."
    end
  end

  private

  attr_reader :operation
end
2 Likes

additionally, pattern matching is super nice in the testing layer for Monad types:

it "resolves the operation pipeline" do
  subject.call(data) in Success(value)
  expect(value.interesting_attribute).to be_interesting
end

it "reports the error information when the pipeline fails" do
  subject.call(bad_data) in Failure(value)
  expect(value.message).to eq(:reason_for_failure)
end
3 Likes

Thanks, everyone. @jedschneider I love this pipeline approach! It allows the failure to be handled both internally as well as externally.

Holy shit this is amazing. It never occured to me that inline pattern match could do this


Edit: okay after playing around with this approach for a few minutes it’s less useful than I had hoped. If the pattern match fails, is just returns false which in this case does nothing, and your spec failure will end up being kind of cryptic because value will be nil rather than directly telling you it was an unexpected result.

I started leaning into monads as a way to reduce NoMethodError so this feels like a step backward.

I had hoped I might write some kind of assert to make this nicer, but I couldn’t find a way to do it cleanly without a syntax error.

I’m still looking for a really good way to combine pattern matching and rspec matchers in my specs. Everything I’ve tried has been kind of awkward.

For now, I write

result = subject.(data)
expect(result).to match Success(ValueClass)
result.bind do |value|
  expect(value.interesting_value).to be_interesting
end

Maybe Dry::Matcher could be pressganged into this. It’ll require an extension to the unexpected pattern behavior.

@alassek the pattern match is intended to catch the outside monad type, not the localized value in the container:

irb(main):010:0> Dry::Monads::Success(nil) in Dry::Monads::Failure(nil)
Traceback (most recent call last):
        4: from /Users/jed/.asdf/installs/ruby/2.7.2/bin/irb:23:in `<main>'
        3: from /Users/jed/.asdf/installs/ruby/2.7.2/bin/irb:23:in `load'
        2: from /Users/jed/.asdf/installs/ruby/2.7.2/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
        1: from (irb):10
NoMatchingPatternError (Success(nil))

however, you can match on the value inside the result as well:

irb(main):007:0> Dry::Monads::Success(nil) in Dry::Monads::Success(nil)
=> nil
irb(main):008:0> Dry::Monads::Success(1) in Dry::Monads::Success(Integer => x)
=> nil
irb(main):009:0> x
=> 1

I’ll leave it to the reader to translate into RSpec.

9 times out of 10, I would say command output should be pattern-matched somehow and your error logging should go there.

However, I did recently run into a similar problem where we wanted to log a failure but recover transparently, so a pattern-match wouldn’t do.

I would say that trapping a Dry::Monads::Do::Halt error entirely is a bad idea, but you can log it and reraise safely. A bare raise without an argument will reraise the same exception without altering the stacktrace.

class MyOperation
  def call
    result1 = yield private_operation_1
    result2 = yield private_operation_2
    result3 = yield private_operation_3
    result4 = yield private_operation_4
  rescue Dry::Monads::Do::Halt => error
    log_failure(error.result.failure)
    raise
  end
end

(side-note: Halt wraps the Failure monad on the result attribute, so you need to unpack that first to access the failure value)

I wrote a helper method in our Command (Operation) base class to do the unwrapping for me

  # Log a Failure Result to Rollbar transparently
  #
  # @param err [StandardError, Failure, Dry::Monads::Do::Halt]
  def log_failure(err)
    err = err.result if err.is_a?(Dry::Monads::Do::Halt)

    case err
    in Failure(StandardError => ex)
      Rollbar.error(ex)
    in Failure[err_name, *errs]
      Rollbar.error("Error Result: #{err_name}", failure: errs.inspect)
    end
  end

Hmm, I wonder if there was a change in behavior with inline matching?

main:0> RUBY_VERSION
=> "3.1.0"
main:0> M::Success(nil) in M::Failure(nil)
=> false

I don’t get a NoMatchingPatternError. I think the exception was removed in later Ruby versions. I believe this makes it more useful generally, but not for this specific purpose.

@alassek I did not know that! I will put this on a ticket tomorrow to change, although our runtime is AWS Linux so we won’t likely be using beyond 2.7 for a bit:

irb(main):011:0> RUBY_VERSION
=> "2.7.2"

It’s super disappointing IMO that the pattern match is no longer exhaustive. it was the main feature of using case in vs case when.

jed@wingmate ruby % irb
irb(main):001:1* case 3
irb(main):002:1* when 2 then "two"
irb(main):003:0> end
=> nil

irb(main):005:1* case 3
irb(main):006:1* in 2
irb(main):007:1*   "two"
irb(main):008:0> end
Traceback (most recent call last):
        4: from /Users/jed/.asdf/installs/ruby/2.7.2/bin/irb:23:in `<main>'
        3: from /Users/jed/.asdf/installs/ruby/2.7.2/bin/irb:23:in `load'
        2: from /Users/jed/.asdf/installs/ruby/2.7.2/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
        1: from (irb):5
NoMatchingPatternError (3)

To clarify, NoMatchingPatternError still happens when you write case..in. It is only the inline matching that doesn’t raise.

1 Like

Makes sense, I’ll probably delegate to a custom matcher that runs the case in expression against expected via a proc or something. A hassle, and still a disappointing change but manageable. Thanks again, I would have been sidelined by a ruby upgrade issue for sure!