How to integrate appsignal with phoenix liveview?

I’m wrapping my Appsignal instrumentation and everything works fine.

def handle_event("submit", %{"waitlist" => params}, socket) do
  live_view_action(__MODULE__, "submit", socket, fn ->
    {:noreply, socket}
  end)
end

I wanted to move this to a decorator so I could do something like this:

@decorate instrument("submit")
def handle_event("submit", %{"waitlist" => params}, socket) do
  {:noreply, socket}
end

# And in my decorator:

import Appsignal.Phoenix.LiveView, only: [live_view_action: 4]

def get_socket(context) do
  # Context looks like this:
  # %Decorator.Decorate.Context{
  #   args: [
  #     "submit",
  #     {:%{}, [line: 23], [{"waitlist", {:params, [line: 23], nil}}]},
  #     {:socket, [line: 23], nil}
  #   ],
  #   arity: 3,
  #   module: HelloWorld.ComingSoonLive,
  #   name: :handle_event
  # }

  Enum.find(context.args, fn arg ->
    [{_, type} | _] = IEx.Info.info(arg)

    if type == "tuple" && elem(arg, 0) == :socket do
      arg
    end
  end)
end

def instrument(name, body, context) do
  quote do
    socket = unquote(get_socket(context))

    live_view_action(unquote(context.module), unquote(name), socket, fn ->
      unquote(body)
    end)
  end
end

socket is always nil in the context at compilation time so I can’t even run my project.

How can I use the value of socket at runtime where I know the socket won’t be nil?

Instead of either of these, I would highly suggest the new Phoenix.LiveView.on_mount/1 function combined with Phoenix.LiveView.attach_hook/4

On mount, attach a hook for handle_event, then instrument.

This looks much better, I’ll post the working solution once I figure it out. Thank you Ben!

Thanks again @benwilson512 - here’s a guide for posterity, hope it helps someone save a day or two.


Right, you want to use Appsignal to monitor your Phoenix Liveview application? Here’s how you do it.

Note: This guide assumes you’re on Liveview 0.17.5 or newer because we depend on the new liveview event hooks.

First install the appsignal_phoenix package. Because this package already depends on the appsignal package, there’s no need for you to manually add it.

