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
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 key | Type | Description |
|---|---|---|
:operation | String | Class name, e.g. "Users::Register" |
:success | Boolean | true unless ctx.fail! was called |
:error | String | nil | ctx.error on failure, nil on success |
:duration | Float | Elapsed milliseconds |
:ctx | Easyop::Ctx | The 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
| Option | Default | Description |
|---|---|---|
model: | required | ActiveRecord class to write records into |
record_params: | true | Set 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
| Option | Description |
|---|---|
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?(notdefined?) when checking instance variables on class objects — correctly handlesfalsevalues - Always call
superinRunWrapper#_easyop_runand 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