Exploring TracePoint in Ruby — Part One — Example Code
TracePoint is an extremely powerful feature of Ruby, but it can be a bit difficult to use and wrap your head around. Let’s take a look at what it does and how we can use it to do some awesome things in Ruby!
This article is rated at a difficulty of 3 out of 5, and requires some prerequisite knowledge in Ruby:
What is TracePoint?
To start out with, what is TracePoint exactly? Well if we take a look into the Ruby documentation, it tells us:
“A class that provides the functionality of Kernel#set_trace_func in a nice Object-Oriented API.”
…ok then, what’s Kernel#set_trace_func
then?
“Establishes proc as the handler for tracing, or disables tracing if the parameter is
nil
.”- https://ruby-doc.org/core-2.6.1/Kernel.html#method-i-set_trace_func
Now what exactly is tracing, and how is that useful to us as Ruby developers?
TracePoint Example
Let’s start instead with some of the example code:
trace = TracePoint.new(:raise) do |tp|
p [tp.lineno, tp.event, tp.raised_exception]
end
#=> #<TracePoint:disabled>
trace.enable
#=> false
0 / 0
#=> [5, :raise, #<ZeroDivisionError: divided by 0>]
Initializing
In this specific example we’re starting by defining a new instance of TracePoint
:
TracePoint.new(:raise)
We’re giving that instance a specific type of event to watch out for, namely raise
, which means exception events. We’ll get into the rest of the events in a moment, but know for now that this means we’re only looking at Ruby code that has to do with exceptions being raised.
Disabled on Initialization
Notice that TracePoint
was disabled on creation:
trace = TracePoint.new(:raise) do |tp|
p [tp.lineno, tp.event, tp.raised_exception]
end
#=> #<TracePoint:disabled>
We can explicitly call enable
to activate this TracePoint
like the code right below it does:
trace.enable
#=> false
This returns false
if the trace was previously disabled, and true
if it was previously enabled. We would have to call disable
to turn it back off.
One trick to remember is that enable can take a block function for performing an isolated run of the TracePoint
:
trace.enable do
# Code to be traced - TracePoint is active in here!
end# TracePoint is off outside of the block
This can be handy for running a TracePoint
in a more isolated section of your code. Remember though, you can’t access the trace itself inside of this block:
trace.enable { p tp.lineno }
#=> RuntimeError: access from outside
Block Function with Initialization
In addition to that we’re giving the initializer a block function:
trace = TracePoint.new(:raise) do |tp|
p [tp.lineno, tp.event, tp.raised_exception]
end
#=> #<TracePoint:disabled>
This function will be run for every exception that’s hit in our program, yielding a value that’s named tp
here. tp
is the TracePoint
instance itself, the equivalent of doing this in plain Ruby:
def initialize(&block)
yield(self)
end
That means that all the methods on TracePoint are available on that block variable.
Available Methods on TracePoint
These methods are all documented in TracePoint’s docs page, but for now let’s take a look at the example and what it’s doing. In this specific example we’re using lineno
, event
, and raised_exception
.
We can see the result of this expression on the last line of the example:
0 / 0
#=> [5, :raise, #<ZeroDivisionError: divided by 0>]
lineno
stands for line number, the event
is the type of event our trace received, and raised_exception
is the exception that was raised.
Some of the other methods I’ve found particularly useful are:
path
— Path of the file that’s currently runningreturn_value
— Return value of the function (only available on return events)defined_class
— What class we’re executing inmethod_id
— The name of the method we’re currently inparameters
— The arguments of the current method or block (not available in exceptions)binding
— The full binding context where the trace activated
Now those last two, that’s where you get some interesting stuff, and we’ll be covering that shortly.
Binding
First off, what’s a binding
in Ruby? It’s the current context of what we’re running in. You might have seen binding.pry
before to use Pry, or binding.irb
in more recent versions of Ruby:
Objects of class
Binding
encapsulate the execution context at some particular place in the code and retain this context for future use. The variables, methods, value ofself
, and possibly an iterator block that can be accessed in this context are all retained. Binding objects can be created usingKernel#binding
, and are made available to the callback ofKernel#set_trace_func
.
That means it applies to TracePoint
as well. Now I won’t go too much into what binding
s are, that’s the subject of another tutorial entirely. Know for now that these are a few of the interesting methods you might want to look into:
eval
— Evaluate code in the current bindinglocal_variables
— Names of the local variables
Give the binding documentation a quick read, as there’s a lot of fun you can have there.
Parameters and Local Variables
If the name parameters sounds familiar, you may remember an article I’d written earlier on destructuring:
Specifically this part of the code:
-> a, *b, c: 1, **d, &fn {}.parameters
=> [[:req, :a], [:rest, :b], [:key, :c], [:keyrest, :d], [:block, :fn]]
Block functions and methods both know their parameters. Now if we know the names of the parameters, it’s not that far of a stretch to say we can get the values of them as well, especially considering they’re kind enough to let us play with the local binding
.
Extracting Arguments
def extract_arguments(trace)
param_names = trace.parameters.map(&:last)
param_names.map { |n| [n, trace.binding.eval(n.to_s)] }.to_h
end
Using the binding
and binding.eval
we can get the values of all of our arguments which can be incredibly useful for the sake of debugging.
The catch? This doesn’t work so well with raise
type events for some reason. There’s currently a bug out against this behavior:
Extracting Local Variables
We can somewhat cheat by getting the local variables instead:
def extract_locals(trace)
local_names = trace.binding.local_variables
local_names.map { |n|
[n, trace.binding.local_variable_get(n)]
}.to_h
end
Not quite the same, but both are insanely useful for the sake of debugging. Knowing your way around binding
can give you a lot of leverage to find out what your Ruby program is doing.
Wrapping Up
TracePoint can be a dense subject, so we’re going to break this up into a few articles to make it easier to follow. Part One was meant to cover the example code given. The next parts are:
TracePoint
can be triggered on multiple types of events. What are they and why might we want to use each one with TracePoint
?
Part Three — Aphyr’s Black Magic
Covering an old classic Ruby post abusing set_trace_func
to do bad things to Ruby. We’re going to modernize some of it to play with some of the more out there features of TracePoint
.
Part Four — Advent of TraceSpy
TracePoint is powerful, but it can be hard to use. Trace Spy is a gem that was written to make it a bit easier to use in the wild with a fluent interface based on Pattern Matching from Qo.
We’ll explore writing fluent wrappers, documentation, and making meta-tools to help us do our jobs as developers more effectively.
Part Five — Giving TraceSpy some Color
Now that we have a fluent wrapper to use, how about some pretty output to make debugging even easier? Let’s take a look into providing utilities to see our local contexts more clearly and make debugging even easier!
We’re going to be going through some deeper parts of Ruby in these next few series in 2019, so buckle up because we’re going to have some fun.