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}", ["@", ".", "/"], "-") %>
+
+
+
+ <% 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,