Debugging With Tracing in Elixir

Nov 02, 2020

Estimated Reading Time: 8 minutes (1531 words)

If you are a video person, and have 24 minutes to spend with, just jump over to this ElixirConf 2020 - Debugging Live Systems on the BEAM talk by Jeffery Utter.

This article is a downgraded version of the video 😂.

I wrote this before the video is published. Then, I came across it and learn a lot more from there.


I use IO.inspect for debugging in Elixir a lot. But there are times when you can’t just IO.inspect to debug stuff, especially in a running production system (without redeploying your code).

Then, I came across the power of tracing for debugging from Fred Herbert “Operable Erlang Elixir” talk. This is especially powerful for debugging in live production system. (however, it is not really recommended to use the options I share below…)

Today, I am going to cover :dbg, where I came across recently while trying to debug my code. Here’s what you can expect from this post:

All of the stuff written here are referenced from the following StackOverflow questions:

Introduction

Starting :dbg

Before we start tracing, we need to start the dbg and tracer process with the following code:

:dbg.start
:dbg.tracer

It won’t do anything until you state what you would like to trace explicitly.

Specifying what to trace

Let’s say we want to trace the Enum.map/2 function that was called in the system. We can specify it by running:

:dbg.tp(Enum, :map, 2, [])
:dbg.p(:all, :c)

Tracing in action

When some part of your code call Enum.map([1,2,3], & &1 + 1), depending on where you are running the above :dbg code, you’ll get different output in your shell.

In Local Shell

If you are on the shell that are running the system, whenever the function you traced is called, you will see something as follow:

