Tracing

Whilst working on a Ruby on Rails 5.1 legacy codebase—recently updated from version 4—I really needed some help with debugging. TracePoint was perfect for the job.

TracePoint is "a class that provides the functionality of Kernel#set_trace_func in a nice Object-Oriented API."

But you have to be judicious. You can bring your application to a grinding halt if you process too many events.

I wanted to track method entry and exit using the :call and :return events.

TracePoint allows you to access the current binding, so you can grab argument names and values, local variable values, and the return value. That's a lot of useful information.


Middleware

I settled on a solution that activates tracing per request—if tracing is enabled. Managing multiple processes and instances of TracePoint was quite challenging, I needed to add 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

And to append this line to config\environments\development.rb:

config.middleware.use TracingMiddleware

Tracing

To configure a TracePoint, 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"

      # set up a default filter to ignore noise
      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 - commented out 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

      @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 messages (cache, session, 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 the following requests:

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

Trace Scripts

To ensure all instances of Trace are enabled or disabled, I use a few scripts to help me.

The first is 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 similarly, trace_off.rb, the only code difference is the endpoint:

Net::HTTP.get('localhost','/trace/off', 3000)

And lastly, trace_filter.rb, to configure the filter:

#!/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. 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 manage the filtering:

./trace_on.rb
./trace_off.rb
./trace_filter.rb

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

Tracing 2

But again, a word of caution. Ensure you filter unwanted events, as per your requirements. It can be 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 it off immediately.


Conclusion

This was the third attempt, 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 the 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. And this post was also helpful.