use super::{MetricBuilder, MetricExporter};
use crate::device::ProcessInfo;
pub const MAX_COMMAND_LABEL_LEN: usize = 256;
pub const MAX_NAME_LABEL_LEN: usize = 128;
pub const MAX_USER_LABEL_LEN: usize = 128;
pub struct ProcessMetricExporter<'a> {
pub process_info: &'a [ProcessInfo],
}
impl<'a> ProcessMetricExporter<'a> {
pub fn truncate_for_label(value: &str, max_len: usize) -> std::borrow::Cow<'_, str> {
if value.len() <= max_len {
return std::borrow::Cow::Borrowed(value);
}
let mut boundary = max_len;
while boundary > 0 && !value.is_char_boundary(boundary) {
boundary -= 1;
}
let dropped = value.len() - boundary;
std::borrow::Cow::Owned(format!(
"{prefix}...({dropped} bytes truncated)",
prefix = &value[..boundary]
))
}
pub fn new(process_info: &'a [ProcessInfo]) -> Self {
Self { process_info }
}
pub fn parse_start_time_seconds_public(start_time: &str) -> u64 {
Self::parse_start_time_seconds(start_time)
}
fn parse_start_time_seconds(start_time: &str) -> u64 {
let trimmed = start_time.trim();
if trimmed.is_empty() {
return 0;
}
let without_fraction = trimmed.split_once('.').map(|(l, _)| l).unwrap_or(trimmed);
let parts: Vec<&str> = without_fraction.split(':').collect();
let mut total: u64 = 0;
let mut multiplier: u64 = 1;
for part in parts.iter().rev() {
match part.parse::<u64>() {
Ok(v) => {
total = total.saturating_add(v.saturating_mul(multiplier));
multiplier = multiplier.saturating_mul(60);
}
Err(_) => return 0,
}
}
total
}
fn export_process_metrics(&self, builder: &mut MetricBuilder, process: &ProcessInfo) {
let pid_str = process.pid.to_string();
let device_id_str = process.device_id.to_string();
let command_label = Self::truncate_for_label(&process.command, MAX_COMMAND_LABEL_LEN);
let name_label = Self::truncate_for_label(&process.process_name, MAX_NAME_LABEL_LEN);
let user_label = Self::truncate_for_label(&process.user, MAX_USER_LABEL_LEN);
let labels = [
("pid", pid_str.as_str()),
("name", name_label.as_ref()),
("user", user_label.as_ref()),
("device_id", device_id_str.as_str()),
("gpu_index", device_id_str.as_str()),
("device_uuid", process.device_uuid.as_str()),
("command", command_label.as_ref()),
];
builder
.help(
"all_smi_process_memory_used_bytes",
"Process GPU memory used in bytes",
)
.type_("all_smi_process_memory_used_bytes", "gauge")
.metric(
"all_smi_process_memory_used_bytes",
&labels,
process.used_memory,
);
let start_seconds = Self::parse_start_time_seconds(&process.start_time);
builder
.help(
"all_smi_process_start_time_seconds",
"Wall-clock seconds since the process started (TIME+ \
equivalent)",
)
.type_("all_smi_process_start_time_seconds", "gauge")
.metric("all_smi_process_start_time_seconds", &labels, start_seconds);
builder
.help(
"all_smi_process_cpu_percent",
"Process CPU utilization percentage",
)
.type_("all_smi_process_cpu_percent", "gauge")
.metric(
"all_smi_process_cpu_percent",
&labels,
format!("{:.2}", process.cpu_percent),
);
}
}
impl<'a> MetricExporter for ProcessMetricExporter<'a> {
fn export_metrics(&self) -> String {
if self.process_info.is_empty() {
return String::new();
}
let mut builder = MetricBuilder::new();
for process in self.process_info {
self.export_process_metrics(&mut builder, process);
}
builder.build()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::device::ProcessInfo;
fn make_process(pid: u32, user: &str, start_time: &str, used_memory: u64) -> ProcessInfo {
ProcessInfo {
device_id: 1,
device_uuid: "GPU-UUID-1".to_string(),
pid,
process_name: "python".to_string(),
used_memory,
cpu_percent: 12.5,
memory_percent: 1.0,
memory_rss: 0,
memory_vms: 0,
user: user.to_string(),
state: "R".to_string(),
start_time: start_time.to_string(),
cpu_time: 0,
command: "python train.py".to_string(),
ppid: 1,
threads: 1,
uses_gpu: used_memory > 0,
priority: 20,
nice_value: 0,
gpu_utilization: 0.0,
}
}
#[test]
fn exporter_is_silent_on_empty_input() {
let exporter = ProcessMetricExporter::new(&[]);
assert_eq!(exporter.export_metrics(), "");
}
#[test]
fn exporter_emits_memory_and_start_time() {
let procs = [make_process(1234, "alice", "01:02:03", 2_000_000_000)];
let exporter = ProcessMetricExporter::new(&procs);
let out = exporter.export_metrics();
assert!(
out.contains("all_smi_process_memory_used_bytes"),
"missing memory metric: {out}"
);
assert!(
out.contains("all_smi_process_start_time_seconds"),
"missing start-time metric: {out}"
);
assert!(
out.contains("3723"),
"expected start-seconds 3723 in output: {out}"
);
assert!(out.contains("user=\"alice\""), "missing user label: {out}");
assert!(out.contains("pid=\"1234\""), "missing pid label: {out}");
assert!(
out.contains("gpu_index=\"1\""),
"missing gpu_index label: {out}"
);
}
#[test]
fn parse_start_time_handles_plain_seconds() {
assert_eq!(ProcessMetricExporter::parse_start_time_seconds("42"), 42);
}
#[test]
fn parse_start_time_handles_mm_ss() {
assert_eq!(
ProcessMetricExporter::parse_start_time_seconds("05:30"),
330
);
}
#[test]
fn parse_start_time_handles_hh_mm_ss() {
assert_eq!(
ProcessMetricExporter::parse_start_time_seconds("02:00:00"),
7200
);
}
#[test]
fn parse_start_time_strips_fraction() {
assert_eq!(
ProcessMetricExporter::parse_start_time_seconds("00:10.55"),
10
);
}
#[test]
fn parse_start_time_returns_zero_on_junk() {
assert_eq!(ProcessMetricExporter::parse_start_time_seconds(""), 0);
assert_eq!(ProcessMetricExporter::parse_start_time_seconds("abc"), 0);
assert_eq!(
ProcessMetricExporter::parse_start_time_seconds("12:xx"),
0,
"malformed input must not panic"
);
}
#[test]
fn truncate_for_label_passes_through_short_strings() {
let short = "python train.py";
let out = ProcessMetricExporter::truncate_for_label(short, 256);
assert_eq!(out.as_ref(), short);
}
#[test]
fn truncate_for_label_clips_long_strings_with_marker() {
let long = "a".repeat(4096);
let out = ProcessMetricExporter::truncate_for_label(&long, 256);
assert!(out.len() < long.len(), "should be shorter than input");
assert!(
out.contains("bytes truncated"),
"expected truncation marker, got: {out}"
);
assert!(
out.starts_with(&"a".repeat(256)),
"should preserve the first 256 bytes verbatim"
);
}
#[test]
fn truncate_for_label_respects_utf8_boundaries() {
let unit = "\u{1F600}";
let long = unit.repeat(75);
let out = ProcessMetricExporter::truncate_for_label(&long, 258);
assert!(out.contains("bytes truncated"));
}
#[test]
fn exporter_emits_truncated_command_label() {
let mut p = make_process(1, "alice", "01:00:00", 0);
p.command = "python ".to_string() + &"A".repeat(4096);
let exporter = ProcessMetricExporter::new(std::slice::from_ref(&p));
let out = exporter.export_metrics();
assert!(
!out.contains(&"A".repeat(4096)),
"full 4 KiB command leaked into scrape output"
);
assert!(
out.contains("bytes truncated"),
"expected truncation marker in scrape output"
);
}
}