EasyOp Docs Plugins

Plugins

EasyOp has an opt-in plugin system. Plugins extend the operation lifecycle via the plugin DSL. They are not required automatically — require each plugin file before using it.

Activation

require "easyop/plugins/instrumentation"
require "easyop/plugins/recording"
require "easyop/plugins/async"
require "easyop/plugins/transactional"

class ApplicationOperation
  include Easyop::Operation

  plugin Easyop::Plugins::Instrumentation
  plugin Easyop::Plugins::Recording,    model: OperationLog
  plugin Easyop::Plugins::Async,        queue: "operations"
  plugin Easyop::Plugins::Transactional
end

# All subclasses inherit these plugins automatically:
class CreateUser < ApplicationOperation
  def call; ctx.user = User.create!(email: ctx.email); end
end

Plugin Execution Order

Plugins wrap _easyop_run via Ruby's prepend. The last installed plugin is the outermost wrapper:

# Installation order: Instrumentation → Recording → Async → Transactional
#
# Execution order (outermost first):
#
#  Transactional (outermost — last installed)
#    Recording::RunWrapper
#      Instrumentation::RunWrapper
#        prepare { before → call → after }  (innermost)
#
# This means:
# - The DB transaction opens first and closes last (wraps everything)
# - Recording measures wall time including transaction overhead
# - The Instrumentation event fires inside the transaction + recording window
# - Async only adds .call_async — it does NOT wrap _easyop_run
Tip Install Transactional last if you want it to be the outermost wrapper. This ensures the transaction contains the complete operation lifecycle including recording and instrumentation.

Plugin: Instrumentation

Emits an ActiveSupport::Notifications event after every operation call. Requires ActiveSupport (included with Rails).

require "easyop/plugins/instrumentation"

class ApplicationOperation
  include Easyop::Operation
  plugin Easyop::Plugins::Instrumentation
end

Event

Event name: "easyop.operation.call"

Payload keyTypeDescription
:operationStringClass name, e.g. "Users::Register"
:successBooleantrue unless ctx.fail! was called
:errorString | nilctx.error on failure, nil on success
:durationFloatElapsed milliseconds
:ctxEasyop::CtxThe result object

Built-in log subscriber

# config/initializers/easyop.rb
Easyop::Plugins::Instrumentation.attach_log_subscriber
# Output: "[EasyOp] Users::Register ok (4.2ms)"
# Output: "[EasyOp] Users::Authenticate FAILED (1.1ms) — Invalid email or password"

Manual subscribe

ActiveSupport::Notifications.subscribe("easyop.operation.call") do |event|
  p = event.payload
  MyAPM.record_span(
    p[:operation],
    success:  p[:success],
    error:    p[:error],
    duration: p[:duration]
  )
end

Plugin: Recording

Persists every operation execution to an ActiveRecord model. Useful for audit trails, debugging, and performance monitoring.

require "easyop/plugins/recording"

class ApplicationOperation
  include Easyop::Operation
  plugin Easyop::Plugins::Recording, model: OperationLog
end

Options

OptionDefaultDescription
model:requiredActiveRecord class to write records into
record_params:trueSet false to skip params_data serialization

Migration

create_table :operation_logs do |t|
  t.string   :operation_name, null: false
  t.boolean  :success,        null: false
  t.string   :error_message
  t.text     :params_data         # JSON — ctx attrs with sensitive keys scrubbed
  t.float    :duration_ms
  t.datetime :performed_at,   null: false
end

Scrubbed keys

These keys are never written to params_data: :password, :password_confirmation, :token, :secret, :api_key.

ActiveRecord objects in ctx are serialized as { "id" => 42, "class" => "User" } rather than their full representation.

Opting out

# Disable recording for this class only:
class Newsletter::SendBroadcast < ApplicationOperation
  recording false
end

# Re-enable in a subclass when parent has recording disabled:
class ImportantOp < BaseWithNoRecording
  recording true
end

# Skip params serialization for high-frequency or sensitive operations:
class Users::TrackPageView < ApplicationOperation
  plugin Easyop::Plugins::Recording, model: OperationLog, record_params: false
end

Recording failures are silently swallowed — a failed database write never breaks the operation.

Plugin: Async

Adds .call_async to any operation class, enqueuing execution as an ActiveJob. Requires ActiveJob (included with Rails).

require "easyop/plugins/async"

class Reports::GeneratePDF < ApplicationOperation
  plugin Easyop::Plugins::Async, queue: "reports"
end

Enqueueing

# Enqueue immediately (default queue):
Reports::GeneratePDF.call_async(report_id: 123)

# Override queue at call time:
Reports::GeneratePDF.call_async(report_id: 123, queue: "low_priority")

# Delay execution:
Reports::GeneratePDF.call_async(report_id: 123, wait: 10.minutes)

# Schedule at a specific time:
Reports::GeneratePDF.call_async(report_id: 123, wait_until: Date.tomorrow.noon)

