Packages

Run WebAssembly from Elixir. Load WASM modules in Rust, Go, C — call them like native functions.

Current section

Files

Jump to
firebird lib firebird metrics.ex
Raw

lib/firebird/metrics.ex

defmodule Firebird.Metrics do
@moduledoc """
Lightweight metrics collection for WASM execution with O(1) memory per function.
Tracks call counts, execution times, and error rates per function using
atomic ETS counters. Unlike a naive approach that stores one row per call
(unbounded memory growth), this module maintains a fixed-size aggregate
per function name — safe for long-running systems handling millions of calls.
Uses `:ets.update_counter/3` for lock-free atomic increments, making it
safe for concurrent access from multiple processes without serialization.
## Memory Model
Each tracked function uses exactly one ETS row containing:
- Call count, error count
- Total elapsed time (for computing averages)
- Min/max elapsed time
- Last call timestamp
This means 10 functions = 10 rows, regardless of whether you've made
10 calls or 10 million calls.
## Example
# Start metrics collection
Firebird.Metrics.start_link()
# Make some calls (metrics collected automatically via timed_call)
Firebird.Metrics.timed_call(instance, :add, [1, 2])
# View metrics
Firebird.Metrics.report()
# => %{
# "add" => %{calls: 100, avg_us: 12.5, min_us: 8, max_us: 45, errors: 0},
# "fibonacci" => %{calls: 50, avg_us: 340.2, ...}
# }
# Reset
Firebird.Metrics.reset()
"""
use GenServer
@table_name :firebird_metrics
# ETS row layout:
# {func_name, calls, errors, total_us, min_us, max_us}
# pos: 1 2 3 4 5 6
@pos_calls 2
@pos_errors 3
@pos_total_us 4
@pos_min_us 5
@pos_max_us 6
def start_link(opts \\ []) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc """
Call a WASM function with timing metrics.
Records execution time and error count. Returns the same result
as `Firebird.call/3`.
## Examples
{:ok, [8]} = Firebird.Metrics.timed_call(instance, :add, [5, 3])
"""
@spec timed_call(pid(), atom() | String.t(), list()) :: {:ok, list()} | {:error, term()}
def timed_call(instance, function, args) do
func_name = to_string(function)
start = System.monotonic_time(:microsecond)
result = Firebird.call(instance, function, args)
elapsed = System.monotonic_time(:microsecond) - start
case result do
{:ok, _} -> record(func_name, elapsed, :ok)
{:error, _} -> record(func_name, elapsed, :error)
end
result
end
@doc """
Get metrics report for all tracked functions.
Returns a map of function name → stats. Each stats map contains:
- `:calls` - total number of calls
- `:errors` - number of failed calls
- `:avg_us` - average execution time in microseconds
- `:min_us` - minimum execution time
- `:max_us` - maximum execution time
- `:total_us` - total accumulated execution time
## Examples
report = Firebird.Metrics.report()
# => %{"add" => %{calls: 100, avg_us: 12.5, ...}}
"""
@spec report() :: map()
def report do
if :ets.whereis(@table_name) != :undefined do
:ets.tab2list(@table_name)
|> Map.new(fn {func_name, calls, errors, total_us, min_us, max_us} ->
avg = if calls > 0, do: Float.round(total_us / calls, 1), else: 0.0
{func_name,
%{
calls: calls,
errors: errors,
avg_us: avg,
min_us: if(min_us == :infinity, do: 0, else: min_us),
max_us: max_us,
total_us: total_us
}}
end)
else
%{}
end
end
@doc """
Get a summary of all metrics across all functions.
## Examples
summary = Firebird.Metrics.summary()
# => %{functions: 3, total_calls: 150, total_errors: 2, ...}
"""
@spec summary() :: map()
def summary do
report = report()
total_calls = report |> Map.values() |> Enum.map(& &1.calls) |> Enum.sum()
total_errors = report |> Map.values() |> Enum.map(& &1.errors) |> Enum.sum()
total_time = report |> Map.values() |> Enum.map(& &1.total_us) |> Enum.sum()
%{
functions: map_size(report),
total_calls: total_calls,
total_errors: total_errors,
total_time_us: total_time,
avg_time_us: if(total_calls > 0, do: Float.round(total_time / total_calls, 1), else: 0.0)
}
end
@doc """
Reset all metrics.
## Examples
Firebird.Metrics.reset()
"""
@spec reset() :: :ok
def reset do
if :ets.whereis(@table_name) != :undefined do
:ets.delete_all_objects(@table_name)
end
:ok
end
@doc """
Get metrics for a single function.
Returns `nil` if the function hasn't been called.
## Examples
stats = Firebird.Metrics.get("add")
# => %{calls: 50, avg_us: 12.5, ...}
"""
@spec get(String.t()) :: map() | nil
def get(func_name) when is_binary(func_name) do
if :ets.whereis(@table_name) != :undefined do
case :ets.lookup(@table_name, func_name) do
[{^func_name, calls, errors, total_us, min_us, max_us}] ->
avg = if calls > 0, do: Float.round(total_us / calls, 1), else: 0.0
%{
calls: calls,
errors: errors,
avg_us: avg,
min_us: if(min_us == :infinity, do: 0, else: min_us),
max_us: max_us,
total_us: total_us
}
[] ->
nil
end
else
nil
end
end
@doc """
Get the number of tracked functions.
## Examples
count = Firebird.Metrics.function_count()
# => 5
"""
@spec function_count() :: non_neg_integer()
def function_count do
if :ets.whereis(@table_name) != :undefined do
:ets.info(@table_name, :size)
else
0
end
end
@doc """
Get memory usage of the metrics table in bytes.
Because we use O(1) storage per function, this stays small
regardless of call volume.
## Examples
bytes = Firebird.Metrics.memory_bytes()
# => 1024
"""
@spec memory_bytes() :: non_neg_integer()
def memory_bytes do
if :ets.whereis(@table_name) != :undefined do
:ets.info(@table_name, :memory) * :erlang.system_info(:wordsize)
else
0
end
end
# GenServer callbacks
@impl true
def init(_opts) do
table =
ensure_table(@table_name, [
:named_table,
:set,
:public,
read_concurrency: true,
write_concurrency: true
])
{:ok, %{table: table}}
end
# Create a named ETS table, or return the existing one if it already exists.
# This prevents crashes when multiple GenServer instances try to create
# the same named tables (e.g., in test environments).
defp ensure_table(name, opts) do
case :ets.whereis(name) do
:undefined ->
try do
:ets.new(name, opts)
rescue
ArgumentError ->
# Race: another process created the table between whereis and new
:ets.whereis(name)
end
ref ->
ref
end
end
# Record a call's metrics using atomic ETS operations.
#
# For the first call to a function, we insert the initial row.
# For subsequent calls, we use :ets.update_counter for calls/errors/total_us
# and a compare-and-swap loop for min/max.
defp record(func_name, elapsed_us, status) do
if :ets.whereis(@table_name) != :undefined do
error_inc = if status == :error, do: 1, else: 0
# Try to atomically increment the counters on an existing row.
# update_counter returns the new values after increment.
try do
:ets.update_counter(@table_name, func_name, [
{@pos_calls, 1},
{@pos_errors, error_inc},
{@pos_total_us, elapsed_us}
])
# Update min/max with compare-and-swap loops
update_min(func_name, elapsed_us)
update_max(func_name, elapsed_us)
rescue
ArgumentError ->
# Row doesn't exist yet — insert it.
# Use insert_new to handle races: if another process inserted
# between our failed update_counter and this insert, we retry.
initial = {func_name, 1, error_inc, elapsed_us, elapsed_us, elapsed_us}
if :ets.insert_new(@table_name, initial) do
:ok
else
# Another process created the row — retry the update path
:ets.update_counter(@table_name, func_name, [
{@pos_calls, 1},
{@pos_errors, error_inc},
{@pos_total_us, elapsed_us}
])
update_min(func_name, elapsed_us)
update_max(func_name, elapsed_us)
end
end
end
end
# Atomically update the minimum value using ETS match operations.
# Uses update_counter with a conditional: only update if new value is smaller.
defp update_min(func_name, elapsed_us) do
# Read current min, update only if new value is smaller.
# This is safe under concurrency: worst case we miss one update
# to min, which is acceptable for metrics (eventual consistency).
case :ets.lookup(@table_name, func_name) do
[{^func_name, _calls, _errors, _total, current_min, _max}]
when elapsed_us < current_min ->
:ets.update_element(@table_name, func_name, {@pos_min_us, elapsed_us})
_ ->
:ok
end
end
# Same pattern for maximum.
defp update_max(func_name, elapsed_us) do
case :ets.lookup(@table_name, func_name) do
[{^func_name, _calls, _errors, _total, _min, current_max}]
when elapsed_us > current_max ->
:ets.update_element(@table_name, func_name, {@pos_max_us, elapsed_us})
_ ->
:ok
end
end
end