metrics: optional OpenMetrics/Prometheus endpoint (metrics_bind, off by default) — commands/messages/connects counters bumped inline via shared atomics, users/channels/servers/links gauges republished each tick; no event round-trip on the hot path

This commit is contained in:
Jean Chevronnet 2026-08-18 22:34:36 +00:00
parent 30754f08b5
commit d4dadf33e6
6 changed files with 114 additions and 0 deletions

View file

@ -475,6 +475,11 @@ impl Ircd {
);
return;
}
use std::sync::atomic::Ordering::Relaxed;
self.server.metrics.commands.fetch_add(1, Relaxed);
if matches!(cmd, "PRIVMSG" | "NOTICE") {
self.server.metrics.messages.fetch_add(1, Relaxed);
}
let _ = handler.handle(&mut self.server, uid, &msg.params);
for m in &mut self.modules {
@ -669,6 +674,16 @@ impl Ircd {
.send(uid, format!("ERROR :Closing link: ({reason})"));
self.quit_user(uid, reason);
}
// republish gauges (the core owns this state; the scrape thread only reads)
use std::sync::atomic::Ordering::Relaxed;
let m = &self.server.metrics;
m.users.store(
self.server.users.values().filter(|u| u.registered).count() as u64,
Relaxed,
);
m.channels.store(self.server.channels.len() as u64, Relaxed);
m.servers.store(self.server.servers.len() as u64, Relaxed);
m.links.store(self.server.links.len() as u64, Relaxed);
}
/// Fire queued notify-hooks. Draining a queue (not iterating in place) lets a

View file

@ -249,6 +249,9 @@ fn main() {
// optional JSON-RPC-over-HTTP control interface (see crate::modules::rpc)
echoircd::modules::rpc::maybe_start(&cfg, tx.clone());
// optional OpenMetrics/Prometheus scrape endpoint (metrics_bind = host:port)
echoircd::modules::metrics::maybe_start(&cfg);
// optional WebSocket transport for browser IRC clients (see crate::websocket)
echoircd::websocket::maybe_start(&cfg, tx.clone(), counter.clone());

89
src/modules/metrics.rs Normal file
View file

@ -0,0 +1,89 @@
//! metrics — an optional Prometheus/OpenMetrics endpoint. Enable with
//! `metrics_bind = 127.0.0.1:9100` in the config (off by default).
//!
//! Counters live in a process-wide `Arc<Metrics>` of atomics: the core bumps them
//! inline (a relaxed atomic add, no lock, no event round-trip), and a tiny HTTP
//! thread reads them on scrape. Gauges (current users/channels/servers) are
//! republished each tick by the core, which owns that state.
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::sync::{Arc, OnceLock};
use crate::config::Config;
/// Every metric echoircd exposes. Counters only ever increase; gauges are set to
/// the live count each tick.
#[derive(Default)]
pub struct Metrics {
// counters (monotonic)
pub commands: AtomicU64,
pub messages: AtomicU64,
pub connects: AtomicU64,
// gauges (republished each tick)
pub users: AtomicU64,
pub channels: AtomicU64,
pub servers: AtomicU64,
pub links: AtomicU64,
}
static METRICS: OnceLock<Arc<Metrics>> = OnceLock::new();
/// The shared metrics handle (created on first use). The core and the HTTP scrape
/// thread both call this, so they see the same atomics.
pub fn handle() -> Arc<Metrics> {
METRICS.get_or_init(|| Arc::new(Metrics::default())).clone()
}
/// Render the current values in OpenMetrics/Prometheus text exposition format.
fn render(m: &Metrics) -> String {
let mut o = String::new();
let counter = |o: &mut String, name: &str, help: &str, v: u64| {
o.push_str(&format!("# HELP {name} {help}\n# TYPE {name} counter\n{name} {v}\n"));
};
let gauge = |o: &mut String, name: &str, help: &str, v: u64| {
o.push_str(&format!("# HELP {name} {help}\n# TYPE {name} gauge\n{name} {v}\n"));
};
counter(&mut o, "echoircd_commands_total", "Commands dispatched.", m.commands.load(Relaxed));
counter(&mut o, "echoircd_messages_total", "PRIVMSG/NOTICE handled.", m.messages.load(Relaxed));
counter(&mut o, "echoircd_connects_total", "Client registrations completed.", m.connects.load(Relaxed));
gauge(&mut o, "echoircd_users", "Registered users online.", m.users.load(Relaxed));
gauge(&mut o, "echoircd_channels", "Channels in existence.", m.channels.load(Relaxed));
gauge(&mut o, "echoircd_servers", "Servers known on the network.", m.servers.load(Relaxed));
gauge(&mut o, "echoircd_links", "Direct server links.", m.links.load(Relaxed));
o
}
/// Start the scrape endpoint if `metrics_bind` is configured. Serves any GET with
/// the exposition text; it carries no secrets, so bind it somewhere private.
pub fn maybe_start(cfg: &Config) {
let Some(bind) = cfg.raw.get("metrics_bind").and_then(|v| v.last()).filter(|s| !s.is_empty()) else {
return;
};
let bind = bind.to_string();
let metrics = handle();
match TcpListener::bind(&bind) {
Ok(listener) => {
eprintln!("echoircd metrics (OpenMetrics) on {bind}");
std::thread::spawn(move || serve(listener, metrics));
}
Err(e) => eprintln!("echoircd: cannot bind metrics {bind}: {e}"),
}
}
fn serve(listener: TcpListener, metrics: Arc<Metrics>) {
for stream in listener.incoming() {
let Ok(mut s) = stream else { continue };
// read (and ignore) the request head, then reply — this is a scrape, no routing
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf);
let body = render(&metrics);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = s.write_all(resp.as_bytes());
}
}

View file

@ -47,6 +47,7 @@ pub mod log_json;
pub mod maphide;
pub mod markread;
pub mod metadata;
pub mod metrics;
pub mod multiline;
pub mod namedmodes;
pub mod network_icon;

View file

@ -174,6 +174,8 @@ pub struct Server {
/// Module-owned server state, keyed by type. Each `modules/*.rs` stores its
/// own struct here so features live in their own file instead of this one.
pub ext: Extensible,
/// Prometheus counters/gauges, shared with the scrape thread (modules::metrics).
pub metrics: Arc<crate::modules::metrics::Metrics>,
}
impl Server {
@ -220,6 +222,7 @@ impl Server {
event_tx,
conn_counter,
ext: Extensible::default(),
metrics: crate::modules::metrics::handle(),
}
}

View file

@ -439,6 +439,9 @@ impl Server {
if let Some(u) = self.users.get_mut(&uid) {
u.registered = true;
}
self.metrics
.connects
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let nick = self
.users
.get(&uid)