diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9f2a7..9b79326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* Ensure dashboard profiles list respects the selected limit + +### Changed + +#### `use PhoenixProfiler` on your Endpoint + +PhoenixProfiler needs to wrap the whole Plug pipeline to get +a complete picture of each request. Make the following changes +in your Endpoint module(s): + +1. Add `use PhoenixProfiler` directly after `use Phoenix.Endpoint`: + +```diff +defmodule MyAppWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :my_app ++ use PhoenixProfiler +``` + +2. Remove the plug from the `code_reloading?` block: + +```diff +if code_reloading? do +- plug PhoenixProfiler +end +``` + ## [0.2.0] - 2022-09-28 ### Added diff --git a/README.md b/README.md index 67b6a99..a42127d 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ Provides a **development tool** that gives detailed information about the execut To start using the profiler, you will need the following steps: 1. Add the `phoenix_profiler` dependency -2. Define a profiler on your supervision tree -3. Enable the profiler on your Endpoint +2. Define a profiler server on your supervision tree +3. Enable your profiler on your Endpoint config 4. Configure LiveView -5. Add the `PhoenixProfiler` plug +5. Use `PhoenixProfiler` on your Endpoint module 6. Mount the profiler on your LiveViews 7. Add the profiler page on your LiveDashboard (optional) @@ -39,10 +39,10 @@ Add phoenix_profiler to your `mix.exs`: {:phoenix_profiler, "~> 0.2.0"} ``` -### 2. Define a profiler on your supervision tree +### 2. Define a profiler server on your supervision tree -You define a profiler on your main application's telemetry supervision -tree (usually in `lib/my_app_web/telemetry.ex`): +You define a profiler on your telemetry supervision tree +(usually in `lib/my_app_web/telemetry.ex`): ```elixir children = [ @@ -61,7 +61,7 @@ The following options are available: * `:request_sweep_interval` - How often to sweep the ETS table where the profiles are stored. Default is `24h` in milliseconds. -### 3. Enable the profiler on your Endpoint +### 3. Enable your profiler on your Endpoint config PhoenixProfiler is disabled by default. In order to enable it, you must update your endpoint's `:dev` configuration to include the @@ -104,24 +104,26 @@ config :my_app, MyAppWeb.Endpoint, live_view: [signing_salt: "SECRET_SALT"] ``` -### 5. Add the PhoenixProfiler plug +### 5. Use PhoenixProfiler -Add the `PhoenixProfiler` plug within the `code_reloading?` -block on your Endpoint (usually in `lib/my_app_web/endpoint.ex`): +Add `use PhoenixProfiler` on your Endpoint module +(usually in `lib/my_app_web/endpoint.ex`): ```elixir - if code_reloading? do + defmodule MyAppWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :my_app + use PhoenixProfiler + # plugs... - plug PhoenixProfiler end ``` -### 6. Mount the profiler on your LiveViews +### 6. Mount PhoenixProfiler on your LiveViews Note this section is required only if you are using LiveView, otherwise you may skip it. -Add the profiler hook to the `live_view` function on your -web module (usually in `lib/my_app_web.ex`): +Add PhoenixProfiler to the `live_view` function on your web +module (usually in `lib/my_app_web.ex`): ```elixir def live_view do diff --git a/dev.exs b/dev.exs index 22940ee..09c8c95 100644 --- a/dev.exs +++ b/dev.exs @@ -224,8 +224,8 @@ defmodule DemoWeb.AppLive.Index do defp apply_profiler_toggle(socket) do if connected?(socket) do - profile = socket.private.phoenix_profiler - next = if profile.info == :enable, do: :disable, else: :enable + info = socket.private.phoenix_profiler.info + next = if info == :enable, do: :disable, else: :enable assign(socket, :toggle_text, String.capitalize(to_string(next)) <> " Profiler") else assign(socket, :toggle_text, nil) @@ -269,8 +269,8 @@ defmodule DemoWeb.AppLive.Index do end def handle_event("toggle-profiler", _, socket) do - profile = socket.private.phoenix_profiler - next = if profile.info == :enable, do: :disable, else: :enable + info = socket.private.phoenix_profiler.info + next = if info == :enable, do: :disable, else: :enable socket = apply(PhoenixProfiler, next, [socket]) {:noreply, apply_profiler_toggle(socket)} @@ -335,8 +335,7 @@ end defmodule DemoWeb.Endpoint do use Phoenix.Endpoint, otp_app: :phoenix_profiler - - plug PhoenixProfiler + use PhoenixProfiler @session_options [ store: :cookie, diff --git a/lib/phoenix_profiler.ex b/lib/phoenix_profiler.ex index b1b19a9..611a412 100644 --- a/lib/phoenix_profiler.ex +++ b/lib/phoenix_profiler.ex @@ -5,6 +5,21 @@ defmodule PhoenixProfiler do |> String.split("") |> Enum.fetch!(1) + defmacro __using__(_) do + quote do + unquote(plug()) + + @before_compile PhoenixProfiler.Endpoint + end + end + + defp plug do + # todo: ensure we are within a Phoenix.Endpoint + quote location: :keep do + plug PhoenixProfiler.Plug + end + end + @doc """ Returns the child specification to start the profiler under a supervision tree. @@ -12,18 +27,10 @@ defmodule PhoenixProfiler do def child_spec(opts) do %{ id: opts[:name] || PhoenixProfiler, - start: {PhoenixProfiler.Supervisor, :start_link, [opts]} + start: {PhoenixProfiler.Profiler, :start_link, [opts]} } end - @behaviour Plug - - @impl Plug - defdelegate init(opts), to: PhoenixProfiler.Plug - - @impl Plug - defdelegate call(conn, opts), to: PhoenixProfiler.Plug - # TODO: Remove when we require LiveView v0.17+. @doc false def mount(params, session, socket) do @@ -39,73 +46,63 @@ defmodule PhoenixProfiler do """ def on_mount(_arg, _params, _session, socket) do - {:cont, PhoenixProfiler.Utils.maybe_mount_profile(socket)} + {:cont, PhoenixProfiler.Utils.maybe_mount_profiler(socket)} end @doc """ Enables the profiler on a given `conn` or connected `socket`. - Normally you do not need to invoke this function manually. It is invoked - automatically by the PhoenixProfiler plug in the Endpoint when a - profiler is enabled. In LiveView v0.16+ it is invoked automatically when - you define `on_mount PhoenixProfiler` on your LiveView. + Useful when choosing to start a profiler with + `[enable: false]`, but normally you do not need to invoke it + manually. - This function will raise if the endpoint is not configured with a profiler, - or if the configured profiler is not running. For LiveView specifically, - this function also raises if the given socket is not connected. + Note the profiler server must be running and the `conn` or + `socket` must have been configured for profiling for this + function to have any effect. ## Example - Within a Phoenix Controller (for example, on a show callback): + Within a Phoenix Controller (for example on a `show` callback): def show(conn, params) do conn = PhoenixProfiler.enable(conn) # code... end - Within a LiveView (for example, on the mount callback): - - def mount(params, session, socket) do - socket = - if connected?(socket) do - PhoenixProfiler.enable(socket) - else - socket - end + Within a LiveView (for example on a `handle_info` callback): + def handle_info(:debug_me, socket) do + socket = PhoenixProfiler.enable(socket) # code... end """ - defdelegate enable(conn_or_socket), to: PhoenixProfiler.Utils, as: :enable_profiler + defdelegate enable(conn_or_socket), to: PhoenixProfiler.Profiler @doc """ Disables profiling on a given `conn` or `socket`. ## Examples - Within a Phoenix Controller (for example, on an update callback): + Within a Phoenix Controller (for example on an `update` callback): def update(conn, params) do conn = PhoenixProfiler.disable(conn) # code... end - Within in a LiveView (for example, on a handle_event callback): + Within in a LiveView (for example on a `handle_event` callback): def handle_event("some-event", _, socket) do socket = PhoenixProfiler.disable(socket) # code... end - Note that only for LiveView, if you invoke `disable/1` on - the LiveView `mount` callback, the profiler may not be - registered yet and it will not receive the disable message. - If you need on-demand profiling, it is recommended you - start with the profiler in a disabled state and enable it - after the LiveView has mounted. + Note that for LiveView, you must invoke `disable/1` _after_ + the LiveView has completed its connected mount for this function + to have any effect. """ - defdelegate disable(conn_or_socket), to: PhoenixProfiler.Utils, as: :disable_profiler + defdelegate disable(conn_or_socket), to: PhoenixProfiler.Profiler @doc """ Resets the storage of the given `profiler`. diff --git a/lib/phoenix_profiler/dashboard.ex b/lib/phoenix_profiler/dashboard.ex index b0e9f53..04412f4 100644 --- a/lib/phoenix_profiler/dashboard.ex +++ b/lib/phoenix_profiler/dashboard.ex @@ -15,6 +15,7 @@ if Code.ensure_loaded?(Phoenix.LiveDashboard) do """ use Phoenix.LiveDashboard.PageBuilder + alias PhoenixProfiler.Profile alias PhoenixProfiler.ProfileStore alias PhoenixProfiler.Utils @@ -188,7 +189,7 @@ if Code.ensure_loaded?(Phoenix.LiveDashboard) do end defp render_panel(:request, assigns) do - conn = assigns.profile.conn + conn = assigns.profile.data.conn nav_bar( items: [ @@ -297,29 +298,26 @@ if Code.ensure_loaded?(Phoenix.LiveDashboard) do defp fetch_profiles(params, profiler, node) do %{search: search, sort_by: sort_by, sort_dir: sort_dir, limit: limit} = params - {profiles, total} = fetch_profiles(node, profiler, search, sort_by, sort_dir, limit) + {profiles, total} = + ProfileStore.remote_list_advanced(node, profiler, search, sort_by, sort_dir, limit) rows = - for {token, prof} <- profiles do - %{at: at, conn: %Plug.Conn{} = conn} = prof + for {token, profile} <- profiles do + %Profile{ + data: %{conn: %Plug.Conn{} = conn}, + system_time: system_time + } = profile conn |> Map.take([:host, :status, :method, :remote_ip]) |> Map.put(:url, Plug.Conn.request_url(conn)) |> Map.put(:token, token) - |> Map.put(:at, at) + |> Map.put(:system_time, system_time) end {rows, total} end - defp fetch_profiles(node, profiler, search, sort_by, sort_dir, limit) do - profiles = - ProfileStore.remote_list_advanced(node, profiler, search, sort_by, sort_dir, limit) - - {profiles, length(profiles)} - end - defp columns do [ %{ @@ -339,8 +337,8 @@ if Code.ensure_loaded?(Phoenix.LiveDashboard) do header: "URL" }, %{ - field: :at, - header: "Profiled at", + field: :system_time, + header: "Time", sortable: :desc, format: &format_time/1 }, diff --git a/lib/phoenix_profiler/endpoint.ex b/lib/phoenix_profiler/endpoint.ex new file mode 100644 index 0000000..960cc7a --- /dev/null +++ b/lib/phoenix_profiler/endpoint.ex @@ -0,0 +1,94 @@ +defmodule PhoenixProfiler.Endpoint do + # Overrides Phoenix.Endpoint.call/2 for profiling. + @moduledoc false + + defmacro __before_compile__(%{module: _module}) do + quote do + defoverridable call: 2 + + # Ignore requests from :phoenix_live_reload + def call(%Plug.Conn{path_info: ["phoenix", "live_reload", "frame" | _suffix]} = conn, opts) do + super(conn, opts) + end + + def call(conn, opts) do + start_time = System.monotonic_time() + + case PhoenixProfiler.Profiler.preflight(__MODULE__) do + {:ok, profile} -> + try do + conn + |> PhoenixProfiler.Endpoint.__prologue__(profile) + |> super(opts) + |> PhoenixProfiler.Endpoint.__epilogue__(start_time) + catch + kind, reason -> + stack = __STACKTRACE__ + PhoenixProfiler.Endpoint.__catch__(conn, kind, reason, stack, profile, start_time) + end + + :error -> + super(conn, opts) + end + end + end + end + + alias PhoenixProfiler.{Profile, Profiler} + + def __prologue__(conn, %Profile{} = profile) do + {:ok, pid} = Profiler.start_collector(conn, profile) + telemetry_execute(:start, %{system_time: System.system_time()}, %{conn: conn}) + + conn + |> Plug.Conn.put_private(:phoenix_profiler, profile) + |> Plug.Conn.put_private(:phoenix_profiler_collector, pid) + end + + def __epilogue__(conn, start_time) do + profile = Map.fetch!(conn.private, :phoenix_profiler) + telemetry_execute(:stop, %{duration: duration(start_time)}, %{conn: conn}) + late_collect(conn, profile) + end + + def __epilogue__(conn, kind, reason, stack, profile, start_time) do + telemetry_execute(:exception, %{duration: duration(start_time)}, %{ + conn: conn, + profile: profile, + kind: kind, + reason: reason, + stacktrace: stack + }) + + {_, pid} = PhoenixProfiler.Utils.collector_info(profile.server, conn) + late_collect(conn, profile, pid) + end + + def __catch__(conn, kind, reason, stack, profile, start_time) do + __epilogue__(conn, kind, reason, stack, profile, start_time) + :erlang.raise(kind, reason, stack) + end + + defp telemetry_execute(action, measurements, metadata) do + :telemetry.execute([:phoenix_profiler, :endpoint, action], measurements, metadata) + end + + defp duration(start_time) when is_integer(start_time) do + System.monotonic_time() - start_time + end + + defp late_collect(conn, profile) do + late_collect(conn, profile, conn.private.phoenix_profiler_collector) + end + + defp late_collect(conn, profile, collector_pid) do + case PhoenixProfiler.Profiler.collect(profile, collector_pid) do + {:ok, profile} -> + true = PhoenixProfiler.Profiler.insert_profile(profile) + conn + + :error -> + conn + end + end +end diff --git a/lib/phoenix_profiler/plug.ex b/lib/phoenix_profiler/plug.ex index de53118..b546676 100644 --- a/lib/phoenix_profiler/plug.ex +++ b/lib/phoenix_profiler/plug.ex @@ -5,30 +5,29 @@ defmodule PhoenixProfiler.Plug do alias PhoenixProfiler.ToolbarView require Logger + @behaviour Plug + @token_header_key "x-debug-token" @profiler_header_key "x-debug-token-link" + @impl Plug def init(opts) do opts end - # TODO: remove this clause when we add config for profiler except_patterns - def call(%Plug.Conn{path_info: ["phoenix", "live_reload", "frame" | _suffix]} = conn, _) do - # this clause is to ignore the phoenix live reload iframe in case someone installs - # the toolbar plug above the LiveReloader plug in their Endpoint. - conn - end - + @impl Plug def call(conn, _) do endpoint = conn.private.phoenix_endpoint - config = endpoint.config(:phoenix_profiler) + start_time = System.monotonic_time() - if config do - conn - |> PhoenixProfiler.Utils.enable_profiler(endpoint, config, System.system_time()) - |> before_send_profile(endpoint, config) - else - conn + case conn.private do + %{phoenix_profiler: %Profile{}} -> + conn + |> telemetry_execute(:start, %{system_time: System.system_time()}) + |> before_send_profile(endpoint, [], start_time) + + _ -> + conn end end @@ -38,21 +37,32 @@ defmodule PhoenixProfiler.Plug do |> put_resp_header(@profiler_header_key, profile.url) end - defp before_send_profile(conn, endpoint, config) do + defp before_send_profile(conn, endpoint, config, start_time) do register_before_send(conn, fn conn -> - case Map.get(conn.private, :phoenix_profiler) do - %Profile{info: :enable} = profile -> - conn - |> apply_profile_headers(profile) - |> PhoenixProfiler.Utils.on_send_resp(profile) - |> maybe_inject_debug_toolbar(profile, endpoint, config) - - _ -> + # todo: look for exceptions in the conn + telemetry_execute(conn, :stop, %{duration: System.monotonic_time() - start_time}) + + case PhoenixProfiler.Profiler.collect( + conn.private.phoenix_profiler, + conn.private.phoenix_profiler_collector + ) do + {:ok, profile} -> + conn = apply_profile_headers(conn, profile) + true = PhoenixProfiler.Profiler.insert_profile(profile) + maybe_inject_debug_toolbar(conn, profile, endpoint, config) + + :error -> conn end end) end + defp telemetry_execute(%Plug.Conn{} = conn, action, measurements) + when action in [:start, :stop] do + :telemetry.execute([:phoenix_profiler, :plug, action], measurements, %{conn: conn}) + conn + end + defp maybe_inject_debug_toolbar(%{resp_body: nil} = conn, _, _, _), do: conn defp maybe_inject_debug_toolbar(conn, profile, endpoint, config) do @@ -106,11 +116,17 @@ defmodule PhoenixProfiler.Plug do name: "Phoenix Web Debug Toolbar" ) + # Note: if the session keys change then you must bump the version: ToolbarView |> Phoenix.View.render("index.html", %{ conn: conn, - session: %{"_" => profile}, - profile: profile, + session: %{ + "vsn" => 1, + "node" => profile.node, + "server" => profile.server, + "token" => profile.token + }, + token: profile.token, toolbar_attrs: attrs }) |> Phoenix.HTML.Safe.to_iodata() diff --git a/lib/phoenix_profiler/profile.ex b/lib/phoenix_profiler/profile.ex index 9800f1d..5a9a507 100644 --- a/lib/phoenix_profiler/profile.ex +++ b/lib/phoenix_profiler/profile.ex @@ -2,19 +2,17 @@ defmodule PhoenixProfiler.Profile do # An internal data structure for a request profile. @moduledoc false defstruct [ - :collector_pid, + :endpoint, :info, :node, :server, - :start_time, :system, :system_time, :token, - :url + :url, + data: %{} ] - @type info :: nil | :enable | :disable - @type system :: %{ :otp => String.t(), :elixir => String.t(), @@ -24,38 +22,35 @@ defmodule PhoenixProfiler.Profile do } @type t :: %__MODULE__{ - :collector_pid => nil | pid(), - :info => info(), + :data => map(), + :endpoint => module(), + :info => nil | :disable | :enable, :token => String.t(), :server => module(), :node => node(), - :start_time => integer(), :system => system(), - :system_time => integer(), + :system_time => nil | integer(), :url => String.t() } @doc """ Returns a new profile. """ - def new(node \\ node(), server, token, info, base_url, system_time) - when is_atom(server) and is_binary(token) and - is_atom(info) and info in [nil, :enable, :disable] and - is_binary(base_url) and is_integer(system_time) do + def new(endpoint, server, token, base_url, info) + when is_atom(endpoint) and is_atom(server) and + is_binary(token) and is_binary(base_url) and + is_atom(info) do + params = %{nav: inspect(server), panel: :request, token: token} + url = base_url <> "?" <> URI.encode_query(params) + %__MODULE__{ + endpoint: endpoint, info: info, - node: node, + node: node(), server: server, - start_time: System.monotonic_time(), system: PhoenixProfiler.ProfileStore.system(server), - system_time: system_time, token: token, - url: build_url(server, token, base_url) + url: url } end - - defp build_url(server, token, base_url) do - params = %{nav: inspect(server), panel: :request, token: token} - base_url <> "?" <> URI.encode_query(params) - end end diff --git a/lib/phoenix_profiler/profile_store.ex b/lib/phoenix_profiler/profile_store.ex index 30c906c..896acd9 100644 --- a/lib/phoenix_profiler/profile_store.ex +++ b/lib/phoenix_profiler/profile_store.ex @@ -71,7 +71,7 @@ defmodule PhoenixProfiler.ProfileStore do """ def profiler(%Plug.Conn{} = conn) do case conn.private[:phoenix_profiler] do - %Profile{server: server} when is_atom(server) -> server + %Profile{server: server} -> server nil -> nil end end @@ -99,17 +99,22 @@ defmodule PhoenixProfiler.ProfileStore do @doc """ Returns a filtered list of profiles. """ - def list_advanced(profiler, _search, sort_by, sort_dir, _limit) do - Utils.sort_by(list(profiler), fn {_, profile} -> profile[sort_by] end, sort_dir) + def list_advanced(profiler, _search, :at, sort_dir, limit) do + results = Utils.sort_by(list(profiler), fn {_, %Profile{} = p} -> p.system_time end, sort_dir) + + {Enum.take(results, limit), length(results)} + end + + def list_advanced(profiler, _search, sort_by, sort_dir, limit) do + results = + Utils.sort_by(list(profiler), fn {_, %Profile{} = p} -> p.data[sort_by] end, sort_dir) + + {Enum.take(results, limit), length(results)} end @doc """ Fetches a profile on a remote node. """ - def remote_get(%Profile{} = profile) do - remote_get(profile.node, profile.server, profile.token) - end - def remote_get(node, profiler, token) do :rpc.call(node, __MODULE__, :get, [profiler, token]) end diff --git a/lib/phoenix_profiler/profiler.ex b/lib/phoenix_profiler/profiler.ex new file mode 100644 index 0000000..4316adb --- /dev/null +++ b/lib/phoenix_profiler/profiler.ex @@ -0,0 +1,134 @@ +defmodule PhoenixProfiler.Profiler do + @moduledoc false + use Supervisor + alias PhoenixProfiler.Profile + alias PhoenixProfiler.ProfileStore + alias PhoenixProfiler.Telemetry + alias PhoenixProfiler.TelemetryServer + alias PhoenixProfiler.Utils + + @doc """ + Builds a profile from data collected for a given `conn`. + """ + def collect(%Profile{info: :enable} = profile, collector_pid) when is_pid(collector_pid) do + time = System.system_time() + + data = + PhoenixProfiler.TelemetryCollector.reduce( + collector_pid, + %{metrics: %{endpoint_duration: nil}}, + fn + {:telemetry, _, _, _, %{endpoint_duration: duration}}, acc -> + %{acc | metrics: Map.put(acc.metrics, :endpoint_duration, duration)} + + {:telemetry, _, _, _, %{metrics: _} = entry}, acc -> + {metrics, rest} = Utils.map_pop!(entry, :metrics) + acc = Map.merge(acc, rest) + %{acc | metrics: Map.merge(acc.metrics, metrics)} + + {:telemetry, _, _, _, data}, acc -> + Map.merge(acc, data) + end + ) + + {:ok, %Profile{profile | data: data, system_time: time}} + end + + def collect(%Profile{info: :disable}, _) do + :error + end + + @doc """ + Inserts a profile into term storage. + """ + def insert_profile(%Profile{} = profile) do + profile + |> ProfileStore.table() + |> :ets.insert({profile.token, profile}) + end + + @doc """ + Disables profiling for a given `conn` or `socket`. + """ + def disable(%{private: %{phoenix_profiler: profile}} = conn_or_socket) do + :ok = TelemetryServer.disable_key(profile.server, Utils.target_pid(conn_or_socket)) + Utils.put_private(conn_or_socket, :phoenix_profiler, %{profile | info: :disable}) + end + + def disable(conn_or_socket), do: conn_or_socket + + @doc """ + Enables profiling for a given `conn` or `socket`. + """ + def enable(%{private: %{phoenix_profiler: profile}} = conn_or_socket) do + :ok = TelemetryServer.enable_key(profile.server, Utils.target_pid(conn_or_socket)) + Utils.put_private(conn_or_socket, :phoenix_profiler, %{profile | info: :enable}) + end + + def enable(conn_or_socket), do: conn_or_socket + + @doc """ + Returns a sparse data structure for a profile. + + Useful mostly for initializing the profile at the beginning of a request. + """ + def preflight(endpoint) do + preflight(endpoint, endpoint.config(:phoenix_profiler)) + end + + def preflight(_endpoint, nil), do: :error + def preflight(endpoint, config), do: preflight(endpoint, config[:server], config) + + defp preflight(endpoint, nil = _server, _config) do + IO.warn("no profiler server found for endpoint #{inspect(endpoint)}") + :error + end + + defp preflight(endpoint, server, config) when is_atom(server) do + info = if config[:enable] == false, do: :disable, else: :enable + token = Utils.random_unique_id() + url = endpoint.url() <> Utils.profile_base_path(config) + + {:ok, Profile.new(endpoint, server, token, url, info)} + end + + def start_link(opts) do + {name, opts} = opts |> Enum.into([]) |> Keyword.pop(:name) + + unless name do + raise ArgumentError, "the :name option is required to start PhoenixProfiler" + end + + Supervisor.start_link(__MODULE__, {name, opts}, name: name) + end + + @impl Supervisor + def init({name, opts}) do + events = (opts[:telemetry] || []) ++ Telemetry.events() + + children = [ + {ProfileStore, {name, opts}}, + {TelemetryServer, [filter: &Telemetry.collect/4, server: name, events: events]} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + @doc """ + Starts a telemetry collector for `conn` for a given `profile`. + """ + def start_collector(conn, %Profile{} = profile) do + case TelemetryServer.listen(profile.server, Utils.target_pid(conn), nil, profile.info) do + {:ok, pid} -> + {:ok, pid} + + {:error, {:already_registered, pid}} -> + case profile.info do + :disable -> TelemetryServer.disable_key(profile.server, Utils.target_pid(conn)) + :enable -> TelemetryServer.enable_key(profile.server, Utils.target_pid(conn)) + end + + {:ok, pid} + end + end +end diff --git a/lib/phoenix_profiler/supervisor.ex b/lib/phoenix_profiler/supervisor.ex deleted file mode 100644 index 9a02de2..0000000 --- a/lib/phoenix_profiler/supervisor.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule PhoenixProfiler.Supervisor do - @moduledoc false - use Supervisor - alias PhoenixProfiler.ProfileStore - alias PhoenixProfiler.Telemetry - alias PhoenixProfiler.TelemetryServer - - def start_link(opts) do - {name, opts} = opts |> Enum.into([]) |> Keyword.pop(:name) - - unless name do - raise ArgumentError, "the :name option is required to start PhoenixProfiler" - end - - Supervisor.start_link(__MODULE__, {name, opts}, name: name) - end - - def init({name, opts}) do - events = (opts[:telemetry] || []) ++ Telemetry.events() - - children = [ - {ProfileStore, {name, opts}}, - {TelemetryServer, [filter: &Telemetry.collect/4, server: name, events: events]} - ] - - Supervisor.init(children, strategy: :one_for_one) - end -end diff --git a/lib/phoenix_profiler/telemetry.ex b/lib/phoenix_profiler/telemetry.ex index 4a7acf8..b0e17ab 100644 --- a/lib/phoenix_profiler/telemetry.ex +++ b/lib/phoenix_profiler/telemetry.ex @@ -10,7 +10,9 @@ defmodule PhoenixProfiler.Telemetry do plug_events = [ [:phoenix, :endpoint, :stop], - [:phxprof, :plug, :stop] + [:phoenix, :error_rendered], + [:phoenix_profiler, :endpoint, :exception], + [:phoenix_profiler, :plug, :stop] ] @events plug_events ++ live_view_events @@ -27,24 +29,23 @@ defmodule PhoenixProfiler.Telemetry do {:keep, %{endpoint_duration: duration}} end - def collect(_, [:phxprof, :plug, :stop], measures, %{conn: conn}) do - profile = conn.private.phoenix_profiler + def collect(_, [:phoenix_profiler, :plug, :stop], measures, %{conn: conn}) do + {:keep, + %{ + conn: %{conn | resp_body: nil, assigns: Map.delete(conn.assigns, :content)}, + metrics: %{ + memory: collect_memory(conn.owner), + total_duration: measures.duration + } + }} + end - case profile.info do - :disable -> - :skip + def collect(_, [:phoenix, :error_rendered], _, meta) do + {:keep, %{exception: Map.take(meta, [:kind, :reason, :stacktrace])}} + end - info when info in [nil, :enable] -> - {:keep, - %{ - at: profile.system_time, - conn: %{conn | resp_body: nil, assigns: Map.delete(conn.assigns, :content)}, - metrics: %{ - memory: collect_memory(conn.owner), - total_duration: measures.duration - } - }} - end + def collect(_, [:phoenix_profiler, :endpoint, :exception], _, meta) do + {:keep, %{exception: Map.take(meta, [:kind, :reason, :stacktrace])}} end def collect(_, [:phoenix, :live_view | _] = event, measures, %{socket: socket} = meta) do diff --git a/lib/phoenix_profiler/telemetry/collector.ex b/lib/phoenix_profiler/telemetry/collector.ex index c9bf139..7c8581c 100644 --- a/lib/phoenix_profiler/telemetry/collector.ex +++ b/lib/phoenix_profiler/telemetry/collector.ex @@ -48,6 +48,18 @@ defmodule PhoenixProfiler.TelemetryCollector do alias PhoenixProfiler.TelemetryRegistry alias PhoenixProfiler.Utils + @doc """ + Disables a given `collector`. + + Disabled collectors will not receive telemetry events. + """ + def disable(collector), do: GenServer.call(collector, {__MODULE__, :disable}) + + @doc """ + Enables a given `collector`. + """ + def enable(collector), do: GenServer.call(collector, {__MODULE__, :enable}) + @doc """ Starts a collector linked to the current process. @@ -88,17 +100,6 @@ defmodule PhoenixProfiler.TelemetryCollector do GenServer.call(pid, {:reduce, initial, func}) end - @doc """ - Sends a message to `collector_pid` to update its status. - - The collector process will update its registry value to - to status returned by `func`, a function that accepts the - current status and returns one of `:enable` or `:disable`. - """ - def update_info(collector_pid, func) when is_function(func, 1) do - send(collector_pid, {:collector_update_info, func}) - end - @impl GenServer def init({server, pid, arg, info}) do case TelemetryRegistry.register(server, pid, {self(), arg}, info) do @@ -112,6 +113,13 @@ defmodule PhoenixProfiler.TelemetryCollector do {:reply, Utils.queue_fold(func, initial, q), state} end + @impl GenServer + def handle_call({__MODULE__, action}, _from, %{pid: pid} = state) + when action in [:disable, :enable] do + _ = TelemetryRegistry.update_info(pid, fn _ -> action end) + {:reply, :ok, state} + end + @impl GenServer def handle_info( {:telemetry, {pid, _}, _, _, _} = event, @@ -120,9 +128,4 @@ defmodule PhoenixProfiler.TelemetryCollector do when pid == self() do {:noreply, %{state | queue: :queue.in(event, q)}} end - - def handle_info({:collector_update_info, func}, %{pid: pid} = state) do - TelemetryRegistry.update_info(pid, func) - {:noreply, state} - end end diff --git a/lib/phoenix_profiler/telemetry/registry.ex b/lib/phoenix_profiler/telemetry/registry.ex index 99ba713..45b3dc4 100644 --- a/lib/phoenix_profiler/telemetry/registry.ex +++ b/lib/phoenix_profiler/telemetry/registry.ex @@ -63,10 +63,20 @@ defmodule PhoenixProfiler.TelemetryRegistry do defp lookup(server, callers) do Enum.reduce_while(callers, :error, fn caller, acc -> - case Registry.lookup(__MODULE__, caller) do - [{_, {^server, _, _}} = collector] -> {:halt, {:ok, collector}} - _ -> {:cont, acc} + case lookup_key(server, caller) do + {:ok, _} = ok -> {:halt, ok} + :error -> {:cont, acc} end end) end + + @doc """ + Returns the collector for `server` registered for `key` if it exists. + """ + def lookup_key(server, key) do + case Registry.lookup(__MODULE__, key) do + [{_, {^server, _, _}} = collector] -> {:ok, collector} + _ -> :error + end + end end diff --git a/lib/phoenix_profiler/telemetry/server.ex b/lib/phoenix_profiler/telemetry/server.ex index 66cc414..d8eedb6 100644 --- a/lib/phoenix_profiler/telemetry/server.ex +++ b/lib/phoenix_profiler/telemetry/server.ex @@ -4,9 +4,6 @@ defmodule PhoenixProfiler.TelemetryServer do alias PhoenixProfiler.TelemetryCollector alias PhoenixProfiler.TelemetryRegistry - @disable_event [:phoenix_profiler, :internal, :collector, :disable] - @enable_event [:phoenix_profiler, :internal, :collector, :enable] - @doc """ Starts a collector for `server` for a given `pid`. """ @@ -22,6 +19,38 @@ defmodule PhoenixProfiler.TelemetryServer do ) end + @doc """ + Disables the collector for `key` if it exists. + """ + def disable_key(server, key) do + case TelemetryRegistry.lookup_key(server, key) do + {:ok, {pid, {_, _, :enable}}} -> + TelemetryCollector.disable(pid) + + {:ok, _} -> + :ok + + :error -> + :error + end + end + + @doc """ + Enables the collector for `key` if it exists. + """ + def enable_key(server, key) do + case TelemetryRegistry.lookup_key(server, key) do + {:ok, {pid, {_, _, :disable}}} -> + TelemetryCollector.enable(pid) + + {:ok, _} -> + :ok + + :error -> + :error + end + end + @doc """ Starts a telemetry server linked to the current process. """ @@ -32,23 +61,13 @@ defmodule PhoenixProfiler.TelemetryServer do GenServer.start_link(__MODULE__, config) end - @doc """ - Executes the collector event for `info` for the current process. - """ - def collector_info_exec(:disable), do: telemetry_exec(@disable_event) - def collector_info_exec(:enable), do: telemetry_exec(@enable_event) - - defp telemetry_exec(event) do - :telemetry.execute(event, %{system_time: System.system_time()}, %{}) - end - @impl true def init(%{events: events, filter: filter, server: server}) do Process.flag(:trap_exit, true) :telemetry.attach_many( {__MODULE__, self()}, - events ++ [@disable_event, @enable_event], + events, &__MODULE__.handle_execute/4, %{filter: filter, server: server} ) @@ -59,17 +78,6 @@ defmodule PhoenixProfiler.TelemetryServer do @doc """ Forwards telemetry events to a registered collector, if it exists. """ - def handle_execute([_, _, _, info] = event, _, _, %{server: server}) - when event in [@disable_event, @enable_event] do - case TelemetryRegistry.lookup(server) do - {:ok, {pid, {^server, _, old_info}}} when old_info !== info -> - TelemetryCollector.update_info(pid, fn _ -> info end) - - _ -> - :ok - end - end - def handle_execute(event, measurements, metadata, %{filter: filter, server: server}) do with {:ok, {pid, {^server, arg, :enable}}} <- TelemetryRegistry.lookup(server) do # todo: ensure span ref is set on data (or message) if it exists diff --git a/lib/phoenix_profiler/toolbar/toolbar_live.ex b/lib/phoenix_profiler/toolbar/toolbar_live.ex index c958339..c78b7cd 100644 --- a/lib/phoenix_profiler/toolbar/toolbar_live.ex +++ b/lib/phoenix_profiler/toolbar/toolbar_live.ex @@ -3,26 +3,25 @@ defmodule PhoenixProfiler.ToolbarLive do @moduledoc false use Phoenix.LiveView, container: {:div, [class: "phxprof-toolbar-view"]} require Logger + alias PhoenixProfiler.Profile alias PhoenixProfiler.ProfileStore alias PhoenixProfiler.Routes alias PhoenixProfiler.TelemetryRegistry alias PhoenixProfiler.Utils @impl Phoenix.LiveView - def mount(_, %{"_" => %PhoenixProfiler.Profile{} = profile}, socket) do - socket = - socket - |> assign_defaults() - |> assign(:profile, profile) + # Note if the session keys change you must bump the version. + def mount(_, %{"vsn" => 1, "node" => node, "server" => server, "token" => token}, socket) do + socket = assign_defaults(socket) socket = - case ProfileStore.remote_get(profile) do + case ProfileStore.remote_get(node, server, token) do nil -> assign_error_toolbar(socket) remote_profile -> assign_toolbar(socket, remote_profile) end if connected?(socket) do - {:ok, _} = TelemetryRegistry.register(profile.server, Utils.transport_pid(socket), nil) + {:ok, _} = TelemetryRegistry.register(server, Utils.transport_pid(socket), nil) end {:ok, socket, temporary_assigns: [exits: []]} @@ -37,6 +36,7 @@ defmodule PhoenixProfiler.ToolbarLive do defp assign_defaults(socket) do assign(socket, + profile: %Profile{system: %{phoenix: nil}}, durations: nil, exits: [], exits_count: 0, @@ -65,9 +65,19 @@ defmodule PhoenixProfiler.ToolbarLive do end defp assign_toolbar(socket, profile) do - %{metrics: metrics} = profile + %Profile{data: %{metrics: metrics}} = profile + + socket = + case profile.data[:exception] do + %{kind: kind, reason: reason, stacktrace: stack} -> + apply_exception(socket, kind, reason, stack) + + _ -> + socket + end socket + |> assign(:profile, profile) |> apply_request(profile) |> assign(:durations, %{ total: duration(metrics.total_duration), @@ -78,7 +88,7 @@ defmodule PhoenixProfiler.ToolbarLive do end defp apply_request(socket, profile) do - %{conn: %Plug.Conn{} = conn} = profile + %Profile{data: %{conn: %Plug.Conn{} = conn}} = profile router = conn.private[:phoenix_router] {helper, plug, action} = Routes.info(socket.assigns.profile.node, conn) socket = %{socket | private: Map.put(socket.private, :phoenix_router, router)} @@ -151,16 +161,18 @@ defmodule PhoenixProfiler.ToolbarLive do {:noreply, socket} end - def handle_info({:collector_update_info, func}, socket) do - TelemetryRegistry.update_info(transport_pid(socket), func) - {:noreply, socket} - end - def handle_info(other, socket) do Logger.debug("ToolbarLive received an unknown message: #{inspect(other)}") {:noreply, socket} end + @impl Phoenix.LiveView + def handle_call({PhoenixProfiler.TelemetryCollector, action}, _, socket) + when action in [:disable, :enable] do + TelemetryRegistry.update_info(Utils.transport_pid(socket), fn _ -> action end) + {:reply, :ok, socket} + end + defp maybe_apply_navigation(socket, data) do if connected?(socket) and not is_nil(data.router) and socket.assigns.root_pid != data.root_pid do @@ -171,17 +183,8 @@ defmodule PhoenixProfiler.ToolbarLive do end defp apply_lifecycle(socket, _stage, :exception, data) do - %{kind: kind, reason: reason, stacktrace: stacktrace} = data - - exception = %{ - ref: Phoenix.LiveView.Utils.random_id(), - reason: Exception.format(kind, reason, stacktrace), - at: Time.utc_now() |> Time.truncate(:second) - } - - socket - |> update(:exits, &[exception | &1]) - |> update(:exits_count, &(&1 + 1)) + %{kind: kind, reason: reason, stacktrace: stack} = data + apply_exception(socket, kind, reason, stack) end defp apply_lifecycle(socket, _stage, _action, _data) do @@ -200,6 +203,18 @@ defmodule PhoenixProfiler.ToolbarLive do socket end + defp apply_exception(socket, kind, reason, stack) do + exception = %{ + ref: Phoenix.LiveView.Utils.random_id(), + reason: Exception.format(kind, reason, stack), + at: Time.utc_now() |> Time.truncate(:second) + } + + socket + |> update(:exits, &[exception | &1]) + |> update(:exits_count, &(&1 + 1)) + end + defp current_duration(durations) do if event = durations.latest_event, do: {event.value, event.label}, diff --git a/lib/phoenix_profiler/toolbar/toolbar_live.html.heex b/lib/phoenix_profiler/toolbar/toolbar_live.html.heex index f0bb060..eaaad06 100644 --- a/lib/phoenix_profiler/toolbar/toolbar_live.html.heex +++ b/lib/phoenix_profiler/toolbar/toolbar_live.html.heex @@ -109,7 +109,7 @@ -
+ <%= if @profile.system[:elixir] do %>
@@ -142,7 +142,7 @@ Made with by @mcrumm
-
+ <% end %> -
+
+
<%= live_render(@conn, ToolbarLive, session: @session) %> diff --git a/lib/phoenix_profiler/utils.ex b/lib/phoenix_profiler/utils.ex index f89e05c..c62e3fd 100644 --- a/lib/phoenix_profiler/utils.ex +++ b/lib/phoenix_profiler/utils.ex @@ -1,108 +1,63 @@ defmodule PhoenixProfiler.Utils do @moduledoc false alias Phoenix.LiveView - alias PhoenixProfiler.Profile - alias PhoenixProfiler.TelemetryServer - @default_profiler_link_base "/dashboard/_profiler" + @default_profiler_base_path "/dashboard/_profiler" @doc """ - Mounts the profile if it has been enabled on the endpoint. - """ - def maybe_mount_profile(%LiveView.Socket{} = socket) do - if LiveView.connected?(socket) and configured?(socket) do - enable_profiler(socket) - else - socket - end - end + Mounts the profiler on a connected LiveView socket only if + it is enabled on the Endpoint. - defp configured?(conn_or_socket) do - endpoint(conn_or_socket).config(:phoenix_profiler, false) != false - end + ## Example - @doc """ - Enables the profiler on a given `conn` or `socket`. + def mount(_params, _session, socket) do + socket = PhoenixProfiler.Utils.maybe_mount_profiler(socket) - Raises if the profiler is not defined or is not started. - For a LiveView socket, raises if the socket is not connected. - """ - def enable_profiler(conn_or_socket) do - endpoint = endpoint(conn_or_socket) - config = endpoint.config(:phoenix_profiler) || [] - enable_profiler(conn_or_socket, endpoint, config, System.system_time()) - end + #code... - def enable_profiler(conn_or_socket, endpoint, config, system_time) - when is_atom(endpoint) and is_list(config) and is_integer(system_time) do - with :ok <- check_requires_profile(conn_or_socket), - :ok <- maybe_check_socket_connection(conn_or_socket), - {:ok, profiler} <- check_profiler_running(config) do - conn_or_socket - |> new_profile(endpoint, profiler, config, system_time) - |> start_collector(profiler) - |> telemetry_execute(:start, %{system_time: system_time}) + {:ok, socket} + end + + """ + def maybe_mount_profiler(%LiveView.Socket{} = socket) do + with true <- Phoenix.LiveView.connected?(socket), + {:ok, profile} <- PhoenixProfiler.Profiler.preflight(socket.endpoint) do + put_private(socket, :phoenix_profiler, profile) else - {:error, reason} -> enable_profiler_error(conn_or_socket, reason) + _ -> socket end end - defp check_requires_profile(conn_or_socket) do - case conn_or_socket.private do - %{:phoenix_profiler => %Profile{}} -> - {:error, :profile_already_exists} + @doc """ + Returns the base path for profile links. + """ + def profile_base_path(config) do + case Keyword.fetch(config, :profiler_link_base) do + {:ok, path} when is_binary(path) and path != "" -> + "/" <> String.trim_leading(path, "/") _ -> - :ok + @default_profiler_base_path end end - defp maybe_check_socket_connection(%Plug.Conn{}), do: :ok - - defp maybe_check_socket_connection(%LiveView.Socket{} = socket) do - check_socket_connection(socket) - end - - defp new_profile(conn_or_socket, endpoint, profiler, config, system_time) do - info = if config[:enable] == false, do: :disable, else: :enable - profiler_base_url = profiler_base_url(endpoint, config) - profile = Profile.new(profiler, random_unique_id(), info, profiler_base_url, system_time) - put_private(conn_or_socket, :phoenix_profiler, profile) - end - - defp profiler_base_url(endpoint, config) do - endpoint.url() <> profiler_link_base(config[:profiler_link_base]) - end - - defp profiler_link_base(path) when is_binary(path) and path != "", do: path - defp profiler_link_base(_), do: @default_profiler_link_base - - defp start_collector(%Plug.Conn{} = conn, server) do - profile = conn.private.phoenix_profiler - - collector_pid = - if is_pid(profile.collector_pid) and Process.alive?(profile.collector_pid) do - TelemetryServer.collector_info_exec(profile.info) - {:ok, profile.collector_pid} - else - TelemetryServer.listen(server, conn.owner, nil, profile.info) - end - |> case do - {:ok, collector_pid} -> collector_pid - {:error, {:already_registered, collector_pid}} -> collector_pid - end - - put_private(conn, :phoenix_profiler, %{profile | collector_pid: collector_pid}) + @doc """ + Returns info for `server` about the registered collector for a + given `conn` or `socket`. + """ + def collector_info(server, conn_or_socket) do + case Registry.lookup(PhoenixProfiler.TelemetryRegistry, target_pid(conn_or_socket)) do + [{pid, {^server, {pid, nil}, info}}] when is_pid(pid) -> {info, pid} + [] -> :error + end end - defp start_collector(%LiveView.Socket{} = socket, _server) do - # ToolbarLive acts as the LiveView Socket collector so we never - # start a collector here, but we can execute telemetry to notify it - # that the state changed. - info = socket.private.phoenix_profiler.info - TelemetryServer.collector_info_exec(info) - socket - end + @doc """ + Returns the pid to target when collecting data. + """ + def target_pid(conn_or_socket) + def target_pid(%Plug.Conn{owner: owner}), do: owner + def target_pid(%LiveView.Socket{} = socket), do: transport_pid(socket) @doc """ Returns the endpoint for a given `conn` or `socket`. @@ -111,59 +66,6 @@ defmodule PhoenixProfiler.Utils do def endpoint(%Plug.Conn{} = conn), do: conn.private.phoenix_endpoint def endpoint(%LiveView.Socket{endpoint: endpoint}), do: endpoint - defp enable_profiler_error(conn_or_socket, :profile_already_exists) do - # notify state change and ensure profile info is :enable - profile = conn_or_socket.private.phoenix_profiler - TelemetryServer.collector_info_exec(:enable) - put_private(conn_or_socket, :phoenix_profiler, %{profile | info: :enable}) - end - - defp enable_profiler_error(%LiveView.Socket{}, :waiting_for_connection) do - raise """ - attempted to enable profiling on a disconnected socket - - In your LiveView mount callback, do the following: - - socket = - if connected?(socket) do - PhoenixProfiler.enable(socket) - else - socket - end - - """ - end - - defp enable_profiler_error(_, :profiler_not_available) do - raise "attempted to enable profiling but no profiler is configured on the endpoint" - end - - defp enable_profiler_error(_, :profiler_not_running) do - raise "attempted to enable profiling but the profiler is not running" - end - - @doc """ - Disables the profiler on a given `conn` or `socket`. - - If a profile is not present on the data structure, this function has no effect. - """ - def disable_profiler( - %{__struct__: kind, private: %{phoenix_profiler: %Profile{} = profile}} = conn_or_socket - ) - when kind in [Plug.Conn, LiveView.Socket] do - conn_or_socket - |> put_private(:phoenix_profiler, %{profile | info: :disable}) - |> unregister_collector() - end - - def disable_profiler(%Plug.Conn{} = conn), do: conn - def disable_profiler(%LiveView.Socket{} = socket), do: socket - - defp unregister_collector(conn_or_socket) do - TelemetryServer.collector_info_exec(:disable) - conn_or_socket - end - @doc """ Checks whether or not a socket is connected. """ @@ -177,25 +79,6 @@ defmodule PhoenixProfiler.Utils do end end - # Note: if we ever call this from the dashboard, we will - # need to ensure we are checking the proper node. - defp check_profiler_running(config) do - cond do - config == [] -> - {:error, :profiler_not_available} - - profiler = config[:server] -> - if GenServer.whereis(profiler) do - {:ok, profiler} - else - {:error, :profiler_not_running} - end - - true -> - {:error, :profiler_not_available} - end - end - @doc """ Assigns a new private key and value in the socket. """ @@ -247,44 +130,6 @@ defmodule PhoenixProfiler.Utils do end) end - @doc false - def on_send_resp(conn, %Profile{} = profile) do - duration = System.monotonic_time() - profile.start_time - conn = telemetry_execute(conn, :stop, %{duration: duration}) - - data = - PhoenixProfiler.TelemetryCollector.reduce( - profile.collector_pid, - %{metrics: %{endpoint_duration: nil}}, - fn - {:telemetry, _, _, _, %{endpoint_duration: duration}}, acc -> - %{acc | metrics: Map.put(acc.metrics, :endpoint_duration, duration)} - - {:telemetry, _, _, _, %{metrics: _} = entry}, acc -> - {metrics, rest} = map_pop!(entry, :metrics) - acc = Map.merge(acc, rest) - %{acc | metrics: Map.merge(acc.metrics, metrics)} - - {:telemetry, _, _, _, data}, acc -> - Map.merge(acc, data) - end - ) - - profile - |> PhoenixProfiler.ProfileStore.table() - |> :ets.insert({profile.token, data}) - - conn - end - - defp telemetry_execute(%LiveView.Socket{} = socket, _, _), do: socket - - defp telemetry_execute(%Plug.Conn{} = conn, action, measurements) - when action in [:start, :stop] do - :telemetry.execute([:phxprof, :plug, action], measurements, %{conn: conn}) - conn - end - @doc false def sort_by(enumerable, sort_by_fun, :asc) do Enum.sort_by(enumerable, sort_by_fun, &<=/2) diff --git a/mix.exs b/mix.exs index 0af83ab..a594fbe 100644 --- a/mix.exs +++ b/mix.exs @@ -10,7 +10,6 @@ defmodule PhoenixProfiler.MixProject do version: @version, elixir: "~> 1.8", compilers: [:phoenix] ++ Mix.compilers(), - elixirc_paths: elixirc_paths(Mix.env()), package: package(), deps: deps(), docs: docs(), @@ -20,9 +19,6 @@ defmodule PhoenixProfiler.MixProject do ] end - defp elixirc_paths(:dev), do: ["lib", "dev"] - defp elixirc_paths(_), do: ["lib"] - def application do [ extra_applications: [:logger], @@ -44,7 +40,7 @@ defmodule PhoenixProfiler.MixProject do {:phoenix_live_dashboard, "~> 0.7.0 or ~> 0.6.0 or ~> 0.5.0", optional: true}, # Dev Dependencies {:phoenix_live_reload, "~> 1.3", only: :dev}, - {:plug_cowboy, "~> 2.0", only: :dev}, + {:plug_cowboy, "~> 2.0", only: [:dev, :test]}, {:jason, "~> 1.0", only: [:dev, :test, :docs]}, {:ex_doc, "~> 0.25", only: :docs}, {:esbuild, "~> 0.2", runtime: false, only: :dev}, diff --git a/test/phoenix_profiler/endpoint_test.exs b/test/phoenix_profiler/endpoint_test.exs new file mode 100644 index 0000000..54b9da4 --- /dev/null +++ b/test/phoenix_profiler/endpoint_test.exs @@ -0,0 +1,61 @@ +defmodule PhoenixProfiler.EndpointTest do + use ExUnit.Case, async: true + use Plug.Test + alias __MODULE__.Profiler + + Application.put_env(:phoenix_profiler, __MODULE__.Endpoint, + url: [host: "example.com"], + server: false, + http: [port: 80], + https: [port: 443], + phoenix_profiler: [server: Profiler] + ) + + defmodule Endpoint do + use Phoenix.Endpoint, otp_app: :phoenix_profiler + use PhoenixProfiler + end + + Application.put_env(:phoenix_profiler, __MODULE__.NoProfilerServerEndpoint, phoenix_profiler: []) + + defmodule NoProfilerServerEndpoint do + use Phoenix.Endpoint, otp_app: :phoenix_profiler + use PhoenixProfiler + end + + setup_all do + start_supervised!({PhoenixProfiler, name: Profiler}) + ExUnit.CaptureLog.capture_log(fn -> start_supervised!(Endpoint) end) + :ok + end + + test "warns if there is no server on the profiler configuration" do + start_supervised!(NoProfilerServerEndpoint) + + assert ExUnit.CaptureIO.capture_io(:stderr, fn -> + conn = conn(:get, "/") + NoProfilerServerEndpoint.call(conn, []) + end) =~ "no profiler server" + end + + test "puts profiler info on conn" do + conn = Endpoint.call(conn(:get, "/"), []) + profile = conn.private.phoenix_profiler + assert %PhoenixProfiler.Profile{} = profile + assert conn.private.phoenix_profiler.server == Profiler + assert conn.private.phoenix_profiler.info == :enable + + assert conn.private.phoenix_profiler.url == + "https://example.com/dashboard/_profiler?nav=#{inspect(Profiler)}&panel=request&token=#{profile.token}" + + assert is_pid(conn.private.phoenix_profiler_collector) + end + + test "skips profiling live_reload frame" do + for path <- ["/phoenix/live_reload/frame", "/phoenix/live_reload/frame/suffix"] do + conn = Endpoint.call(conn(:get, path), []) + refute Map.has_key?(conn.private, :phoenix_profiler) + refute Map.has_key?(conn.private, :phoenix_profiler_collector) + end + end +end diff --git a/test/phoenix_profiler/integration/endpoint_test.exs b/test/phoenix_profiler/integration/endpoint_test.exs new file mode 100644 index 0000000..7eb9858 --- /dev/null +++ b/test/phoenix_profiler/integration/endpoint_test.exs @@ -0,0 +1,353 @@ +Code.require_file("../../support/endpoint_helper.exs", __DIR__) +Code.require_file("../../support/http_client.exs", __DIR__) + +defmodule PhoenixProfiler.Integration.EndpointTest do + use ExUnit.Case, async: true + import ExUnit.CaptureLog + + import PhoenixProfiler.Integration.EndpointHelper + + alias __MODULE__.DebugEndpoint + alias __MODULE__.DisabledEndpoint + alias __MODULE__.EnabledEndpoint + alias __MODULE__.NotConfiguredEndpoint + alias __MODULE__.Profiler + + [debug, disabled, enabled, noconf] = get_unused_port_numbers(4) + @debug debug + @disabled disabled + @enabled enabled + @noconf noconf + + Application.put_env(:phoenix_profiler, DebugEndpoint, + http: [port: @debug], + live_view: [signing_salt: gen_salt()], + secret_key_base: gen_secret_key(), + server: true, + drainer: false, + debug_errors: true, + phoenix_profiler: [server: Profiler] + ) + + Application.put_env(:phoenix_profiler, DisabledEndpoint, + http: [port: @disabled], + live_view: [signing_salt: gen_salt()], + secret_key_base: gen_secret_key(), + server: true, + drainer: false, + phoenix_profiler: [server: Profiler, enable: false] + ) + + Application.put_env(:phoenix_profiler, EnabledEndpoint, + http: [port: @enabled], + live_view: [signing_salt: gen_salt()], + secret_key_base: gen_secret_key(), + server: true, + drainer: false, + phoenix_profiler: [server: Profiler] + ) + + Application.put_env(:phoenix_profiler, NotConfiguredEndpoint, + http: [port: @noconf], + secret_key_base: gen_secret_key(), + live_view: [signing_salt: gen_salt()], + server: true, + drainer: false + ) + + defmodule Router do + @moduledoc """ + Let's use a plug router to test this endpoint. + """ + use Plug.Router + + plug :html + plug :match + plug :dispatch + + get "/" do + send_resp(conn, 200, "ok") + end + + get "/router/oops" do + _ = conn + raise "oops" + end + + get "/router/enable" do + conn + |> PhoenixProfiler.enable() + |> send_resp(200, "enable") + end + + get "/router/disable" do + conn + |> PhoenixProfiler.disable() + |> send_resp(200, "disable") + end + + def do_before_send(conn, _) do + Enum.reduce(conn.private[:before_send] || [], conn, fn func, conn -> + func.(conn) + end) + end + + match _ do + raise Phoenix.Router.NoRouteError, conn: conn, router: __MODULE__ + end + + def __routes__ do + [] + end + + def html(conn, _) do + put_resp_header(conn, "content-type", "text/html") + end + end + + for mod <- [DebugEndpoint, DisabledEndpoint, EnabledEndpoint, NotConfiguredEndpoint] do + defmodule mod do + use Phoenix.Endpoint, otp_app: :phoenix_profiler + use PhoenixProfiler + + plug :oops + plug Router + + @doc """ + Verify errors from the plug stack too (before the router). + """ + def oops(conn, _opts) do + if conn.path_info == ~w(oops) do + raise "oops" + else + conn + end + end + end + end + + def get_profile(server, token) do + PhoenixProfiler.ProfileStore.get(server, token) + end + + def now, do: System.monotonic_time(:millisecond) + + def wait_for_profile_data(server, token, func, timeout \\ 5000) do + wait_for_profile_data(server, token, func, timeout, now()) + end + + def wait_for_profile_data(server, token, func, timeout, start) do + result = get_profile(server, token) + + cond do + func.(result) -> + :ok + + now() - start >= timeout -> + raise "timeout" + + true -> + :timer.sleep(100) + wait_for_profile_data(server, token, func, timeout, start) + end + end + + alias PhoenixProfiler.Integration.HTTPClient + + setup do + pid = start_supervised!({PhoenixProfiler, name: Profiler}) + {:ok, profiler_pid: pid} + end + + test "starts collector and injects headers and toolbar and saves profile to storage for debug" do + # with debug_errors: true + {:ok, _} = DebugEndpoint.start_link([]) + + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@debug}", %{}) + assert resp.status == 200 + assert [token] = HTTPClient.get_resp_header(resp, "x-debug-token") + assert [link] = HTTPClient.get_resp_header(resp, "x-debug-token-link") + assert link =~ "/dashboard/_profiler" + assert resp.body =~ ~s|
+ case profile.data do + %{exception: exception} -> + true + + other -> + false + end + end) + + assert capture_log(fn -> + # Errors in the Plug stack will not be caught by the profiler + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@debug}/oops", %{}) + assert resp.status == 500 + assert HTTPClient.get_resp_header(resp, "x-debug-token") == [] + assert HTTPClient.get_resp_header(resp, "x-debug-token-link") == [] + + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@debug}/router/oops", %{}) + assert resp.status == 500 + assert [token] = HTTPClient.get_resp_header(resp, "x-debug-token") + assert [link] = HTTPClient.get_resp_header(resp, "x-debug-token-link") + assert link =~ "/dashboard/_profiler" + + assert wait_for_profile_data(Profiler, token, fn %PhoenixProfiler.Profile{} = profile -> + case profile.data do + %{exception: exception} -> + true + + other -> + false + end + end) + + Supervisor.stop(DebugEndpoint) + end) =~ "** (RuntimeError) oops" + end + + test "starts collector and injects headers and toolbar and saves profile to storage unless disabled for enabled" do + # with debug_errors: false + {:ok, _} = EnabledEndpoint.start_link([]) + + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@enabled}", %{}) + assert resp.status == 200 + assert [token] = HTTPClient.get_resp_header(resp, "x-debug-token") + assert [link] = HTTPClient.get_resp_header(resp, "x-debug-token-link") + assert link =~ "/dashboard/_profiler" + assert resp.body =~ ~s|
+ case profile.data do + %{exception: exception} -> + true + + other -> + false + end + end) + + # Disables the profiler on-demand + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@enabled}/router/disable", %{}) + assert resp.status == 200 + assert HTTPClient.get_resp_header(resp, "x-debug-token") == [] + assert HTTPClient.get_resp_header(resp, "x-debug-token-link") == [] + + assert capture_log(fn -> + # Errors in the Plug stack will not be caught by the Profiler + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@enabled}/oops", %{}) + assert resp.status == 500 + assert HTTPClient.get_resp_header(resp, "x-debug-token") == [] + assert HTTPClient.get_resp_header(resp, "x-debug-token-link") == [] + assert link =~ "/dashboard/_profiler" + + {:ok, resp} = + HTTPClient.request(:get, "http://127.0.0.1:#{@enabled}/router/oops", %{}) + + assert resp.status == 500 + assert [token] = HTTPClient.get_resp_header(resp, "x-debug-token") + assert [link] = HTTPClient.get_resp_header(resp, "x-debug-token-link") + assert link =~ "/dashboard/_profiler" + + assert wait_for_profile_data(Profiler, token, fn %PhoenixProfiler.Profile{} = profile -> + case profile.data do + %{exception: exception} -> + true + + other -> + false + end + end) + + Supervisor.stop(EnabledEndpoint) + end) =~ "** (RuntimeError) oops" + end + + test "skips injecting headers and toolbar and profile storage unless enabled for disabled" do + {:ok, _} = DisabledEndpoint.start_link([]) + + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@disabled}", %{}) + assert resp.status == 200 + assert HTTPClient.get_resp_header(resp, "x-debug-token") == [] + assert HTTPClient.get_resp_header(resp, "x-debug-token-link") == [] + refute resp.body =~ ~s|class="phxprof-toolbar"| + + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@disabled}/unknown", %{}) + assert resp.status == 404 + assert HTTPClient.get_resp_header(resp, "x-debug-token") == [] + assert HTTPClient.get_resp_header(resp, "x-debug-token-link") == [] + refute resp.body =~ ~s|class="phxprof-toolbar"| + + # Enables the profiler on-demand + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@disabled}/router/enable", %{}) + assert resp.status == 200 + assert [token] = HTTPClient.get_resp_header(resp, "x-debug-token") + assert [link] = HTTPClient.get_resp_header(resp, "x-debug-token-link") + assert link =~ "/dashboard/_profiler" + assert resp.body =~ ~s|
+ {:ok, resp} = + HTTPClient.request(:get, "http://127.0.0.1:#{@disabled}/router/oops", %{}) + + assert resp.status == 500 + assert HTTPClient.get_resp_header(resp, "x-debug-token") == [] + assert HTTPClient.get_resp_header(resp, "x-debug-token-link") == [] + + Supervisor.stop(DisabledEndpoint) + end) =~ "** (RuntimeError) oops" + end + + test "skips headers and toolbar and profile storage for noconf" do + {:ok, _} = NotConfiguredEndpoint.start_link([]) + + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@noconf}", %{}) + assert resp.status == 200 + assert HTTPClient.get_resp_header(resp, "x-debug-token") == [] + assert HTTPClient.get_resp_header(resp, "x-debug-token-link") == [] + refute resp.body =~ ~s|class="phxprof-toolbar"| + + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@noconf}/unknown", %{}) + assert resp.status == 404 + assert HTTPClient.get_resp_header(resp, "x-debug-token") == [] + assert HTTPClient.get_resp_header(resp, "x-debug-token-link") == [] + refute resp.body =~ ~s|class="phxprof-toolbar"| + + assert capture_log(fn -> + {:ok, resp} = HTTPClient.request(:get, "http://127.0.0.1:#{@noconf}/oops", %{}) + assert resp.status == 500 + assert HTTPClient.get_resp_header(resp, "x-debug-token") == [] + assert HTTPClient.get_resp_header(resp, "x-debug-token-link") == [] + + {:ok, resp} = + HTTPClient.request(:get, "http://127.0.0.1:#{@noconf}/router/oops", %{}) + + assert resp.status == 500 + assert HTTPClient.get_resp_header(resp, "x-debug-token") == [] + assert HTTPClient.get_resp_header(resp, "x-debug-token-link") == [] + + Supervisor.stop(NotConfiguredEndpoint) + end) =~ "** (RuntimeError) oops" + end +end diff --git a/test/phoenix_profiler/integrations/phoenix_profiler_test.exs b/test/phoenix_profiler/integrations/phoenix_profiler_test.exs index e0f4923..b67e68b 100644 --- a/test/phoenix_profiler/integrations/phoenix_profiler_test.exs +++ b/test/phoenix_profiler/integrations/phoenix_profiler_test.exs @@ -1,6 +1,7 @@ defmodule PhoenixProfiler.PhoenixProfilerTest do use ExUnit.Case, async: true import Phoenix.ConnTest + alias PhoenixProfiler.Profile alias PhoenixProfiler.ProfileStore alias PhoenixProfilerTest.Endpoint @@ -22,24 +23,24 @@ defmodule PhoenixProfiler.PhoenixProfilerTest do assert url == "http://localhost:4000/dashboard/_profiler?nav=PhoenixProfilerTest.Profiler&panel=request&token=#{token}" - assert profiler = ProfileStore.profiler(conn) - - %{ - conn: %Plug.Conn{ - host: "www.example.com", - method: "GET", - path_info: [], - private: %{ - phoenix_action: :index, - phoenix_controller: PhoenixProfilerTest.PageController, - phoenix_endpoint: PhoenixProfilerTest.Endpoint, - phoenix_router: PhoenixProfilerTest.Router, - phoenix_view: PhoenixProfilerTest.PageView + %Profile{ + data: %{ + conn: %Plug.Conn{ + host: "www.example.com", + method: "GET", + path_info: [], + private: %{ + phoenix_action: :index, + phoenix_controller: PhoenixProfilerTest.PageController, + phoenix_endpoint: PhoenixProfilerTest.Endpoint, + phoenix_router: PhoenixProfilerTest.Router, + phoenix_view: PhoenixProfilerTest.PageView + }, + status: 200 }, - status: 200 - }, - metrics: metrics - } = ProfileStore.get(profiler, token) + metrics: metrics + } + } = conn |> ProfileStore.profiler() |> ProfileStore.get(token) assert metrics.total_duration > 0 assert metrics.endpoint_duration > 0 @@ -50,8 +51,7 @@ defmodule PhoenixProfiler.PhoenixProfilerTest do conn = get(conn, "/plug-router") assert [token] = Plug.Conn.get_resp_header(conn, @token_header_key) assert [_] = Plug.Conn.get_resp_header(conn, @profiler_header_key) - assert profiler = ProfileStore.profiler(conn) - assert ProfileStore.get(profiler, token) + assert conn |> ProfileStore.profiler() |> ProfileStore.get(token) end test "profiling an api request", %{conn: conn} do @@ -63,24 +63,24 @@ defmodule PhoenixProfiler.PhoenixProfilerTest do assert url == "http://localhost:4000/dashboard/_profiler?nav=PhoenixProfilerTest.Profiler&panel=request&token=#{token}" - assert profiler = ProfileStore.profiler(conn) - - %{ - conn: %Plug.Conn{ - host: "www.example.com", - method: "GET", - path_info: ["api"], - private: %{ - phoenix_action: :index, - phoenix_controller: PhoenixProfilerTest.APIController, - phoenix_endpoint: PhoenixProfilerTest.Endpoint, - phoenix_router: PhoenixProfilerTest.Router, - phoenix_view: PhoenixProfilerTest.APIView + %Profile{ + data: %{ + conn: %Plug.Conn{ + host: "www.example.com", + method: "GET", + path_info: ["api"], + private: %{ + phoenix_action: :index, + phoenix_controller: PhoenixProfilerTest.APIController, + phoenix_endpoint: PhoenixProfilerTest.Endpoint, + phoenix_router: PhoenixProfilerTest.Router, + phoenix_view: PhoenixProfilerTest.APIView + }, + status: 200 }, - status: 200 - }, - metrics: metrics - } = ProfileStore.get(profiler, token) + metrics: metrics + } + } = conn |> ProfileStore.profiler() |> ProfileStore.get(token) assert metrics.endpoint_duration > 0 assert metrics.memory > 0 @@ -91,10 +91,5 @@ defmodule PhoenixProfiler.PhoenixProfilerTest do assert Plug.Conn.get_resp_header(conn, @token_header_key) == [] assert Plug.Conn.get_resp_header(conn, @profiler_header_key) == [] - - assert %PhoenixProfiler.Profile{info: :disable, server: server, token: token} = - conn.private.phoenix_profiler - - refute ProfileStore.get(server, token) end end diff --git a/test/phoenix_profiler/live_view_test.exs b/test/phoenix_profiler/live_view_test.exs new file mode 100644 index 0000000..640fbe7 --- /dev/null +++ b/test/phoenix_profiler/live_view_test.exs @@ -0,0 +1,50 @@ +defmodule PhoenixProfiler.LiveViewTest do + use ExUnit.Case + alias Phoenix.LiveView.Socket + alias PhoenixProfiler.Profile + + defp build_socket(endpoint \\ PhoenixProfilerTest.Endpoint) do + %Socket{endpoint: endpoint} + end + + defp connect(%Socket{} = socket) do + # TODO: replace with struct update when we require LiveView v0.15+. + socket = Map.put(socket, :transport_pid, self()) + + # TODO: remove when we require LiveView v0.15+. + if Map.has_key?(socket, :connected?) do + Map.put(socket, :connected?, true) + else + socket + end + end + + describe "on_mount/4" do + test "when the socket is disconnected, is a no-op" do + socket = build_socket() + assert PhoenixProfiler.on_mount(:default, %{}, %{}, socket) == {:cont, socket} + end + + test "when the profiler is enabled on the endpoint, configures an enabled profile" do + socket = build_socket() |> connect() |> PhoenixProfiler.Utils.maybe_mount_profiler() + + assert {:cont, %{private: %{phoenix_profiler: %Profile{info: :enable}}}} = + PhoenixProfiler.on_mount(:default, %{}, %{}, socket) + end + + test "when the profiler is disabled on the endpoint, configures a disabled profile" do + socket = + PhoenixProfilerTest.EndpointDisabled + |> build_socket() + |> connect() + + assert {:cont, %Socket{private: %{phoenix_profiler: %Profile{info: :disable}}}} = + PhoenixProfiler.on_mount(:default, %{}, %{}, socket) + end + + test "when the profiler is not defined on the endpoint, is a no-op" do + socket = PhoenixProfilerTest.EndpointNotConfigured |> build_socket() |> connect() + assert PhoenixProfiler.on_mount(:default, %{}, %{}, socket) == {:cont, socket} + end + end +end diff --git a/test/phoenix_profiler/plug_test.exs b/test/phoenix_profiler/plug_test.exs index 04b2286..ed93fb9 100644 --- a/test/phoenix_profiler/plug_test.exs +++ b/test/phoenix_profiler/plug_test.exs @@ -1,23 +1,24 @@ defmodule PhoenixProfiler.PlugTest do use ExUnit.Case, async: true - import Plug.Test - import Plug.Conn + use ProfilerHelper @token_header_key "x-debug-token" @profiler_header_key "x-debug-token-link" - defp conn(path) do :get |> conn(path) |> put_private(:phoenix_endpoint, PhoenixProfilerTest.Endpoint) + + conn(:get, path) end test "injects debug token headers if configured" do - opts = PhoenixProfiler.init([]) + opts = PhoenixProfiler.Plug.init([]) conn = conn("/") - |> PhoenixProfiler.call(opts) + |> profile_thru(PhoenixProfilerTest.Endpoint) + |> PhoenixProfiler.Plug.call(opts) |> send_resp(200, "") assert [token] = Plug.Conn.get_resp_header(conn, @token_header_key) @@ -28,12 +29,12 @@ defmodule PhoenixProfiler.PlugTest do end test "skips debug token when disabled at the Endpoint" do - opts = PhoenixProfiler.init([]) + opts = PhoenixProfiler.Plug.init([]) conn = conn("/") - |> put_private(:phoenix_endpoint, PhoenixProfilerTest.EndpointDisabled) - |> PhoenixProfiler.call(opts) + |> profile_thru(PhoenixProfilerTest.EndpointDisabled) + |> PhoenixProfiler.Plug.call(opts) |> send_resp(200, "") assert get_resp_header(conn, @token_header_key) == [] @@ -41,27 +42,29 @@ defmodule PhoenixProfiler.PlugTest do end test "injects debug toolbar for html requests if configured and contains the tag" do - opts = PhoenixProfiler.init([]) + opts = PhoenixProfiler.Plug.init([]) conn = conn("/") + |> profile_thru(PhoenixProfilerTest.Endpoint) |> put_resp_content_type("text/html") - |> PhoenixProfiler.call(opts) + |> PhoenixProfiler.Plug.call(opts) |> send_resp(200, "

PhoenixProfiler

") - profile = conn.private.phoenix_profiler + assert [token] = get_resp_header(conn, @token_header_key) assert to_string(conn.resp_body) =~ - ~s[

PhoenixProfiler

\n
] + ~s[

PhoenixProfiler

\n
] end test "injects debug toolbar for html requests if configured and contains multiple tags" do - opts = PhoenixProfiler.init([]) + opts = PhoenixProfiler.Plug.init([]) conn = conn("/") + |> profile_thru(PhoenixProfilerTest.Endpoint) |> put_resp_content_type("text/html") - |> PhoenixProfiler.call(opts) + |> PhoenixProfiler.Plug.call(opts) |> send_resp(200, "

PhoenixProfiler

") profile = conn.private.phoenix_profiler @@ -71,13 +74,13 @@ defmodule PhoenixProfiler.PlugTest do end test "skips debug toolbar injection when disabled at the Endpoint" do - opts = PhoenixProfiler.init([]) + opts = PhoenixProfiler.Plug.init([]) conn = conn("/") |> put_private(:phoenix_endpoint, PhoenixProfilerTest.EndpointDisabled) |> put_resp_content_type("text/html") - |> PhoenixProfiler.call(opts) + |> PhoenixProfiler.Plug.call(opts) |> send_resp(200, "

PhoenixProfiler

") assert get_resp_header(conn, @token_header_key) == [] @@ -86,29 +89,31 @@ defmodule PhoenixProfiler.PlugTest do end test "skips toolbar injection if html response is missing the body tag" do - opts = PhoenixProfiler.init([]) + opts = PhoenixProfiler.Plug.init([]) conn = conn("/") + |> profile_thru(PhoenixProfilerTest.Endpoint) |> put_resp_content_type("text/html") - |> PhoenixProfiler.call(opts) + |> PhoenixProfiler.Plug.call(opts) |> send_resp(200, "

PhoenixProfiler

") assert to_string(conn.resp_body) == "

PhoenixProfiler

" end test "skips toolbar injection if not an html request" do - opts = PhoenixProfiler.init([]) + opts = PhoenixProfiler.Plug.init([]) conn = conn("/") + |> profile_thru(PhoenixProfilerTest.Endpoint) |> put_resp_content_type("application/json") - |> PhoenixProfiler.call(opts) + |> PhoenixProfiler.Plug.call(opts) |> send_resp(200, "") - profile = conn.private.phoenix_profiler + assert [token] = get_resp_header(conn, @token_header_key) refute to_string(conn.resp_body) =~ - ~s(
:disable end) + {:reply, :ok, pid} + end + + @impl true + def handle_call({TelemetryCollector, :enable}, _from, pid) do + TelemetryRegistry.update_info(pid, fn _ -> :enable end) + {:reply, :ok, pid} + end + + @impl true + def handle_info({:telemetry, _, _, _, _} = event, pid) do + send(pid, {:collector, event}) + {:noreply, pid} + end + end + describe "collecting telemetry" do test "events from watched pid" do name = unique_debug_name() @@ -114,30 +146,20 @@ defmodule PhoenixProfiler.TelemetryCollectorTest do name = unique_debug_name() start_supervised!({PhoenixProfiler, name: name, telemetry: [[:debug, :me]]}) - {:ok, _} = TelemetryRegistry.register(name, self()) + start_supervised!({Collector, {name, self()}}) :ok = :telemetry.execute([:debug, :me], %{system_time: 1}, %{}) - assert_received {:telemetry, nil, [:debug, :me], 1, _} + assert_receive {:collector, {:telemetry, nil, [:debug, :me], 1, _}}, 10 - :ok = TelemetryServer.collector_info_exec(:disable) - - receive do - {:collector_update_info, func} -> - TelemetryRegistry.update_info(self(), func) - end + :ok = TelemetryServer.disable_key(name, self()) :ok = :telemetry.execute([:debug, :me], %{system_time: 2}, %{}) - refute_received {:telemetry, nil, [:debug, :me], 2, _} - - :ok = TelemetryServer.collector_info_exec(:enable) + refute_receive {:collector, {:telemetry, nil, [:debug, :me], 2, _}}, 10 - receive do - {:collector_update_info, func} -> - TelemetryRegistry.update_info(self(), func) - end + :ok = TelemetryServer.enable_key(name, self()) :ok = :telemetry.execute([:debug, :me], %{system_time: 3}, %{}) - assert_received {:telemetry, nil, [:debug, :me], 3, _} + assert_receive {:collector, {:telemetry, nil, [:debug, :me], 3, _}}, 10 end end diff --git a/test/phoenix_profiler/telemetry/server_test.exs b/test/phoenix_profiler/telemetry/server_test.exs index 7e17e10..c875695 100644 --- a/test/phoenix_profiler/telemetry/server_test.exs +++ b/test/phoenix_profiler/telemetry/server_test.exs @@ -77,11 +77,9 @@ defmodule PhoenixProfiler.TelemetryServerTest do {:ok, collector_pid} = TelemetryServer.listen(name, self()) :ok = :telemetry.execute([:debug, :me], %{system_time: 1}, %{}) - :ok = TelemetryServer.collector_info_exec(:disable) - :timer.sleep(1) + :ok = TelemetryServer.disable_key(name, self()) :ok = :telemetry.execute([:debug, :me], %{system_time: 2}, %{}) - :ok = TelemetryServer.collector_info_exec(:enable) - :timer.sleep(1) + :ok = TelemetryServer.enable_key(name, self()) :ok = :telemetry.execute([:debug, :me], %{system_time: 3}, %{}) assert reduce_events(collector_pid) == [ @@ -100,15 +98,11 @@ defmodule PhoenixProfiler.TelemetryServerTest do {:ok, collector_pid} = TelemetryServer.listen(name, self()) :ok = :telemetry.execute([:debug, :me], %{system_time: 1}, %{}) - :ok = TelemetryServer.collector_info_exec(:disable) - :timer.sleep(1) - :ok = TelemetryServer.collector_info_exec(:disable) - :timer.sleep(1) + :ok = TelemetryServer.disable_key(name, self()) + :ok = TelemetryServer.disable_key(name, self()) :ok = :telemetry.execute([:debug, :me], %{system_time: 2}, %{}) - :ok = TelemetryServer.collector_info_exec(:enable) - :timer.sleep(1) - :ok = TelemetryServer.collector_info_exec(:enable) - :timer.sleep(1) + :ok = TelemetryServer.enable_key(name, self()) + :ok = TelemetryServer.enable_key(name, self()) :ok = :telemetry.execute([:debug, :me], %{system_time: 3}, %{}) assert reduce_events(collector_pid) == [ diff --git a/test/phoenix_profiler/utils_test.exs b/test/phoenix_profiler/utils_test.exs index d158232..4c8a855 100644 --- a/test/phoenix_profiler/utils_test.exs +++ b/test/phoenix_profiler/utils_test.exs @@ -1,48 +1,3 @@ defmodule PhoenixProfiler.UtilsTest do use ExUnit.Case - alias Phoenix.LiveView - - defp build_socket(endpoint \\ PhoenixProfilerTest.Endpoint) do - %LiveView.Socket{endpoint: endpoint} - end - - defp connect(%LiveView.Socket{} = socket) do - # TODO: replace with struct update when we require LiveView v0.15+. - socket = Map.put(socket, :transport_pid, self()) - - # TODO: remove when we require LiveView v0.15+. - if Map.has_key?(socket, :connected?) do - Map.put(socket, :connected?, true) - else - socket - end - end - - describe "maybe_mount_profile1/" do - test "when the socket is disconnected, is a no-op" do - socket = build_socket() - refute socket.private[:phoenix_profiler] - assert PhoenixProfiler.Utils.maybe_mount_profile(socket) == socket - end - - test "when the profiler is enabled on the endpoint, configures an enabled profile" do - socket = build_socket() |> connect() |> PhoenixProfiler.Utils.maybe_mount_profile() - assert %PhoenixProfiler.Profile{info: :enable} = socket.private.phoenix_profiler - end - - test "when the profiler is disabled on the endpoint, configures a disabled profile" do - socket = - PhoenixProfilerTest.EndpointDisabled - |> build_socket() - |> connect() - |> PhoenixProfiler.Utils.maybe_mount_profile() - - assert %PhoenixProfiler.Profile{info: :disable} = socket.private.phoenix_profiler - end - - test "when the profiler is not defined on the endpoint, is a no-op" do - socket = PhoenixProfilerTest.EndpointNotConfigured |> build_socket() |> connect() - assert PhoenixProfiler.Utils.maybe_mount_profile(socket) == socket - end - end end diff --git a/test/phoenix_profiler_test.exs b/test/phoenix_profiler_test.exs index fd4b2e2..643ee71 100644 --- a/test/phoenix_profiler_test.exs +++ b/test/phoenix_profiler_test.exs @@ -1,7 +1,7 @@ defmodule PhoenixProfilerUnitTest do use ExUnit.Case, async: true + use ProfilerHelper alias Phoenix.LiveView.Socket - alias PhoenixProfiler.Profile doctest PhoenixProfiler @@ -19,11 +19,6 @@ defmodule PhoenixProfilerUnitTest do Plug.Test.conn(:get, "/") end - defp build_conn(endpoint) do - build_conn() - |> PhoenixProfiler.Utils.put_private(:phoenix_endpoint, endpoint) - end - defp build_socket(view \\ TestLive) do %Socket{endpoint: EndpointMock, view: view} end @@ -50,100 +45,43 @@ defmodule PhoenixProfilerUnitTest do assert [AllRunning_1, AllRunning_2] -- PhoenixProfiler.all_running() == [] end - describe "enable/1 with Plug.Conn" do - test "raises when the profiler is not defined on the endpoint" do - assert_raise RuntimeError, - ~r/attempted to enable profiling but no profiler is configured on the endpoint/, - fn -> - NoConfigEndpoint - |> build_conn() - |> PhoenixProfiler.enable() - end - end - - test "raises when the profiler is not running" do - assert_raise RuntimeError, - ~r/attempted to enable profiling but the profiler is not running/, - fn -> - EndpointMock - |> build_conn() - |> PhoenixProfiler.enable() - end - end + test "disable/1 with Plug.Conn" do + profiler = start_profiler!() - test "puts a profile on the conn" do - start_supervised!({PhoenixProfiler, name: MyProfiler}) + conn = build_conn() + assert PhoenixProfiler.Utils.collector_info(profiler, conn) == :error - conn = - build_conn() - |> PhoenixProfiler.Utils.put_private(:phoenix_endpoint, EndpointMock) - |> PhoenixProfiler.enable() + conn = profile_thru(conn, EndpointMock) + assert {:enable, _pid} = PhoenixProfiler.Utils.collector_info(profiler, conn) - %Profile{server: MyProfiler, info: :enable} = conn.private.phoenix_profiler - end + conn = PhoenixProfiler.disable(conn) + assert conn.private.phoenix_profiler.info == :disable end - describe "enable/1 with LiveView.Socket" do - test "raises when socket is not connected" do - assert_raise RuntimeError, - ~r/attempted to enable profiling on a disconnected socket/, - fn -> - PhoenixProfiler.enable(build_socket()) - end - end - - test "raises when the profiler is not configured on the endpoint" do - assert_raise RuntimeError, - ~r/attempted to enable profiling but no profiler is configured on the endpoint/, - fn -> - build_socket() - |> Map.put(:endpoint, NoConfigEndpoint) - |> connect() - |> PhoenixProfiler.enable() - end - end - - test "raises when the profiler is not running" do - assert_raise RuntimeError, - ~r/attempted to enable profiling but the profiler is not running/, - fn -> - build_socket() |> connect() |> PhoenixProfiler.enable() - end - end + test "disable/1 with LiveView.Socket" do + profiler = start_profiler!() - test "puts a profile on the socket" do - start_supervised!({PhoenixProfiler, name: MyProfiler}) - socket = build_socket() |> connect() |> PhoenixProfiler.enable() - assert %Profile{server: MyProfiler, info: :enable} = socket.private.phoenix_profiler - end - end + socket = build_socket() |> connect() |> PhoenixProfiler.Utils.maybe_mount_profiler() - test "disable/1 when no profile is set" do - conn = build_conn(EndpointMock) - assert PhoenixProfiler.disable(conn) == conn + assert PhoenixProfiler.Utils.collector_info(profiler, socket) == :error - socket = build_socket() |> connect() |> PhoenixProfiler.disable() - assert PhoenixProfiler.disable(socket) == socket - end + {:ok, pid} = + PhoenixProfiler.TelemetryServer.listen( + profiler, + PhoenixProfiler.Utils.transport_pid(socket) + ) - test "disable/1 with Plug.Conn" do - start_supervised!({PhoenixProfiler, name: MyProfiler}) + assert {:enable, ^pid} = PhoenixProfiler.Utils.collector_info(profiler, socket) - conn = - build_conn() - |> PhoenixProfiler.Utils.put_private(:phoenix_endpoint, EndpointMock) - |> PhoenixProfiler.enable() + socket = PhoenixProfiler.disable(socket) + assert socket.private.phoenix_profiler.info == :disable - assert conn.private.phoenix_profiler.info == :enable - conn = PhoenixProfiler.disable(conn) - assert conn.private.phoenix_profiler.info == :disable + :timer.sleep(10) + assert {:disable, ^pid} = PhoenixProfiler.Utils.collector_info(profiler, socket) end - test "disable/1 with LiveView.Socket" do - start_supervised!({PhoenixProfiler, name: MyProfiler}) - socket = build_socket() |> connect() |> PhoenixProfiler.enable() - assert socket.private.phoenix_profiler.info == :enable - socket = PhoenixProfiler.disable(socket) - assert socket.private.phoenix_profiler.info == :disable + defp start_profiler!(name \\ MyProfiler) do + start_supervised!({PhoenixProfiler, name: name}) + name end end diff --git a/test/support/endpoint_helper.exs b/test/support/endpoint_helper.exs new file mode 100644 index 0000000..91aedbe --- /dev/null +++ b/test/support/endpoint_helper.exs @@ -0,0 +1,49 @@ +# Copyright (c) 2021 Chris McCord +# https://github.com/phoenixframework/phoenix/blob/aa9e708fec303f1114b9aa9c41a32a3f72c8a06c/test/support/endpoint_helper.exs +defmodule PhoenixProfiler.Integration.EndpointHelper do + @moduledoc """ + Utility functions for integration testing endpoints. + """ + + @doc """ + Finds `n` unused network port numbers. + """ + def get_unused_port_numbers(n) when is_integer(n) and n > 1 do + 1..n + # Open up `n` sockets at the same time, so we don't get + # duplicate port numbers + |> Enum.map(&listen_on_os_assigned_port/1) + |> Enum.map(&get_port_number_and_close/1) + end + + defp listen_on_os_assigned_port(_) do + {:ok, socket} = :gen_tcp.listen(0, []) + socket + end + + defp get_port_number_and_close(socket) do + {:ok, port_number} = :inet.port(socket) + :gen_tcp.close(socket) + port_number + end + + @doc """ + Generates a signing salt for a LiveView configuration. + """ + def gen_salt do + gen_secret(8) + end + + @doc """ + Generates a secret key base for an Endpoint configuration. + """ + def gen_secret_key do + gen_secret(64) + end + + defp gen_secret(length) do + :crypto.strong_rand_bytes(length) + |> Base.encode64(padding: false) + |> binary_part(0, length) + end +end diff --git a/test/support/http_client.exs b/test/support/http_client.exs new file mode 100644 index 0000000..acdaef0 --- /dev/null +++ b/test/support/http_client.exs @@ -0,0 +1,75 @@ +# Copyright (c) 2014 Chris McCord +# https://github.com/phoenixframework/phoenix/blob/aa9e708fec303f1114b9aa9c41a32a3f72c8a06c/test/support/http_client.exs +defmodule PhoenixProfiler.Integration.HTTPClient do + @doc """ + Performs HTTP Request and returns Response + + * method - The http method, for example :get, :post, :put, etc + * url - The string url, for example "http://example.com" + * headers - The map of headers + * body - The optional string body. If the body is a map, it is converted + to a URI encoded string of parameters + + ## Examples + + iex> HTTPClient.request(:get, "http://127.0.0.1", %{}) + {:ok, %Response{..}) + + iex> HTTPClient.request(:post, "http://127.0.0.1", %{}, param1: "val1") + {:ok, %Response{..}) + + iex> HTTPClient.request(:get, "http://unknownhost", %{}, param1: "val1") + {:error, ...} + + """ + def request(method, url, headers, body \\ "") + + def request(method, url, headers, body) when is_map(body) do + request(method, url, headers, URI.encode_query(body)) + end + + def request(method, url, headers, body) do + url = String.to_charlist(url) + headers = headers |> Map.put_new("content-type", "text/html") + ct_type = headers["content-type"] |> String.to_charlist() + + header = + Enum.map(headers, fn {k, v} -> + {String.to_charlist(k), String.to_charlist(v)} + end) + + # Generate a random profile per request to avoid reuse + profile = :crypto.strong_rand_bytes(4) |> Base.encode16() |> String.to_atom() + {:ok, pid} = :inets.start(:httpc, profile: profile) + + resp = + case method do + :get -> :httpc.request(:get, {url, header}, [], [body_format: :binary], pid) + _ -> :httpc.request(method, {url, header, ct_type, body}, [], [body_format: :binary], pid) + end + + :inets.stop(:httpc, pid) + format_resp(resp) + end + + defp format_resp({:ok, {{_http, status, _status_phrase}, headers, body}}) do + headers = Enum.map(headers, fn {k, v} -> {to_string(k), to_string(v)} end) + {:ok, %{status: status, headers: headers, body: body}} + end + + defp format_resp({:error, reason}), do: {:error, reason} + + @doc """ + Returns the values of the response header specified by `key`. + + ## Examples + + iex> req = %{req | headers: [{"content-type", "text/plain"}]} + iex> HTTPClient.get_resp_header(req, "content-type") + ["text/plain"] + + """ + def get_resp_header(%{headers: headers}, key) when is_list(headers) and is_binary(key) do + for {^key, value} <- headers, do: value + end +end diff --git a/test/support/profiler_helper.exs b/test/support/profiler_helper.exs new file mode 100644 index 0000000..09343a5 --- /dev/null +++ b/test/support/profiler_helper.exs @@ -0,0 +1,29 @@ +defmodule ProfilerHelper do + @moduledoc """ + Helpers for testing profilers. + + Must not be used to test endpoints because they perform + some setup that could skew the results of the endpoint + tests. + """ + + import Plug.Conn, only: [put_private: 3] + alias PhoenixProfiler.Profiler + + defmacro __using__(_) do + quote do + use Plug.Test + import ProfilerHelper + end + end + + def profile_thru(%Plug.Conn{} = conn, endpoint) do + {:ok, profile} = Profiler.preflight(endpoint) + {:ok, pid} = Profiler.start_collector(conn, profile) + + conn + |> put_private(:phoenix_endpoint, endpoint) + |> put_private(:phoenix_profiler, profile) + |> put_private(:phoenix_profiler_collector, pid) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 5e8a509..81a5101 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,8 +1,12 @@ +Code.require_file("support/endpoint_helper.exs", __DIR__) +Code.require_file("support/profiler_helper.exs", __DIR__) + +alias PhoenixProfiler.Integration.EndpointHelper + Application.put_env(:phoenix_profiler, PhoenixProfilerTest.Endpoint, url: [host: "localhost", port: 4000], - secret_key_base: "LIyk9co9Mt8KowH/g1WeMkufq/9Bz1XuEZMhCZAwnBc7VFKCfkDq/vRw+Xso4Q0q", - live_view: [signing_salt: "NbA2FdHo"], - render_errors: [view: PhoenixProfilerTest.ErrorView], + secret_key_base: EndpointHelper.gen_secret_key(), + live_view: [signing_salt: EndpointHelper.gen_salt()], check_origin: false, pubsub_server: PhoenixProfilerTest.PubSub, phoenix_profiler: [server: PhoenixProfilerTest.Profiler] @@ -10,9 +14,8 @@ Application.put_env(:phoenix_profiler, PhoenixProfilerTest.Endpoint, Application.put_env(:phoenix_profiler, PhoenixProfilerTest.EndpointDisabled, url: [host: "localhost", port: 4000], - secret_key_base: "LIyk9co9Mt8KowH/g1WeMkufq/9Bz1XuEZMhCZAwnBc7VFKCfkDq/vRw+Xso4Q0q", - live_view: [signing_salt: "NbA2FdHo"], - render_errors: [view: PhoenixProfilerTest.ErrorView], + secret_key_base: EndpointHelper.gen_secret_key(), + live_view: [signing_salt: EndpointHelper.gen_salt()], check_origin: false, pubsub_server: PhoenixProfilerTest.PubSub, phoenix_profiler: [server: PhoenixProfilerTest.Profiler, enable: false] @@ -20,18 +23,25 @@ Application.put_env(:phoenix_profiler, PhoenixProfilerTest.EndpointDisabled, Application.put_env(:phoenix_profiler, PhoenixProfilerTest.EndpointNotConfigured, url: [host: "localhost", port: 4000], - secret_key_base: "LIyk9co9Mt8KowH/g1WeMkufq/9Bz1XuEZMhCZAwnBc7VFKCfkDq/vRw+Xso4Q0q", - live_view: [signing_salt: "NbA2FdHo"], - render_errors: [view: PhoenixProfilerTest.ErrorView], + secret_key_base: EndpointHelper.gen_secret_key(), + live_view: [signing_salt: EndpointHelper.gen_salt()], check_origin: false, pubsub_server: PhoenixProfilerTest.PubSub ) -defmodule PhoenixProfilerTest.ErrorView do - use Phoenix.View, root: "test/templates" +defmodule PhoenixProfiler.ErrorView do + def render(template, %{conn: conn}) do + unless conn.private.phoenix_endpoint do + raise "no endpoint in error view" + end + + err = "#{template} from PhoenixProfiler.ErrorView" - def template_not_found(template, _assigns) do - Phoenix.Controller.status_message_from_template(template) + if String.ends_with?(template, ".html") do + Phoenix.HTML.html_escape("#{err}") + else + err + end end end @@ -107,8 +117,8 @@ end defmodule PhoenixProfilerTest.Endpoint do use Phoenix.Endpoint, otp_app: :phoenix_profiler + use PhoenixProfiler - plug PhoenixProfiler plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] plug Plug.Session, @@ -121,6 +131,9 @@ end defmodule PhoenixProfilerTest.EndpointDisabled do use Phoenix.Endpoint, otp_app: :phoenix_profiler + use PhoenixProfiler + + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] end defmodule PhoenixProfilerTest.EndpointNotConfigured do