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:
parent
30754f08b5
commit
d4dadf33e6
6 changed files with 114 additions and 0 deletions
15
src/ircd.rs
15
src/ircd.rs
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
89
src/modules/metrics.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue