CloudWatch custom metrics with Rails

I recently had to implement Rails application monitoring using CloudWatch in a work project. App in question had multiple integrations to other REST services so it made sense to be able track their response times, error status counts etc to better debug performance anomalies.

Here’s steps how I used Active Support Instrumentation API of Rails to feed those as custom CloudWatch metrics.

Building blocks

Rails Instrumentation API

Instrumentation API is a part of Rails core in Active Support and it implements publish-subscribe pattern for providing information about app behavior, when certain events started and finished. Rails has number of internal events described in Rails guides, and eg. logging SQL query times, or AcionView render times is based on this feature.

Instrumentation API could be extended with custom events, so it would make perfect sense to use track request times to 3rd party APIs.

HTTP client middleware

Faraday is my go to HTTP library when implementing API clients in Ruby. I like its architecture and way of using middlewares to extend and re-use logic. Official faraday_middleware gem also includes Instrumentation middleware to plug Faraday connection to ActiveSupport::Notifications.

CloudWatch

We are hosting the app I was working with at the AWS and had CloudWatch dashboard set up to monitor Fargate resource usage, RDS connections, ElastiCache hits & misses etc, so it would be nice to include something to infer 3rd party API health from same dashboard.

That’s possible using custom metrics, and they are pretty convenient to upload using official Ruby SDK for CloudWatch.

However keep in mind that CloudWatch pricing is based on the number of dashboards, metrics, request counts etc so the cost definitely will increase when you add more features using it.

Steps

1. Install gems

Install necessary gems, by including following in your Gemfile:

gem "faraday"
gem "faraday_middleware"
gem "aws-sdk-cloudwatch", require: false

2. Plug Faraday based client to Instrumentation API

I usually have some place where default connection for the custom client library is instantiated and necessary middlewares are included. For example it could be something like:

def client_connection
  Faraday.new do
    faraday.use Faraday::Response::RaiseError
    faraday.use :instrumentation, name: "request.example_api"
    faraday.response :json
    faraday.adapter Faraday.default_adapter
  end
end

Name option given to middleware could be anything what makes sense in your application domain, and it is the name which is used to subscribe for these instrumentation events. Here we used request.example_api. If name is omitted default request.faraday is used.

3. Feed metrics to CloudWatch

For now I’ve been using simple initializer to hook into events. So I have following at config/initializers/example_api_metrics.rb:

require "aws-sdk-cloudwatch"

def put_request_metrics(name, starts, ends, env)
  duration = ends - starts
  timestamp = Time.now.utc

  # Derive status code category to be used as metric name for request
  # counts enabling us to track number of interesting results.
  status_metric = begin
                    if env[:status] >= 200 && env[:status] < 300
                      "Status2XX"
                    elsif env[:status] >= 400 && env[:status] < 500
                      "Status4XX"
                    elsif env[:status] >= 500 && env[:status] < 600
                      "Status5XX"
                    else
                      "StatusOther"
                    end
                  end

  client = Aws::CloudWatch::Client.new
  client.put_metric_data(
    namespace: "ExampleApi",
    metric_data: [
      {
        metric_name: "ResponseTime",
        dimensions: [{
          name: "Environment",
          value: Rails.env.to_s
        }],
        timestamp: timestamp,
        value: duration,
        unit: "Seconds"
      }, {
        metric_name: status_metric,
        dimensions: [{
          name: "Environment",
          value: Rails.env.to_s
        }],
        timestamp: timestamp,
        value: 1,
        unit: "Count"
      }
    ]
  )
end

ActiveSupport::Notifications
  .subscribe("request.example_api") do |name, starts, ends, _, env|
    put_request_metrics name, starts, ends, env
end

In above example dimensions is completely optional but it allows adding extra information to recorded values making it easier for example to tell apart multiple environments sharing same AWS account.

Notice that Aws::CloudWatch::Client.new is called without any additional parameters. This way it will try to retrieve AWS credentials and region info from environment variables, instance profiles or task execution roles. Of course you could include them as arguments too if you prefer Rails 5 encrypted credentials instead.