Tracing in Rails

This post is about using TracePoint in Rails 5. It’s a reference for myself, but may be helpful to someone else as a solution. Any suggestions to improve the code are much appreciated.

When working with a large, unfamiliar codebase, and not being up to speed with the business domain, I need all the help I can get when tracking down bugs or making changes. Indeed, the older I get, the more help I need.

One approach I find useful is to increase the amount of data being logged, and at the same time filtering the noise. This is especially important when working with threaded code as a debugger doesn’t scale.

The IDE I use for work currently aggregates log data from several sources including:

  • Console output from the Puma Server (Server/Rails/Source debug messages/output)
  • Standard events from CefSharp event handlers, such as console.log messages
  • A Custom Request Handler in CefSharp to gather request information and post data

As useful as this is, sometimes I wish I could increase the log verbosity in a certain context, when performing a certain action, to understand more; for example when creating a new customer.

It turns out that TracePoint in Ruby is perfect for that. TracePoint is “A class that provides the functionality of Kernel#set_trace_func in a nice Object-Oriented API.”

Sounds great, but you have to be judicious. You can literally bring your application to a grinding halt if you process too many events. Ask only for those events you need, and of those, filter out whichever you don’t need. The filtering aspect should be something very debug/task specific to avoid being flooded with messages.

In my case I wanted to track method entry and exit of my Rails classes, which the :call and :return events allow you to do. But as a pleasant bonus, TracePoint allows you to access the current binding, so you can also grab argument names and values, local variable values, and the return value. That’s a lot of useful information! I also want to enable or disable the tracing as needed.

Middleware

I’ve settled on a solution that activates tracing per request – if tracing is enabled. Managing multiple processes and instances of TracePoint was quite challenging:

app\middleware\tracing_middleware.rb:

return unless Rails.env.development?

class TracingMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    Tracing::Settings::Trace.start
    status, headers, response = @app.call(env)
    Tracing::Settings::Trace.stop
    [status, headers, response]
  end
end

We need append this line to config\environments\development.rb:

config.middleware.use TracingMiddleware

Tracing

To configure a TracePoint, and expose it to my controller, I created the following tracing.rb file in the config\initializers folder:

return unless Rails.env.development?

module Tracing
  class Settings
    # variables we are not interested in
    Ignore = %[session]

    # Trace is a constant so the controller can access it
    Trace = Settings.new

    attr_accessor :tracing, :filter

    def initialize
      @tp = nil
      @tracing = false
      @filter = nil
    end

    def start
      return if @tracing.blank?

      unless @tp.nil?
        @tp.enable
        return
      end

      puts "[#{Process.pid}] configuring trace"

      if @filter.blank?
        puts "[#{Process.pid}] using default filter"

        @filter = [
          'cache',
          'tenant',
          'user_database_connector',
          'coredatabase',
          'module_permission',
          'user_session_check',
        ];
      end

      # we only want the :call and :return events
      @tp = TracePoint.trace(:call, :return) do |tp|

        # source must be a class - off for now
        # next unless tp.self.is_a?(Class)

        # get the source path - includes the method
        path = caller[0]

        # path must include the project app folder
        next unless path.include? 'servicestream'
        next unless path.include? 'app'

        # apply the filter
        next if @filter.any? { |f| path.include? f }

        # output the pid file, line, and event type
        pid = Process.pid
        puts "[#{pid}] #{path} [#{tp.event}]"

        #examine the call event and grab the arguments
        if tp.event == :call
          args = tp.parameters.map(&:last).reject { |n|
            Ignore.include? n.to_s
          }

          if args.any?
            print "[#{pid}] args:"
            puts args.map { |n|
              [n, tp.binding.eval(n.to_s).inspect]
            }.to_h
          end
        end

        # next we examine the return event
        if tp.event == :return

          # and get any local values
          locals = tp.binding.
            local_variables.reject { |n| 
            Ignore.include? n.to_s 
          }

          if locals.any?
            print "[#{pid}] locals:"
            puts locals.map { |n|
              [n, tp.binding.local_variable_get(n).inspect]
            }.to_h
          end

          # and finally the return value
          if tp.return_value.present?
            print "[#{pid}] returns:"
            puts tp.return_value.inspect
          end
        end
      end

      @tp.enable
    end

    def stop
      @tp.disable unless @tp.nil?
    end
  end
end

As per the code comments, the filtering is very specific to my project:

  • Only in development mode
  • Restricted to the :call and :return events
  • Must be a Class (this rule is currently relaxed)
  • The path must include my project app folder
  • Not interested in a bunch of (very frequent) messages (cache, user_session_check, etc.)

The TracePoint is only configured and enabled if the @tracing flag has been enabled. The start and stop methods are responsible for enabling, disabling, and configuring the TracePoint.

Controller

The next piece of the puzzle is trace_controller which handles requests to enable or disable the @tracing flag. There’s also a method to update the @filter:

