2026-06-06 17:53:25 +00:00
|
|
|
//! The shared per-node telemetry emitter.
|
|
|
|
|
//!
|
|
|
|
|
//! Both the generic `swactor` node and the pipeline-parallel GPU worker emit
|
|
|
|
|
//! the same datastream through this one type. A caller extracts only the few
|
|
|
|
|
//! node-specific values each tick (SWIM members, runtime counts, relay flags)
|
|
|
|
|
//! and hands them in via [`TickInput`]; the emitter owns the mux, the host/CPU
|
|
|
|
|
//! sampler, and the membership differ, and ships every assembled [`Frame`]
|
|
|
|
|
//! through a pluggable [`FrameSink`].
|
|
|
|
|
//!
|
|
|
|
|
//! The emitter is transport-agnostic: it never depends on iroh. A node ships
|
|
|
|
|
//! over the swactor cluster ([`ClusterFrameSink`]); a raw demo or test ships
|
|
|
|
|
//! over UDP or collects in memory. Swapping the sink does not change a byte of
|
|
|
|
|
//! emission logic.
|
|
|
|
|
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use std::sync::OnceLock;
|
|
|
|
|
|
|
|
|
|
use swactor::actor::ActorAddress;
|
|
|
|
|
use swactor::process_observer::ProcessOutputObserver;
|
|
|
|
|
use swactor::runtime::Runtime;
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
use super::catalog::{
|
2026-06-21 19:54:41 +00:00
|
|
|
self, ActorRuntimeDetail, DatastoreState, DatastreamHealth, DistributionState, IdentityRecord,
|
|
|
|
|
MembershipTransition, ProcStream, Record, RuntimeStats, TransportInternals, WorkerCounters,
|
2026-06-09 09:29:07 +00:00
|
|
|
};
|
2026-06-06 17:53:25 +00:00
|
|
|
use super::frame::{Frame, Lifetime, NodeId, StreamId};
|
|
|
|
|
use super::mux::Mux;
|
2026-06-21 19:54:41 +00:00
|
|
|
use super::source::{self, HostSampler, MembershipTracker};
|
2026-06-06 17:53:25 +00:00
|
|
|
use super::wire::{encode_delivery, DatastreamFrame};
|
|
|
|
|
|
|
|
|
|
/// Where assembled frames go once the mux has ordered them. A sink is the only
|
|
|
|
|
/// place transport lives; the emitter knows nothing about it.
|
|
|
|
|
pub trait FrameSink: Send {
|
|
|
|
|
/// Ship one ordered frame for `stream`. Best-effort: a sink may drop.
|
|
|
|
|
fn ship(&mut self, stream: &StreamId, frame: &Frame);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Static identity a node needs to build its emitter.
|
|
|
|
|
pub struct EmitterConfig {
|
|
|
|
|
pub node_hex: String,
|
|
|
|
|
pub life: u64,
|
|
|
|
|
pub mux_capacity: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The node-specific values a caller extracts each tick. Everything else the
|
|
|
|
|
/// emitter samples itself.
|
|
|
|
|
pub struct TickInput<'a> {
|
|
|
|
|
/// `(node_id, swim_state)` for every known peer, keyed by stable id.
|
|
|
|
|
pub members: &'a [(String, String)],
|
|
|
|
|
/// Actor-runtime metrics this tick.
|
|
|
|
|
pub runtime: RuntimeStats,
|
|
|
|
|
pub relay_connected: bool,
|
|
|
|
|
pub relay_peers: u32,
|
2026-06-21 19:54:41 +00:00
|
|
|
/// Median SWIM probe round-trip time (ms) this tick; `0` when no probe has
|
|
|
|
|
/// completed (e.g. a lone node, or before a real SWIM observer is installed).
|
|
|
|
|
pub rtt_ms_p50: u32,
|
2026-06-06 17:53:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Forwards managed-process output into a node's mux as `proc.<label>.*` text
|
|
|
|
|
/// frames, so every process the node spawns is captured with no per-spawn
|
|
|
|
|
/// wiring. Installed on the runtime via `set_process_output_observer`.
|
|
|
|
|
struct MuxProcObserver {
|
|
|
|
|
mux: Arc<Mux>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ProcessOutputObserver for MuxProcObserver {
|
|
|
|
|
fn on_output(&self, label: &str, is_stderr: bool, data: &[u8]) {
|
|
|
|
|
let stream = if is_stderr {
|
|
|
|
|
ProcStream::Stderr
|
|
|
|
|
} else {
|
|
|
|
|
ProcStream::Stdout
|
|
|
|
|
};
|
|
|
|
|
self.mux
|
|
|
|
|
.submit(catalog::process_output(label, stream), data.to_vec());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The one per-node emitter. Owns ordering (its [`Mux`]) and the periodic
|
|
|
|
|
/// samplers; ships through its [`FrameSink`].
|
|
|
|
|
pub struct DatastreamEmitter {
|
|
|
|
|
stream_id: StreamId,
|
|
|
|
|
mux: Arc<Mux>,
|
2026-06-21 19:54:41 +00:00
|
|
|
host: HostSampler,
|
2026-06-06 17:53:25 +00:00
|
|
|
membership: MembershipTracker,
|
|
|
|
|
sink: Box<dyn FrameSink>,
|
2026-06-21 19:54:41 +00:00
|
|
|
/// When set, [`tick`](Self::tick) does **not** synthesize membership from the
|
|
|
|
|
/// member-list diff (M4) — the caller drives `membership` from a real event
|
|
|
|
|
/// source via [`submit_membership`](Self::submit_membership), so the diff
|
|
|
|
|
/// would only duplicate it with empty reasons.
|
|
|
|
|
external_membership: bool,
|
2026-06-06 17:53:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DatastreamEmitter {
|
|
|
|
|
/// Build a node's emitter: a mux keyed by its stream id, with the identity
|
|
|
|
|
/// frame emitted first so the consumer can attribute the stream.
|
|
|
|
|
pub fn new(cfg: EmitterConfig, sink: Box<dyn FrameSink>) -> Self {
|
|
|
|
|
let stream_id = StreamId::new(NodeId::new(&cfg.node_hex), Lifetime(cfg.life));
|
|
|
|
|
let mux = Arc::new(Mux::new(stream_id.clone(), cfg.mux_capacity));
|
|
|
|
|
mux.submit(
|
|
|
|
|
catalog::IDENTITY,
|
|
|
|
|
source::identity_record(&cfg.node_hex, cfg.life).encode(),
|
|
|
|
|
);
|
|
|
|
|
Self {
|
|
|
|
|
stream_id,
|
|
|
|
|
mux,
|
2026-06-21 19:54:41 +00:00
|
|
|
host: HostSampler::new(),
|
2026-06-06 17:53:25 +00:00
|
|
|
membership: MembershipTracker::new(),
|
|
|
|
|
sink,
|
2026-06-21 19:54:41 +00:00
|
|
|
external_membership: false,
|
2026-06-06 17:53:25 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 19:54:41 +00:00
|
|
|
/// Switch `membership` to an external event source: [`tick`](Self::tick) stops
|
|
|
|
|
/// diffing the member list (M4), and the caller emits transitions via
|
|
|
|
|
/// [`submit_membership`](Self::submit_membership) with a real `reason`.
|
|
|
|
|
pub fn use_external_membership(&mut self) {
|
|
|
|
|
self.external_membership = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 17:53:25 +00:00
|
|
|
/// The node's mux, for producers (e.g. a raw demo) that submit directly.
|
|
|
|
|
pub fn mux(&self) -> &Arc<Mux> {
|
|
|
|
|
&self.mux
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// An observer that taps managed-process output onto this node's stream.
|
|
|
|
|
/// Register it on the runtime with `Runtime::set_process_output_observer`.
|
|
|
|
|
pub fn process_observer(&self) -> Arc<dyn ProcessOutputObserver> {
|
|
|
|
|
Arc::new(MuxProcObserver {
|
|
|
|
|
mux: self.mux.clone(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One main-loop iteration: when `sample_periodic`, submit the
|
|
|
|
|
/// host/runtime/transport records; every call diff membership and submit
|
|
|
|
|
/// transitions; then drain the mux and ship every ordered frame.
|
|
|
|
|
pub fn tick(&mut self, input: TickInput, sample_periodic: bool) {
|
|
|
|
|
if sample_periodic {
|
|
|
|
|
self.mux.submit(
|
|
|
|
|
catalog::HOST_RESOURCE,
|
2026-06-21 19:54:41 +00:00
|
|
|
source::read_host_resource(&mut self.host).encode(),
|
2026-06-06 17:53:25 +00:00
|
|
|
);
|
|
|
|
|
self.mux.submit(catalog::RUNTIME_STATS, input.runtime.encode());
|
|
|
|
|
let transport = TransportInternals {
|
|
|
|
|
relay_connected: input.relay_connected,
|
|
|
|
|
direct_peers: input
|
|
|
|
|
.members
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|(_, s)| s == "alive")
|
|
|
|
|
.count() as u32,
|
|
|
|
|
relay_peers: input.relay_peers,
|
2026-06-21 19:54:41 +00:00
|
|
|
rtt_ms_p50: input.rtt_ms_p50,
|
2026-06-06 17:53:25 +00:00
|
|
|
};
|
|
|
|
|
self.mux
|
|
|
|
|
.submit(catalog::TRANSPORT_INTERNALS, transport.encode());
|
2026-06-21 19:54:41 +00:00
|
|
|
|
|
|
|
|
// The pipe reporting on its own integrity: positions assigned vs.
|
|
|
|
|
// frames dropped on mux overflow. Read before submitting this frame,
|
|
|
|
|
// so the figures exclude the health frame itself. Rides every tick, so
|
|
|
|
|
// it ships on any node that ticks (the demo's FleetEmitter included).
|
|
|
|
|
let assigned = self.mux.assigned();
|
|
|
|
|
let dropped = self.mux.dropped();
|
|
|
|
|
let loss_rate_ppm = if assigned > 0 {
|
|
|
|
|
((dropped as u128 * 1_000_000) / assigned as u128).min(u32::MAX as u128) as u32
|
|
|
|
|
} else {
|
|
|
|
|
0
|
|
|
|
|
};
|
|
|
|
|
self.mux.submit(
|
|
|
|
|
catalog::DATASTREAM_HEALTH,
|
|
|
|
|
DatastreamHealth {
|
|
|
|
|
assigned,
|
|
|
|
|
dropped,
|
|
|
|
|
loss_rate_ppm,
|
|
|
|
|
}
|
|
|
|
|
.encode(),
|
|
|
|
|
);
|
2026-06-06 17:53:25 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-21 19:54:41 +00:00
|
|
|
// M4 fallback: synthesize membership from the member-list diff, unless the
|
|
|
|
|
// caller drives it from a real event source (`use_external_membership`).
|
|
|
|
|
if !self.external_membership {
|
|
|
|
|
for transition in self.membership.diff(input.members) {
|
|
|
|
|
self.mux.submit(catalog::MEMBERSHIP, transition.encode());
|
|
|
|
|
}
|
2026-06-06 17:53:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for frame in self.mux.drain() {
|
|
|
|
|
self.sink.ship(&self.stream_id, &frame);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
/// Submit the consolidated distribution-subsystem state. Periodic; the node
|
|
|
|
|
/// builds this from its actor mirrors each refresh. Drained by the next
|
|
|
|
|
/// [`tick`](Self::tick).
|
|
|
|
|
pub fn submit_dist_state(&self, state: &DistributionState) {
|
|
|
|
|
self.mux.submit(catalog::DIST_STATE, state.encode());
|
|
|
|
|
}
|
2026-06-06 17:53:25 +00:00
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
/// Submit the consolidated datastore steady metrics. Periodic.
|
|
|
|
|
pub fn submit_datastore_state(&self, state: &DatastoreState) {
|
|
|
|
|
self.mux.submit(catalog::DATASTORE_STATE, state.encode());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Submit the per-actor runtime detail table. Periodic.
|
|
|
|
|
pub fn submit_actor_detail(&self, detail: &ActorRuntimeDetail) {
|
|
|
|
|
self.mux.submit(catalog::RUNTIME_ACTORS, detail.encode());
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 19:54:41 +00:00
|
|
|
/// Submit the aggregated worker-runtime counters (routing/error tallies +
|
|
|
|
|
/// tick timing) onto the pipe — the deep slice the thin `runtime.stats`
|
|
|
|
|
/// heartbeat omits. Periodic.
|
|
|
|
|
pub fn submit_worker_counters(&self, counters: &WorkerCounters) {
|
|
|
|
|
self.mux.submit(catalog::RUNTIME_WORKERS, counters.encode());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Submit one membership transition from a real event source (the SWIM
|
|
|
|
|
/// observer), carrying a non-empty `reason`. Event-driven; pairs with
|
|
|
|
|
/// [`use_external_membership`](Self::use_external_membership), which turns off
|
|
|
|
|
/// the M4 diff so this is the sole `membership` source.
|
|
|
|
|
pub fn submit_membership(&self, transition: &MembershipTransition) {
|
|
|
|
|
self.mux.submit(catalog::MEMBERSHIP, transition.encode());
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
/// Re-emit the identity record once late-bound fields (name, listen addr,
|
|
|
|
|
/// relay URL, version) are known. "Latest wins" on the consumer, so this
|
|
|
|
|
/// supersedes the minimal boot identity emitted in [`new`](Self::new).
|
|
|
|
|
pub fn update_identity(&self, identity: &IdentityRecord) {
|
|
|
|
|
self.mux.submit(catalog::IDENTITY, identity.encode());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A cheap, cloneable handle for submitting event-driven frames onto this
|
|
|
|
|
/// node's stream from any thread — the same shared-mux path as the process
|
|
|
|
|
/// observer, for sources (e.g. the datastore) whose events fire on their own
|
|
|
|
|
/// worker threads rather than in the main loop.
|
|
|
|
|
pub fn event_sink(&self) -> DatastreamEventSink {
|
|
|
|
|
DatastreamEventSink {
|
|
|
|
|
mux: self.mux.clone(),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-06 17:53:25 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
/// A thread-safe submit handle (see [`DatastreamEmitter::event_sink`]). Holds a
|
|
|
|
|
/// clone of the node's mux; submitted frames drain in the node's main loop.
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct DatastreamEventSink {
|
|
|
|
|
mux: Arc<Mux>,
|
2026-06-06 17:53:25 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
impl DatastreamEventSink {
|
|
|
|
|
/// Record one datastore operation as a `datastore.events` text line (JSON,
|
|
|
|
|
/// one object per line — the text channel carries structured op records the
|
|
|
|
|
/// consumer tails to rebuild the recent-operations timeline).
|
|
|
|
|
pub fn datastore_event(
|
|
|
|
|
&self,
|
|
|
|
|
timestamp_ms: u64,
|
|
|
|
|
kind: &str,
|
|
|
|
|
hash: &str,
|
|
|
|
|
name: Option<&str>,
|
|
|
|
|
size_bytes: u64,
|
|
|
|
|
) {
|
|
|
|
|
let line = serde_json::json!({
|
|
|
|
|
"timestamp_ms": timestamp_ms,
|
|
|
|
|
"kind": kind,
|
|
|
|
|
"hash": hash,
|
|
|
|
|
"name": name,
|
|
|
|
|
"size_bytes": size_bytes,
|
|
|
|
|
})
|
|
|
|
|
.to_string();
|
|
|
|
|
self.mux
|
|
|
|
|
.submit(catalog::datastore_event(), line.into_bytes());
|
2026-06-06 17:53:25 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
/// A sink that drops everything. Used by a node before it knows where to ship
|
|
|
|
|
/// (e.g. a standalone node with no orchestrator), so the mux still drains and
|
|
|
|
|
/// stays bounded.
|
|
|
|
|
pub struct NoopSink;
|
|
|
|
|
|
|
|
|
|
impl FrameSink for NoopSink {
|
|
|
|
|
fn ship(&mut self, _stream: &StreamId, _frame: &Frame) {}
|
2026-06-06 17:53:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Ships frames over the swactor cluster to the orchestrator's `datastream-sink`
|
|
|
|
|
/// actor, reusing the exact `register_name`/`resolve_name` + transport-router
|
|
|
|
|
/// path the application already uses. The destination is late-bound through a
|
|
|
|
|
/// shared `OnceLock`: until the sink resolves (the orchestrator may not have
|
|
|
|
|
/// joined yet) frames are dropped, and the mux's bounded buffer absorbs the
|
|
|
|
|
/// gap — the same tolerance as a lazily re-resolved collector.
|
|
|
|
|
pub struct ClusterFrameSink {
|
|
|
|
|
rt: Arc<Runtime>,
|
|
|
|
|
sink: Arc<OnceLock<ActorAddress>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ClusterFrameSink {
|
|
|
|
|
pub fn new(rt: Arc<Runtime>, sink: Arc<OnceLock<ActorAddress>>) -> Self {
|
|
|
|
|
Self { rt, sink }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl FrameSink for ClusterFrameSink {
|
|
|
|
|
fn ship(&mut self, stream: &StreamId, frame: &Frame) {
|
|
|
|
|
if let Some(addr) = self.sink.get() {
|
|
|
|
|
let _ = self.rt.send_to(
|
|
|
|
|
*addr,
|
|
|
|
|
DatastreamFrame {
|
|
|
|
|
payload: encode_delivery(stream, frame),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|