Phoenix Dev Blog – Server logs in the browser console

a hand picking up happy bugs with smiley faces in the background
Image by Annie Ruygt

We’re Fly.io and we transmute containers into VMs, running them on our hardware around the world. We have fast booting VM’s and GPUs; so why not take advantage of them?

There are some features that are such quality of life improvements I’m shocked we didn’t ship them sooner. The latest phoenix_live_reload which streams servers logs down to the client is one such example.

The phoenix_live_reload project is about as old as Phoenix itself. It has been around for almost 10 years as a humble dev dependency that refreshes your browser or updates your stylesheets whenever your files change on disk. We dog-food Phoenix’s built-in bidirectional cli/server communication layer to make this happen.

When your web framework does realtime sockets out-of-the-box as a core feature, you can do a lot for free. So why stop at merely refreshing the browser?

The Problem

Anyone building web applications knows this grind – you’re plugging away on a feature and clicking around the browser. Something breaks. Maybe the UI shows some kind of error state. Maybe nothing happens when something should have happened. You check the browser’s web console for errors. Maybe our js threw an error? Nope? Okay but it didn’t work like we expected, so did the server send what we expected? Let’s check the browsers network tab. Hmm looks good. Okay let’s check the server logs back in whatever terminal we are using. Time to scroll through a thousand lines of scroll-back and look for our logged errors.

This is a problem whether you’re working on a SPA, a LiveView project, or those dusty jQuery $.ajax() calls on some legacy workhorse. The UI is necessarily coupled to the server, and either can fail or have bugs.

It’s time to cut down on the window flailing debug experience.

The Solution

It’s obvious. Let’s collocate the server logs with the client logs! The first place you’re going to look while using the UI is the browser’s web console. Your UI framework logs and UI errors are already there and built into your workflow. Interlace your server logs there, and it becomes a one-stop-shop of useful info.

The implementation is boring. We already have Phoenix channels for trivial bidirectional communication, and we already have a live reload channel running in dev for all phoenix applications. We add a custom Erlang log handler on the server and use Elixir’s Registry to broadcast log events to channel processes. Those channels push the logs down to the client and effectively console.log with some colors based on log level. The result is dev bliss:

Erlang makes it easy to hook into the VM logger. The entire meat of the live reload log shipping mechanism is less than 30 LOC:

defmodule Phoenix.LiveReloader.WebConsoleLogger do
  @registry Phoenix.LiveReloader.WebConsoleLoggerRegistry

  def attach_logger do
    :ok =
      :logger.add_handler(__MODULE__, __MODULE__, %{
        formatter: Logger.default_formatter(colors: [enabled: false])
      })
  end

  def child_spec(_args) do
    Registry.child_spec(name: @registry, keys: :duplicate)
  end

  def subscribe(prefix) do
    {:ok, _} = Registry.register(@registry, :all, prefix)
    :ok
  end

  # Erlang/OTP log handler
  def log(%{meta: meta, level: level} = event, config) do
    %{formatter: {formatter_mod, formatter_config}} = config
    msg = IO.iodata_to_binary(formatter_mod.format(event, formatter_config))

    Registry.dispatch(@registry, :all, fn entries ->
      event = %{level: level, msg: msg, file: meta[:file], line: meta[:line]}
      for {pid, prefix} <- entries, do: send(pid, {prefix, event})
    end)
  end
end

The attach_logger function uses Erlang’s :logger.add_handler/3 to attach our logger. This will call the log/2 function every time a log occurs. In our log function, we use Elixir’s Registry.dispatch/3 to act as a local pubsub and notify the live reload channels whenever a new message is logged.

This has been released with {:phoenix_live_reload, "~> 1.5"}, and you can try it out by enabling the web_console_logger in your config/dev.exs live reload config:

config :my_app, MyAppWeb.Endpoint,
  live_reload: [
+   web_console_logger: true,
    patterns: [
      ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
      ~r"priv/gettext/.*(po)$",
      ~r"lib/my_app_web/(controllers|live|components)/.*(ex|heex)$"
    ]
  ]

Then in your assets/js/app.js enable the server logs when live reload is attached:

window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
  // Enable server log streaming to client.
  // Disable with reloader.disableServerLogs()
  reloader.enableServerLogs()
  window.liveReloader = reloader
})

That’s it! Happy hacking!