From 079ed517b7dffa8124595e2c9c4b38cbf7a6dc5e Mon Sep 17 00:00:00 2001 From: Denis Donici Date: Thu, 26 Feb 2026 01:52:46 +0200 Subject: [PATCH] chore: added ssr telemetry --- lib/ssr.ex | 21 ++++++- mix.exs | 1 + test/ssr_telemetry_test.exs | 108 ++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 test/ssr_telemetry_test.exs diff --git a/lib/ssr.ex b/lib/ssr.ex index 0a00bb9..0fc3300 100644 --- a/lib/ssr.ex +++ b/lib/ssr.ex @@ -11,6 +11,22 @@ defmodule LiveSvelte.SSR do To define a custom renderer, change the application config in `config.exs`: config :live_svelte, ssr_module: MyCustomSSRModule + + ## Telemetry + + Exposes a telemetry span for each render under the key `[:live_svelte, :ssr]`. + + The following events are emitted: + + * `[:live_svelte, :ssr, :start]` — fired when a render begins. + Metadata: `%{component: name, props: props, slots: slots}`. + + * `[:live_svelte, :ssr, :stop]` — fired when a render completes successfully. + Metadata: same. Measurements include `%{duration: duration}` in native time units + (convert with `:erlang.convert_time_unit(duration, :native, :millisecond)`). + + * `[:live_svelte, :ssr, :exception]` — fired when the renderer raises. + The exception is re-raised after the event is emitted. """ @type component_name :: String.t() @@ -41,8 +57,11 @@ defmodule LiveSvelte.SSR do @spec render(component_name, props, slots) :: render_response | no_return def render(name, props, slots) do mod = Application.get_env(:live_svelte, :ssr_module, LiveSvelte.SSR.NodeJS) + meta = %{component: name, props: props, slots: slots} - mod.render(name, props, slots) + :telemetry.span([:live_svelte, :ssr], meta, fn -> + {mod.render(name, props, slots), meta} + end) end @deprecated "Use LiveSvelte.SSR.NodeJS.server_path/0 instead." diff --git a/mix.exs b/mix.exs index 06b8ad8..a88c394 100644 --- a/mix.exs +++ b/mix.exs @@ -67,6 +67,7 @@ defmodule LiveSvelte.MixProject do {:jason, "~> 1.2", optional: true}, {:lazy_html, ">= 0.1.0", only: :test}, {:nodejs, "~> 3.1"}, + {:telemetry, "~> 0.4 or ~> 1.0"}, {:phoenix, ">= 1.7.0"}, {:phoenix_html, ">= 3.3.1"}, {:phoenix_live_view, ">= 0.18.0"} diff --git a/test/ssr_telemetry_test.exs b/test/ssr_telemetry_test.exs new file mode 100644 index 0000000..3f093fb --- /dev/null +++ b/test/ssr_telemetry_test.exs @@ -0,0 +1,108 @@ +defmodule LiveSvelte.SSRTelemetryTest do + # must be synchronous — modifies global config and attaches telemetry handlers + use ExUnit.Case, async: false + + alias LiveSvelte.SSR + + # Named handler to avoid telemetry "local function" performance penalty log warning. + # test_pid is threaded through the telemetry config argument. + def handle_telemetry(event, measurements, metadata, test_pid) do + send(test_pid, {:telemetry_event, event, measurements, metadata}) + end + + defmodule MockSSRRenderer do + @moduledoc false + @behaviour SSR + + @impl true + def render("Success", _props, _slots) do + %{"head" => "", "html" => "
Hello
"} + end + + def render("Failing", _props, _slots) do + raise RuntimeError, "SSR render failed" + end + + # Catch-all: prevents unhelpful FunctionClauseError on typos in test component names + def render(name, _props, _slots) do + raise "MockSSRRenderer: no handler for #{inspect(name)}" + end + end + + setup do + original_module = Application.get_env(:live_svelte, :ssr_module) + Application.put_env(:live_svelte, :ssr_module, MockSSRRenderer) + + test_pid = self() + handler_id = {__MODULE__, :telemetry_test, make_ref()} + + :telemetry.attach_many( + handler_id, + [ + [:live_svelte, :ssr, :start], + [:live_svelte, :ssr, :stop], + [:live_svelte, :ssr, :exception] + ], + &__MODULE__.handle_telemetry/4, + test_pid + ) + + on_exit(fn -> + :telemetry.detach(handler_id) + + if original_module do + Application.put_env(:live_svelte, :ssr_module, original_module) + else + Application.delete_env(:live_svelte, :ssr_module) + end + end) + + :ok + end + + describe "SSR telemetry" do + test "emits :start event with component, props, and slots metadata" do + props = %{"count" => 42} + slots = %{"default" => "slot content"} + + SSR.render("Success", props, slots) + + assert_receive {:telemetry_event, [:live_svelte, :ssr, :start], _measurements, metadata} + assert metadata.component == "Success" + assert metadata.props == %{"count" => 42} + assert metadata.slots == %{"default" => "slot content"} + end + + test "emits :stop event with duration measurement and passes through render result" do + result = SSR.render("Success", %{}, %{}) + + assert result == %{"head" => "", "html" => "
Hello
"} + assert_receive {:telemetry_event, [:live_svelte, :ssr, :stop], measurements, metadata} + assert metadata.component == "Success" + assert is_integer(measurements.duration) + assert measurements.duration >= 0 + end + + test "emits :exception event with metadata and duration when renderer raises" do + assert_raise RuntimeError, "SSR render failed", fn -> + SSR.render("Failing", %{}, %{}) + end + + assert_receive {:telemetry_event, [:live_svelte, :ssr, :exception], measurements, metadata} + assert metadata.component == "Failing" + assert is_integer(measurements.duration) + assert measurements.duration >= 0 + end + + test "emits independent telemetry events for each render call" do + SSR.render("Success", %{"call" => 1}, %{}) + SSR.render("Success", %{"call" => 2}, %{}) + + assert_receive {:telemetry_event, [:live_svelte, :ssr, :start], _, + %{component: "Success", props: %{"call" => 1}}} + + assert_receive {:telemetry_event, [:live_svelte, :ssr, :start], _, + %{component: "Success", props: %{"call" => 2}}} + end + end +end