Problem

You spend hours trying to understand errors. Sometimes it’s so difficult you give up.

Root cause

Not having enough context around the error.

Solution

Alongside every error, store structured context.

Implementation

Base Class

class StructuredError < StandardError
  attr_reader :context

  def initialize(message, context: nil)
    @context = context || {}
    super(message)
  end
  
  def to_h
    @context.merge(message: message)
  end
end

Domain Specific Error

A factory method .from with named arguments can be useful:

class InvalidOrder < StructuredError
  def self.from(order:)
    new(
		  "Invalid order",
		  context: {
			  app: {
					order: {
					  id: order.id
					  status: order.status,
		  			errors: order.errors.full_messages,
		  			customer: {
			  			id: order.customer.id
			  		}
					},
				},
		  }
		)
  end
end

Usage

class OrdersController
  def process
    @order = Order.find(params[:id])
    @order.process!
  end
end

class Order
  def process!
		# ...
		raise InvalidOrder.from(order: self) if invalid?
		# ...
	end
end

Send To Observability Tool

Before we send the error, extract the context and merge into the tags.

# Example for Sentry - ask me for others!
Sentry.init do |config|
  # ...
  config.before_send = lambda do |event, hint|
    # Deep merge context into tags to allow searching in the UI
	  event.tags.deep_merge!(hint[:exception].context)
	  event
  rescue NoMethodError => _
	  event
  end
end