ActiveSubscriber
ActiveSubscriber provides a clean interface for implementing the publisher-subscriber pattern in Rails applications using ActiveSupport Notifications, with automatic subscriber discovery and lifecycle callbacks.
Installation
Add this line to your application's Gemfile:
gem 'active_subscriber'And then execute:
$ bundle installOr install it yourself as:
$ gem install active_subscriberQuick Start
- Install ActiveSubscriber:
rails generate active_subscriber:install- Create a subscriber:
rails generate active_subscriber:subscriber Analytics --events user_signed_in user_signed_up- Include the Publisher module in your services:
class AnalyticsService
include ActiveSubscriber::Publisher
def self.track_analytics(event_name, properties = {}, context: analytics_context)
publish_event(event_name, properties, context)
end
def self.track_analytics_with_timing(event_name, properties = {}, context = {}, &block)
publish_event_with_timing(event_name, properties, context, &block)
end
end- Include the helper in your controllers:
class PostController < ApplicationController
include ActiveSubscriber::Helpers::AnalyticsHelper
def index
@posts = Post.all
track_analytics("posts.viewed", { count: @posts.count })
end
endUsage
Creating Subscribers
Subscribers inherit from ActiveSubscriber::Base and can subscribe to specific events or event patterns:
class LoggingSubscriber < ActiveSubscriber::Base
subscribe_to :user_signed_in, :user_signed_up
subscribe_to_pattern "analytics.*"
# Lifecycle callbacks
after_handle_event :increment_stats
around_handle_event :with_timing
before_handle_event :setup_context
def handle_user_signed_in(data)
Rails.logger.info "User signed in: #{data[:payload][:user_id]}"
end
def handle_user_signed_up(data)
Rails.logger.info "New user signed up: #{data[:payload][:user_id]}"
end
def handle_all_events(event_name, data)
Rails.logger.info "Analytics event: #{event_name}"
end
private
def increment_stats
# Called after handling any event
end
def with_timing
start_time = Time.current
yield
duration = Time.current - start_time
Rails.logger.debug "Event handled in #{duration * 1000}ms"
end
def setup_context
# Called before handling any event
end
endPublishing Events
Using the Publisher Module
class UserService
include ActiveSubscriber::Publisher
def sign_in_user(user)
# ... sign in logic ...
publish_event("user_signed_in", { user_id: user.id }, { ip: request.ip })
end
def create_user(params)
result = publish_event_with_timing("user_creation", { email: params[:email] }) do
User.create!(params)
end
publish_event("user_signed_up", { user_id: result.id })
result
end
endUsing the Analytics Helper
class ApplicationController < ActionController::Base
include ActiveSubscriber::Helpers::AnalyticsHelper
def show
@post = Post.find(params[:id])
track_analytics("post.viewed", {
post_id: @post.id,
category: @post.category
})
end
endEvent Data Structure
Events published through ActiveSubscriber include:
{
payload: { /* your custom data */ },
context: { /* additional context */ },
published_at: Time.current,
publisher: "YourServiceClass"
}When using the analytics helper, additional context is automatically added:
{
controller: "posts",
action: "show",
user_id: current_user&.id,
user_agent: request.user_agent,
ip_address: request.remote_ip,
# ... more request context
}Configuration
Configure ActiveSubscriber in config/initializers/active_subscriber.rb:
ActiveSubscriber.configure do |config|
# Enable or disable ActiveSubscriber (default: true)
config.enabled = true
# Namespace for Notification events
config.namespace = "my_app"
# Paths where subscribers are located (default: ["app/subscribers"])
config.subscriber_paths = ["app/subscribers", "lib/subscribers"]
# Filter events (optional)
config.event_filter = ->(event_name) { !event_name.start_with?("internal.") }
# Auto-load subscribers (default: true)
config.auto_load = true
endLifecycle Callbacks
Subscribers support three types of lifecycle callbacks:
-
before_handle_event- Called before handling any event -
after_handle_event- Called after handling any event -
around_handle_event- Wraps the event handling (must yield)
class TimingSubscriber < ActiveSubscriber::Base
subscribe_to_pattern ".*"
around_handle_event :with_timing
after_handle_event :log_completion
def handle_all_events(event_name, data)
# Handle the event
end
private
def with_timing
start_time = Time.current
yield
@duration = Time.current - start_time
end
def log_completion
Rails.logger.info "Event completed in #{@duration * 1000}ms"
end
endPattern Matching
Subscribe to events using patterns with wildcards:
class PatternSubscriber < ActiveSubscriber::Base
subscribe_to_pattern "user.*" # Matches user.created, user.updated, etc.
subscribe_to_pattern "analytics.*" # Matches analytics.page_view, analytics.click, etc.
subscribe_to_pattern ".*" # Matches all events
def handle_all_events(event_name, data)
case event_name
when /^user\./
handle_user_event(event_name, data)
when /^analytics\./
handle_analytics_event(event_name, data)
end
end
endError Handling
ActiveSubscriber includes built-in error handling. If a subscriber raises an exception, it will be logged but won't prevent other subscribers from processing the event.
Errors are logged to Rails.logger with the format:
ActiveSubscriber: Error handling event 'event_name': Error message
Thread Safety
ActiveSubscriber is designed to be thread-safe:
- The subscriber registry uses a mutex for thread-safe registration
- No shared mutable state in core components
- Each subscriber instance handles events independently
Acknowledgements
This gem was inspired by a project at a previous role that needed a clean way to handle pub/sub notifications for third-party analytics. ActiveSubscriber is written entirely on personal time and resources, but the design is heavily informed by the needs of that work — along with my own.
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/afomera/active_subscriber.