Debugging Elixir Phoenix: Beyond IO.inspect/2

There are many ways to debug your Elixir code to fix errors. We always start with IO.inspect/2 and have used it for 90% of our debugging efforts. In this post, we’ll begin with some options to enhance IO.inspect/2. Then we’ll to cover some additional debugging tools available to you in Elixir. If you set up error monitoring, this post will help you resolve those errors and get your Elixir applications back up and running as quickly as possible.

Elixir 1.14 added Kernel.dbg/2 to improve the developer experience. Check out Debugging Elixir Phoenix with dbg to see how dbg can replace IO.inspect/2 in your debugging workflow.

Overview

We’re going to go over the techniques described in the Elixir Debugging docs to debug a simple Phoenix application.

  1. Create new Phoenix app
  2. IO.inspect/2
  3. IEx.pry/0 and IEx.break!/2
  4. Debugger
  5. Observer
  6. Phoenix LiveDashboard

Prerequisites

To follow this guide, make sure you have at least the following applications installed on your machine. In the brackets are the versions with which this guide was written.

  • Elixir (1.12.3-otp-24)
  • Erlang (OTP 24.1)
  • Phoenix (1.6.2)
  • Node.js with npm (15.14.0)

Create New Phoenix App

We are going to create a simple project called HelloDebugging. Use the command line to update your Phoenix version and create a new application.

$ mix archive.install hex phx_new 1.6.2
$ mix phx.new hello_debugging

Change into the project directory, create your database and start your server.

$ cd hello_debugging
$ mix ecto.create
$ mix phx.server

Now visit http://localhost:4000 and you should see the Welcome to Phoenix! page.

Add users

Now we want to add users to the project with the phx.gen html task. We’ll use the user create and update functions to see how the different debugging tools work.

mix phx.gen.html Accounts User users name:string email:string \
bio:string number_of_pets:integer

Follow the directions to add the resource to the router.ex file and migrate the database with mix ecto.migrate.

Now start your server with mix phx.server and visit http://localhost:4000/users on your local machine to see the index of users. Add a new user to test out the database.

new user

We’ll use this project to learn about the different debugging tools.

IO.inspect/2

IO.inspect/2 is useful for debugging because it returns the item argument passed to it without affecting the behavior of the original code. Let’s imagine we are getting an unexpected error while updating our user. We’ll use IO.inspect/2 to dig into the issue.

Open the HelloDebugging.Accounts module at lib/hello_debugging/accounts.ex. We can add IO.inspect throughout the Accounts.update_user/2 function.

  def update_user(%User{} = user, attrs) do
    user
    |> IO.inspect()
    |> User.changeset(attrs)
    |> IO.inspect()
    |> Repo.update()
    |> IO.inspect()
  end

Restart the server, visit http://localhost:4000/users, and click Edit for the user you just created.

Now update a couple of the user attributes and click Save.

edit user

You should see the following in you Terminal.

%HelloDebugging.Accounts.User{
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  bio: "A user",
  email: "[email protected]",
  id: 1,
  inserted_at: ~N[2021-11-19 04:13:23],
  name: "User",
  number_of_pets: 1,
  updated_at: ~N[2021-11-19 04:13:23]
}
#Ecto.Changeset<
  action: nil,
  changes: %{bio: "Updated bio", number_of_pets: 2},
  errors: [],
  data: #HelloDebugging.Accounts.User<>,
  valid?: true
>
[debug] QUERY OK db=1.1ms queue=0.4ms idle=1177.4ms
UPDATE "users" SET "bio" = $1, "number_of_pets" = $2, "updated_at" = $3 WHERE "id" = $4 ["Updated bio", 2, ~N[2021-11-19 04:21:16], 1]
{:ok,
 %HelloDebugging.Accounts.User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   bio: "Updated bio",
   email: "[email protected]",
   id: 1,
   inserted_at: ~N[2021-11-19 04:13:23],
   name: "User",
   number_of_pets: 2,
   updated_at: ~N[2021-11-19 04:21:16]
 }}

You can make out the original user, the changeset, and then the updated user. You’ll also see logging info from the database update.

As you can see IO.inspect/2 allows you to “spy” on values almost anywhere in your code without altering the result, making it very helpful inside of a pipeline like in the above case.

label option

It isn’t too hard to scan through the output, but we can make it even easier to see the output associated with each function in the pipeline. also IO.inspect/2 provides the ability to decorate the output with a label option.

  def update_user(%User{} = user, attrs) do
    user
    |> IO.inspect(label: "original")
    |> User.changeset(attrs)
    |> IO.inspect(label: "changeset")
    |> Repo.update()
    |> IO.inspect(label: "updated")
  end

