From 9c47687715f6afdcbad09874d3f6bef542ce232f Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 6 May 2026 10:18:24 +0000 Subject: [PATCH 1/6] fix(live_view): scrub sensitive data from LiveView breadcrumbs Sentry.LiveViewHook previously stored raw event params, handle_params params, and URIs directly in breadcrumbs. Form submissions over the LiveView WebSocket frequently contain passwords, tokens, and other secrets, which were forwarded to Sentry unredacted. The hook now passes breadcrumb data through Sentry.Scrubber.scrub_map/2 and URIs through Sentry.Scrubber.scrub_url/2 before adding them to the breadcrumb trail. Users can override the scrubber by passing a {module, function, args} tuple via on_mount opts, mirroring the override mechanism already provided by Sentry.PlugCapture: on_mount {Sentry.LiveViewHook, scrubber: {MyApp.Scrubber, :scrub, []}} Co-Authored-By: Claude Opus 4.7 --- lib/sentry/live_view_hook.ex | 96 ++++++++++++++++++++++++++--- test/sentry/live_view_hook_test.exs | 70 ++++++++++++++++++++- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/lib/sentry/live_view_hook.ex b/lib/sentry/live_view_hook.ex index 31144377..fea52a43 100644 --- a/lib/sentry/live_view_hook.ex +++ b/lib/sentry/live_view_hook.ex @@ -39,6 +39,28 @@ if Code.ensure_loaded?(Phoenix.LiveView) do You can also set this in your `MyAppWeb` module, so that all LiveViews that `use MyAppWeb, :live_view` will have this hook. + + ## Scrubbing Sensitive Data + + *Available since v13.1.0.* + + LiveView events and `handle_params` calls frequently carry user-submitted + form data, which may include passwords or other sensitive values. Before + storing this data in breadcrumbs, this hook scrubs it using + `Sentry.Scrubber.scrub_map/2`. URI query strings stored in breadcrumbs are + scrubbed via `Sentry.Scrubber.scrub_url/2`. + + To customize the scrubbing logic, pass a `:scrubber` option when attaching + the hook. The scrubber must be a `{module, function, args}` tuple; the + breadcrumb `data` map is prepended to `args` before invoking the function, + which must return a map. + + on_mount {Sentry.LiveViewHook, scrubber: {MyApp.Scrubber, :scrub, []}} + + The default scrubber is equivalent to: + + {Sentry.LiveViewHook, :default_scrubber, []} + """ @moduledoc since: "10.5.0" @@ -49,16 +71,73 @@ if Code.ensure_loaded?(Phoenix.LiveView) do require Logger + @scrubber_pdict_key {__MODULE__, :scrubber} + # See also: # https://develop.sentry.dev/sdk/event-payloads/request/ @doc false - @spec on_mount(:default, map() | :not_mounted_at_router, map(), struct()) :: {:cont, struct()} - def on_mount(:default, %{} = params, _session, socket), do: on_mount(params, socket) - def on_mount(:default, :not_mounted_at_router, _session, socket), do: {:cont, socket} + @spec on_mount(:default | keyword(), map() | :not_mounted_at_router, map(), struct()) :: + {:cont, struct()} + def on_mount(:default, params, session, socket), + do: on_mount([], params, session, socket) + + def on_mount(opts, %{} = params, _session, socket) when is_list(opts) do + store_scrubber(opts) + on_mount(params, socket) + end + + def on_mount(opts, :not_mounted_at_router, _session, socket) when is_list(opts) do + store_scrubber(opts) + {:cont, socket} + end + + @doc """ + The default scrubber applied to LiveView breadcrumb data. + + Delegates to `Sentry.Scrubber.scrub_map/2` with the default sensitive + parameter keys. + """ + @doc since: "13.1.0" + @spec default_scrubber(map()) :: map() + def default_scrubber(data) when is_map(data) do + Sentry.Scrubber.scrub_map(data) + end ## Helpers + defp store_scrubber(opts) do + case Keyword.get(opts, :scrubber, {__MODULE__, :default_scrubber, []}) do + {mod, fun, args} = scrubber when is_atom(mod) and is_atom(fun) and is_list(args) -> + Process.put(@scrubber_pdict_key, scrubber) + + other -> + raise ArgumentError, + "expected :scrubber to be a {module, function, args} tuple, got: #{inspect(other)}" + end + end + + defp scrub(data) when is_map(data) do + {mod, fun, args} = + Process.get(@scrubber_pdict_key, {__MODULE__, :default_scrubber, []}) + + case apply(mod, fun, [data | args]) do + result when is_map(result) -> + result + + other -> + Logger.error( + "Sentry.LiveViewHook scrubber returned non-map value: #{inspect(other)}; " <> + "falling back to redacted data", + event_source: :logger + ) + + %{} + end + end + + defp scrub_uri(uri) when is_binary(uri), do: Sentry.Scrubber.scrub_url(uri) + defp on_mount(params, %Phoenix.LiveView.Socket{} = socket) do Context.set_extra_context(%{socket_id: socket.id}) Context.set_request_context(%{url: socket.host_uri}) @@ -66,7 +145,7 @@ if Code.ensure_loaded?(Phoenix.LiveView) do Context.add_breadcrumb(%{ category: "web.live_view.mount", message: "Mounted live view", - data: params + data: scrub(params) }) if uri = get_connect_info_if_root(socket, :uri) do @@ -105,7 +184,7 @@ if Code.ensure_loaded?(Phoenix.LiveView) do Context.add_breadcrumb(%{ category: "web.live_view.event", message: inspect(event), - data: %{event: event, params: params} + data: scrub(%{event: event, params: params}) }) {:cont, socket} @@ -121,13 +200,14 @@ if Code.ensure_loaded?(Phoenix.LiveView) do end defp handle_params_hook(params, uri, socket) do + scrubbed_uri = scrub_uri(uri) Context.set_extra_context(%{socket_id: socket.id}) - Context.set_request_context(%{url: uri}) + Context.set_request_context(%{url: scrubbed_uri}) Context.add_breadcrumb(%{ category: "web.live_view.params", - message: "#{uri}", - data: %{params: params, uri: uri} + message: "#{scrubbed_uri}", + data: scrub(%{params: params, uri: scrubbed_uri}) }) {:cont, socket} diff --git a/test/sentry/live_view_hook_test.exs b/test/sentry/live_view_hook_test.exs index ad95e484..06e93faf 100644 --- a/test/sentry/live_view_hook_test.exs +++ b/test/sentry/live_view_hook_test.exs @@ -14,7 +14,7 @@ defmodule SentryTest.Live do {:ok, socket} end - def handle_event("refresh", _params, socket) do + def handle_event(_event, _params, socket) do {:noreply, socket} end @@ -23,6 +23,22 @@ defmodule SentryTest.Live do end end +defmodule SentryTest.CustomScrubber do + def scrub(data), do: Sentry.Scrubber.scrub_map(data, keys: ["api_key"]) +end + +defmodule SentryTest.CustomScrubberLive do + use Phoenix.LiveView + + on_mount {Sentry.LiveViewHook, scrubber: {SentryTest.CustomScrubber, :scrub, []}} + + def render(assigns), do: ~H"