(<0.106.0>) call 'Elixir.Enum':map([1,2,3],#Fun<erl_eval.44.97283095>)

In Remote Shell

You’ll not see any output as the tracer is logging the output in the live process instead of your remote shell. In order to see the trace, you’ll need to go through your logs file (E.g. erlang.log.1).

If you want it to output in your remote shell, you’ll need to start your tracer process differently:

:dbg.tracer(:process, {fn msg, n -> IO.inspect(msg); n+1 end, 0})

:dbg.tracer accept a second argument where you can customize how you want to handle each of the traced events. Here, we are telling the tracer to log output to our local shell instead.

Stopping the tracing

To stop the tracing, it is as simple as:

:dbg.stop_clear

It is very important to stop your tracing after you get the information you need. This is because:

And this become significant especially when you are tracing a high frequency functions or a high volume system. Hence, it is actually better to do use the following when starting your tracer process in a live production system:

:dbg.tracer(:process, {fn _, 5 -> :dbg.stop_clear()
                        msg, n -> IO.inspect(msg); n+1 end, 0})

The first function clause tell the tracer to stop after 5 events. The second one tell the tracer how it should handle the receive event. In this case, we are just printing it and increment the counter.

All together

Here is the code you can copy all together and past it in your iex to see tracing in action:

:dbg.start
:dbg.tracer(:process, {fn _, 5 -> :dbg.stop_clear()
                        msg, n -> IO.inspect(msg); n+1 end, 0})
:dbg.tpl(Enum, :map, [])
:dbg.p(:all, :c)

# With trace
Enum.map([1,2,3,4], & &1 + 1)
Enum.map([1,2,3,4], & &1 + 1)
Enum.map([1,2,3,4], & &1 + 1)
Enum.map([1,2,3,4], & &1 + 1)
Enum.map([1,2,3,4], & &1 + 1)

# No more trace
Enum.map([1,2,3,4], & &1 + 1)

Customization

Sometimes you would want to know more than the arguments being passed to the function, you might want to know the return result, or the timestamps when the function run.

You can achieve this by providing more arguments to some of the function we used above.

Getting return trace/value

To get the return trace, we can specify more options in :dbg.tpl as follow:

:dbg.tpl(Enum, :map, 2, [{:_, [], [{:return_trace}]}])

The additional options we provide is called the MatchSpec, which we will cover further later.

Example:

:dbg.start
:dbg.tracer

:dbg.tpl(Enum, :map, 2, [{:_, [], [{:return_trace}]}])

:dbg.p(:all, :c)

Enum.map([1,2,3], & &1 + 1)
# (<0.106.0>) call 'Elixir.Enum':map([1,2,3],#Fun<erl_eval.44.97283095>)
# (<0.106.0>) returned from 'Elixir.Enum':map/2 -> [2,3,4]
# [2,3,4]

:dbg.stop

Include timestamps of function call

To include the timestamps of the function call, we can include :timestamp when calling :dbg.p:

:dbg.p(:all, [:c, :timestamp])

Example:

:dbg.start
:dbg.tracer
:dbg.tpl(Enum, :map, 2, [{:_, [], [{:return_trace}]}])

:dbg.p(:all, [:c, :timestamp])

Enum.map([1,2,3,4], & &1 + 1)
# [2, 3, 4, 5]
# (<0.105.0>) call 'Elixir.Enum':map([1,2,3,4],#Fun<erl_eval.44.40011524>) (Timestamp: {1624, 687361, 187278})
# (<0.105.0>) returned from 'Elixir.Enum':map/2 -> [2,3,4,5] (Timestamp: {1624, 687361, 187303})
# ^ The timestmap is in Erlang Timestamp tuple format.

:dbg.stop

Tracing more specific function call

You might also want to only trace function called with specific arguments, for example a user id, or specific category. You could do this by modifying the match spec for :dbg.tpl:

:dbg.tpl(Enum, :map, 2, [{[[1, 2, 3], :_], [], [{:return_trace}]}])

The first argument in the first tuple of the match spec, is the function parameter that we want to match. Here, we want to match Enum.map/2 with:

Do note the enclosing {} for :return_trace. It is crucial to have it for the tracer to trace your return value as well (which is missing from the previous version of this post).

Example:

:dbg.start
:dbg.tracer

:dbg.tpl(Enum, :map, 2, [{[[1, 2, 3], :_], [], [{:return_trace}]}])

:dbg.p(:all, :c)

Enum.map([1,2,3], & &1 + 1)
# (<0.106.0>) call 'Elixir.Enum':map([1,2,3],#Fun<erl_eval.44.97283095>)
# (<0.106.0>) returned from 'Elixir.Enum':map/2 -> [2,3,4]
# [2, 3, 4]

Enum.map([1,2], & &1 + 1)
# Nothing is logged
# [2, 3]

“How do I write those complicated match spec?", you might be wondering. Rest assure, that’s what we cover next.

Writing match spec

It can be hard to write match spec for complicated scenario. Luckily, :dbg.fun2ms can be used to help you transform your function to a match spec:

:dbg.fun2ms(fn [[1,2,3], _] -> :return_trace end)
# [{[1, 2, 3], [], [:return_trace]}]

Notice that the function parameter is expecting a list of parameter [] instead of multiple parameter values. If you attempt to do multiple parameters like this, you’ll get an error:

:dbg.fun2ms(fn [1,2,3], _ -> :return_trace end)
# Error: dbg:fun2ms requires fun with single variable or list parameter
# {:error, :transform_error}

However, the match spec is not entirely correct. To have your return trace working correctly, it need to be wrapped in {}. So this won’t work:

:dbg.start
:dbg.tracer

match_spec = :dbg.fun2ms(fn [[1,2,3], _] -> :return_trace end)
:dbg.tpl(Enum, :map, match_spec)
:dbg.p(:all, :c)

# With trace of function call but no return value
Enum.map([1,2,3], & &1 + 1)
# (<0.105.0>) call 'Elixir.Enum':map([1,2,3],#Fun<erl_eval.44.40011524>)
# [2, 3, 4]

:dbg.stop

Hence, you’ll have to transform the match spec abit. Here’s how we can do it:

match_spec = :dbg.fun2ms(fn [[1,2,3], _] -> :return_trace end)
match_spec = Enum.map(match_spec, fn {args, guards, [:return_trace]} ->
  {args, guards, [{:return_trace}]}
end)

So, the full working example looks like this:

:dbg.start
:dbg.tracer

match_spec = :dbg.fun2ms(fn [[1,2,3], _] -> :return_trace end)
match_spec = Enum.map(match_spec, fn {args, guards, [:return_trace]} ->
  {args, guards, [{:return_trace}]}
end)

:dbg.tpl(Enum, :map, match_spec)
:dbg.p(:all, :c)

# With trace of function call and return value.
Enum.map([1,2,3], & &1 + 1)
# (<0.105.0>) call 'Elixir.Enum':map([1,2,3],#Fun<erl_eval.44.40011524>)
# (<0.105.0>) returned from 'Elixir.Enum':map/2 -> [2,3,4]
# [2, 3, 4]

Wrap Up

That’s all I’m re sharing today. :dbg can be a bit low level. If you prefer a simpler interface, consider using recon_trace from recon.

One of the benefits of :dbg over recon_trace is, it’s build in. There is no need to add any additional dependencies to your codebase. However, if you find yourself doing this a lot, especially in a live production system, I highly recommend adding recon as your dependencies and use recon_trace instead.

recon bring in a lot more tooling than just tracing. It also allows you to diagnose your system safely. If you are interested into topic like this, might also consider get yourself a free copy of Erlang in Anger where the author of recon wrote about diagnosing BEAM application with recon and many more.

If you would like to learn more about using tracing with recon, you can read here as well.


Updated (26th June 2021):