return unless Rails.env.development?

class TraceController < ApplicationController
  skip_forgery_protection

  def enable
    Tracing::Settings::Trace.tracing = true
    puts "tracing turned on in Process #{Process.pid}"
    head :ok, content_type: "text/html"
  end

  def disable
    Tracing::Settings::Trace.tracing = false
    puts "tracing turned off in Process #{Process.pid}"
    head :ok, content_type: "text/html"
  end

  def filter
    filter = params[:Filters]
    Tracing::Settings::Trace.filter = filter
    puts "tracing filter updated in Process #{Process.pid}"
    head :ok, content_type: "text/html"
  end
end

Routes

With a couple of new routes in routes.rb:

if Rails.env.development?
  get  'trace/on'        => 'trace#enable'
  get  'trace/off'       => 'trace#disable'
  post 'trace/filter'    => 'trace#filter'
end

Allowing us to make the following requests:

http://localhost:3000/trace/on
http://localhost:3000/trace/off
http://localhost:3000/trace/filter

Trace Scripts

Since multiple worker processes are fired on start up, to ensure all instances of Trace are enabled or disabled, I use a few scripts to help me. They basically spam the same request in parallel. A third script updates the filter.

This is the first trace_on.rb:

#!/usr/bin/ruby

require 'net/http'
require 'thread'

threads = []

10.times do 
  threads << Thread.new do 
    Net::HTTP.get('localhost','/trace/on', 3000)
  end
end

threads.each do |thread|
  thread.join
end

And the second, trace_off.rb:

#!/usr/bin/ruby

require 'net/http'
require 'thread'

threads = []

10.times do 
  threads << Thread.new do 
    Net::HTTP.get('localhost','/trace/off', 3000)
  end
end

threads.each do |thread|
  thread.join
end

And the third script to configure the filter, trace_filter.rb:

#!/usr/bin/ruby

require 'net/http'
require 'uri'
require 'json'
require 'thread'

criteria = [
          'replace_html_reserved',
          'user_database_migrator',
          'login_helper',
          'desktop_hour',
          'database_corrector',
          'escape_field_alias'
        ];

body = {
  Filters: criteria
}.to_json

uri = URI.parse('http://localhost:3000/trace/filter')

threads = []

7.times do
  threads << Thread.new do
    http = Net::HTTP.new(uri.host, uri.port)
    
    request = Net::HTTP::Post.new(
      uri.request_uri, 
      'Content-Type' => 'application/json')
    
    request.body = body
    response = http.request(request)
  end
end

threads.each do |thread|
  thread.join
end

The reason I’m spamming the requests is to make sure all processes receive the updates. It doesn’t hurt if we send a couple of extras. Although each process has a TracePoint, because we are managing tracing via middleware, only one is ever active per request. The use case for this tracing is to capture detailed information about a specific user task we step through (i.e. new customer) not application performance profiling or tracking threading issues, etc.

After configuring the permissions:

chmod u+x trace_on.rb
chmod u+x trace_off.rb
chmod u+x trace_filter.rb

It’s simple to turn on tracing:

./trace_on.rb

…to turn off:

./trace_off.rb

…and to configure the filter:

./trace_filter.rb

When enabled, the Rails Server output should now contain the extra log information.

IDE Improvements

I’ve added a few buttons to the IDE to simplify working with the trace_controller:

In order for this to work, I had to add skip_forgery_protection to trace_controller.rb, and to allow unauthorized access to the controller in my application_controller.rb:

if Rails.env.development?
  return if params[:controller] == 'trace'
end

# rest of code to manage page access etc....

There’s a new basic filter configuration dialog. When I get some spare time, I’ll improve on it, but it is sufficient for now. The IDE takes care of the requests for me, there’s no need for the trace scripts:


So far, everything is working nicely, with the IDE capturing the server output:

Clicking on the logged items will perform some action, like jumping to the source file and line number, or showing the arguments in JSON format, etc. The vision is an interactive log.

But again, a word of caution. Ensure you filter unwanted events, as per your requirements. One event I captured logged to 900kb in size! It’s very verbose, so you want to capture just enough otherwise it becomes noise. I typically navigate proximate to the action I want to investigate, turn on the tracing, perform the action, then turn off tracing immediately.

Although I put guards around the code, I won’t be checking it into GitHub…this is staying on my local machine, I don’t fancy a 2am phone call from the boss about a production issue 🙂

Conclusion

This is my third attempt at this task, I’m now using middleware to manage the TracePoints across multiple processes. It’s running well, but if I find any problems, or a better way, I’ll be sure to update this post. It’s been a good opportunity to learn more about the Rails initialization process.

Although a bit dated, this Railscast is a nice introduction to middleware. This SO post was helpful. We’re using Rails 5.2, in earlier or later versions of Rails there may be initialization differences.

I hope you may have found this post useful.

Advertisement

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s