Skip to content

Commit b2c30d7

Browse files
dl-alexandreOpenCode
andauthored
fix(live_view_hook): handle Bandit.TransportError and URI struct in connect_info (#1062)
* fix(live_view_hook): handle Bandit.TransportError and URI struct in connect_info Wrap `get_connect_info/2` calls in `try/rescue` to gracefully handle `Bandit.TransportError` when the WebSocket transport is already closed during mount (fixes #1025). Convert `socket.host_uri` to a string with `URI.to_string/1` before storing it in request context, preventing JSON encoding failures when `:uri` is not included in `connect_info` (fixes #1040). Also sanitize non-JSON-encodable values in `:request` during event rendering as defense-in-depth. Co-Authored-By: OpenCode <noreply@opencode.ai> * docs(live_view_hook): clarify connect_info requirements and rescue rationale Expand the moduledoc to explain what each connect_info key provides and that the hook still works when keys are omitted. Add a comment to the try/rescue in get_connect_info_if_root/2 explaining why we rescue broadly (Bandit is optional and we must never crash the LiveView process). Co-Authored-By: OpenCode <noreply@opencode.ai> --------- Co-authored-by: OpenCode <noreply@opencode.ai>
1 parent 02a5f39 commit b2c30d7

4 files changed

Lines changed: 114 additions & 7 deletions

File tree

lib/sentry/client.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,12 @@ defmodule Sentry.Client do
298298
|> update_if_present(:breadcrumbs, fn bcs -> Enum.map(bcs, &Map.from_struct/1) end)
299299
|> update_if_present(:sdk, &Map.from_struct/1)
300300
|> update_if_present(:message, &sanitize_message(&1, json_library))
301-
|> update_if_present(:request, &(&1 |> Map.from_struct() |> remove_nils()))
301+
|> update_if_present(:request, fn req ->
302+
req
303+
|> Map.from_struct()
304+
|> remove_nils()
305+
|> sanitize_non_jsonable_values(json_library)
306+
end)
302307
|> update_if_present(:extra, &sanitize_non_jsonable_values(&1, json_library))
303308
|> update_if_present(:user, &sanitize_non_jsonable_values(&1, json_library))
304309
|> update_if_present(:tags, &sanitize_non_jsonable_values(&1, json_library))

lib/sentry/live_view_hook.ex

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,22 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
1212
* The user agent and user's IP address
1313
* Breadcrumbs for events that happen within LiveView
1414
15-
To make this module work best, you'll need to fetch information from the LiveView's
16-
WebSocket. You can do that when calling the `socket/3` macro in your Phoenix endpoint.
17-
For example:
15+
To get the most complete request context, you'll need to fetch information from
16+
the LiveView's WebSocket. You can do that when calling the `socket/3` macro in your
17+
Phoenix endpoint. For example:
1818
1919
socket "/live", Phoenix.LiveView.Socket,
2020
websocket: [connect_info: [:peer_data, :uri, :user_agent]]
2121
22+
Each key in `connect_info` enriches the Sentry context as follows:
23+
24+
* `:uri` — overrides the request URL with the WebSocket connect URI
25+
* `:user_agent` — adds the user agent to extra context
26+
* `:peer_data` — adds the client's IP address to user context
27+
28+
The hook still works when these keys are omitted, but the corresponding context
29+
fields will not be populated.
30+
2231
## Examples
2332
2433
defmodule MyApp.UserLive do
@@ -156,7 +165,14 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
156165

157166
defp on_mount(params, %Phoenix.LiveView.Socket{} = socket) do
158167
Context.set_extra_context(%{socket_id: socket.id})
159-
Context.set_request_context(%{url: socket.host_uri})
168+
169+
case socket.host_uri do
170+
%URI{} = uri ->
171+
Context.set_request_context(%{url: URI.to_string(uri)})
172+
173+
_ ->
174+
:ok
175+
end
160176

161177
Context.add_breadcrumb(%{
162178
category: "web.live_view.mount",
@@ -231,8 +247,18 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
231247

232248
defp get_connect_info_if_root(socket, key) do
233249
case socket.parent_pid do
234-
nil -> get_connect_info(socket, key)
235-
pid when is_pid(pid) -> nil
250+
nil ->
251+
# Bandit raises when the transport is already closed (e.g. client disconnect
252+
# during mount). Because Bandit is an optional dependency, we rescue broadly
253+
# rather than matching on Bandit.TransportError directly.
254+
try do
255+
get_connect_info(socket, key)
256+
rescue
257+
_ -> nil
258+
end
259+
260+
pid when is_pid(pid) ->
261+
nil
236262
end
237263
end
238264

test/sentry/client_test.exs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ defmodule Sentry.ClientTest do
5252
}
5353
end
5454

55+
test "safely inspects non-JSON-encodable values in :request" do
56+
event =
57+
Event.create_event(request: %{url: %URI{scheme: "http", host: "example.com", port: 80}})
58+
59+
rendered = Client.render_event(event)
60+
assert rendered.request.url == inspect(%URI{scheme: "http", host: "example.com", port: 80})
61+
end
62+
5563
test "safely inspects terms that cannot be converted to JSON" do
5664
event =
5765
Event.create_event(

test/sentry/live_view_hook_test.exs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ defmodule SentryTest.Endpoint do
129129
plug SentryTest.Router
130130
end
131131

132+
defmodule SentryTest.EndpointNoUri do
133+
use Phoenix.Endpoint, otp_app: :sentry
134+
135+
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [:peer_data, :user_agent]]
136+
137+
plug SentryTest.Router
138+
end
139+
132140
defmodule Sentry.LiveViewHookTest do
133141
use Sentry.Case, async: false
134142

@@ -327,3 +335,63 @@ defmodule Sentry.LiveViewHookTest do
327335
sentry_context
328336
end
329337
end
338+
339+
defmodule Sentry.LiveViewHookNoUriTest do
340+
use Sentry.Case, async: false
341+
342+
import ExUnit.CaptureLog
343+
import Phoenix.ConnTest
344+
import Phoenix.LiveViewTest
345+
346+
@endpoint SentryTest.EndpointNoUri
347+
348+
setup_all do
349+
Application.put_env(:sentry, SentryTest.EndpointNoUri,
350+
secret_key_base: "TMnue44VMTf1VmyD6SYKR30cqKpHluHOFZGXcVkC33hKVVKTVZ1HBQLLLLLLLLLL",
351+
live_view: [signing_salt: "F8ftIAbYdeTzhwgl"]
352+
)
353+
354+
pid = start_supervised!(SentryTest.EndpointNoUri)
355+
Process.link(pid)
356+
:ok
357+
end
358+
359+
setup do
360+
%{conn: build_conn()}
361+
end
362+
363+
test "stores string URL when :uri is not in connect_info", %{conn: conn} do
364+
conn = Plug.Conn.put_req_header(conn, "user-agent", "sentry-testing 1.0")
365+
366+
{:ok, view, html} = live(conn, "/hook_test")
367+
assert html =~ "<h1>Testing Sentry hooks</h1>"
368+
369+
context = get_sentry_context(view)
370+
371+
assert is_binary(context.request.url)
372+
assert context.request.url == "http://www.example.com/hook_test"
373+
assert context.extra.user_agent == "sentry-testing 1.0"
374+
end
375+
376+
test "does not log a Sentry error when connect_info is unavailable", %{conn: conn} do
377+
conn = Plug.Conn.put_req_header(conn, "user-agent", "sentry-testing 1.0")
378+
379+
logs =
380+
capture_log(fn ->
381+
{:ok, _view, _html} = live(conn, "/hook_test")
382+
end)
383+
384+
refute logs =~ "Sentry.LiveView.on_mount hook errored out"
385+
end
386+
387+
defp get_sentry_context(view) do
388+
{:dictionary, pdict} = Process.info(view.pid, :dictionary)
389+
390+
assert {:ok, sentry_context} =
391+
pdict
392+
|> Keyword.fetch!(:"$logger_metadata$")
393+
|> Map.fetch(Sentry.Context.__logger_metadata_key__())
394+
395+
sentry_context
396+
end
397+
end

0 commit comments

Comments
 (0)