Phoenix LiveView is one of those technologies that feels like cheating. You get rich, interactive UIs without writing JavaScript, and the server handles the state. It’s elegant. But that elegance comes with a trade-off that’s easy to forget: all that interactivity runs on your server.
Traditional request/response endpoints are straightforward to monitor. A request comes in, you do some work, you send a response. LiveView is more like a conversation. A mount happens, then the user clicks things, types things, navigates around, and each of those interactions triggers a callback on the server. CPU and memory pressure accumulates differently. A slow handle_event callback doesn’t show up as a slow page load; it shows up as a laggy UI that frustrates users in ways that are hard to reproduce and harder to diagnose.
With Scout APM’s Elixir agent, we now instrument LiveView automatically through Phoenix’s telemetry system. No manual wrapping of your callbacks. No decorators. One line of setup and you get visibility into every mount, every event, and every param change across your application.
What We Instrument
The agent attaches to nine telemetry events covering the three core LiveView callbacks:
Mounts ([:phoenix, :live_view, :mount, :start | :stop | :exception]): When a user hits a LiveView route, the mount is captured as a Controller-type transaction. This means it shows up right alongside your regular Phoenix controller actions in Scout, which makes it easy to compare LiveView performance against traditional endpoints.
Handle Events ([:phoenix, :live_view, :handle_event, :start | :stop | :exception]): Every user interaction that triggers handle_event becomes its own transaction. If a user clicks a button, submits a form, or triggers any phx-event, we capture it.
Handle Params ([:phoenix, :live_view, :handle_params, :start | :stop | :exception]): URL changes within a LiveView (live navigation, query string updates) are tracked as separate transactions too.
Each of these captures timing, any database or external service calls made during the callback, and (when things go wrong) exception details.
Setup
Add one line to your application’s start/2 function:
defmodule MyApp.Application do
use Application
def start(_type, _args) do
ScoutApm.Instruments.LiveViewTelemetry.attach()
children = [
MyApp.Repo,
MyAppWeb.Endpoint
# ...
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
That’s it. The agent calls :telemetry.attach_many/4 under the hood, subscribing to all nine LiveView events with a single handler. No per-module configuration, no code changes to your LiveView modules.
How Naming Works
Nobody wants to see Elixir.MyAppWeb.DashboardLive cluttering up their Scout dashboard. The agent strips the app prefix from module names and formats them to be scannable at a glance.
MyAppWeb.DashboardLive becomes:
DashboardLive#mountfor mount callbacksDashboardLive#handle_event:refreshfor a “refresh” eventDashboardLive#handle_paramsfor param changes
The naming follows the pattern ModuleName#callback:event_name, which mirrors the Controller#action convention you already see for regular Phoenix controllers. If you have nested modules like MyAppWeb.Admin.SettingsLive, it becomes Admin.SettingsLive#mount, keeping the structural context without the noise.
A Practical Example
Consider a dashboard LiveView that loads metrics:
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
# Captured as "DashboardLive#mount" in Scout
metrics = MyApp.Metrics.load_dashboard_data()
{:ok, assign(socket, metrics: metrics)}
end
def handle_event("refresh", _params, socket) do
# Captured as "DashboardLive#handle_event:refresh" in Scout
metrics = MyApp.Metrics.load_dashboard_data()
{:noreply, assign(socket, metrics: metrics)}
end
def handle_params(%{"range" => range}, _uri, socket) do
# Captured as "DashboardLive#handle_params" in Scout
metrics = MyApp.Metrics.load_dashboard_data(range: range)
{:noreply, assign(socket, metrics: metrics)}
end
end
In Scout, you’d see DashboardLive#mount as a transaction in your endpoints list. Drill into it and you’ll see the time breakdown: how much was spent in the controller layer itself, how much in Ecto queries triggered by load_dashboard_data/0, and how much in any external HTTP calls. If users are hammering the refresh button and DashboardLive#handle_event:refresh starts showing high throughput with increasing response times, you’ll see that too.
The key insight here is that load_dashboard_data/0 might be perfectly fast on its own, but when 200 connected LiveView clients all trigger a refresh within the same second, the aggregate load tells a different story. Scout’s throughput and response time charts for that specific event will make the pattern obvious.
Error Capture
When a LiveView callback raises an exception, the agent captures it automatically. The error includes:
- The exception type and message
- The full stacktrace
- The socket’s URI for request context
- The controller name (e.g.,
DashboardLive#mount)
# If this raises, Scout captures the error with full context
def handle_event("delete", %{"id" => id}, socket) do
MyApp.Items.delete!(id) # Raises on failure
{:noreply, assign(socket, items: MyApp.Items.list())}
end
This feeds into Scout’s error monitoring, so you can see error rates per LiveView callback and correlate them with performance data. An endpoint that’s both slow and throwing errors is a different kind of problem than one that’s just slow.
Ignoring and Downsampling Noisy Events
Some LiveView events fire constantly. A "cursor_move" or "typing" event that updates on every keystroke can generate hundreds of transactions per user session. That’s a lot of data, and most of it looks the same. You probably don’t want to pay (in overhead or in attention) to track every single one.
Since each LiveView callback runs as its own tracked request, you can call ScoutApm.TrackedRequest.ignore() to drop it before any data is recorded. The simplest version is a hard ignore for a specific event name:
def handle_event("cursor_move", params, socket) do
ScoutApm.TrackedRequest.ignore()
# Your actual logic still runs, Scout just won't track it
{:noreply, assign(socket, cursor: params)}
end
For events that are worth monitoring but fire too frequently, you can downsample instead of ignoring entirely. Track a percentage of them and let the aggregate data tell the story:
def handle_event("typing", %{"value" => value}, socket) do
# Only track 10% of typing events
if :rand.uniform() > 0.10 do
ScoutApm.TrackedRequest.ignore()
end
{:noreply, assign(socket, search_query: value)}
end
This gives you representative performance data without flooding Scout with identical transactions. You still see if "typing" events are getting slow or throwing errors, you just don’t need every single one to know that.
A middle ground that works well in practice is to ignore events that don’t do any real work (pure UI updates, local state changes) and keep tracking events that hit the database or call external services. Those are the ones where performance actually varies and where problems hide.
What You See in Scout
LiveView transactions appear in the same places you’d expect for any web transaction:
- Endpoints list:
DashboardLive#mount,DashboardLive#handle_event:refresh, etc. show up alongsidePageController#indexand your other actions. Sort by response time or throughput to find the ones that need attention. - Trace details: Click into any LiveView transaction and you get the full layer breakdown. Ecto queries, HTTP calls to external services, and custom instrumentation all nest inside the LiveView callback layer.
- Error monitoring: LiveView exceptions are grouped and tracked with the same tools you use for controller errors.
The fact that LiveView transactions use the same “Controller” type as regular endpoints is deliberate. We didn’t want to create a parallel universe of monitoring that you have to check separately. Your LiveView performance data lives right next to everything else, because that’s where it’s useful.
Getting Started
If you’re already using the Scout APM Elixir agent, add the attach/0 call to your application startup and deploy. LiveView data will start flowing within minutes.
If you’re not using Scout yet, we offer a 14-day free trial with full access. The Elixir agent takes about five minutes to set up, and LiveView instrumentation is one attach/0 call on top of that. Worth a look if you’re running LiveView in production and want to understand what’s actually happening on the server side of those real-time interactions.