Restart the server, visit http://localhost:4000/users, and click Edit for the user you just created.

Now update a couple of the user attributes again and click Save. You should see the following output in your terminal.

original: %HelloDebugging.Accounts.User{
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  bio: "Updated bio",
  email: "[email protected]",
  id: 1,
  inserted_at: ~N[2021-11-19 04:13:23],
  name: "User",
  number_of_pets: 2,
  updated_at: ~N[2021-11-19 04:21:16]
}
changeset: #Ecto.Changeset<
  action: nil,
  changes: %{bio: "Another update", number_of_pets: 5},
  errors: [],
  data: #HelloDebugging.Accounts.User<>,
  valid?: true
>
[debug] QUERY OK db=0.7ms queue=0.4ms idle=1097.1ms
UPDATE "users" SET "bio" = $1, "number_of_pets" = $2, "updated_at" = $3 WHERE "id" = $4 ["Another update", 5, ~N[2021-11-19 04:28:05], 1]
updated: {:ok,
 %HelloDebugging.Accounts.User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   bio: "Another update",
   email: "[email protected]",
   id: 1,
   inserted_at: ~N[2021-11-19 04:13:23],
   name: "User",
   number_of_pets: 5,
   updated_at: ~N[2021-11-19 04:28:05]
 }}

The original, changeset, and updateddata is labeled in the output.

binding()

You can also use IO.inspect/2 with binding(), which returns all variable names and their values

  def update_user(%User{} = user, attrs) do
    IO.inspect binding()

    user
    |> User.changeset(attrs)
    |> Repo.update()
  end

Restart the server, visit http://localhost:4000/users, and click Edit for the user you just created.

Now update a couple of the user attributes again and click Save. You should see the following output in your terminal.

[
  attrs: %{
    "bio" => "I added some pets",
    "email" => "[email protected]",
    "name" => "User",
    "number_of_pets" => "8"
  },
  user: %HelloDebugging.Accounts.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    bio: "Another update",
    email: "[email protected]",
    id: 1,
    inserted_at: ~N[2021-11-19 04:13:23],
    name: "User",
    number_of_pets: 5,
    updated_at: ~N[2021-11-19 04:28:05]
  }
]

We get a list of our variable names and their values.

Check out IO.inspect/2 to read more about other ways in which one could use this function. Also, to see the full list of formatting options to use with IO.inspect/2, see Inspect.Opts.

While IO.inspect/2 will work for a majority of you debugging needs, next we’re going to cover some other more dynamic options.

IEx.pry/0 and IEx.break!/2

While IO.inspect/2 provides static output, Elixir’s interactive shell provides more dynamic ways to interact with debugged code.

The first one is with IEx.pry/0 which can be used instead of IO.inspect binding(). Update the Accounts.update_user/2 function.

  def update_user(%User{} = user, attrs) do
    require IEx
    IEx.pry()

    user
    |> User.changeset(attrs)
    |> Repo.update()
  end

Once the function above is executed inside an iex session, IEx will ask if we want to pry into the current code. If accepted, we will be able to access all variables, as well as imports and aliases from the code, directly From IEx. While pry is running, the code execution stops, until you call continue. IEx will finish execution and it will start a new shell.

This time restart your server with an iex session.

iex -S mix phx.server

Visit http://localhost:4000/users, and click Edit for the user you created. Then update a couple of user attributes again and click Save.

Once you confirm you want to pry into the code, you can see the value of the attrs variable. Then you enter continue to finish execution.

Request to pry #PID<0.605.0> at HelloDebugging.Accounts.update_user/2 (lib/hello_debugging/accounts.ex:72)

   70:   def update_user(%User{} = user, attrs) do
   71:     require IEx
   72:     IEx.pry()
   73: 
   74:     user

Allow? [Yn] y
        
Interactive Elixir (1.12.3) - press Ctrl+C to exit (type h() ENTER for help)
pry(1)> attrs
%{      
  "bio" => "Two more!",
  "email" => "[email protected]",
  "name" => "User",
  "number_of_pets" => "10"
}
pry(2)> continue

Unfortunately, similar to IO.inspect/2, IEx.pry/0 also requires us to change the code we intend to debug.

Luckily IEx also provides a break!/2 function which allows you to set and manage breakpoints on any Elixir code without modifying its source.

Remove IEx.pry().

  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end

Restart your server with an iex session again.

iex -S mix phx.server

Call break!/1 with the function we want to debug.

Visit http://localhost:4000/users, and click Edit for the user you created. Then update a couple of user attributes again and click Save.

This time call whereami while using pry to get debugging info before entering continue to finish execution.

iex(2)> break! HelloDebugging.Accounts.update_user/2                                                 1                     
                                                  
Request to pry #PID<0.594.0> at HelloDebugging.Accounts.update_user/2 (lib/hello_debugging/accounts.ex:70)

   68: 
   69:   """
   70:   def update_user(%User{} = user, attrs) do
   71:     user
   72:     |> User.changeset(attrs)

Allow? [Yn] y
        
Interactive Elixir (1.12.3) - press Ctrl+C to exit (type h() ENTER for help)
pry(1)> whereami
Location: lib/hello_debugging/accounts.ex:70
        
   68: 
   69:   """
   70:   def update_user(%User{} = user, attrs) do
   71:     user
   72:     |> User.changeset(attrs)

    (hello_debugging 0.1.0) HelloDebugging.Accounts.update_user/2
    (hello_debugging 0.1.0) lib/hello_debugging_web/controllers/user_controller.ex:43: HelloDebuggingWeb.UserController.update/2
    (hello_debugging 0.1.0) lib/hello_debugging_web/controllers/user_controller.ex:1: HelloDebuggingWeb.UserController.action/2
    (hello_debugging 0.1.0) lib/hello_debugging_web/controllers/user_controller.ex:1: HelloDebuggingWeb.UserController.phoenix_controller_pipeline/2
    (phoenix 1.6.2) lib/phoenix/router.ex:355: Phoenix.Router.__call__/2
    (hello_debugging 0.1.0) lib/hello_debugging_web/endpoint.ex:1: HelloDebuggingWeb.Endpoint.plug_builder_call/2
    (hello_debugging 0.1.0) lib/plug/debugger.ex:136: HelloDebuggingWeb.Endpoint."call (overridable 3)"/2
    (hello_debugging 0.1.0) lib/hello_debugging_web/endpoint.ex:1: HelloDebuggingWeb.Endpoint.call/2
    (phoenix 1.6.2) lib/phoenix/endpoint/cowboy2_handler.ex:43: Phoenix.Endpoint.Cowboy2Handler.init/4

pry(2)> continue

break!/1 is a great option of you don’t want to update your code to debug.

Debugger

If you are looking for a visual debugger with breakpoints, Erlang/OTP ships with a graphical debugger named :debugger.

When you start the debugger, a Graphical User Interface (GUI) will open in your machine.

debugger
iex(2)> :debugger.start()
{:ok, #PID<0.569.0>}
iex(3)> :int.ni(HelloDebugging.Accounts)
{:module, HelloDebugging.Accounts}
iex(4)> :int.break(HelloDebugging.Accounts, 73)
:ok

We call :int.ni(HelloDebugging.Accounts) to prepare our module for debugging. Then add a breakpoint

You can see we’ve set the breakpoint in the code.

debugging breakpoint

Visit http://localhost:4000/users, and click Edit for the user you created. Then update a couple of user attributes again and click Save.

We can see our process with break status in the debugger.

debugging elixir phoenix

The process is blocked as in IEx.pry/0. We can add a new breakpoint in the monitor window, inspect the code, see the variables and navigate it in steps.

Debugger has more options and command instructions that you can use. Check out the Debbuger docs for more information.

Observer

For debugging complex systems, sometime digging into the code is not enough. It is necessary to have an understanding of the whole virtual machine, processes, applications, as well as set up tracing mechanisms. All of this info is available in Erlang with :observer.

:observer.start from an IEx sesion will open a GUI that provides many panes to fully understand and navigate the runtime and your Phoenix project.

elixir observer

Finally, you can also get a mini-overview of the runtime info by calling runtime_info/0 directly in IEx.

Phoenix LiveDashboard

LiveDashboard provides real-time performance monitoring and debugging tools for Phoenix developers. Often times it is easier to open Phoenix LiveDashboard than using remote observer in production.

Your Phoenix project has a link to LiveDashboard from the default homepage. You’ll see a number of different modules available in the tab bar.

Phoenix LIveDashboard

One of the most useful tabs is the Processes list. You can sort by memory usage or reductions to debug performance issues.

Phoenix LiveDashboard processes

Conclusion

If you’ve made it this far, we’re going to admit we’ve used IO.inspect/2 for 99% of our debugging efforts with the addition of :label accounting for the other 1%. However, we learned a lot putting together this post, including how to debug using break!/2 without changing the code.

If you liked learning about debugging, we have an entire book about managing your app in production. See below for how to get the Quick Reference Guide from the Phoenix Deployment Handbook for free.

Want To Know How To Deploy Phoenix Apps Using A single Command?

This brand-new FREE training reveals the most powerful new way to reduce your deployment time and skyrocket your productivity… and truly see your programming career explode off the charts!