custom

" + + def mount(_params, _session, socket), do: {:ok, socket} + + def handle_event(_event, _params, socket), do: {:noreply, socket} +end + defmodule SentryTest.LiveComponent do use Phoenix.LiveComponent @@ -66,6 +82,7 @@ defmodule SentryTest.Router do scope "/" do get "/dead_test", SentryTest.PageController, :page live "/hook_test", SentryTest.Live + live "/custom_scrubber", SentryTest.CustomScrubberLive end end @@ -164,6 +181,57 @@ defmodule Sentry.LiveViewHookTest do assert Logger.metadata() == [] end + test "scrubs sensitive data from breadcrumbs by default", %{conn: conn} do + {:ok, view, _html} = live(conn, "/hook_test") + + render_hook(view, :login, %{ + "email" => "user@example.com", + "password" => "supersecret", + "card" => "4111111111111111" + }) + + [event_breadcrumb | _] = get_sentry_context(view).breadcrumbs + + assert event_breadcrumb.data == %{ + event: "login", + params: %{ + "email" => "user@example.com", + "password" => "*********", + "card" => "*********" + } + } + end + + test "scrubs sensitive query params from URI in handle_params breadcrumb", %{conn: conn} do + {:ok, view, _html} = live(conn, "/hook_test?password=supersecret&visible=ok") + + breadcrumbs = get_sentry_context(view).breadcrumbs + params_breadcrumb = Enum.find(breadcrumbs, &(&1.category == "web.live_view.params")) + + refute params_breadcrumb.data.uri =~ "supersecret" + assert params_breadcrumb.data.uri =~ "password=%2A%2A%2A%2A%2A%2A%2A%2A%2A" + assert params_breadcrumb.data.uri =~ "visible=ok" + end + + test "uses a user-supplied scrubber when configured", %{conn: conn} do + {:ok, view, _html} = live(conn, "/custom_scrubber") + + render_hook(view, :submit, %{ + "api_key" => "topsecret", + "other" => "not-redacted" + }) + + [event_breadcrumb | _] = get_sentry_context(view).breadcrumbs + + assert event_breadcrumb.data == %{ + event: "submit", + params: %{ + "api_key" => "*********", + "other" => "not-redacted" + } + } + end + defp get_sentry_context(view) do {:dictionary, pdict} = Process.info(view.pid, :dictionary) From 3c95f6dade4a06e2ff43f577d63d19a2f7813377 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 07:51:45 +0000 Subject: [PATCH 2/6] fix(live_view): scrub uri too from connect info --- lib/sentry/live_view_hook.ex | 2 +- test/sentry/live_view_hook_test.exs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/sentry/live_view_hook.ex b/lib/sentry/live_view_hook.ex index fea52a43..0b938e39 100644 --- a/lib/sentry/live_view_hook.ex +++ b/lib/sentry/live_view_hook.ex @@ -149,7 +149,7 @@ if Code.ensure_loaded?(Phoenix.LiveView) do }) if uri = get_connect_info_if_root(socket, :uri) do - Context.set_request_context(%{url: URI.to_string(uri)}) + Context.set_request_context(%{url: uri |> URI.to_string() |> scrub_uri()}) end if user_agent = get_connect_info_if_root(socket, :user_agent) do diff --git a/test/sentry/live_view_hook_test.exs b/test/sentry/live_view_hook_test.exs index 06e93faf..e92feeea 100644 --- a/test/sentry/live_view_hook_test.exs +++ b/test/sentry/live_view_hook_test.exs @@ -205,12 +205,15 @@ defmodule Sentry.LiveViewHookTest do test "scrubs sensitive query params from URI in handle_params breadcrumb", %{conn: conn} do {:ok, view, _html} = live(conn, "/hook_test?password=supersecret&visible=ok") - breadcrumbs = get_sentry_context(view).breadcrumbs - params_breadcrumb = Enum.find(breadcrumbs, &(&1.category == "web.live_view.params")) + context = get_sentry_context(view) + params_breadcrumb = Enum.find(context.breadcrumbs, &(&1.category == "web.live_view.params")) refute params_breadcrumb.data.uri =~ "supersecret" assert params_breadcrumb.data.uri =~ "password=%2A%2A%2A%2A%2A%2A%2A%2A%2A" assert params_breadcrumb.data.uri =~ "visible=ok" + + refute context.request.url =~ "supersecret" + assert context.request.url =~ "password=%2A%2A%2A%2A%2A%2A%2A%2A%2A" end test "uses a user-supplied scrubber when configured", %{conn: conn} do From 277bae781466c99aac4acfaaf84f1ad4626cf297 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 07:56:13 +0000 Subject: [PATCH 3/6] test(live_view): add test to scrub sensitive params from mount breadcrumb --- test/sentry/live_view_hook_test.exs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/sentry/live_view_hook_test.exs b/test/sentry/live_view_hook_test.exs index e92feeea..9caa3a59 100644 --- a/test/sentry/live_view_hook_test.exs +++ b/test/sentry/live_view_hook_test.exs @@ -202,6 +202,15 @@ defmodule Sentry.LiveViewHookTest do } end + test "scrubs sensitive params from mount breadcrumb", %{conn: conn} do + {:ok, view, _html} = live(conn, "/hook_test?password=supersecret&visible=ok") + + breadcrumbs = get_sentry_context(view).breadcrumbs + mount_breadcrumb = Enum.find(breadcrumbs, &(&1.category == "web.live_view.mount")) + + assert mount_breadcrumb.data == %{"password" => "*********", "visible" => "ok"} + end + test "scrubs sensitive query params from URI in handle_params breadcrumb", %{conn: conn} do {:ok, view, _html} = live(conn, "/hook_test?password=supersecret&visible=ok") From 34a98c8186b4b2af9a1e773a2d832011c908fff8 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 08:50:47 +0000 Subject: [PATCH 4/6] feat(live_view): cover scrubbing error paths in tests --- test/sentry/live_view_hook_test.exs | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/sentry/live_view_hook_test.exs b/test/sentry/live_view_hook_test.exs index 9caa3a59..7365ecbf 100644 --- a/test/sentry/live_view_hook_test.exs +++ b/test/sentry/live_view_hook_test.exs @@ -39,6 +39,22 @@ defmodule SentryTest.CustomScrubberLive do def handle_event(_event, _params, socket), do: {:noreply, socket} end +defmodule SentryTest.NonMapScrubber do + def scrub(_data), do: :not_a_map +end + +defmodule SentryTest.NonMapScrubberLive do + use Phoenix.LiveView + + on_mount {Sentry.LiveViewHook, scrubber: {SentryTest.NonMapScrubber, :scrub, []}} + + def render(assigns), do: ~H"

