Packages

A self-hosted, real-time dashboard for running bloccs networks — topology, live per-node metrics, and coverage, mounted into your Phoenix app.

Current section

Files

Jump to
bloccs_web lib bloccs web telemetry flow.ex
Raw

lib/bloccs/web/telemetry/flow.ex

defmodule Bloccs.Web.Telemetry.Flow do
@moduledoc """
The pure core behind the Messages panel: a bounded record of messages moving
through a network. Each event is one edge traversal (a `[:bloccs, :emit]`
correlated with the emitting node's outcome and latency) or a failure/drop.
Keeps a ring of the most recent events for the activity feed and per-second
buckets for the throughput chart. Like the rest of the collector core it owns
no clock — `now` (ms) is passed in — so it is deterministically testable.
"""
@max_recent 100
@bucket_window 60
@keep_seconds 90
@type endpoint :: {atom(), atom()}
@type outcome :: :ok | :failed | :dropped | :skipped | :retry | :dispatch_error
@typedoc "A normalized flow event (before timestamping)."
@type event :: %{
node: atom(),
out_port: atom() | nil,
to: endpoint() | nil,
outcome: outcome(),
duration_ms: number() | nil,
reason: term() | nil,
payload: String.t() | nil
}
@type t :: %{recent: [map()], buckets: %{integer() => map()}}
@spec new() :: t()
def new, do: %{recent: [], buckets: %{}}
@spec record(t(), event(), integer()) :: t()
def record(state, event, now) do
sec = div(now, 1000)
cls = classify(event.outcome)
bucket =
state.buckets
|> Map.get(sec, %{ok: 0, failed: 0, dropped: 0, other: 0})
|> Map.update!(cls, &(&1 + 1))
%{
state
| recent: [Map.put(event, :at, now) | state.recent] |> Enum.take(@max_recent),
buckets: state.buckets |> Map.put(sec, bucket) |> prune(sec)
}
end
@doc """
A snapshot for the panel: the recent events (newest first), a per-second
series for the chart, and the current rate (events in the last full second).
"""
@spec snapshot(t(), integer()) :: %{events: [map()], series: [map()], rate: non_neg_integer()}
def snapshot(state, now) do
cur = div(now, 1000)
series =
for s <- (cur - @bucket_window + 1)..cur do
b = Map.get(state.buckets, s, %{ok: 0, failed: 0, dropped: 0, other: 0})
%{ok: b.ok, failed: b.failed, dropped: b.dropped, other: b.other, total: total(b)}
end
%{
events: state.recent,
series: series,
rate: state.buckets |> Map.get(cur - 1, %{}) |> total()
}
end
# ---- internals ----
defp classify(:ok), do: :ok
defp classify(:failed), do: :failed
defp classify(:dispatch_error), do: :failed
defp classify(:dropped), do: :dropped
defp classify(:skipped), do: :dropped
defp classify(_), do: :other
defp total(b),
do:
Map.get(b, :ok, 0) + Map.get(b, :failed, 0) + Map.get(b, :dropped, 0) +
Map.get(b, :other, 0)
defp prune(buckets, cur), do: Map.reject(buckets, fn {s, _} -> s < cur - @keep_seconds end)
end