Introduction
Modern observability platforms thrive on structured data.
They can parse JSON, extract fields, build dashboards,
and alert on specific conditions.
But Rails has traditionally given us Rails.logger,
which produces human readable but unstructured log lines.
Parsing these logs for analytics is painful. We end up writing regex patterns, hoping the log format doesn’t change, and losing valuable context along the way.
Rails 8.1 introduces a first class solution:
the Structured Event Reporter,
accessible via Rails.event.
Before
Before this change, logging in Rails meant working with unstructured text.
Rails.logger.info("User created: id=#{user.id}, name=#{user.name}")This produces a log line like:
User created: id=123, name=John DoeTo extract meaningful data from this, observability tools need to parse the string. If we change the format slightly, our parsing breaks.
We also lack consistent metadata. Where did this log come from? What request triggered it? What was the timestamp with nanosecond precision?
Teams often build custom solutions, wrapping loggers with JSON formatters, manually adding request IDs, and hoping everyone follows the same conventions.
After
Rails 8.1 provides Rails.event,
a structured event reporter that emits events with consistent metadata.
Rails.event.notify("user.signup", user_id: 123, email: "[email protected]")This produces a structured event:
{
name: "user.signup",
payload: { user_id: 123, email: "[email protected]" },
tags: {},
context: { request_id: "abc123", user_agent: "Mozilla..." },
timestamp: 1738964843208679035, # nanosecond precision
source_location: {
filepath: "app/services/user_service.rb",
lineno: 45,
label: "UserService#create"
}
}Every event includes standardized metadata: the event name, payload, tags, context, timestamp, and the exact source location where it was emitted.
Adding Tags and Context
The Event Reporter provides two mechanisms for enriching events: tags and context.
Tags
Tags add domain specific context that nests. All events within a tagged block inherit those tags.
Consider an e-commerce application where we want to track all events happening within the checkout flow:
class CheckoutsController < ApplicationController
def create
Rails.event.tagged("checkout") do
Rails.event.notify("checkout.started", cart_id: @cart.id, item_count: @cart.items.count)
process_payment
create_order
Rails.event.notify("checkout.completed", order_id: @order.id, total: @order.total)
end
end
endAll events within the block will include tags: { checkout: true },
making it easy to filter and analyze checkout related events in our observability platform.
Tags can also be nested for more granular categorization:
class Admin::OrdersController < ApplicationController
def update
Rails.event.tagged("api") do
Rails.event.tagged(version: "v2", section: "admin") do
Rails.event.notify("order.updated", order_id: @order.id, status: @order.status)
# Event includes tags: { api: true, version: "v2", section: "admin" }
end
end
end
endThis is useful when we want to categorize events by feature area, API version, or any other dimension.
Context
Context is designed for request or job scoped metadata that spans the entire execution.
A common use case is setting context in a middleware or controller callback so all events in the request include relevant metadata:
class ApplicationController < ActionController::Base
before_action :set_event_context
private
def set_event_context
Rails.event.set_context(
request_id: request.request_id,
user_id: current_user&.id,
ip_address: request.remote_ip,
user_agent: request.user_agent
)
end
endNow every event emitted during the request will automatically include this context:
class OrdersController < ApplicationController
def create
@order = Order.create!(order_params)
Rails.event.notify("order.created", order_id: @order.id, total: @order.total)
# Event automatically includes context: { request_id: "...", user_id: 123, ... }
end
endContext expands over the course of a unit of work and attaches to every event automatically.
Debug Mode
Sometimes we want verbose logging during development but not in production. The Event Reporter supports conditional debug events.
Rails.event.with_debug do
Rails.event.debug("sql.query", sql: "SELECT * FROM users WHERE id = ?", binds: [123])
# Only reported when debug mode is active
endThis replaces the need for log levels.
We either emit a regular event with notify,
or a debug event with debug that only fires in debug mode.
Schematized Events
For teams that want strongly typed events, the Event Reporter accepts event objects.
class OrderCompletedEvent
attr_reader :order_id, :total, :item_count, :payment_method
def initialize(order_id:, total:, item_count:, payment_method:)
@order_id = order_id
@total = total
@item_count = item_count
@payment_method = payment_method
end
end
Rails.event.notify(
OrderCompletedEvent.new(
order_id: 456,
total: 99.99,
item_count: 3,
payment_method: "credit_card"
)
)This allows us to define schemas for our events, ensuring consistency across the codebase. The event object is passed directly to subscribers in the payload field, and subscribers can handle serialization based on the object type.
Subscribing to Events
The Event Reporter separates emission from processing.
Applications register subscribers to control how events are serialized and emitted.
Subscribers must implement an #emit method,
which receives the event hash.
class LogSubscriber
def emit(event)
payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ")
source_location = event[:source_location]
log = "[#{event[:name]}] #{payload} at #{source_location[:filepath]}:#{source_location[:lineno]}"
Rails.logger.info(log)
end
end
Rails.event.subscribe(LogSubscriber.new)This would produce a log line like:
[user.signup] user_id=123 [email protected] at app/services/user_service.rb:45Multiple subscribers can be registered, allowing events to flow to different destinations: a logging pipeline, metrics system, or analytics platform.
class DatadogSubscriber
def emit(event)
Datadog.log(event.to_json)
end
end
Rails.event.subscribe(LogSubscriber.new)
Rails.event.subscribe(DatadogSubscriber.new)Testing Support
The Event Reporter includes test helpers for asserting on event emission.
# Assert specific events are reported
assert_event_reported("user.created", payload: { id: 123 }) do
UserService.create_user(name: "John")
end
# Assert no events are reported
assert_no_event_reported("user.deleted") do
UserService.update_user(id: 123, name: "Jane")
endThis makes it easy to verify that our code emits the right events with the expected payloads.
Key Design Decisions
Fiber based Isolation
Tags and context are scoped per fiber. Child fibers and threads inherit context from the parent, but the context is copied on write. Changes in a child do not propagate back to the parent.
No Log Levels
The API intentionally avoids traditional log levels like info, warn, error.
These are often used inconsistently
and interpreted differently across teams.
Instead, the API provides two methods:
notifyfor events users care aboutdebugfor events developers care about during debugging
Payload vs Tags vs Context
These three serve different purposes:
- Payload: Information about the specific event that occurred
- Tags: Domain specific context that forms a stack within blocks
- Context: Request or job level metadata that spans the entire execution
Conclusion
Structured event reporting brings Rails logging into the modern observability era. Instead of parsing unstructured text, we get consistent, machine readable events with rich metadata out of the box.
The Rails.event API provides a unified interface
for structured logs, business events, and telemetry.
Combined with the subscriber pattern,
teams can route events to any destination they need.
This feature originated from Shopify’s production needs and is now available for all Rails 8.1 applications.