nonmap

" + + def mount(_params, _session, socket), do: {:ok, socket} + + def handle_event(_event, _params, socket), do: {:noreply, socket} +end + defmodule SentryTest.LiveComponent do use Phoenix.LiveComponent @@ -83,6 +99,7 @@ defmodule SentryTest.Router do get "/dead_test", SentryTest.PageController, :page live "/hook_test", SentryTest.Live live "/custom_scrubber", SentryTest.CustomScrubberLive + live "/non_map_scrubber", SentryTest.NonMapScrubberLive end end @@ -225,6 +242,29 @@ defmodule Sentry.LiveViewHookTest do assert context.request.url =~ "password=%2A%2A%2A%2A%2A%2A%2A%2A%2A" end + test "raises ArgumentError when :scrubber is not an MFA tuple" do + assert_raise ArgumentError, + ~r/expected :scrubber to be a \{module, function, args\} tuple/, + fn -> + Sentry.LiveViewHook.on_mount([scrubber: :not_a_tuple], %{}, %{}, %{}) + end + end + + test "logs error and uses empty data when scrubber returns a non-map", %{conn: conn} do + {view, log} = + ExUnit.CaptureLog.with_log(fn -> + {:ok, view, _html} = live(conn, "/non_map_scrubber") + render_hook(view, :submit, %{"foo" => "bar"}) + view + end) + + assert log =~ "Sentry.LiveViewHook scrubber returned non-map value" + + [event_breadcrumb | _] = get_sentry_context(view).breadcrumbs + assert event_breadcrumb.category == "web.live_view.event" + assert event_breadcrumb.data == %{} + end + test "uses a user-supplied scrubber when configured", %{conn: conn} do {:ok, view, _html} = live(conn, "/custom_scrubber") From b6fd192025110c723a28a367dfcc5a9d7e233572 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 08:52:03 +0000 Subject: [PATCH 5/6] docs(live_view): clarify scrubber resolution timing in docs --- lib/sentry/live_view_hook.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/sentry/live_view_hook.ex b/lib/sentry/live_view_hook.ex index 0b938e39..e923a6c8 100644 --- a/lib/sentry/live_view_hook.ex +++ b/lib/sentry/live_view_hook.ex @@ -61,6 +61,9 @@ if Code.ensure_loaded?(Phoenix.LiveView) do {Sentry.LiveViewHook, :default_scrubber, []} + The scrubber is resolved once at `on_mount` time and applies to every + breadcrumb recorded for the lifetime of the LiveView process. + """ @moduledoc since: "10.5.0" From 7effd06cf0499db60a262cf56c61e2942a39235f Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 08:58:46 +0000 Subject: [PATCH 6/6] fix(live_view): handle scrubber errors gracefully --- lib/sentry/live_view_hook.ex | 25 ++++++++++++++++------ test/sentry/live_view_hook_test.exs | 32 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/lib/sentry/live_view_hook.ex b/lib/sentry/live_view_hook.ex index e923a6c8..c13ed7d8 100644 --- a/lib/sentry/live_view_hook.ex +++ b/lib/sentry/live_view_hook.ex @@ -124,13 +124,26 @@ if Code.ensure_loaded?(Phoenix.LiveView) do {mod, fun, args} = Process.get(@scrubber_pdict_key, {__MODULE__, :default_scrubber, []}) - case apply(mod, fun, [data | args]) do - result when is_map(result) -> - result - - other -> + try do + case apply(mod, fun, [data | args]) do + result when is_map(result) -> + result + + other -> + Logger.error( + "Sentry.LiveViewHook scrubber returned non-map value: #{inspect(other)}; " <> + "falling back to redacted data", + event_source: :logger + ) + + %{} + end + catch + # We must NEVER raise an error in a hook, as it will crash the LiveView process + # and we don't want Sentry to be responsible for that. + kind, reason -> Logger.error( - "Sentry.LiveViewHook scrubber returned non-map value: #{inspect(other)}; " <> + "Sentry.LiveViewHook scrubber raised an error: #{Exception.format(kind, reason)}; " <> "falling back to redacted data", event_source: :logger ) diff --git a/test/sentry/live_view_hook_test.exs b/test/sentry/live_view_hook_test.exs index 7365ecbf..a8292249 100644 --- a/test/sentry/live_view_hook_test.exs +++ b/test/sentry/live_view_hook_test.exs @@ -55,6 +55,22 @@ defmodule SentryTest.NonMapScrubberLive do def handle_event(_event, _params, socket), do: {:noreply, socket} end +defmodule SentryTest.RaisingScrubber do + def scrub(_data), do: raise("scrubber crashed!") +end + +defmodule SentryTest.RaisingScrubberLive do + use Phoenix.LiveView + + on_mount {Sentry.LiveViewHook, scrubber: {SentryTest.RaisingScrubber, :scrub, []}} + + def render(assigns), do: ~H"

