Skip to content

Commit b79a551

Browse files
committed
watt: implement prometheus metrics
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Iede663e1bbef1492b8f5c683159a625c6a6a6964
1 parent cd8a771 commit b79a551

8 files changed

Lines changed: 255 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ log = "0.4.29"
2626
nix = { features = [ "fs", "process", "signal" ], version = "0.31.2" }
2727
num_cpus = "1.17.0"
2828
serde = { features = [ "derive" ], version = "1.0.228" }
29+
tiny_http = "0.12.0"
30+
tokio = { features = [ "macros", "rt-multi-thread", "signal", "time" ], version = "1.52.3" }
2931
toml = "1.1.2"
3032
yansi = { features = [ "detect-env", "detect-tty" ], version = "1.0.1" }
3133
zbus = { default-features = false, features = [ "tokio" ], version = "5.13.2" }
32-
tokio = { features = [ "macros", "rt-multi-thread", "signal", "time" ], version = "1.49.0" }
3334

3435
[profile.release]
3536
codegen-units = 1

watt/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@ log.workspace = true
2424
nix.workspace = true
2525
num_cpus.workspace = true
2626
serde.workspace = true
27+
tiny_http = { optional = true, workspace = true }
28+
tokio.workspace = true
2729
toml.workspace = true
2830
yansi.workspace = true
2931
zbus.workspace = true
30-
tokio.workspace = true
32+
33+
[features]
34+
default = [ ]
35+
metrics = [ "dep:tiny_http" ]
3136

3237
[dev-dependencies]
3338
proptest = "1.9.0"

