Project

trailer

0.0
Repository is archived
No release in over 3 years
Low commit activity in last 3 years
Provides a framework for tracing events within a service, or across multiple services.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies
 Project Readme

CircleCI Gem Version

Trailer

Trailer provides a Ruby framework for tracing events in the context of a request or background job. It allows you to tag and log events with metadata, so that you can search later for e.g. all events and exceptions related to a particular request.

Installation

Add this line to your application's Gemfile:

gem 'trailer'

And then execute:

$ bundle

Or install it yourself as:

$ gem install trailer

Usage

Configuration

Configure the gem in config/initializers/trailer.rb:

Trailer.configure do |config|
  config.application_name      = 'shuttlerock'
  config.aws_access_key_id     = 'XXXXXXXXXXXXXXXXXXXX'
  config.aws_secret_access_key = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
  config.service_name          = 'auth'
end
Option Required? Default Description
application_name Yes The global application or company name. This can also be configured with the TRAILER_APPLICATION_NAME environment variable.
auto_tag_fields /(_id|_at)$/ When tracing ActiveRecord instances, automatically tag the trace with fields matching this regex.
aws_access_key_id Yes (if using CloudWatch storage) AWS access key with CloudWatch write permission.
aws_region 'us-east-1' The AWS region to log to.
aws_secret_access_key Yes (if using CloudWatch storage) The AWS secret key.
current_user_method Allows you provide the name of a method (eg. :current_user) that provides a user instance. Trailer will automatically tag the id of this user if the option is provided (disabled by default).
enabled true Allows tracing to be conditionally disabled.
environment The environment that the application is running (eg. production, test). This can also be configured with the TRAILER_ENV, RAILS_ENV or RACK_ENV environment variables.
host_name The name of the individual host or server within the service. This can also be configured with the TRAILER_HOST_NAME environment variable.
service_name Yes The name of the service within the application. This can also be configured with the TRAILER_SERVICE_NAME environment variable.
storage Trailer::Storage::CloudWatch The storage class to use.
tag_fields ['name'] When tracing ActiveRecord instances, tag the trace with these fields.

Plain Ruby

Tracing consists of a start, a number of writes, and a finish:

trail = Trailer.new
trail.start
...
order = Order.new(state: :open)
order.save!
trail.write(order_id: order.id, state: order.state)
...
order.update(state: :closed, price_cents: 1_000)
trail.write(order_id: order.id, state: order.state, price: order.price_cents)

# Finish, and flush data to storage.
trail.finish

Each call to start will create a unique trace ID, that will be persisted with each write, allowing you to e.g. search for all events related to a particular HTTP request. Data will not be persisted until finish is called. You can start and finish the same Trailer instance multiple times, as long as you finish the previous trace before you start a new one.

Rails

Trailer::Middleware::Rack will be automatically added to Rails for you. Trailer::Concern provides three methods to simplify the tracing of objects:

  • trace_method
  • trace_class
  • trace_event

The simplest way to start tracing is to include Trailer::Concern and wrap an operation with trace_method:

class PagesController < ApplicationController
  include Trailer::Concern

  def index
    trace_method do
      book = Book.find(params[:id])
      expensive_operation_to_list_pages(book)
    end
  end
end

Every time index is requested, Trailer will record that the method was called, and add some metadata:

{
  "event":        "PagesController#index",
  "duration":     112,
  "environment":  "production",
  "host_name":    "web.1",
  "service_name": "studio-api",
  "trace_id":     "1-5f465669-97185c244365a889fca9c6fc"
}

This is not particularly useful by itself - you didn't record anything about the book whose pages you are indexing. You can pass the Book instance to improve visibility:

def index
  book = Book.find(params[:id])

  trace_method(book) do
    expensive_operation_to_list_pages(book)
  end
end

Now every time index is requested you'll see Book metadata as well, such as the book_id, author_id and Rails timestamps:

{
  "event":        "PagesController#index",
  "book_id":      15,
  "author_id":    12,
  "created_at":   "2020-08-26 21:56:12 +0900",
  "updated_at":   "2020-08-26 21:57:05 +0900",
  ...
}

The auto_tag_fields and tag_fields configuration options are used to decide which fields from the Book instance you collect (see Configuration for more details). The resource provided doesn't have to be an ActiveRecord instance - a Hash will work as well.

If you only want to record the class name rather than the class + method, use the trace_class method:

class ArchiveJob
  def perform(book)
    trace_class(book) do
      book.archive!
    end
  end
end

This will record "event": "ArchiveJob" instead of "event": "ArchiveJob#perform". This is useful in situations where the method name doesn't provide any additional information (eg. background jobs always implement perform, and GraphQL resolvers implement resolve).

The trace_event method is similar to trace_method and trace_class, but it requires an event name to be passed as the first argument:

class PagesController < ApplicationController
  include Trailer::Concern

  def index
    book = Book.find(params[:id])

    @pages = trace_event(:list_pages, book) do
      expensive_operation_to_list_pages(book)
    end
  end

  def destroy
    page = Page.find(params[:id])

    trace_event(:destroy_page, page) do
      page.destroy!
    end

    redirect_to pages_path
  end