Scheduling options

OptionDescription
queue:Override the queue (default from plugin install)
wait:Delay (e.g. 5.minutes)
wait_until:Schedule at exact time

Queue inheritance

class BaseOp
  include Easyop::Operation
  plugin Easyop::Plugins::Async, queue: "operations"
end

class ChildOp < BaseOp
  def call; end
end

ChildOp._async_default_queue  # => "operations"  (inherited from parent)

ActiveRecord serialization

# AR objects are serialized as { "__ar_class" => "Order", "__ar_id" => 42 }
# and re-fetched via Order.find(42) in the job:
Orders::SendConfirmation.call_async(order: @order, user: current_user)

# Only pass serializable values:
# String, Integer, Float, Boolean, nil, Hash, Array, ActiveRecord::Base

Job class

# Easyop::Plugins::Async::Job is created lazily on first .call_async call
# This means you can require the plugin before ActiveJob loads
Easyop::Plugins::Async::Job  # => the ActiveJob subclass (after first call_async)

Plugin: Transactional

Wraps every operation call in a database transaction. Supports ActiveRecord and Sequel — adapter is detected automatically.

require "easyop/plugins/transactional"

# On a specific operation:
class TransferFunds < ApplicationOperation
  plugin Easyop::Plugins::Transactional

  def call
    ctx.from_account.debit!(ctx.amount)
    ctx.to_account.credit!(ctx.amount)
    ctx.transaction_id = SecureRandom.uuid
  end
end

# Globally — all subclasses get transactions:
class ApplicationOperation
  include Easyop::Operation
  plugin Easyop::Plugins::Transactional
end

Opting out

class ReadOnlyReport < ApplicationOperation
  transactional false   # no transaction overhead for read-only ops
end

Include style (also works)

class LegacyOp
  include Easyop::Operation
  include Easyop::Plugins::Transactional
end

Lifecycle scope

The transaction wraps the entire prepare chain — before hooks, call, and after hooks all run inside the same transaction. If ctx.fail! is called (raising Ctx::Failure), the transaction rolls back.

With Flow

# Applied per-step: each step gets its own transaction
class ProcessOrder
  include Easyop::Flow
  flow ValidateCart, ChargePayment, CreateOrder  # each step has its own transaction
end

# For a flow-wide transaction, apply it to the Flow class itself:
class ProcessOrder
  include Easyop::Flow
  plugin Easyop::Plugins::Transactional  # entire flow in one transaction

  flow ValidateCart, ChargePayment, CreateOrder
end

Building a Custom Plugin

A plugin is any object responding to .install(base_class, **options). Inherit from Easyop::Plugins::Base for a clean interface.

require "easyop/plugins/base"

module TimingPlugin
  def self.install(base, threshold_ms: 500, **_opts)
    base.prepend(RunWrapper)
    base.extend(ClassMethods)
    base.instance_variable_set(:@_timing_threshold_ms, threshold_ms)
  end

  module ClassMethods
    # Per-class DSL: `timing false` to disable
    def timing(enabled)
      @_timing_enabled = enabled
    end

    def _timing_enabled?
      # Use instance_variable_defined? (not defined?) to handle false correctly
      return @_timing_enabled if instance_variable_defined?(:@_timing_enabled)
      superclass.respond_to?(:_timing_enabled?) ? superclass._timing_enabled? : true
    end

    def _timing_threshold_ms
      @_timing_threshold_ms ||
        (superclass.respond_to?(:_timing_threshold_ms) ? superclass._timing_threshold_ms : 500)
    end
  end

  module RunWrapper
    def _easyop_run(ctx, raise_on_failure:)
      return super unless self.class._timing_enabled?

      start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      super.tap do
        ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
        if ms > self.class._timing_threshold_ms
          logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
          logger.warn "[SLOW] #{self.class.name} took #{ms.round(1)}ms"
        end
      end
    end
  end
end

# Activate:
class ApplicationOperation
  include Easyop::Operation
  plugin TimingPlugin, threshold_ms: 200
end

# Opt out on a specific class:
class Cache::Lookup < ApplicationOperation
  timing false
end

Plugin building conventions

  • Prefix all internal instance/class methods with _pluginname_ (e.g. _timing_enabled?) to avoid collisions
  • Use instance_variable_defined? (not defined?) when checking instance variables on class objects — correctly handles false values
  • Always call super in RunWrapper#_easyop_run and return ctx
  • Implement per-class opt-out using the instance_variable_defined? pattern
  • Walk the inheritance chain (superclass.respond_to?) to inherit configuration

Easyop::Plugins::Base

Abstract base class. Inherit from it to document intent — it raises NotImplementedError if .install is not overridden.

class MyPlugin < Easyop::Plugins::Base
  def self.install(base, **options)
    # your implementation
  end
end
EasyOp v0.1.0 · MIT License · GitHub