watt/config.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#[cfg(feature = "metrics")] use std::net::IpAddr;
12
use std::{
23
collections::{
34
HashMap,
@@ -1120,10 +1121,42 @@ impl Default for Rule {
11201121
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
11211122
#[serde(default, rename_all = "kebab-case")]
11221123
pub struct DaemonConfig {
1124+
#[cfg(feature = "metrics")]
1125+
#[serde(skip_serializing_if = "Option::is_none")]
1126+
pub metrics: Option<MetricsConfig>,
1127+
1128+
#[cfg(not(feature = "metrics"))]
1129+
#[serde(
1130+
default,
1131+
skip_serializing,
1132+
deserialize_with = "deserialize_metrics_disabled"
1133+
)]
1134+
metrics: (),
1135+
11231136
#[serde(rename = "rule")]
11241137
pub rules: Vec<Rule>,
11251138
}
11261139

1140+
#[cfg(feature = "metrics")]
1141+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1142+
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
1143+
pub struct MetricsConfig {
1144+
pub listen_addr: IpAddr,
1145+
pub port: u16,
1146+
}
1147+
1148+
#[cfg(not(feature = "metrics"))]
1149+
fn deserialize_metrics_disabled<'de, D>(
1150+
_deserializer: D,
1151+
) -> Result<(), D::Error>
1152+
where
1153+
D: serde::Deserializer<'de>,
1154+
{
1155+
Err(serde::de::Error::custom(
1156+
"metrics config requires building watt with --features metrics",
1157+
))
1158+
}
1159+
11271160
impl DaemonConfig {
11281161
const DEFAULT: &str = include_str!("config.toml");
11291162

watt/config.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Watt Default Configuration
22

3+
# Metrics are available only when watt is built with `--features metrics`.
4+
# Uncomment this section to expose Prometheus metrics.
5+
# [metrics]
6+
# listen-addr = "[::]"
7+
# port = 9790
8+
39
# Rules are evaluated by priority (higher number => higher priority).
410
# Each rule can specify conditions and actions for CPU and power management.
511

watt/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod config;
1515
pub mod lock;
1616

1717
pub mod dbus;
18+
#[cfg(feature = "metrics")] pub mod metrics;
1819
pub mod profile;
1920

2021
#[derive(clap::Parser, Debug)]

watt/metrics.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
use std::{
2+
fmt::Write as _,
3+
net::SocketAddr,
4+
sync::Arc,
5+
thread,
6+
};
7+
8+
use anyhow::Context as _;
9+
use tiny_http::{
10+
Header,
11+
Method,
12+
Response,
13+
Server,
14+
StatusCode,
15+
};
16+
use tokio::sync::RwLock;
17+
18+
use crate::{
19+
config,
20+
profile,
21+
system::DaemonState,
22+
};
23+
24+
pub fn start(
25+
config: &config::MetricsConfig,
26+
state: Arc<RwLock<DaemonState>>,
27+
) -> anyhow::Result<()> {
28+
let address = SocketAddr::new(config.listen_addr, config.port);
29+
let server = Server::http(address)
30+
.map_err(|err| anyhow::anyhow!(err))
31+
.with_context(|| {
32+
format!("failed to bind metrics HTTP server to {address}")
33+
})?;
34+
35+
thread::Builder::new()
36+
.name("watt-metrics".to_owned())
37+
.spawn(move || serve(server, state))
38+
.context("failed to spawn metrics server thread")?;
39+
40+
log::info!("serving Prometheus metrics at http://{address}/metrics",);
41+
42+
Ok(())
43+
}
44+
45+
fn serve(server: Server, state: Arc<RwLock<DaemonState>>) {
46+
for request in server.incoming_requests() {
47+
let response =
48+
if request.method() == &Method::Get && request.url() == "/metrics" {
49+
let metrics = render_metrics(&state);
50+
let content_type = Header::from_bytes(
51+
b"Content-Type",
52+
b"text/plain; version=0.0.4; charset=utf-8",
53+
)
54+
.expect("static metrics content type header is valid");
55+
56+
Response::from_string(metrics).with_header(content_type)
57+
} else {
58+
Response::from_string("not found\n").with_status_code(StatusCode(404))
59+
};
60+
61+
if let Err(error) = request.respond(response) {
62+
log::warn!("failed to respond to metrics request: {error}");
63+
}
64+
}
65+
66+
log::error!("metrics HTTP server loop exited unexpectedly");
67+
}
68+
69+
fn render_metrics(state: &RwLock<DaemonState>) -> String {
70+
let state = state.blocking_read();
71+
let mut metrics = String::new();
72+
73+
metric_help(
74+
&mut metrics,
75+
"watt_rule_count",
76+
"Configured daemon rule count.",
77+
);
78+
metric_type(&mut metrics, "watt_rule_count", "gauge");
79+
metric(&mut metrics, "watt_rule_count", state.rule_count() as f64);
80+
81+
metric_help(&mut metrics, "watt_cpu_count", "Detected CPU count.");
82+
metric_type(&mut metrics, "watt_cpu_count", "gauge");
83+
metric(&mut metrics, "watt_cpu_count", state.cpu_count() as f64);
84+
85+
if let Some(cpu_log) = state.latest_cpu_log() {
86+
metric_help(
87+
&mut metrics,
88+
"watt_cpu_usage_ratio",
89+
"CPU usage ratio from 0 to 1.",
90+
);
91+
metric_type(&mut metrics, "watt_cpu_usage_ratio", "gauge");
92+
metric(&mut metrics, "watt_cpu_usage_ratio", cpu_log.usage);
93+
94+
metric_help(
95+
&mut metrics,
96+
"watt_cpu_load_average_1m",
97+
"One minute CPU load average.",
98+
);
99+
metric_type(&mut metrics, "watt_cpu_load_average_1m", "gauge");
100+
metric(
101+
&mut metrics,
102+
"watt_cpu_load_average_1m",
103+
cpu_log.load_average,
104+
);
105+
106+
if let Some(temperature) = cpu_log.temperature {
107+
metric_help(
108+
&mut metrics,
109+
"watt_cpu_temperature_celsius",
110+
"CPU temperature in degrees Celsius.",
111+
);
112+
metric_type(&mut metrics, "watt_cpu_temperature_celsius", "gauge");
113+
metric(&mut metrics, "watt_cpu_temperature_celsius", temperature);
114+
}
115+
}
116+
117+
metric_help(
118+
&mut metrics,
119+
"watt_power_supply_discharging",
120+
"Whether the system is currently discharging.",
121+
);
122+
metric_type(&mut metrics, "watt_power_supply_discharging", "gauge");
123+
metric(
124+
&mut metrics,
125+
"watt_power_supply_discharging",
126+
u8::from(state.is_discharging()) as f64,
127+
);
128+
129+
metric_help(
130+
&mut metrics,
131+
"watt_active_profile",
132+
"Active power profile labelled by profile name.",
133+
);
134+
metric_type(&mut metrics, "watt_active_profile", "gauge");
135+
136+
let active_profile = state.active_profile();
137+
for profile in profile::PowerProfile::all() {
138+
let value = u8::from(profile == active_profile) as f64;
139+
labelled_metric(
140+
&mut metrics,
141+
"watt_active_profile",
142+
"profile",
143+
profile.as_str(),
144+
value,
145+
);
146+
}
147+
148+
metrics
149+
}
150+
151+
fn metric_help(metrics: &mut String, name: &str, help: &str) {
152+
let _ = writeln!(metrics, "# HELP {name} {help}");
153+
}
154+
155+
fn metric_type(metrics: &mut String, name: &str, ty: &str) {
156+
let _ = writeln!(metrics, "# TYPE {name} {ty}");
157+
}
158+
159+
fn metric(metrics: &mut String, name: &str, value: f64) {
160+
let _ = writeln!(metrics, "{name} {value}");
161+
}
162+
163+
fn labelled_metric(
164+
metrics: &mut String,
165+
name: &str,
166+
label: &str,
167+
label_value: &str,
168+
value: f64,
169+
) {
170+
let _ = writeln!(metrics, r#"{name}{{{label}="{label_value}"}} {value}"#);
171+
}

watt/system.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,11 @@ pub async fn run_daemon(config: config::DaemonConfig) -> anyhow::Result<()> {
977977

978978
let state = Arc::new(RwLock::new(DaemonState::new(config.rules.len())));
979979

980+
#[cfg(feature = "metrics")]
981+
if let Some(metrics_config) = &config.metrics {
982+
crate::metrics::start(metrics_config, Arc::clone(&state))?;
983+
}
984+
980985
tokio::spawn({
981986
let state = Arc::clone(&state);
982987
async move {

0 commit comments

Comments
 (0)