end

You can also provide your own tags to any of the trace methods to augment the automated tags:

trace_event(:destroy_page, page, user: current_user.id, role: user.role) do
  page.destroy!
end

The concern is not restricted to Rails controllers - it should work with any Ruby class:

class ExpensiveService
  include Trailer::Concern

  def calculate(record)
    trace_event(:expensive_calculation, record) do
      ...
    end
  end
end

If you have a method similar to Devise's current_user, you can automatically augment the trace with the ID of the user performing the action:

# config/initializers/trailer.rb
Trailer.configure do |config|
  config.current_user_method = :current_user
end

# app/controllers/pages_controller.rb
class PagesController < ApplicationController
  include Trailer::Concern

  def index
    book = Book.find(params[:id])

    trace_method(book) do
      expensive_operation_to_list_pages(book)
    end
  end

  def current_user
    User.find(session[:user_id])
  end
end

This will add the current_user_id to the trace metadata:

{
  "event":           "PagesController#index",
  "current_user_id": 26,
  ...
}

The middleware will automatically trace exceptions as well:

def index
  book = Book.find(params[:id])

  trace_method(book) do
    expensive_operation_to_list_pages(book)
  end

  raise StandardError, 'Something went wrong!'
end

This will record both the method call and the exception:

{
  "event":    "PagesController#index",
  "trace_id": "1-5f465669-97185c244365a889fca9c6fc",
  ...
}

{
  "exception": "StandardError",
  "message":   "Something went wrong!",
  "trace_id":  "1-5f465669-97185c244365a889fca9c6fc",
  "trace":     [...]
  ...
}

The result of the block is returned, so you can assign a trace to a variable:

record = trace_method(params[:advert]) do
  Advert.create(params[:advert])
end

Similarly, you can use a trace as the return value of a method:

def add(a, b)
  trace_method { a + b }
end

You can use any of the trace methods without providing a block:

class Order < ApplicationRecord
  include Trailer::Concern

  after_update_commit :trace_state_change, if: -> { saved_change_to_state? }

  def trace_state_change
    state_before, state_now = saved_change_to_state
    trace_event('Order State Change', order_id: id, state_is: state_now, state_was: state_before)
  end
end

No Rails?

You can use the Middleware in any rack application. You'll have to add this somewhere:

use Trailer::Middleware::Rack

Sidekiq

If you are using Rails with Sidekiq, Trailer::Middleware::Sidekiq will be automatically added to the sidekiq middle chain for you. You can trace operations using the standard Trailer::Concern method:

class AuditJob < ApplicationJob
  include Trailer::Concern

  def perform(user)
    trace_class(user) do
      expensive_operation()
    end
  end
end

If you're not using Rails, you'll need to add the Sidekiq middleware explicitly:

::Sidekiq.configure_server do |config|
  config.server_middleware do |chain|
    chain.add Trailer::Middleware::Sidekiq
  end
end

Storage

Currently the only provided storage backend is AWS CloudWatch Logs, but you can easily implement your own backend if necessary. New backends should:

  • Include Concurrent::Async from concurrent-ruby in order to provide non-blocking writes.
  • Implement a write method that takes a hash as an argument.
  • Implement a flush method that persists the data.
class MyStorage
  include Concurrent::Async

  def write(data)
    ...
  end

  def flush
    ...
  end
end

Trailer.configure do |config|
  config.storage = MyStorage
end

CloudWatch Permissions

The AWS account needs the following CloudWatch Logs permissions:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:DescribeLogGroups",
                "logs:DescribeLogStreams",
                "logs:CreateLogGroup",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:us-east-1:XXXXXXXXXXXX:log-group:my-log-group-name",
                "arn:aws:logs:us-east-1:XXXXXXXXXXXX:log-group:my-log-group-name:log-stream:my-log-stream-name"
            ]
        }
    ]
}

The ARNs in the Resource section are for demonstration purposes only - substitute your own, or use "Resource": "*" to allow global access.

Searching for traces in AWS CloudWatch

CloudWatch allows you to search for specific attributes:

  • Search for a specific order_id: { $.order_id = "aaa" }
  • Search for all records from a particular request or job: { $.trace_id = "1-5f44617e-6bcd7259689e5d303d4ad430" })
  • Search for multiple attributes: { $.order_id = "order-aaa" && $.duration = 1 }
  • Search for one of several attributes: { $.order_id = "aaa" || $.order_id = "bbb" }
  • Search for a specific user: { $.current_user_id = 1234 }
  • Search for all records containing a particular attribute, regardless of its value: { $.duration = * }

Trailer provides some standard attributes that might be useful:

Attribute Description
duration The duration of the trace in milliseconds.
host_name The (optional) host name specified during Trailer.configure.
service_name The service name specified during Trailer.configure.
trace_id A unique ID identifying all records from a single request or Sidekiq job. This allows you to track all events within the context of a single request.

You can also filter by partial wildcard, search nested objects, and much more - see Filter and Pattern Syntax for more information.

Searching CloudWatch

Todo

  • Allow the trace ID to be set manually, in case we want to trace distributed systems.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/shuttlerock/trailer.