diff --git a/instrumentation/opentelemetry_phoenix/README.md b/instrumentation/opentelemetry_phoenix/README.md index f9e45179..ac2612e5 100644 --- a/instrumentation/opentelemetry_phoenix/README.md +++ b/instrumentation/opentelemetry_phoenix/README.md @@ -47,3 +47,59 @@ end ``` The [Phoenix endpoint.ex template](https://github.com/phoenixframework/phoenix/blob/v1.6.0/installer/templates/phx_web/endpoint.ex#L39) can be used as a reference + +## Note on Phoenix LiveView + +Phoenix LiveView async operations does not have automatic propagation. It is necessary to replace `Phoenix.LiveView.assign_async/4` and `Phoenix.LiveView.start_async/4` with `OpentelemetryPhoenix.LiveView.assign_async/4` and `OpentelemetryPhoenix.LiveView.start_async/4`. + +Before: + +```elixir +def mount(%{"slug" => slug}, _, socket) do + {:ok, + socket + |> assign(:foo, "bar") + |> assign_async(:org, fn -> {:ok, %{org: fetch_org!(slug)}} end) + |> assign_async([:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} +end +``` + +After: + +```elixir +def mount(%{"slug" => slug}, _, socket) do + {:ok, + socket + |> assign(:foo, "bar") + |> OpentelemetryPhoenix.LiveView.assign_async(:org, fn -> {:ok, %{org: fetch_org!(slug)}} end) + |> OpentelemetryPhoenix.LiveView.assign_async([:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} +end +``` + +`OpentelemetryPhoenix.LiveView` must be required in all the live vie wmodules where it is used, sucha s the `live_view` and `live_component` macros: + +```elixir +defmodule MyAppWeb do + # ... + def live_view do + quote do + use Phoenix.LiveView, + layout: {MyAppWeb.Layouts, :app} + + require OpentelemetryPhoenix.LiveView + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + require OpentelemetryPhoenix.LiveView + + unquote(html_helpers()) + end + end +end +``` diff --git a/instrumentation/opentelemetry_phoenix/lib/opentelemetry_phoenix/live_view.ex b/instrumentation/opentelemetry_phoenix/lib/opentelemetry_phoenix/live_view.ex new file mode 100644 index 00000000..586566a0 --- /dev/null +++ b/instrumentation/opentelemetry_phoenix/lib/opentelemetry_phoenix/live_view.ex @@ -0,0 +1,97 @@ +if Code.ensure_loaded(Phoenix.LiveView) do +defmodule OpentelemetryPhoenix.LiveView do + @moduledoc """ + `OpentelemetryPhoenix.LiveView` provides a extensions to the async functions + in the `Phoenix.LiveView` to reduce boilerplate in propagating OpenTelemetry + contexts across process boundaries. + + > #### Module Redefinement {: .info} + > + > This module does not redefine the `Phoenix.Liveview` module, instead + > it provides wrappers for async functions, so this functionality will + > not globally modify the default behavior of the `Phoenix.Liveview` module. + + ## Usage + + Require `OpentelemetryPhoenix.LiveView` in your `live_view` and + `live_component` macros: + + ```elixir + defmodule MyAppWeb do + # ... + def live_view do + quote do + use Phoenix.LiveView, + layout: {MyAppWeb.Layouts, :app} + + require OpentelemetryPhoenix.LiveView + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + require OpentelemetryPhoenix.LiveView + + unquote(html_helpers()) + end + end + end + ``` + + Update the references to `assign_async` and `start_async` to use this module:application + + ```elixir + def mount(%{"slug" => slug}, _, socket) do + {:ok, + socket + |> assign(:foo, "bar") + |> OpentelemetryPhoenix.LiveView.assign_async(:org, fn -> {:ok, %{org: fetch_org!(slug)}} end) + |> OpentelemetryPhoenix.LiveView.assign_async([:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} + end + ``` + """ + require Phoenix.LiveView + + defmacro assign_async(socket, key_or_keys, func, opts \\ []) do + quote do + require OpenTelemetry.Tracer + + ctx = OpenTelemetry.Ctx.get_current() + + Phoenix.LiveView.assign_async( + unquote(socket), + unquote(key_or_keys), + fn -> + OpenTelemetry.Ctx.attach(ctx) + + unquote(func).() + end, + unquote(opts) + ) + end + end + + defmacro start_async(socket, name, func, opts \\ []) do + quote do + require OpenTelemetry.Tracer + + ctx = OpenTelemetry.Ctx.get_current() + + Phoenix.LiveView.start_async( + unquote(socket), + unquote(name), + fn -> + OpenTelemetry.Ctx.attach(ctx) + + unquote(func).() + end, + unquote(opts) + ) + end + end +end +end diff --git a/instrumentation/opentelemetry_phoenix/mix.exs b/instrumentation/opentelemetry_phoenix/mix.exs index 462d79c9..7d3702bb 100644 --- a/instrumentation/opentelemetry_phoenix/mix.exs +++ b/instrumentation/opentelemetry_phoenix/mix.exs @@ -70,18 +70,18 @@ defmodule OpentelemetryPhoenix.MixProject do {:otel_http, "~> 0.2"}, {:telemetry, "~> 1.0"}, {:plug, ">= 1.11.0"}, + {:phoenix_live_view, "~> 1.0", optional: true}, {:cowboy_telemetry, "~> 0.4", only: [:dev, :test]}, {:opentelemetry_exporter, "~> 1.8", only: [:dev, :test]}, {:opentelemetry, "~> 1.5", only: [:dev, :test]}, {:opentelemetry_bandit, "~> 0.2.0", only: [:dev, :test]}, {:opentelemetry_cowboy, "~> 1.0.0", only: [:dev, :test]}, {:ex_doc, "~> 0.35", only: [:dev], runtime: false}, - {:phoenix, "~> 1.7", only: [:dev, :test]}, - {:phoenix_html, "~> 4.1", only: [:dev, :test]}, {:plug_cowboy, "~> 2.5", only: [:dev, :test]}, {:bandit, "~> 1.5", only: [:dev, :test]}, {:req, "~> 0.5", only: [:dev, :test]}, - {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false} + {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, + {:floki, ">= 0.30.0", only: :test} ] end end diff --git a/instrumentation/opentelemetry_phoenix/mix.lock b/instrumentation/opentelemetry_phoenix/mix.lock index 30a8597d..b755cf96 100644 --- a/instrumentation/opentelemetry_phoenix/mix.lock +++ b/instrumentation/opentelemetry_phoenix/mix.lock @@ -12,6 +12,7 @@ "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ex_doc": {:hex, :ex_doc, "0.35.0", "14dcaac6ee0091d1e6938a7ddaf62a4a8c6c0d0b0002e6a9252997a08df719a0", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d69a789ea0248a108c80eef509ec88ffe277f74828169c33f6f7ddaef89c98a5"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, @@ -36,6 +37,7 @@ "otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"}, "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.1", "5389a30658176c0de816636ce276567478bffd063c082515a6e8368b8fc9a0db", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c0f517e6f290f10dbb94343ac22e0109437fb1fa6f0696e7c73967b789c1c285"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, diff --git a/instrumentation/opentelemetry_phoenix/test/opentelemetry_phoenix/live_view_test.exs b/instrumentation/opentelemetry_phoenix/test/opentelemetry_phoenix/live_view_test.exs new file mode 100644 index 00000000..2d3245b8 --- /dev/null +++ b/instrumentation/opentelemetry_phoenix/test/opentelemetry_phoenix/live_view_test.exs @@ -0,0 +1,131 @@ +defmodule OpentelemetryPhoenix.LiveViewTest do + defmodule ErrorHTML do + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end + end + + defmodule TestLive do + use Phoenix.LiveView, layout: false + + require OpenTelemetry.Tracer + require OpentelemetryPhoenix.LiveView + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(_params, _url, socket) do + socket = + OpenTelemetry.Tracer.with_span "parent span" do + socket + |> OpentelemetryPhoenix.LiveView.assign_async(:assign_async, fn -> + OpenTelemetry.Tracer.with_span "assign_async span" do + {:ok, %{assign_async: "assign_async.loaded"}} + end + end) + |> OpentelemetryPhoenix.LiveView.start_async(:start_async, fn -> + OpenTelemetry.Tracer.with_span "start_async span" do + "start_async.loaded" + end + end) + end + + {:noreply, socket} + end + + @impl true + def handle_async(:start_async, {:ok, value}, socket) do + {:noreply, assign(socket, :start_async, Phoenix.LiveView.AsyncResult.ok(value))} + end + + @impl true + def render(assigns) do + ~H""" + <%= @assign_async.ok? && @assign_async.result %> + <%= assigns[:start_async] && @start_async.ok? && @start_async.result %> + """ + end + end + + defmodule Router do + use Phoenix.Router, helpers: false + + import Phoenix.LiveView.Router + + live "/test", TestLive, :show + end + + defmodule Endpoint do + use Phoenix.Endpoint, otp_app: :opentelemetry_phoenix + + plug(Router) + end + + use ExUnit.Case, async: false + + import Phoenix.ConnTest + import Phoenix.LiveViewTest + + require Record + + for {name, spec} <- Record.extract_all(from_lib: "opentelemetry/include/otel_span.hrl") do + Record.defrecord(name, spec) + end + + @endpoint Endpoint + + setup do + Application.put_env( + :opentelemetry_phoenix, + Endpoint, + [ + secret_key_base: "secret_key_base", + live_view: [signing_salt: "signing_salt"], + render_errors: [formats: [html: ErrorHTML]] + ] + ) + :otel_simple_processor.set_exporter(:otel_exporter_pid, self()) + + {:ok, _} = start_supervised(Endpoint) + + {:ok, conn: Phoenix.ConnTest.build_conn()} + end + + @tag capture_log: true + test "render_async", %{conn: conn} do + {:ok, view, _html} = live(conn, "/test") + + assert html = render_async(view) + assert html =~ "assign_async.loaded" + assert html =~ "start_async.loaded" + + # Initial parent span from the REST request + assert_receive {:span, span(name: "parent span")} + + # Parent span from the socket + assert_receive {:span, + span( + name: "parent span", + trace_id: trace_id, + span_id: process_span_id + )} + + assert_receive {:span, + span( + name: "assign_async span", + trace_id: ^trace_id, + parent_span_id: ^process_span_id + )} + + + assert_receive {:span, + span( + name: "start_async span", + trace_id: ^trace_id, + parent_span_id: ^process_span_id + )} + end +end