Rails Event Store

Logging request metadata

In Rails environment, every event is enhanced with the request metadata provided by rack server as long as you configure your event store instance in config.event_store. This can help with debugging and building an audit log from events for the future use.

Setup

In order to enhance your events with metadata, you need to setup your client as described in Installation.

Defaults

With default configuration metadata is enhanced by:

  • :remote_ip - IP of the HTTP client which issued the request.
  • :request_id - An unique ID of the request.

This metadata is included only for published events. So creating a new event instance by hand won't add metadata to it:

event = MyEvent.new(event_data)
event.metadata # empty, unless you provided your own data called 'metadata'.

If you publish an event, the special field called metadata will get filled in with request details:

event_store.publish(MyEvent.new(data: { foo: "bar" }))

my_event = event_store.read.last
my_event.metadata[:remote_ip] # your IP
my_event.metadata[:request_id] # unique ID

Configuration

You can configure which metadata you'd like to catch. To do so, you need to provide a lambda which takes Rack environment and returns a metadata hash/object.

This can be configurable when instantinating the RailsEventStore::Client instance with request_metadata option.

Here is an example of such configuration (in config/application.rb), replicating the default behaviour:

require File.expand_path("../boot", __FILE__)

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "rails/test_unit/railtie"

Bundler.require(*Rails.groups)

module YourAppName
  class Application < Rails::Application
    config.event_store =
      RailsEventStore::Client.new(
        request_metadata: ->(env) do
          request = ActionDispatch::Request.new(env)
          { remote_ip: request.remote_ip, request_id: request.uuid }
        end,
      )

    # ...
  end
end

You can read more about your possible options by reading ActionDispatch::Request documentation.

Passing your own metadata using with_metadata

Apart from using the middleware, you can also set your metadata with RubyEventStore::Client#with_metadata method. You can specify custom metadata that will be added to all events published inside a block:

event_store.with_metadata(remote_ip: "1.2.3.4", request_id: SecureRandom.uuid) do
  event_store.publish(MyEvent.new(data: { foo: "bar" }))
end

my_event = event_store.read.last

my_event.metadata[:remote_ip] #=> '1.2.3.4'
my_event.metadata[:request_id] #=> unique ID

When using with_metadata, the timestamp is still added to the metadata unless you explicitly specify it on your own. Additionally, if you are nesting with_metadata blocks or also using the middleware & request_metadata lambda, your metadata passed as with_metadata argument will be merged with the result of rails_event_store.request_metadata proc:

event_store.with_metadata(causation_id: 1234567890) do
  event_store.with_metadata(correlation_id: 987654321) do
    event_store.publish(MyEvent.new(data: {foo: 'bar'}))
  end
end

my_event = event_store.read.last
my_event.metadata[:remote_ip]      #=> your IP from request metadata proc
my_event.metadata[:request_id      #=> unique ID from request metadata proc
my_event.metadata[:causation_id]   #=> 1234567890 from with_metadata argument
my_event.metadata[:correlation_id] #=> 987654321 from with_metadata argument
my_event.metadata[:timestamp]      #=> a timestamp

Recording current user in request–response cycle

One can use metadata to associate a logged-in user with domain events published in a request-response cycle of a web application. This is useful for auditing purpose.

Consider following Rails ApplicationController with a method returning currently logged-in user or nil:

class ApplicationController < ActionController::Base
  def current_user
    # ...
  end
end

We can use with_metadata combined with an around_action to wrap an action handling incoming request. Every domain event that originated from a controller action would get user_id key in its metadata with the value taken from current_user.id or being nil:

class ApplicationController < ActionController::Base
  around_action :use_request_metadata

  private

  def use_request_metadata(&block)
    Rails.configuration.event_store.with_metadata(request_metadata, &block)
  end

  def request_metadata
    { user_id: current_user&.id }
  end
end

This metadata would be added to domain events published in synchronous handlers as well, as they happen immediately in the same request-response cycle.

Passing metadata to asynchronous handlers

Asynchronous event handling by design happens outside of the request-response cycle — in a different thread or completely different process. Our only source of metadata is the event we're processing in a handler.

Let's look at an example how to retain it's metadata in any domain event published in an asynchronous handler.

OrderPlaced = Class.new(RailsEventStore::Event)

module MetadataHandler
  def perform(event)
    event_store.with_metadata(**event.metadata.to_h) { super }
  end

  def event_store
    Rails.configuration.event_store
  end
end

class OrderHandler < ActiveJob::Base
  prepend RubyEventStore::AsyncHandler
  prepend MetadataHandler

  def perform(event)
    # ...
  end
end

event_store = RailsEventStore::Client.new
event_store.subscribe(OrderHandler, to: [OrderPlaced])

In MetadataHandler we first read existing metadata from the currently processed event and then decorate any further event publications inside the handler with that metadata.