diff --git a/CHANGELOG.md b/CHANGELOG.md index cb34a44..2299c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG (v0.1.X) +## 0.1.9 🚀 () + +### Backwards incompatible changes for 0.1.8 + * None + +### Bug fixes + * None + +### Enhancements + * [[`PR-16`](https://github.com/thiagoesteves/observer_web/pull/16)] Adding Beam VM statistics (ports, atoms and processes) + ## 0.1.8 🚀 (2025-04-03) ### Backwards incompatible changes for 0.1.7 diff --git a/guides/installation.md b/guides/installation.md index 635796f..01f9b11 100644 --- a/guides/installation.md +++ b/guides/installation.md @@ -124,7 +124,7 @@ providing a more integrated monitoring experience. ### Metrics -#### Retention period for metrics +#### 1. Retention period for metrics Observer Web can monitor Beam VM metrics (along with many others) and uses ETS tables to store the data and there is a possibility of configuration for the retention @@ -147,12 +147,12 @@ config :observer_web, ObserverWeb.Telemetry, > storage, refer to the Configuration section to set up a Central Hub application, > which can aggregate and retain metrics. -#### Configuration +#### 2. Configuration Observer Web can operate in two distinct metrics configurations: `Standalone` and `Metric Hub`. These configurations determine how metrics are collected, stored, and managed. -#### Standalone Configuration (default) +##### Standalone Configuration (default) In this mode, all applications with Observer Web installed operate independently. Each application receives and stores its own metrics within its ETS tables. The image below @@ -162,7 +162,7 @@ illustrates this configuration: __**NOTE: No additional configuration is required for this mode**__ -#### Metric Hub Configuration +##### Metric Hub Configuration In this mode, one application is designated as the central hub to store all metrics, while the remaining applications broadcast their data to this designated hub. This @@ -197,6 +197,22 @@ config :observer_web, ObserverWeb.Telemetry, The application in `observer mode` will also retain its own metrics in addition to aggregating metrics from other applications. +#### 3. Metrics Polling Interval + +Observer Web allows configuration of two polling intervals: + * Phoenix Liveview sockets - Default: `5_000` ms + * Beam VM statistics - Default: `1_000` ms + +```elixir +config :observer_web, ObserverWeb.Telemetry, + phx_lv_sckt_poller_interval_ms: 5_000, + beam_vm_poller_interval_ms: 1_000 +``` + +> #### For applications running by [DeployEx][dye] {: .attention} +> +> When using DeployEx, the BEAM VM statistics polling is also used to monitor and, if necessary, restart the application. The polling interval directly affects how quickly these actions are performed. While ports, atoms, and processes are configured via Observer Web, the memory check interval (also used by [DeployEx][dye]) is configured separately—refer to the relevant [documentation][mtc] for details. + ### Usage with Web and Clustering The Observer Web provides observer ability for the local application as well as any other that is @@ -216,4 +232,5 @@ via OTP distribution! [ac]: Observer.Web.Resolver.html#c:resolve_access/1 [ba]: https://hexdocs.pm/basic_auth/readme.html [oi]: installation.html -[dye]: https://github.com/thiagoesteves/deployex \ No newline at end of file +[dye]: https://github.com/thiagoesteves/deployex +[mtc]: https://hexdocs.pm/telemetry_metrics/Telemetry.Metrics.html \ No newline at end of file diff --git a/lib/observer_web/telemetry/producer/beam_vm.ex b/lib/observer_web/telemetry/producer/beam_vm.ex new file mode 100644 index 0000000..6d8d29a --- /dev/null +++ b/lib/observer_web/telemetry/producer/beam_vm.ex @@ -0,0 +1,37 @@ +defmodule ObserverWeb.Telemetry.Producer.BeamVm do + @moduledoc """ + This module contains the reporting functions for Beam VM + """ + + ### ========================================================================== + ### Public APIs + ### ========================================================================== + def process do + measurements = %{ + total: :erlang.system_info(:port_count), + limit: :erlang.system_info(:port_limit) + } + + :telemetry.execute([:vm, :port], measurements, %{}) + + measurements = %{ + total: :erlang.system_info(:atom_count), + limit: :erlang.system_info(:atom_limit) + } + + :telemetry.execute([:vm, :atom], measurements, %{}) + + measurements = %{ + total: :erlang.system_info(:process_count), + limit: :erlang.system_info(:process_limit) + } + + :telemetry.execute([:vm, :process], measurements, %{}) + + :ok + end + + ### ========================================================================== + ### Private Functions + ### ========================================================================== +end diff --git a/lib/observer_web/telemetry/producer/phx_lv_socket.ex b/lib/observer_web/telemetry/producer/phx_lv_socket.ex index 252157e..2a94c29 100644 --- a/lib/observer_web/telemetry/producer/phx_lv_socket.ex +++ b/lib/observer_web/telemetry/producer/phx_lv_socket.ex @@ -21,7 +21,7 @@ defmodule ObserverWeb.Telemetry.Producer.PhxLvSocket do ### Public functions ### ========================================================================== - def process_phoenix_liveview_sockets do + def process do cached_phx_endpoints = Process.get(@cache_key, []) # Check if the cache is populated, if not, verify if there is any diff --git a/lib/web/components/metrics/vm_limits.ex b/lib/web/components/metrics/vm_limits.ex new file mode 100644 index 0000000..56c7385 --- /dev/null +++ b/lib/web/components/metrics/vm_limits.ex @@ -0,0 +1,142 @@ +defmodule Observer.Web.Components.Metrics.VmLimits do + @moduledoc false + use Observer.Web, :html + + use Phoenix.Component + + alias Observer.Web.Components.Metrics.Common + + attr :title, :string, required: true + attr :service, :string, required: true + attr :metric, :string, required: true + attr :metrics, :list, required: true + attr :cols, :integer, default: 2 + + attr :supported_metrics, :list, + default: [ + "vm.atom.total", + "vm.process.total", + "vm.port.total" + ] + + def content(assigns) do + ~H""" +
+ <% id = String.replace("#{@service}-#{@metric}", ["@", ".", "/"], "-") %> +
+
+
+
+

