Packages

Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.

Current section

Files

Jump to
raxol lib raxol terminal metrics metrics_server.ex
Raw

lib/raxol/terminal/metrics/metrics_server.ex

defmodule Raxol.Terminal.Metrics.MetricsServer do
@moduledoc """
ETS-backed metrics collection and export module.
Provides centralized metrics storage using ETS for high-performance
concurrent writes and reads. Supports multiple metric types and
Prometheus-compatible export.
## Design
Uses ETS for write-heavy workloads (per Rich Hickey's feedback).
No GenServer serialization for metric recording - direct ETS writes.
## Metric Types
- **Counters**: Monotonically increasing values (e.g., operations count)
- **Gauges**: Point-in-time values (e.g., memory usage)
- **Histograms**: Distribution of values (e.g., latency percentiles)
## Usage
# Initialize (call once at app startup)
MetricsServer.init()
# Record metrics
MetricsServer.increment(:requests_total, %{path: "/api"})
MetricsServer.gauge(:memory_bytes, 1024000, %{type: :heap})
MetricsServer.histogram(:latency_ms, 42.5, %{endpoint: :render})
# Query metrics
MetricsServer.get(:requests_total, %{path: "/api"})
MetricsServer.get_all()
# Export for monitoring
MetricsServer.export(:prometheus)
MetricsServer.export(:json)
"""
@metrics_table :raxol_metrics
@histograms_table :raxol_histograms
@errors_table :raxol_metric_errors
@type metric_name :: atom()
@type labels :: map()
@type metric_value :: number()
# ============================================================================
# Initialization
# ============================================================================
@doc """
Initializes the metrics storage tables.
Call once during application startup.
"""
@spec init() :: :ok
def init do
# Main metrics table (counters and gauges)
_ =
if :ets.whereis(@metrics_table) == :undefined do
:ets.new(@metrics_table, [
:named_table,
:public,
:set,
write_concurrency: true,
read_concurrency: true
])
end
# Histograms table (stores individual samples)
_ =
if :ets.whereis(@histograms_table) == :undefined do
:ets.new(@histograms_table, [
:named_table,
:public,
:bag,
write_concurrency: true
])
end
# Errors table
_ =
if :ets.whereis(@errors_table) == :undefined do
:ets.new(@errors_table, [
:named_table,
:public,
:ordered_set,
write_concurrency: true
])
end
:ok
end
@doc """
Checks if metrics storage is initialized.
"""
@spec initialized?() :: boolean()
def initialized? do
:ets.whereis(@metrics_table) != :undefined
end
# ============================================================================
# Counter Operations
# ============================================================================
@doc """
Increments a counter metric.
## Examples
MetricsServer.increment(:requests_total)
MetricsServer.increment(:requests_total, %{path: "/api"})
MetricsServer.increment(:requests_total, %{path: "/api"}, 5)
"""
@spec increment(metric_name(), labels(), pos_integer()) :: :ok
def increment(name, labels \\ %{}, amount \\ 1) do
ensure_initialized!()
key = metric_key(name, labels)
:ets.update_counter(@metrics_table, key, {3, amount}, {key, :counter, 0})
:ok
end
@doc """
Gets the current value of a counter.
"""
@spec get_counter(metric_name(), labels()) :: non_neg_integer()
def get_counter(name, labels \\ %{}) do
ensure_initialized!()
key = metric_key(name, labels)
case :ets.lookup(@metrics_table, key) do
[{^key, :counter, value}] -> value
[] -> 0
end
end
# ============================================================================
# Gauge Operations
# ============================================================================
@doc """
Sets a gauge metric to a specific value.
## Examples
MetricsServer.gauge(:memory_bytes, 1024000)
MetricsServer.gauge(:cpu_percent, 45.5, %{core: 0})
"""
@spec gauge(metric_name(), metric_value(), labels()) :: :ok
def gauge(name, value, labels \\ %{}) do
ensure_initialized!()
key = metric_key(name, labels)
timestamp = System.system_time(:millisecond)
:ets.insert(@metrics_table, {key, :gauge, value, timestamp})
:ok
end
@doc """
Gets the current value of a gauge.
"""
@spec get_gauge(metric_name(), labels()) :: metric_value() | nil
def get_gauge(name, labels \\ %{}) do
ensure_initialized!()
key = metric_key(name, labels)
case :ets.lookup(@metrics_table, key) do
[{^key, :gauge, value, _ts}] -> value
[] -> nil
end
end
# ============================================================================
# Histogram Operations
# ============================================================================
@doc """
Records a value in a histogram.
## Examples
MetricsServer.histogram(:latency_ms, 42.5)
MetricsServer.histogram(:latency_ms, 15.2, %{endpoint: :render})
"""
@spec histogram(metric_name(), metric_value(), labels()) :: :ok
def histogram(name, value, labels \\ %{}) do
ensure_initialized!()
key = metric_key(name, labels)
timestamp = System.system_time(:millisecond)
:ets.insert(@histograms_table, {key, value, timestamp})
:ok
end
@doc """
Gets histogram statistics.
Returns count, sum, min, max, and percentiles.
"""
@spec get_histogram(metric_name(), labels()) :: map()
def get_histogram(name, labels \\ %{}) do
ensure_initialized!()
key = metric_key(name, labels)
values =
:ets.lookup(@histograms_table, key)
|> Enum.map(fn {_key, value, _ts} -> value end)
|> Enum.sort()
calculate_histogram_stats(values)
end
# ============================================================================
# Generic Operations (Backward Compatibility)
# ============================================================================
@doc """
Records a metric value (generic interface).
"""
@spec record_metric(metric_name(), metric_value(), labels(), atom()) :: :ok
def record_metric(name, value, labels \\ %{}, _store_name \\ nil) do
gauge(name, value, labels)
end
@doc """
Gets a metric value (generic interface).
"""
@spec get_metric(metric_name(), labels(), atom()) ::
{:ok, metric_value()} | {:error, :not_found}
def get_metric(name, labels \\ %{}, _store_name \\ nil) do
case get_gauge(name, labels) do
nil -> {:error, :not_found}
value -> {:ok, value}
end
end
# ============================================================================
# Error Recording
# ============================================================================
@doc """
Records an error occurrence.
"""
@spec record_error(String.t(), labels(), atom()) :: :ok
def record_error(message, labels \\ %{}, _store_name \\ nil) do
ensure_initialized!()
timestamp = System.system_time(:millisecond)
key = {timestamp, :crypto.strong_rand_bytes(4)}
:ets.insert(@errors_table, {key, message, labels, timestamp})
# Increment error counter
increment(:errors_total, labels)
:ok
end
@doc """
Gets error statistics.
"""
@spec get_error_stats(labels(), atom()) :: {:ok, map()}
def get_error_stats(labels \\ %{}, _store_name \\ nil) do
ensure_initialized!()
# Get recent errors (last 100)
errors =
:ets.tab2list(@errors_table)
|> Enum.filter(fn {_key, _msg, err_labels, _ts} ->
labels_match?(err_labels, labels)
end)
|> Enum.sort_by(fn {_key, _msg, _labels, ts} -> ts end, :desc)
|> Enum.take(100)
|> Enum.map(fn {_key, msg, _labels, ts} ->
%{message: msg, timestamp: ts}
end)
{:ok, %{count: length(errors), errors: errors}}
end
# ============================================================================
# Cleanup
# ============================================================================
@doc """
Cleans up old metrics.
## Options
* `:older_than` - Remove entries older than this many milliseconds
* `:type` - Only clean this type (:histogram, :error)
"""
@spec cleanup_metrics(keyword(), atom()) :: :ok
def cleanup_metrics(opts \\ [], _store_name \\ nil) do
ensure_initialized!()
older_than = Keyword.get(opts, :older_than, 3_600_000)
cutoff = System.system_time(:millisecond) - older_than
type = Keyword.get(opts, :type)
# Clean histograms
_ =
if type in [nil, :histogram] do
:ets.select_delete(@histograms_table, [
{{:_, :_, :"$1"}, [{:<, :"$1", cutoff}], [true]}
])
end
# Clean errors
_ =
if type in [nil, :error] do
:ets.select_delete(@errors_table, [
{{:_, :_, :_, :"$1"}, [{:<, :"$1", cutoff}], [true]}
])
end
:ok
end
# ============================================================================
# Export
# ============================================================================
@doc """
Exports metrics in the specified format.
## Formats
* `:prometheus` - Prometheus text format
* `:json` - JSON format
## Examples
MetricsServer.export(:prometheus)
MetricsServer.export(:json)
"""
@spec export_metrics(keyword(), atom()) ::
String.t() | {:error, :unsupported_format}
def export_metrics(opts \\ [], _store_name \\ nil) do
format = Keyword.get(opts, :format, :json)
export(format)
end
@doc """
Exports metrics in the specified format.
"""
@spec export(atom()) :: String.t() | {:error, :unsupported_format}
def export(format) do
ensure_initialized!()
case format do
:prometheus -> export_prometheus()
:json -> export_json()
_ -> {:error, :unsupported_format}
end
end
# ============================================================================
# Query Operations
# ============================================================================
@doc """
Gets all metrics as a map.
"""
@spec get_all() :: map()
def get_all do
ensure_initialized!()
metrics =
:ets.tab2list(@metrics_table)
|> Enum.map(fn
{key, :counter, value} ->
{key, %{type: :counter, value: value}}
{key, :gauge, value, timestamp} ->
{key, %{type: :gauge, value: value, timestamp: timestamp}}
end)
|> Map.new()
histograms =
:ets.tab2list(@histograms_table)
|> Enum.group_by(fn {key, _val, _ts} -> key end)
|> Enum.map(fn {key, entries} ->
values = Enum.map(entries, fn {_, v, _} -> v end)
{key, %{type: :histogram, stats: calculate_histogram_stats(values)}}
end)
|> Map.new()
Map.merge(metrics, histograms)
end
# ============================================================================
# Private Functions
# ============================================================================
defp ensure_initialized! do
unless initialized?() do
init()
end
end
defp metric_key(name, labels) when map_size(labels) == 0, do: {name}
defp metric_key(name, labels) do
sorted_labels =
labels
|> Enum.sort_by(fn {k, _v} -> k end)
|> Enum.map(fn {k, v} -> {k, v} end)
{name, sorted_labels}
end
defp labels_match?(_err_labels, filter_labels)
when map_size(filter_labels) == 0 do
true
end
defp labels_match?(err_labels, filter_labels) do
Enum.all?(filter_labels, fn {k, v} ->
Map.get(err_labels, k) == v
end)
end
defp calculate_histogram_stats([]),
do: %{count: 0, sum: 0, min: 0, max: 0, p50: 0, p90: 0, p99: 0}
defp calculate_histogram_stats(values) do
count = length(values)
sum = Enum.sum(values)
%{
count: count,
sum: sum,
min: List.first(values),
max: List.last(values),
avg: sum / count,
p50: percentile(values, 50),
p90: percentile(values, 90),
p99: percentile(values, 99)
}
end
defp percentile(sorted_values, p) do
count = length(sorted_values)
index = trunc(p / 100 * count)
Enum.at(sorted_values, min(index, count - 1), 0)
end
defp export_prometheus do
metrics = get_all()
Enum.map_join(metrics, "\n", fn {key, data} ->
name = format_metric_name(key)
format_prometheus_metric(name, key, data)
end)
end
defp format_prometheus_metric(name, key, %{type: :counter, value: value}) do
labels = format_prometheus_labels(key)
"""
# TYPE #{name} counter
#{name}#{labels} #{value}
"""
end
defp format_prometheus_metric(name, key, %{type: :gauge, value: value}) do
labels = format_prometheus_labels(key)
"""
# TYPE #{name} gauge
#{name}#{labels} #{value}
"""
end
defp format_prometheus_metric(name, key, %{type: :histogram, stats: stats}) do
labels = format_prometheus_labels(key)
"""
# TYPE #{name} histogram
#{name}_count#{labels} #{stats.count}
#{name}_sum#{labels} #{stats.sum}
#{name}_bucket{le="0.5"} #{stats.p50}
#{name}_bucket{le="0.9"} #{stats.p90}
#{name}_bucket{le="0.99"} #{stats.p99}
"""
end
defp format_metric_name({name}), do: "raxol_#{name}"
defp format_metric_name({name, _labels}), do: "raxol_#{name}"
defp format_prometheus_labels({_name}), do: ""
defp format_prometheus_labels({_name, labels}) do
label_str = Enum.map_join(labels, ",", fn {k, v} -> "#{k}=\"#{v}\"" end)
"{#{label_str}}"
end
defp export_json do
metrics = get_all()
data =
Enum.map(metrics, fn {key, data} ->
%{
name: format_metric_name(key),
labels: extract_labels(key),
type: data.type,
value: Map.get(data, :value) || Map.get(data, :stats)
}
end)
Jason.encode!(%{metrics: data, timestamp: System.system_time(:millisecond)})
end
defp extract_labels({_name}), do: %{}
defp extract_labels({_name, labels}), do: Map.new(labels)
end