defp deps do
  [
    {:appsignal_phoenix, "~> 2.0"},

Then we’re going to hook into the lifecycle in two spots. One for the mount event, and one for the handle_event.

In your myproject_web.ex add two on_mount handlers.

  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {GamingWeb.LayoutView, "live.html"}

      on_mount(GamingWeb.InitAssigns)
      on_mount(GamingWeb.Appsignal)

      unquote(view_helpers())
    end
  end

Tracking mount events

Let’s start by creating the InitAssigns module.

defmodule GamingWeb.InitAssigns do
  @moduledoc false
  import Phoenix.LiveView
  import Appsignal.Phoenix.LiveView, only: [live_view_action: 5]

  def on_mount(:default, params, session, socket) do
    # here's where we're calling appsignal instrumentation
    live_view_action(socket.view, "mount", params, socket, fn ->
      socket =
        socket
        |> assign_new(:current_user, fn ->
          get_user(session["user_token"])
        end)

      {:cont, socket}
    end)
  end
end

We’re done, every mount will be tracked properly on Appsignal.

image.png

Tracking handle_event events

We’re going to track the handle_event lifecycle event. If you want you can track others like:

defmodule GamingWeb.Appsignal do
  @moduledoc false
  import Phoenix.LiveView
  import Appsignal.Phoenix.LiveView, only: [live_view_action: 5]

  def on_mount(:default, _params, _session, socket) do
    socket =
      attach_hook(socket, :mount_hook, :handle_event, fn
        event, params, socket ->
          live_view_action(socket.view, event, params, socket, fn ->
            {:cont, socket}
          end)
      end)

    {:cont, socket}
  end
end

And we’re done. Here I’m tracking a simple form event named submit.

def handle_event("submit", %{"waitlist" => params}, socket) do
  {:noreply, socket}
end

image.png

I tested your approach. Sadly, it doesn’t fully work. The attached hook runs before the real handle_event life cycle of your LiveView. So the live_view_action instrumentation is not wrapped around everything you do there (e.g. Ecto queries etc.), it only tracks the construction of the {:cont, socket} tuple.

You’re right. This isn’t tracking the query timings of the actual liveview mount function. Maybe someone can help. I’ll try to dig into this over the weekend.

I just coded a GenServer that listens to phoenix liveview telemetry events to send them to AppSignal.
Here it is: A Phoenix Telemetry agent to monitor all LiveView events & errors · GitHub

It instruments all requests (with params, event name …) and also report errors with same metadata + stacktrace.

Still a last issue to solve: errors are reported twice to AppSignal (since the error is re-raised it is also catched by AppSignal background reporter)

Can you describe you how enable AppSignalTelemetry for your liveview app? Where do you set it? In the application list?

Yes add it as a child in application.ex

Interesting! This goes far beyond my current Elixir skills, so that’s nice to see.

I would love to contribute this server to appsignal’s repository, but I need their help to fix the duplicate error issue

I thought about how to solve this without having to wrap AppSignal’s live_view_action function around the body of every handle_params and handle_event function in all of my live views. That is just redundant.

I came up with the following solution: in my Phoenix web module I define the import of live_view_action once in the live_view macro, and add macros instrumented_handle_params and instrumented_handle_event. They just delegate to do_handle_... methods, with live_view_action wrapped around it. In my live views, I simply have to use these macros, and rename my handle_... functions to do_handle_....

It looks like this:

defmodule MyPhoenixAppWeb do

  # [...]

  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {MyPhoenixAppWeb.LayoutView, "live.html"}

      import Appsignal.Phoenix.LiveView, only: [live_view_action: 4, live_view_action: 5]

      unquote(view_helpers())
    end
  end

  def instrumented_handle_params do
    quote do
      @impl true
      def handle_params(params, url, socket) do
        live_view_action(__MODULE__, "handle_params", params, socket, fn ->
          do_handle_params(params, url, socket)
        end)
      end
    end
  end

  def instrumented_handle_event do
    quote do
      @impl true
      def handle_event(event, params, socket) do
        live_view_action(__MODULE__, event, params, socket, fn ->
          do_handle_event(event, params, socket)
        end)
      end
    end
  end
  
  # [...]
  
end

defmodule MyPhoenixAppWeb.MyLive.Index do
  use MyPhoenixAppWeb, :live_view
  use MyPhoenixAppWeb, :instrumented_handle_params
  use MyPhoenixAppWeb, :instrumented_handle_event
  
  defp do_handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do
    {:noreply,
    socket
    |> assign(filter_params: fetch_filter_params(params))
    |> apply_action(live_action, params)}
  end
  
  defp do_handle_event("my_event_name", %{"id" => id}, socket) do
    # [...]
  end
  
  # [...]
  
end

Achievements:

  • Call to AppSignal’s live_view_action only in one place
  • My live views only sparsely change: just add a line use MyPhoenixAppWeb, :instrumented_handle_params or use MyPhoenixAppWeb, :instrumented_handle_event at the top

The approach can be extended to also instrument the mount function, I just didn’t need it yet. Also, the approach can be extended to live components, then you want to instrument the update life cycle function, too.

This was a big help. I now see actual timings on AppSignal using your genserver example.

One thing I noticed is that everything is lopped into a big event in the timeline. I don’t see specific timings for our Ecto queries, or Elixir code.

How did you add support for that on your app?

Thank you!

I actually don’t have a detailed breakdown of time spent in queries either. I will investigate to see if I can do something, tut it can be quite difficult because LiveView & Ecto are running in separate processes.

By the way, I just updated my gist, it’s now solving the duplicate error issue. You just need to use the latest appsignal from GitHub, because of this feature I needed:

https://github.com/appsignal/appsignal-elixir/pull/764

Have you already tried my approach, for comparison? It should give you the breakdown of Ecto queries, for example.

@sergio It’s now working properly with detailed breakdown, just updated my gist :wink:

bro you’re a beast! i’m going to try it out this AM after standup this looks promising!

It worked out really well - amazing! :facepunch:

I wonder if there’s a way to stacktrace/measure the Elixir gap as shown in the graph here. Something is happening on the Elixir side that takes 300ms.

I’m still wondering why the rendering part is not in the breakdown.
Something else to figure out :slight_smile:

My PR has been merged, Liveview instrumentation is now available in appsignal_phoenix :slight_smile: