Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-ci-metrics/src/otlp.rs
atlas b072fbf4d6 push ci run metrics to victoriametrics, with a grafana board
A swarm-ci-metrics host unit on a 5-minute timer lists every forge
repo's Actions runs as the new read-only ci-metrics account and pushes
each finished run's duration and queue wait over OTLP, stamped at the
run's stop time. The first pass backfills from run 1. swarm-controller
mints the account's token into bao and keeps a ci-metrics team with
read on the Actions unit in every organisation.

Closes #5376
2026-10-16 20:43:11 +02:00

138 lines
4.6 KiB
Rust

//! The OTLP/HTTP JSON body for a batch of runs.
//!
//! Two gauges, one data point per run each, stamped with the run's `stopped`.
//! Victoriametrics stores an OTLP point at its `timeUnixNano`, so a run
//! finished months ago lands at that time. With `run` among the labels every
//! run is a series of its own, and a query reads them with `last_over_time`,
//! which a re-pushed identical point does not change.
use serde_json::{Value, json};
use crate::sample::{DURATION_METRIC, Labels, QUEUE_WAIT_METRIC, RunSamples};
/// The `service.name` resource attribute, which becomes a label.
pub const SERVICE_NAME: &str = "swarm-ci-metrics";
fn attribute(key: &str, value: &str) -> Value {
json!({ "key": key, "value": { "stringValue": value } })
}
fn attributes(l: &Labels) -> Value {
json!([
attribute("repo", &l.repo),
attribute("run", &l.run),
attribute("workflow", &l.workflow),
attribute("pr", &l.pr),
attribute("sha", &l.sha),
attribute("outcome", &l.outcome),
attribute("event", &l.event),
])
}
/// OTLP JSON carries a 64-bit integer as a decimal string.
fn point(l: &Labels, at_unix_nanos: i128, value: f64) -> Value {
json!({
"attributes": attributes(l),
"timeUnixNano": at_unix_nanos.to_string(),
"asDouble": value,
})
}
fn gauge(name: &str, points: &[Value]) -> Value {
json!({ "name": name, "unit": "s", "gauge": { "dataPoints": points } })
}
/// The request body for `runs`.
pub fn body(runs: &[RunSamples]) -> Value {
let durations: Vec<Value> = runs
.iter()
.map(|r| point(&r.labels, r.at_unix_nanos, r.duration_s))
.collect();
let waits: Vec<Value> = runs
.iter()
.filter_map(|r| r.queue_wait_s.map(|w| point(&r.labels, r.at_unix_nanos, w)))
.collect();
json!({
"resourceMetrics": [{
"resource": { "attributes": [attribute("service.name", SERVICE_NAME)] },
"scopeMetrics": [{
"scope": { "name": SERVICE_NAME },
"metrics": [gauge(DURATION_METRIC, &durations), gauge(QUEUE_WAIT_METRIC, &waits)],
}],
}],
})
}
#[cfg(test)]
mod tests {
use super::*;
fn samples(sha: &str, queue_wait_s: Option<f64>) -> RunSamples {
RunSamples {
labels: Labels {
repo: "hyperhive/hyperhive".to_owned(),
run: "5638".to_owned(),
workflow: "ci.yml".to_owned(),
pr: "5372".to_owned(),
sha: sha.to_owned(),
outcome: "success".to_owned(),
event: "pull_request".to_owned(),
},
at_unix_nanos: 1_792_163_610_000_000_000,
duration_s: 1093.0,
queue_wait_s,
}
}
#[test]
fn each_run_is_one_point_per_gauge_at_its_stopped_time() {
let b = body(&[samples("aaa", Some(279.0)), samples("bbb", None)]);
let rm = &b["resourceMetrics"][0];
assert_eq!(
rm["resource"]["attributes"][0],
json!({"key": "service.name", "value": {"stringValue": "swarm-ci-metrics"}})
);
let metrics = &rm["scopeMetrics"][0]["metrics"];
assert_eq!(metrics[0]["name"], DURATION_METRIC);
assert_eq!(metrics[0]["unit"], "s");
assert_eq!(metrics[1]["name"], QUEUE_WAIT_METRIC);
let durations = metrics[0]["gauge"]["dataPoints"]
.as_array()
.expect("points");
assert_eq!(durations.len(), 2);
assert_eq!(durations[0]["timeUnixNano"], "1792163610000000000");
assert_eq!(durations[0]["asDouble"], 1093.0);
let labels: Vec<(&str, &str)> = durations[0]["attributes"]
.as_array()
.expect("attributes")
.iter()
.map(|a| {
(
a["key"].as_str().expect("key"),
a["value"]["stringValue"].as_str().expect("value"),
)
})
.collect();
assert_eq!(
labels,
[
("repo", "hyperhive/hyperhive"),
("run", "5638"),
("workflow", "ci.yml"),
("pr", "5372"),
("sha", "aaa"),
("outcome", "success"),
("event", "pull_request"),
]
);
// The run with no wait has a duration and no wait point.
let waits = metrics[1]["gauge"]["dataPoints"]
.as_array()
.expect("points");
assert_eq!(waits.len(), 1);
assert_eq!(waits[0]["asDouble"], 279.0);
assert_eq!(waits[0]["timeUnixNano"], "1792163610000000000");
}
}