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;
|
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);
|
let _ = handler.handle(&mut self.server, uid, &msg.params);
|
||||||
|
|
||||||
for m in &mut self.modules {
|
for m in &mut self.modules {
|
||||||
|
|
@ -669,6 +674,16 @@ impl Ircd {
|
||||||
.send(uid, format!("ERROR :Closing link: ({reason})"));
|
.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||||
self.quit_user(uid, 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
|
/// 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)
|
// optional JSON-RPC-over-HTTP control interface (see crate::modules::rpc)
|
||||||
echoircd::modules::rpc::maybe_start(&cfg, tx.clone());
|
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)
|
// optional WebSocket transport for browser IRC clients (see crate::websocket)
|
||||||
echoircd::websocket::maybe_start(&cfg, tx.clone(), counter.clone());
|
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 maphide;
|
||||||
pub mod markread;
|
pub mod markread;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
|
pub mod metrics;
|
||||||
pub mod multiline;
|
pub mod multiline;
|
||||||
pub mod namedmodes;
|
pub mod namedmodes;
|
||||||
pub mod network_icon;
|
pub mod network_icon;
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,8 @@ pub struct Server {
|
||||||
/// Module-owned server state, keyed by type. Each `modules/*.rs` stores its
|
/// 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.
|
/// own struct here so features live in their own file instead of this one.
|
||||||
pub ext: Extensible,
|
pub ext: Extensible,
|
||||||
|
/// Prometheus counters/gauges, shared with the scrape thread (modules::metrics).
|
||||||
|
pub metrics: Arc<crate::modules::metrics::Metrics>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
|
|
@ -220,6 +222,7 @@ impl Server {
|
||||||
event_tx,
|
event_tx,
|
||||||
conn_counter,
|
conn_counter,
|
||||||
ext: Extensible::default(),
|
ext: Extensible::default(),
|
||||||
|
metrics: crate::modules::metrics::handle(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -439,6 +439,9 @@ impl Server {
|
||||||
if let Some(u) = self.users.get_mut(&uid) {
|
if let Some(u) = self.users.get_mut(&uid) {
|
||||||
u.registered = true;
|
u.registered = true;
|
||||||
}
|
}
|
||||||
|
self.metrics
|
||||||
|
.connects
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
let nick = self
|
let nick = self
|
||||||
.users
|
.users
|
||||||
.get(&uid)
|
.get(&uid)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue