Skip to content

Commit a3ae3c9

Browse files
authored
Add LiveView hook (#722)
Closes #484.
1 parent c26a782 commit a3ae3c9

File tree

13 files changed

+666
-382
lines changed

13 files changed

+666
-382
lines changed

.formatter.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[
2-
import_deps: [:plug],
2+
import_deps: [:plug, :phoenix, :phoenix_live_view],
33
inputs: [
44
"lib/**/*.ex",
55
"config/*.exs",

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ jobs:
3131
otp: '25.3'
3232

3333
# Oldest supported Elixir/Erlang pair.
34-
- elixir: '1.11.4'
35-
otp: '21.3'
34+
- elixir: '1.13.4-otp-22'
35+
otp: '22.3.4'
3636

3737
steps:
3838
- name: Check out this repository

lib/sentry/live_view_hook.ex

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
if Code.ensure_loaded?(Phoenix.LiveView) do
2+
defmodule Sentry.LiveViewHook do
3+
@moduledoc """
4+
A module that provides a `Phoenix.LiveView` hook to add Sentry context and breadcrumbs.
5+
6+
*Available since v10.5.0.*
7+
8+
This module sets context and breadcrumbs for the live view process through
9+
`Sentry.Context`. It sets things like:
10+
11+
* The request URL
12+
* The user agent and user's IP address
13+
* Breadcrumbs for events that happen within LiveView
14+
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:
18+
19+
socket "/live", Phoenix.LiveView.Socket,
20+
websocket: [connect_info: [:peer_data, :uri, :user_agent]]
21+
22+
## Examples
23+
24+
defmodule MyApp.UserLive do
25+
use Phoenix.LiveView
26+
27+
on_mount Sentry.LiveViewHook
28+
29+
# ...
30+
end
31+
32+
You can do the same at the router level:
33+
34+
live_session :default, on_mounbt: Sentry.LiveViewHook do
35+
scope "..." do
36+
# ...
37+
end
38+
end
39+
40+
You can also set this in your `MyAppWeb` module, so that all LiveViews that
41+
`use MyAppWeb, :live_view` will have this hook.
42+
"""
43+
44+
@moduledoc since: "10.5.0"
45+
46+
import Phoenix.LiveView, only: [attach_hook: 4, get_connect_info: 2]
47+
48+
alias Sentry.Context
49+
50+
require Logger
51+
52+
# See also:
53+
# https://develop.sentry.dev/sdk/event-payloads/request/
54+
55+
@doc false
56+
@spec on_mount(:default, map(), map(), struct()) :: {:cont, struct()}
57+
def on_mount(:default, params, _session, socket), do: on_mount(params, socket)
58+
59+
## Helpers
60+
61+
defp on_mount(params, %Phoenix.LiveView.Socket{} = socket) do
62+
Context.set_extra_context(%{socket_id: socket.id})
63+
Context.set_request_context(%{url: socket.host_uri})
64+
65+
Context.add_breadcrumb(%{
66+
category: "web.live_view.mount",
67+
message: "Mounted live view",
68+
data: params
69+
})
70+
71+
if uri = get_connect_info(socket, :uri) do
72+
Context.set_request_context(%{url: URI.to_string(uri)})
73+
end
74+
75+
if user_agent = get_connect_info(socket, :user_agent) do
76+
Context.set_extra_context(%{user_agent: user_agent})
77+
end
78+
79+
# :peer_data returns t:Plug.Conn.Adapter.peer_data/0.
80+
# https://hexdocs.pm/plug/Plug.Conn.Adapter.html#t:peer_data/0
81+
if ip_address = socket |> get_connect_info(:peer_data) |> get_safe_ip_address() do
82+
Context.set_user_context(%{ip_address: ip_address})
83+
end
84+
85+
socket
86+
|> maybe_attach_hook_handle_params()
87+
|> attach_hook(__MODULE__, :handle_event, &handle_event_hook/3)
88+
|> attach_hook(__MODULE__, :handle_info, &handle_info_hook/2)
89+
catch
90+
# We must NEVER raise an error in a hook, as it will crash the LiveView process
91+
# and we don't want Sentry to be responsible for that.
92+
kind, reason ->
93+
Logger.error(
94+
"Sentry.LiveView.on_mount hook errored out: #{Exception.format(kind, reason)}",
95+
event_source: :logger
96+
)
97+
98+
{:cont, socket}
99+
else
100+
socket -> {:cont, socket}
101+
end
102+
103+
defp handle_event_hook(event, params, socket) do
104+
Context.add_breadcrumb(%{
105+
category: "web.live_view.event",
106+
message: inspect(event),
107+
data: %{event: event, params: params}
108+
})
109+
110+
{:cont, socket}
111+
end
112+
113+
defp handle_info_hook(message, socket) do
114+
Context.add_breadcrumb(%{
115+
category: "web.live_view.info",
116+
message: inspect(message, pretty: true)
117+
})
118+
119+
{:cont, socket}
120+
end
121+
122+
defp handle_params_hook(params, uri, socket) do
123+
Context.set_extra_context(%{socket_id: socket.id})
124+
Context.set_request_context(%{url: uri})
125+
126+
Context.add_breadcrumb(%{
127+
category: "web.live_view.params",
128+
message: "#{uri}",
129+
data: %{params: params, uri: uri}
130+
})
131+
132+
{:cont, socket}
133+
end
134+
135+
defp maybe_attach_hook_handle_params(socket) do
136+
case socket.parent_pid do
137+
nil -> attach_hook(socket, __MODULE__, :handle_params, &handle_params_hook/3)
138+
pid when is_pid(pid) -> socket
139+
end
140+
end
141+
142+
defp get_safe_ip_address(%{ip_address: ip} = _peer_data) do
143+
case :inet.ntoa(ip) do
144+
ip_address when is_list(ip_address) -> List.to_string(ip_address)
145+
{:error, _reason} -> nil
146+
end
147+
catch
148+
_kind, _reason -> nil
149+
end
150+
151+
defp get_safe_ip_address(_peer_data) do
152+
nil
153+
end
154+
end
155+
end

mix.exs

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ defmodule Sentry.Mixfile do
4646
"Upgrade Guides": [~r{^pages/upgrade}]
4747
],
4848
groups_for_modules: [
49-
"Plug and Phoenix": [Sentry.PlugCapture, Sentry.PlugContext],
49+
"Plug and Phoenix": [Sentry.PlugCapture, Sentry.PlugContext, Sentry.LiveViewHook],
5050
Loggers: [Sentry.LoggerBackend, Sentry.LoggerHandler],
5151
"Data Structures": [Sentry.Attachment, Sentry.CheckIn],
5252
HTTP: [Sentry.HTTPClient, Sentry.HackneyClient],
@@ -91,6 +91,8 @@ defmodule Sentry.Mixfile do
9191
# Optional dependencies
9292
{:hackney, "~> 1.8", optional: true},
9393
{:jason, "~> 1.1", optional: true},
94+
{:phoenix, "~> 1.6", optional: true},
95+
{:phoenix_live_view, "~> 0.20", optional: true},
9496
{:plug, "~> 1.6", optional: true},
9597
{:telemetry, "~> 0.4 or ~> 1.0", optional: true},
9698

@@ -99,27 +101,11 @@ defmodule Sentry.Mixfile do
99101
{:dialyxir, "~> 1.0", only: [:test, :dev], runtime: false},
100102
{:ex_doc, "~> 0.29.0", only: :dev},
101103
{:excoveralls, "~> 0.17.1", only: [:test]},
102-
{:phoenix, "~> 1.5", only: [:test]},
103-
{:phoenix_html, "~> 2.0", only: [:test]}
104-
] ++ maybe_oban_optional_dependency() ++ maybe_quantum_optional_dependency()
105-
end
106-
107-
# TODO: Remove this once we drop support for Elixir < 1.13.
108-
defp maybe_oban_optional_dependency do
109-
if Version.match?(System.version(), "~> 1.13") do
110-
[{:oban, "~> 2.17 and >= 2.17.6", only: [:test]}]
111-
else
112-
[]
113-
end
114-
end
115-
116-
# TODO: Remove this once we drop support for Elixir < 1.12.
117-
defp maybe_quantum_optional_dependency do
118-
if Version.match?(System.version(), "~> 1.12") do
119-
[{:quantum, "~> 3.0", only: [:test]}]
120-
else
121-
[]
122-
end
104+
# Required by Phoenix.LiveView's testing
105+
{:floki, ">= 0.30.0", only: :test},
106+
{:oban, "~> 2.17 and >= 2.17.6", only: [:test]},
107+
{:quantum, "~> 3.0", only: [:test]}
108+
]
123109
end
124110

125111
defp package do

0 commit comments

Comments
 (0)