Skip to content

Commit f90c334

Browse files
committed
fix(mimir): apply custom labels to all series and drop reserved-name collisions
Custom per-target labels were moved into the probe_success series and so never reached any other metric. Build every series through a shared merge step so user labels apply to all of them, with phase-duration series adding their phase label on top. Drop custom labels that reuse a reserved name (__name__, instance, job, module, target, phase); a duplicate label name is rejected by Mimir. Also simplify handle_target_probe (drop the redundant labels argument) and use saturating_sub for the poll interval to satisfy clippy.
1 parent 8287efc commit f90c334

3 files changed

Lines changed: 176 additions & 89 deletions

File tree

‎oxybox/src/http_probe/probe.rs‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,6 @@ pub async fn run_probe_loop(
322322
let target = target.clone();
323323
let tenant_name = tenant_name.clone();
324324
let org_id = org_config.organisation_id.clone();
325-
let labels = target.labels.clone();
326325
let mimir_endpoint = mimir_endpoint.clone();
327326

328327
handles.push(tokio::spawn(async move {
@@ -340,7 +339,6 @@ pub async fn run_probe_loop(
340339
&resolver,
341340
&mimir_endpoint,
342341
max_org_width,
343-
labels,
344342
)
345343
.await
346344
})
@@ -359,10 +357,7 @@ pub async fn run_probe_loop(
359357
}
360358

361359
let elapsed = start_time.elapsed().as_secs();
362-
let wait = org_config
363-
.polling_interval_seconds
364-
.checked_sub(elapsed)
365-
.unwrap_or(0);
360+
let wait = org_config.polling_interval_seconds.saturating_sub(elapsed);
366361

367362
sleep(Duration::from_secs(wait)).await;
368363
}
@@ -388,7 +383,6 @@ fn to_fixed_width(input: &str, width: usize) -> String {
388383
/// * `resolver` - The DNS resolver for resolving hostnames.
389384
/// * `mimir_target` - The Mimir endpoint to send metrics to.
390385
/// * `max_width` - The maximum width for tenant name formatting in logs.
391-
/// * `labels` - Optional additional labels to include in the metrics.
392386
async fn handle_target_probe(
393387
tenant: String,
394388
org_id: &str,
@@ -397,7 +391,6 @@ async fn handle_target_probe(
397391
resolver: &TokioAsyncResolver,
398392
mimir_target: &str,
399393
max_width: usize,
400-
labels: Option<Vec<(String, String)>>,
401394
) {
402395
let url = &target.url;
403396
let result = probe_url(tls_connector, resolver, url).await;
@@ -408,7 +401,7 @@ async fn handle_target_probe(
408401
.as_secs_f64();
409402
let padded_tenant = to_fixed_width(&tenant, max_width);
410403

411-
let labels = labels.as_ref().map(|l| {
404+
let labels = target.labels.as_ref().map(|l| {
412405
l.iter()
413406
.map(|(k, v)| (k.as_str(), v.as_str()))
414407
.collect::<Vec<(&str, &str)>>()

‎oxybox/src/mimir/client.rs‎

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -127,38 +127,31 @@ mod tests {
127127
let mimir_url = "http://localhost:9009"; // Adjust to your Mimir instance
128128
let tenant_id = Some("demo"); // Optional, remove if Mimir is single-tenant
129129

130-
let mut metrics_to_send = Vec::new();
131-
132-
// Metric 1: A counter for requests
133-
metrics_to_send.push(create_time_series(
134-
"my_app_http_requests_total",
135-
&[("method", "GET"), ("status", "200")],
136-
1.0, // For a counter, typically increment by 1 per event
137-
None, // Use current timestamp
138-
));
139-
140-
// Metric 2: A gauge for CPU usage
141-
metrics_to_send.push(create_time_series(
142-
"my_app_cpu_usage_percent",
143-
&[("host", "server-a")],
144-
25.5, // Current value for a gauge
145-
Some(Utc::now().timestamp_millis()), // Specific timestamp
146-
));
147-
148-
// Metric 3: Another counter with different labels
149-
metrics_to_send.push(create_time_series(
150-
"my_app_database_queries_total",
151-
&[("db", "users"), ("type", "read")],
152-
1.0,
153-
None,
154-
));
155-
156-
// Attempt to send
130+
let metrics_to_send = vec![
131+
create_time_series(
132+
"my_app_http_requests_total",
133+
&[("method", "GET"), ("status", "200")],
134+
1.0,
135+
None,
136+
),
137+
create_time_series(
138+
"my_app_cpu_usage_percent",
139+
&[("host", "server-a")],
140+
25.5,
141+
Some(Utc::now().timestamp_millis()),
142+
),
143+
create_time_series(
144+
"my_app_database_queries_total",
145+
&[("db", "users"), ("type", "read")],
146+
1.0,
147+
None,
148+
),
149+
];
150+
151+
// Attempt to send; a missing local Mimir is tolerated so this stays a smoke test.
157152
match send_to_mimir(mimir_url, tenant_id, metrics_to_send).await {
158153
Ok(_) => log::debug!("Test metrics sent successfully."),
159154
Err(e) => log::debug!("Failed to send test metrics: {}", e),
160155
}
161-
162-
assert!(true);
163156
}
164157
}

‎oxybox/src/mimir/mod.rs‎

Lines changed: 152 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,23 @@ use crate::http_probe::result::ProbeResult;
44

55
pub mod client;
66

7+
const NAME_LABEL: &str = "__name__";
78
const INSTANCE_LABEL: &str = "instance";
89
const JOB_LABEL: &str = "job";
910
const MODULE_LABEL: &str = "module";
1011
const TARGET_LABEL: &str = "target";
12+
const PHASE_LABEL: &str = "phase";
13+
14+
/// Label names owned by Oxybox. A user-supplied label reusing one of these would
15+
/// produce a duplicate label name, which Mimir rejects, so such labels are dropped.
16+
const RESERVED_LABELS: [&str; 6] = [
17+
NAME_LABEL,
18+
INSTANCE_LABEL,
19+
JOB_LABEL,
20+
MODULE_LABEL,
21+
TARGET_LABEL,
22+
PHASE_LABEL,
23+
];
1124
const PROBE_SUCCESS_METRIC: &str = "probe_success";
1225
const PROBE_DURATION_METRIC: &str = "probe_duration_seconds";
1326
const PROBE_HTTP_STATUS_METRIC: &str = "probe_http_status_code";
@@ -62,18 +75,30 @@ pub fn create_probe_metrics(
6275
probe_success: bool,
6376
labels: Option<Vec<(&str, &str)>>,
6477
) -> Vec<prompb::TimeSeries> {
65-
let mut metrics = Vec::new();
66-
let probe_successful = match probe_success {
67-
true => 1.0,
68-
false => 0.0,
78+
let custom: Vec<(&str, &str)> = labels
79+
.unwrap_or_default()
80+
.into_iter()
81+
.filter(|(key, _)| {
82+
let reserved = RESERVED_LABELS.contains(key);
83+
if reserved {
84+
log::warn!(
85+
"Ignoring custom label '{key}' for {}: it collides with a reserved label",
86+
probe_result.url
87+
);
88+
}
89+
!reserved
90+
})
91+
.collect();
92+
let series = |metric: &str, value: f64, phase: Option<&str>| {
93+
let mut extra = custom.clone();
94+
if let Some(phase) = phase {
95+
extra.push((PHASE_LABEL, phase));
96+
}
97+
create_time_series(metric, &probe_result.url, value, Some(extra))
6998
};
7099

71-
metrics.push(create_time_series(
72-
PROBE_SUCCESS_METRIC,
73-
&probe_result.url,
74-
probe_successful,
75-
labels,
76-
));
100+
let probe_successful = if probe_success { 1.0 } else { 0.0 };
101+
let mut metrics = vec![series(PROBE_SUCCESS_METRIC, probe_successful, None)];
77102

78103
let phases = [
79104
(probe_result.dns_time, "resolve"),
@@ -82,77 +107,153 @@ pub fn create_probe_metrics(
82107
(probe_result.processing_time, "processing"),
83108
(probe_result.transfer_time, "transfer"),
84109
];
85-
86110
for (duration_opt, phase) in phases.iter() {
87111
if let Some(duration) = duration_opt {
88-
metrics.push(create_time_series(
89-
PROBE_HTTP_DURATION_METRIC,
90-
&probe_result.url,
91-
*duration,
92-
Some(vec![("phase", *phase)]),
93-
));
112+
metrics.push(series(PROBE_HTTP_DURATION_METRIC, *duration, Some(*phase)));
94113
}
95114
}
96115

97-
metrics.push(create_time_series(
116+
metrics.push(series(
98117
PROBE_DURATION_METRIC,
99-
&probe_result.url,
100118
probe_result.total_probe_time,
101119
None,
102120
));
121+
103122
if let Some(http_status) = probe_result.http_status {
104-
metrics.push(create_time_series(
105-
PROBE_HTTP_STATUS_METRIC,
106-
&probe_result.url,
107-
http_status as f64,
108-
None,
109-
));
123+
metrics.push(series(PROBE_HTTP_STATUS_METRIC, http_status as f64, None));
110124
}
111125

112126
if let Some(dns_time) = probe_result.dns_time {
113-
metrics.push(create_time_series(
114-
PROBE_DNS_LOOKUP_TIME_METRIC,
115-
&probe_result.url,
116-
dns_time,
117-
None,
118-
));
127+
metrics.push(series(PROBE_DNS_LOOKUP_TIME_METRIC, dns_time, None));
119128
}
120129

121-
let ssl_enabled = match probe_result.cert_validity_seconds {
122-
Some(_) => 1.0,
123-
None => 0.0,
130+
let ssl_enabled = if probe_result.cert_validity_seconds.is_some() {
131+
1.0
132+
} else {
133+
0.0
124134
};
125-
126-
metrics.push(create_time_series(
127-
PROBE_HTTP_SSL_ENABLED_METRIC,
128-
&probe_result.url,
129-
ssl_enabled,
130-
None,
131-
));
135+
metrics.push(series(PROBE_HTTP_SSL_ENABLED_METRIC, ssl_enabled, None));
132136

133137
if let Some(cert_validity_seconds) = probe_result.cert_validity_seconds {
134-
metrics.push(create_time_series(
138+
metrics.push(series(
135139
PROBE_HTTP_SSL_EARLIEST_EXPIERY_METRIC,
136-
&probe_result.url,
137140
cert_validity_seconds,
138141
None,
139142
));
140143
}
144+
141145
if let Some(http_version) = probe_result.http_version {
142-
metrics.push(create_time_series(
143-
PROBE_HTTP_VERSION_METRIC,
144-
&probe_result.url,
145-
http_version,
146-
None,
147-
));
146+
metrics.push(series(PROBE_HTTP_VERSION_METRIC, http_version, None));
148147
}
149148

150-
metrics.push(create_time_series(
149+
metrics.push(series(
151150
PROBE_HTTP_REDIRECTS_METRIC,
152-
&probe_result.url,
153151
probe_result.redirects as f64,
154152
None,
155153
));
156154

157155
metrics
158156
}
157+
158+
#[cfg(test)]
159+
mod tests {
160+
use super::*;
161+
use crate::http_probe::result::ProbeResult;
162+
163+
fn sample_probe() -> ProbeResult {
164+
ProbeResult {
165+
url: "https://example.com".to_string(),
166+
dns_time: Some(0.01),
167+
connect_time: Some(0.02),
168+
tls_time: Some(0.03),
169+
processing_time: Some(0.04),
170+
transfer_time: Some(0.05),
171+
cert_validity_seconds: Some(1_700_000_000.0),
172+
http_status: Some(200),
173+
http_version: Some(2.0),
174+
total_probe_time: 0.15,
175+
redirects: 1,
176+
}
177+
}
178+
179+
fn metric_name(ts: &prompb::TimeSeries) -> String {
180+
ts.labels
181+
.iter()
182+
.find(|l| l.name == "__name__")
183+
.map(|l| l.value.clone())
184+
.unwrap_or_default()
185+
}
186+
187+
#[test]
188+
fn custom_labels_apply_to_every_series() {
189+
let labels = Some(vec![("environment", "production"), ("region", "eu")]);
190+
let metrics = create_probe_metrics(&sample_probe(), true, labels);
191+
192+
assert!(metrics.len() > 1);
193+
for ts in &metrics {
194+
for (key, value) in [("environment", "production"), ("region", "eu")] {
195+
assert!(
196+
ts.labels.iter().any(|l| l.name == key && l.value == value),
197+
"series `{}` is missing custom label {key}={value}",
198+
metric_name(ts),
199+
);
200+
}
201+
}
202+
}
203+
204+
#[test]
205+
fn phase_series_keep_their_phase_label_alongside_custom_labels() {
206+
let labels = Some(vec![("environment", "production")]);
207+
let metrics = create_probe_metrics(&sample_probe(), true, labels);
208+
209+
let phase_series: Vec<_> = metrics
210+
.iter()
211+
.filter(|ts| metric_name(ts) == PROBE_HTTP_DURATION_METRIC)
212+
.collect();
213+
assert_eq!(phase_series.len(), 5);
214+
for ts in phase_series {
215+
assert!(ts.labels.iter().any(|l| l.name == "phase"));
216+
assert!(
217+
ts.labels
218+
.iter()
219+
.any(|l| l.name == "environment" && l.value == "production")
220+
);
221+
}
222+
}
223+
224+
#[test]
225+
fn reserved_custom_labels_are_dropped() {
226+
let labels = Some(vec![
227+
(INSTANCE_LABEL, "evil"),
228+
(PHASE_LABEL, "nope"),
229+
("environment", "production"),
230+
]);
231+
let metrics = create_probe_metrics(&sample_probe(), true, labels);
232+
233+
assert!(metrics.len() > 1);
234+
for ts in &metrics {
235+
assert_eq!(
236+
ts.labels.iter().filter(|l| l.name == INSTANCE_LABEL).count(),
237+
1,
238+
"series `{}` has a duplicate instance label",
239+
metric_name(ts),
240+
);
241+
assert!(ts.labels.iter().all(|l| l.value != "evil"));
242+
assert!(ts.labels.iter().all(|l| l.value != "nope"));
243+
assert!(
244+
ts.labels
245+
.iter()
246+
.any(|l| l.name == "environment" && l.value == "production")
247+
);
248+
}
249+
}
250+
251+
#[test]
252+
fn no_custom_labels_still_produces_valid_series() {
253+
let metrics = create_probe_metrics(&sample_probe(), true, None);
254+
assert!(metrics.iter().any(|ts| metric_name(ts) == PROBE_SUCCESS_METRIC));
255+
for ts in &metrics {
256+
assert!(ts.labels.iter().any(|l| l.name == INSTANCE_LABEL));
257+
}
258+
}
259+
}

0 commit comments

Comments
 (0)