[debug] Few things we can do with LiveView PID
May 9, 2021
This post assumes you know some things about:
Text on a button wasn’t what I expected it to be.
I’ve checked code and there was something like this inside my component: (simplified)
~L"""
<% copy = if(@initial, do: "Create and continue", else: "Update") %>
<%= button(copy) %>
"""
I’ve got a variable @initial
. It was a multistep form, and it should have been true
but somehow, along the way, it was set to false
or maybe it was never set to true
,
or maybe something else was wrong?
At that point, I started wondering how I can debug this more easily than
adding IEx.pry
, reloading the page and starting over again.
It was only happening when I was creating the resource and it was multistep form,
not the most pleasant way of debugging.
How to get Phoenix LiveView PID?
We can get PID in few ways. I’ll list only the methods that I’m using.
- HTML
- Phoenix LiveDashboard
- iex console
HTML
I've started including @elixirphoenix LiveView PID in HTML comment. It makes debugging 10x easier when you need to send a message to LiveView process or just simply inspect wtf it has in it. #MyElixirStatus pic.twitter.com/T2ugJk71za
— arathunku (@arathunku) April 29, 2021
(in case there’s a problem with loading the tweet)
I’ve started including @elixirphoenix LiveView PID in HTML comment. It makes debugging 10x easier when you need to send a message to LiveView process or just simply inspect wtf it has in it. #MyElixirStatus
And on screenshots, there’s a snippet where I add:
<!-- pid: <%= raw inspect(self()) %> -->
to my live.html.eex
.
On left side is my app, on right side HTML code with PID. This LiveView process controls the view.
LiveDashboard
We can also find PID easily without adding any special code.
In Phoenix LiveDashboard, we can go to “Processes” view and we’re looking for Phoenix.LiveView.Channel
I’ve got two tabs open so there’re 2 processes.
Console
This one is simple and basically does what LiveDashboard does under the hood, just without pretty UI:
Process.list()
|> Enum.map(& {
&1,
Process.info(&1, [:dictionary])
|> hd()
|> elem(1)
|> Keyword.get(:"$initial_call", {})
})
|> Enum.filter(fn {_, process} ->
process != nil && process != {} &&
elem(process, 0) == Phoenix.LiveView.Channel
end)
The last line is very verbose so I usually put such methods into my dev
helper module.
defmodule H do
def live_list do
Process.list()
|> Enum.map(& {
&1,
Process.info(&1, [:dictionary])
|> hd()
|> elem(1)
|> Keyword.get(:"$initial_call", {})
})
|> Enum.filter(fn {_, process} ->
process != nil && process != {} &&
elem(process, 0) == Phoenix.LiveView.Channel
end)
|> Enum.map(& elem(&1, 0))
end
end
iex(app@127.0.0.1)38> H.live_list()
[#PID<0.1015.0>]
Now, we have obtained process PID, and assigned it to beautifully named and descriptive variable p
.
p = H.live_list() |> hd()
It will be used throughout the rest of the post, so if you see p
anywhere - that’s the PID we’re looking into!
I’m mostly interested in two things - inspecting, and interacting with the process from the console.
Inspecting state
For this, we’re going to use :sys.get_state/1
iex(app@127.0.0.1)31> :sys.get_state(p)
%{
components: {%{
1 => {ReviewsaurusWeb.Dashboard.RemindersLive.FormDetailsComponent,
"reminder:1ec30c53-b0a2-496c-a828-ce0a6804ebc4",
%{
chset: #Ecto.Changeset<action: nil, changes: %{}, errors: [],
data: #Reviewsaurus.Reminders.Reminder<>, valid?: true>,
current_user: #Reviewsaurus.Accounts.User<
id: "4e3a9f23-49e7-45d5-9819-88629e23d228",
username: "arathunku",
...
>,
flash: %{},
initial: false,
myself: %Phoenix.LiveComponent.CID{cid: 1},
reminder: #Reviewsaurus.Reminders.Reminder<
id: "1ec30c53-b0a2-496c-a828-ce0a6804ebc4",
name: "alles",
...
>
}, %{changed: %{}}, {78102743095449827113310915107249600236, %{}}}
},
%{
ReviewsaurusWeb.Dashboard.RemindersLive.FormDetailsComponent => %{
"reminder:1ec30c53-b0a2-496c-a828-ce0a6804ebc4" => 1
}
}, 2},
join_ref: "4",
serializer: Phoenix.Socket.V2.JSONSerializer,
socket: #Phoenix.LiveView.Socket<
assigns: %{
current_user: #Reviewsaurus.Accounts.User<
id: "4e3a9f23-49e7-45d5-9819-88629e23d228",
username: "arathunku",
...
>,
flash: %{},
initial: true,
live_action: :details,
reminder: #Reviewsaurus.Reminders.Reminder<
id: "1ec30c53-b0a2-496c-a828-ce0a6804ebc4",
name: "alles",
...
>,
timezone: "Etc/UTC"
},
changed: %{},
endpoint: ReviewsaurusWeb.Endpoint,
id: "phx-FnyLqoxd6Tb3yAIF",
parent_PID: nil,
root_PID: #PID<0.1383.0>,
router: ReviewsaurusWeb.Router,
view: ReviewsaurusWeb.Dashboard.RemindersLive.Form,
...
>,
topic: "lv:phx-FnyLqoxd6Tb3yAIF",
transport_PID: #PID<0.1377.0>,
upload_names: %{},
upload_PIDs: %{}
}
We can see everything! Internal LiveView.Channel data, our assigns, endpoint, related PIDs, nested components and more.
In my case of a faulty copy on a button, I’m interested in assigns.
Not sure if you notice, but I’ve already spotted why I had a different copy on a button -
somehow my initial
value isn’t updated in nested live component with its own state.
I’ve added this method too to a helper:
defmodule H do
...
def state(pid) when is_pid(pid), do: :sys.get_state(pid)
end
It’s basically an alias at this point but I find it easier to have all my helpers in one place.
Let’s see what else we can do with p
.
Interacting with LiveView Process
Given that we have process’s PID, Erlang VM allows us to for example… replace process’s state!
Example, setting :foobar
key in assigns to true
:
iex(app@127.0.0.1)> :sys.replace_state(p, & put_in(&1, [:socket, Access.key(:assigns), :foobar], true))
# ...large output ommited
iex(app@127.0.0.1)> H.state(p) |> get_in([:socket, Access.key(:assigns), :foobar])
true
Access.elem(:assigns)
is required because Phoenix.LiveView.Socket
doesn’t implement Access behavior:
iex(app@127.0.0.1)> :sys.replace_state(p, & put_in(&1, [:socket, :assigns, :foobar], true));0
** (ErlangError) Erlang error: {:callback_failed, {:gen_server, :system_replace_state},
{:error, %UndefinedFunctionError{
arity: 3, function: :get_and_update, message: nil, module: Phoenix.LiveView.Socket,
reason: "Phoenix.LiveView.Socket does not implement the Access behaviour"}
}}
(stdlib 3.15) sys.erl:160: :sys.replace_state/2
Unfortunately, nothing will change on the page after doing that.
That’s because no events are actually sent on the websocket connection to the client.
Phoenix.LiveView.Channel doesn’t know about our replace_state
calls,
but we can do something else.
Updating state
Given that replace_state
wasn’t enough, it won’t forward updates to the page,
and I want to avoid digging into Phoenix.LiveView.Channel
internals(I didn’t avoid it, you can) on how to “force” push an update.
My “hack” around that is to add additional helper on dev env.
It’s done by default when use FooWeb, :live_view
is used.
All components receive this helper method:
@impl true
def handle_info({:_dev_update, f}, socket) do
{:noreply, f.(socket)}
end
and it can be called with: (remember, p
is our stored earlier in a variable PID
of the process)
iex(app@127.0.0.1)> send(p, {:_dev_update, & Phoenix.LiveView.assign(&1, :initial, true) } )
{:_dev_update, #Function<44.37215449/1 in :erl_eval.expr/5>}
iex(app@127.0.0.1)> send(p, {:_dev_update, & Phoenix.LiveView.assign(&1, :initial, false) } )
{:_dev_update, #Function<44.37215449/1 in :erl_eval.expr/5>}
iex(app@127.0.0.1)>
This allows us to update anything in socket’s assigns without a problem. It also sends updates to the page.
You can do more interesting things with it like, replacing current user with another one and seeing how the page would look like for them!
Last thing that I was interested in after I had already fixed my bug was tracing.
Can I easily see all events, without IO.inspect
in thoughtfully selectedrandom places?
Tracing updates
What events our component receives, what it sends back to the client?
To trace processes, we can use :dgb
, more precisely:
:dbg.tracer()
:dbg.p(p)
Watch out! This will be extremely verbose! We would also have to remember about closing the trace. My console output was all messed up because it would trace and print everything, including all HTML that’s pushed via websocket connection.
Let’s add another helper function!
defmodule H do
...
def trace_component(PID, stop_after_count \\ 1) when is_PID(PID) and is_integer(stop_after_count) do
:dbg.tracer(
:process,
{
fn
_msg, ^stop_after_count ->
IO.inspect("trace done")
:dbg.stop_clear()
{:trace, _PID, :send, {:socket_push, _, _}, _}, i ->
IO.inspect("truncated socket push")
i + 1
msg, i ->
IO.inspect(msg)
i + 1
end,
0
}
)
:dbg.p(PID)
end
end
In trace_component/2
call above we do few things:
- Take pid and number of events we want to collect. I enforce this number because otherwise we’d have to remember about manually stopping it, and there’s a lot of going on inside LiveView process!
- Start tracer and attach it to our process that controls LiveView.Channel
- We explicitly handle events related to
:socket_push
because they produce a lot of noise, we could further down trim down logged out data if we needed to - Print anything else!
This also work remotely within a cluster, but I’ll leave this as an exercise for the reader.
Watch out
When you’re sending events to Phoenix.LiveView.Channel, you’re actually sending them to the parent component. If there’re nested stateful live components, you need to do dig deeper with updates and most likely define additional filtering when tracing events. Stateful components are still within the same Erlang process.
Kill process
Process.exit(p, :kill)
Check if your app handles brief disconnect nicely. This can happen during deployment.
There’s more than one way to do what was described in the post, and it’s not extensive list.
You could also use :observer
or something else that provides
similar functionalities in the console. You could also attach to remote iex
console and debug on production.
If you know about other methods that are a bit friendlier from dev UX perspective - please share them. I’d be more than happy to link/include them in the post.
In case you have any comments or questions - feel free to contact me at email.