+ {@title} +

+
+
+
+ + <% metrics = Enum.map(@metrics.inserts, fn {_id, _index, data, _} -> data end) %> + <% normalized_metrics = normalize(metrics) %> + <% echart_config = config(normalized_metrics) %> + +
+
+
+
+
+ """ + end + + defp normalize(metrics) do + empty_series_data = %{ + limit: [], + total: [] + } + + # NOTE: Streams are retrieved in the reverse order + {series_data, categories_data} = + Enum.reduce(metrics, {empty_series_data, []}, fn + %ObserverWeb.Telemetry.Data{value: nil} = metric, {series_data, categories_data} -> + timestamp = Common.timestamp_to_string(metric.timestamp) + + {%{ + limit: [nil] ++ series_data.limit, + total: [nil] ++ series_data.total + }, [timestamp] ++ categories_data} + + metric, {series_data, categories_data} -> + timestamp = Common.timestamp_to_string(metric.timestamp) + + {%{ + limit: [metric.measurements.limit] ++ series_data.limit, + total: [metric.measurements.total] ++ series_data.total + }, [timestamp] ++ categories_data} + end) + + datasets = + [ + %{ + name: "Limit", + type: "line", + data: series_data.limit + }, + %{ + name: "Total", + type: "line", + data: series_data.total + } + ] + + %{ + datasets: datasets, + categories: categories_data + } + end + + defp config(%{datasets: datasets, categories: categories}) do + %{ + tooltip: %{ + trigger: "axis" + }, + legend: %{ + data: [ + "Limit", + "Total" + ], + right: "25%" + }, + grid: %{ + left: "3%", + right: "4%", + bottom: "3%", + top: "30%", + containLabel: true + }, + toolbox: %{ + feature: %{ + dataZoom: %{}, + dataView: %{}, + saveAsImage: %{} + } + }, + yAxis: %{ + type: "value", + axisLabel: %{ + formatter: "{value} bytes" + } + }, + series: datasets, + xAxis: %{ + type: "category", + boundaryGap: false, + data: categories + } + } + end +end diff --git a/lib/web/pages/metrics/page.ex b/lib/web/pages/metrics/page.ex index 14f84a5..2e14530 100644 --- a/lib/web/pages/metrics/page.ex +++ b/lib/web/pages/metrics/page.ex @@ -11,6 +11,7 @@ defmodule Observer.Web.Metrics.Page do alias Observer.Web.Components.Core alias Observer.Web.Components.Metrics.Phoenix, as: MetricsPhoenix alias Observer.Web.Components.Metrics.PhxLvSocket + alias Observer.Web.Components.Metrics.VmLimits alias Observer.Web.Components.Metrics.VmMemory alias Observer.Web.Components.Metrics.VmRunQueue alias Observer.Web.Components.MultiSelect @@ -114,6 +115,13 @@ defmodule Observer.Web.Metrics.Page do cols={@form.params["num_cols"]} metrics={Map.get(@streams, data_key)} /> + + :ok + + [:vm, :atom], + %{ + total: _, + limit: _ + }, + %{} -> + :ok + + [:vm, :process], + %{ + total: _, + limit: _ + }, + %{} -> + :ok + end, + attach: fn _id, _event, _handler, _config -> + :ok + end do + assert :ok == BeamVm.process() + end + end +end diff --git a/test/observer_web/phx_lv_socket_test.exs b/test/observer_web/phx_lv_socket_test.exs index c08921b..90b8efd 100644 --- a/test/observer_web/phx_lv_socket_test.exs +++ b/test/observer_web/phx_lv_socket_test.exs @@ -1,4 +1,4 @@ -defmodule ObserverWeb.PhxLvSocket do +defmodule ObserverWeb.PhxLvSocketTest do use ExUnit.Case, async: false import Mock @@ -25,7 +25,7 @@ defmodule ObserverWeb.PhxLvSocket do attach: fn _id, _event, _handler, _config -> :ok end do - assert :ok == PhxLvSocket.process_phoenix_liveview_sockets() + assert :ok == PhxLvSocket.process() end end end diff --git a/test/observer_web/web/live/metrics/vm_limits_test.exs b/test/observer_web/web/live/metrics/vm_limits_test.exs new file mode 100644 index 0000000..35e9b36 --- /dev/null +++ b/test/observer_web/web/live/metrics/vm_limits_test.exs @@ -0,0 +1,210 @@ +defmodule Observer.Web.Metrics.VmLimitsTest do + use Observer.Web.ConnCase, async: false + + import Phoenix.LiveViewTest + import Mox + + alias Observer.Web.Mocks.TelemetryStubber + alias ObserverWeb.TelemetryFixtures + + setup [ + :set_mox_global, + :verify_on_exit! + ] + + %{ + 1 => %{metric: "vm.atom.total"}, + 2 => %{metric: "vm.port.total"}, + 3 => %{metric: "vm.process.total"} + } + |> Enum.each(fn {element, %{metric: metric}} -> + test "#{element} - Add/Remove Service + #{metric}", %{conn: conn} do + node = Node.self() |> to_string + service_id = String.replace(node, "@", "-") + metric = unquote(metric) + metric_id = String.replace(metric, ".", "-") + + TelemetryStubber.defaults() + |> expect(:subscribe_for_new_keys, fn -> :ok end) + |> expect(:subscribe_for_new_data, fn ^node, ^metric -> :ok end) + |> expect(:unsubscribe_for_new_data, fn ^node, ^metric -> :ok end) + |> expect(:list_data_by_node_key, fn ^node, ^metric, _ -> [] end) + |> stub(:get_keys_by_node, fn _node -> [metric] end) + + {:ok, liveview, _html} = live(conn, "/observer/metrics") + + liveview + |> element("#metrics-multi-select-toggle-options") + |> render_click() + + liveview + |> element("#metrics-multi-select-services-#{service_id}-add-item") + |> render_click() + + html = + liveview + |> element("#metrics-multi-select-metrics-#{metric_id}-add-item") + |> render_click() + + assert html =~ "services:#{node}" + assert html =~ "metrics:#{metric}" + + html = + liveview + |> element("#metrics-multi-select-services-#{service_id}-remove-item") + |> render_click() + + refute html =~ "services:#{node}" + assert html =~ "metrics:#{metric}" + + html = + liveview + |> element("#metrics-multi-select-metrics-#{metric_id}-remove-item") + |> render_click() + + refute html =~ "services:#{node}" + refute html =~ "metrics:#{metric}" + end + end) + + %{ + 1 => %{metric: "vm.atom.total"}, + 2 => %{metric: "vm.port.total"}, + 3 => %{metric: "vm.process.total"} + } + |> Enum.each(fn {element, %{metric: metric}} -> + test "#{element} - #{metric} + Service", %{conn: conn} do + node = Node.self() |> to_string + service_id = String.replace(node, "@", "-") + metric = unquote(metric) + metric_id = String.replace(metric, ".", "-") + + TelemetryStubber.defaults() + |> expect(:subscribe_for_new_keys, fn -> :ok end) + |> expect(:subscribe_for_new_data, fn ^node, ^metric -> :ok end) + |> expect(:unsubscribe_for_new_data, fn ^node, ^metric -> :ok end) + |> expect(:list_data_by_node_key, fn ^node, ^metric, _ -> [] end) + |> stub(:get_keys_by_node, fn _node -> [metric] end) + + {:ok, liveview, _html} = live(conn, "/observer/metrics") + + liveview + |> element("#metrics-multi-select-toggle-options") + |> render_click() + + liveview + |> element("#metrics-multi-select-metrics-#{metric_id}-add-item") + |> render_click() + + html = + liveview + |> element("#metrics-multi-select-services-#{service_id}-add-item") + |> render_click() + + assert html =~ "services:#{node}" + assert html =~ "metrics:#{metric}" + + html = + liveview + |> element("#metrics-multi-select-metrics-#{metric_id}-remove-item") + |> render_click() + + assert html =~ "services:#{node}" + refute html =~ "metrics:#{metric}" + + html = + liveview + |> element("#metrics-multi-select-services-#{service_id}-remove-item") + |> render_click() + + refute html =~ "services:#{node}" + refute html =~ "metrics:#{metric}" + end + end) + + %{ + 1 => %{ + metric: "vm.atom.total", + init: 2_000_000, + update: 3_000_000 + }, + 2 => %{ + metric: "vm.port.total", + init: 4_000_000, + update: 5_000_000 + }, + 3 => %{ + metric: "vm.process.total", + init: 6_000_000, + update: 7_000_000 + } + } + |> Enum.each(fn {element, %{metric: metric, init: init, update: update}} -> + test "#{element} - Beam VM statistics Start - Init and Push #{metric}", %{ + conn: conn + } do + node = Node.self() |> to_string + service_id = String.replace(node, "@", "-") + metric = unquote(metric) + metric_id = String.replace(metric, ".", "-") + + init = unquote(init) + update = unquote(update) + + test_pid_process = self() + + TelemetryStubber.defaults() + |> expect(:subscribe_for_new_keys, fn -> :ok end) + |> expect(:subscribe_for_new_data, fn ^node, ^metric -> + send(test_pid_process, {:liveview_pid, self()}) + :ok + end) + |> expect(:list_data_by_node_key, fn ^node, ^metric, _ -> + [ + TelemetryFixtures.build_telemetry_beam_vm_total(1_737_982_400_500, init) + ] + end) + |> stub(:get_keys_by_node, fn _ -> [metric] end) + + {:ok, liveview, _html} = live(conn, "/observer/metrics") + + liveview + |> element("#metrics-multi-select-toggle-options") + |> render_click() + + liveview + |> element("#metrics-multi-select-metrics-#{metric_id}-add-item") + |> render_click() + + html = + liveview + |> element("#metrics-multi-select-services-#{service_id}-add-item") + |> render_click() + + assert_receive {:liveview_pid, liveview_pid}, 1_000 + + # assert initial data + assert html =~ "2025-01-27 12:53:20.500" + + send( + liveview_pid, + {:metrics_new_data, node, metric, + TelemetryFixtures.build_telemetry_beam_vm_total(1_737_982_400_700, update)} + ) + + # assert live updated data + html = render(liveview) + assert html =~ "2025-01-27 12:53:20.700Z" + + # Assert nil data received, this will indicate the application has restarted + send( + liveview_pid, + {:metrics_new_data, node, metric, + TelemetryFixtures.build_telemetry_beam_vm_total(1_737_982_379_789, nil)} + ) + + html = render(liveview) + assert html =~ "2025-01-27 12:52:59.789Z" + end + end) +end diff --git a/test/support/fixtures/telemetry.ex b/test/support/fixtures/telemetry.ex index 8f96e5b..70f6844 100644 --- a/test/support/fixtures/telemetry.ex +++ b/test/support/fixtures/telemetry.ex @@ -70,6 +70,22 @@ defmodule ObserverWeb.TelemetryFixtures do } end + def build_telemetry_beam_vm_total( + timestamp \\ :rand.uniform(2_000_000_000_000), + value \\ 555 + ) do + %ObserverWeb.Telemetry.Data{ + timestamp: timestamp, + value: value, + unit: "", + tags: %{}, + measurements: %{ + limit: value, + total: value + } + } + end + def build_telemetry_data( timestamp \\ :rand.uniform(2_000_000_000_000), value \\ 70_973.064,