raising

" + + def mount(_params, _session, socket), do: {:ok, socket} + + def handle_event(_event, _params, socket), do: {:noreply, socket} +end + defmodule SentryTest.LiveComponent do use Phoenix.LiveComponent @@ -100,6 +116,7 @@ defmodule SentryTest.Router do live "/hook_test", SentryTest.Live live "/custom_scrubber", SentryTest.CustomScrubberLive live "/non_map_scrubber", SentryTest.NonMapScrubberLive + live "/raising_scrubber", SentryTest.RaisingScrubberLive end end @@ -250,6 +267,21 @@ defmodule Sentry.LiveViewHookTest do end end + test "logs error and uses empty data when scrubber raises", %{conn: conn} do + {view, log} = + ExUnit.CaptureLog.with_log(fn -> + {:ok, view, _html} = live(conn, "/raising_scrubber") + render_hook(view, :submit, %{"foo" => "bar"}) + view + end) + + assert log =~ "Sentry.LiveViewHook scrubber raised an error" + + [event_breadcrumb | _] = get_sentry_context(view).breadcrumbs + assert event_breadcrumb.category == "web.live_view.event" + assert event_breadcrumb.data == %{} + end + test "logs error and uses empty data when scrubber returns a non-map", %{conn: conn} do {view, log} = ExUnit.CaptureLog.with_log(fn ->