Skip to content

Commit dc51b4c

Browse files
authored
Export BEAM processes to OTLP metrics endpoint (plausible#6188)
* Export BEAM processes to OTLP metrics endpoint * fix max nesting * update mix.lock * switch to Enum.map insted
1 parent fca00f9 commit dc51b4c

5 files changed

Lines changed: 148 additions & 0 deletions

File tree

config/runtime.exs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,30 @@ else
960960
traces_exporter: :none
961961
end
962962

963+
beam_metrics_enabled? = get_bool_from_path_or_env(config_dir, "BEAM_METRICS_ENABLED", false)
964+
965+
if beam_metrics_enabled? do
966+
beam_metrics_interval = get_int_from_path_or_env(config_dir, "BEAM_METRICS_INTERVAL_MS", 5_000)
967+
968+
beam_metrics_otlp_endpoint =
969+
get_var_from_path_or_env(config_dir, "OTEL_EXPORTER_OTLP_ENDPOINT") || otlp_endpoint
970+
971+
config :opentelemetry_experimental,
972+
readers: [
973+
%{
974+
module: :otel_metric_reader,
975+
config: %{
976+
export_interval_ms: beam_metrics_interval,
977+
exporter:
978+
{:otel_exporter_metrics_otlp,
979+
%{
980+
endpoints: [beam_metrics_otlp_endpoint]
981+
}}
982+
}
983+
}
984+
]
985+
end
986+
963987
config :tzdata, :data_dir, Path.join(persistent_cache_dir || System.tmp_dir!(), "tzdata_data")
964988

965989
promex_disabled? = get_bool_from_path_or_env(config_dir, "PROMEX_DISABLED", true)

lib/plausible/application.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,10 @@ defmodule Plausible.Application do
373373
OpentelemetryEcto.setup([:plausible, :clickhouse_repo], db_statement: :enabled)
374374
OpentelemetryOban.setup()
375375
Plausible.OpenTelemetry.Logger.setup()
376+
377+
if Application.get_env(:opentelemetry_experimental, :readers, []) != [] do
378+
Plausible.OpenTelemetry.BeamMetrics.setup()
379+
end
376380
end
377381

378382
defp setup_geolocation do
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
defmodule Plausible.OpenTelemetry.BeamMetrics do
2+
@moduledoc """
3+
Periodic BEAM process sampling via OpenTelemetry observable gauges.
4+
5+
Uses `:recon.proc_count/2` to sample top-N processes by memory, reductions,
6+
and message queue length. Emits data as OTel observable gauge observations,
7+
exported via OTLP to the configured OTel Collector.
8+
9+
Disabled by default. Enable with `BEAM_METRICS_ENABLED=true`.
10+
Collection interval controlled by `BEAM_METRICS_INTERVAL_MS` (default: 5000).
11+
"""
12+
13+
require Logger
14+
15+
@top_n 20
16+
@metrics [:memory, :reductions, :message_queue_len]
17+
@process_info_keys [
18+
:registered_name,
19+
:current_function,
20+
:initial_call,
21+
:memory,
22+
:reductions,
23+
:message_queue_len
24+
]
25+
26+
@instruments %{
27+
memory:
28+
{:"beam.top_process.memory",
29+
%{description: "Memory usage of top BEAM processes", unit: :bytes}},
30+
reductions:
31+
{:"beam.top_process.reductions",
32+
%{description: "Reductions of top BEAM processes", unit: :"1"}},
33+
message_queue_len:
34+
{:"beam.top_process.message_queue_len",
35+
%{description: "Message queue length of top BEAM processes", unit: :"1"}}
36+
}
37+
38+
@doc """
39+
Registers OTel observable gauge instruments and a shared callback.
40+
41+
Should be called once during application startup when BEAM metrics are enabled.
42+
"""
43+
def setup do
44+
scope = :opentelemetry.instrumentation_scope("plausible_beam_metrics", "0.1.0", :undefined)
45+
meter = :opentelemetry_experimental.get_meter(scope)
46+
47+
gauges =
48+
Enum.map(@metrics, fn metric ->
49+
{name, opts} = Map.fetch!(@instruments, metric)
50+
:otel_meter.create_observable_gauge(meter, name, opts)
51+
end)
52+
53+
:otel_meter.register_callback(meter, gauges, &observe_top_processes/1, [])
54+
55+
Logger.info("BEAM metrics setup complete — sampling top #{@top_n} processes per metric")
56+
:ok
57+
end
58+
59+
@doc """
60+
Callback invoked by the OTel Metric Reader on each collection cycle.
61+
62+
Returns named observations for all three gauge instruments.
63+
"""
64+
def observe_top_processes(_callback_args) do
65+
Enum.map(@metrics, fn metric ->
66+
{gauge_name, _opts} = Map.fetch!(@instruments, metric)
67+
observations = collect_observations(metric)
68+
{gauge_name, observations}
69+
end)
70+
end
71+
72+
defp collect_observations(metric) do
73+
metric
74+
|> :recon.proc_count(@top_n)
75+
|> Enum.flat_map(fn {pid, value, _info} ->
76+
case Process.info(pid, @process_info_keys) do
77+
nil -> []
78+
info -> [{value, build_attributes(pid, info)}]
79+
end
80+
end)
81+
end
82+
83+
defp build_attributes(pid, info) do
84+
registered_name =
85+
case Keyword.get(info, :registered_name) do
86+
[] -> ""
87+
name when is_atom(name) -> Atom.to_string(name)
88+
_ -> ""
89+
end
90+
91+
current_function = format_mfa(Keyword.get(info, :current_function))
92+
initial_call = format_mfa(Keyword.get(info, :initial_call))
93+
94+
%{
95+
"beam.process.pid" => inspect(pid),
96+
"beam.process.registered_name" => registered_name,
97+
"beam.process.current_function" => current_function,
98+
"beam.process.initial_call" => initial_call,
99+
"beam.process.memory" => to_string(Keyword.get(info, :memory, 0)),
100+
"beam.process.reductions" => to_string(Keyword.get(info, :reductions, 0)),
101+
"beam.process.message_queue_len" => to_string(Keyword.get(info, :message_queue_len, 0))
102+
}
103+
end
104+
105+
defp format_mfa({m, f, a}), do: Exception.format_mfa(m, f, a)
106+
defp format_mfa(_), do: ""
107+
end

mix.exs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,18 @@ defmodule Plausible.MixProject do
109109
{:observer_cli, "~> 1.7"},
110110
{:opentelemetry, "~> 1.7"},
111111
{:opentelemetry_api, "~> 1.5"},
112+
{:opentelemetry_api_experimental,
113+
git: "https://github.com/open-telemetry/opentelemetry-erlang.git",
114+
ref: "b241ebf4feb7558738d009583bae8602b3c49fd0",
115+
sparse: "apps/opentelemetry_api_experimental",
116+
override: true},
112117
{:opentelemetry_ecto, "~> 1.2"},
113118
{:opentelemetry_exporter, "~> 1.10"},
119+
{:opentelemetry_experimental,
120+
git: "https://github.com/open-telemetry/opentelemetry-erlang.git",
121+
ref: "b241ebf4feb7558738d009583bae8602b3c49fd0",
122+
sparse: "apps/opentelemetry_experimental",
123+
override: true},
114124
{:opentelemetry_phoenix, "~> 2.0.1"},
115125
{:opentelemetry_oban, "~> 1.1"},
116126
{:opentelemetry_cowboy, "~> 1.0"},
@@ -132,6 +142,7 @@ defmodule Plausible.MixProject do
132142
{:prom_ex, "~> 1.8"},
133143
{:peep, "~> 3.0"},
134144
{:public_suffix, git: "https://github.com/axelson/publicsuffix-elixir"},
145+
{:recon, "~> 2.5"},
135146
{:ref_inspector, "~> 2.0"},
136147
{:referrer_blocklist, git: "https://github.com/plausible/referrer-blocklist.git"},
137148
{:sentry, "~> 11.0.4"},

mix.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,10 @@
106106
"open_api_spex": {:hex, :open_api_spex, "3.22.1", "38c99d8bf107dc7ffb112dc669f33e0a287396c6c9fd6bb7eeb27e3fe8dbba0e", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "fa51ecd04ececbad89a8ede55ebd9db7aa9e55cc7ddbb46455522e0f3c098290"},
107107
"opentelemetry": {:hex, :opentelemetry, "1.7.0", "20d0f12d3d1c398d3670fd44fd1a7c495dd748ab3e5b692a7906662e2fb1a38a", [:rebar3], [{:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "a9173b058c4549bf824cbc2f1d2fa2adc5cdedc22aa3f0f826951187bbd53131"},
108108
"opentelemetry_api": {:hex, :opentelemetry_api, "1.5.0", "1a676f3e3340cab81c763e939a42e11a70c22863f645aa06aafefc689b5550cf", [:mix, :rebar3], [], "hexpm", "f53ec8a1337ae4a487d43ac89da4bd3a3c99ddf576655d071deed8b56a2d5dda"},
109+
"opentelemetry_api_experimental": {:git, "https://github.com/open-telemetry/opentelemetry-erlang.git", "b241ebf4feb7558738d009583bae8602b3c49fd0", [ref: "b241ebf4feb7558738d009583bae8602b3c49fd0", sparse: "apps/opentelemetry_api_experimental"]},
109110
"opentelemetry_cowboy": {:hex, :opentelemetry_cowboy, "1.0.0", "786c7cde66a2493323c79d2c94e679ff501d459a9b403d8b60b9bef116333117", [:rebar3], [{:cowboy_telemetry, "~> 0.4", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7575716eaccacd0eddc3e7e61403aecb5d0a6397183987d6049094aeb0b87a7c"},
110111
"opentelemetry_ecto": {:hex, :opentelemetry_ecto, "1.2.0", "2382cb47ddc231f953d3b8263ed029d87fbf217915a1da82f49159d122b64865", [:mix], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "70dfa2e79932e86f209df00e36c980b17a32f82d175f0068bf7ef9a96cf080cf"},
112+
"opentelemetry_experimental": {:git, "https://github.com/open-telemetry/opentelemetry-erlang.git", "b241ebf4feb7558738d009583bae8602b3c49fd0", [ref: "b241ebf4feb7558738d009583bae8602b3c49fd0", sparse: "apps/opentelemetry_experimental"]},
111113
"opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.10.0", "972e142392dbfa679ec959914664adefea38399e4f56ceba5c473e1cabdbad79", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.7.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "33a116ed7304cb91783f779dec02478f887c87988077bfd72840f760b8d4b952"},
112114
"opentelemetry_oban": {:hex, :opentelemetry_oban, "1.1.1", "519e9ba60d3dc3483ad2df3fade131d47056e0dae74f0724c8a40b9718f089d1", [:mix], [{:oban, "~> 2.0", [hex: :oban, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.2", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ae6aed431626a94a4bb6bf5b268247ced687ec8f99eced6887e3754f9d3a2089"},
113115
"opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "2.0.1", "c664cdef205738cffcd409b33599439a4ffb2035ef6e21a77927ac1da90463cb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a24fdccdfa6b890c8892c6366beab4a15a27ec0c692b0f77ec2a862e7b235f6e"},

0 commit comments

Comments
 (0)