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