Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,14 @@ jobs:
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run clippy (no default features)
run: cargo clippy --all-targets --no-default-features -- -D warnings
- name: Run clippy (discovery only)
run: cargo clippy --all-targets --no-default-features --features discovery -- -D warnings
- name: Run tests (all features)
run: cargo test --all-features
- name: Run tests (no default features)
run: cargo test --no-default-features
- name: Run tests (discovery only)
run: cargo test --no-default-features --features discovery
- name: Run clippy (OTAP compatibility suite)
env:
CARGO_TARGET_DIR: target
Expand All @@ -56,6 +60,8 @@ jobs:
run: cargo check --all-targets --all-features
- name: Check MSRV (no default features)
run: cargo check --all-targets --no-default-features
- name: Check MSRV (discovery only)
run: cargo check --all-targets --no-default-features --features discovery

go-fixtures:
name: Go testdata sync check
Expand Down
57 changes: 57 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ exclude = ["tests/otap-compat"]
[features]
default = ["arrow", "dto"]
arrow = ["dep:arrow-array", "dep:arrow-buffer", "dep:arrow-schema"]
discovery = ["dep:serde", "dep:serde_json"]
dto = ["dep:prost", "dep:prost-types"]

[dependencies]
Expand All @@ -23,6 +24,8 @@ arrow-buffer = { version = "59", default-features = false, optional = true }
arrow-schema = { version = "59", default-features = false, optional = true }
prost = { version = "0.14", optional = true }
prost-types = { version = "0.14", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
thiserror = "2"

[dev-dependencies]
Expand Down
250 changes: 250 additions & 0 deletions src/discovery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// Copyright 2026 The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Prometheus-compatible target discovery configuration and target group types.

use std::collections::BTreeMap;
use std::time::Duration;

use serde::{Deserialize, Deserializer, Serialize, Serializer};

/// Reserved Prometheus label holding the `<host>:<port>` scrape target address.
pub const ADDRESS_LABEL: &str = "__address__";

/// Ordered map of label names to label values matching Prometheus `model.LabelSet`.
pub type LabelSet = BTreeMap<String, String>;

/// Error returned when validating or running target discovery.
#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
/// Configuration validation failed.
#[error("{0}")]
InvalidConfig(String),
/// I/O or filesystem error.
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// JSON payload decoding error.
#[error("JSON decode error: {0}")]
Json(#[from] serde_json::Error),
}

/// Static target group configuration matching Prometheus `static_configs` entries.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StaticConfig {
/// List of target `"<host>:<port>"` addresses.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub targets: Vec<String>,
/// Labels assigned to all targets in the group.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: LabelSet,
}

impl StaticConfig {
/// Converts this static configuration entry into a [`TargetGroup`] with the given `source`.
pub fn into_target_group(self, source: impl Into<String>) -> TargetGroup {
let targets = self
.targets
.into_iter()
.map(|addr| BTreeMap::from([(ADDRESS_LABEL.to_owned(), addr)]))
.collect();
TargetGroup {
targets,
labels: self.labels,
source: source.into(),
}
}
}

/// A group of discovered targets sharing a common label set and discovery source,
/// matching Prometheus `discovery/targetgroup.Group`.
///
/// When serialized or deserialized directly with Serde, `TargetGroup` uses the same
/// `{ "targets": ["host:port", ...], "labels": { ... } }` wire format as Prometheus's
/// `targetgroup.Group`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TargetGroup {
/// Discovered target label sets (each containing at least [`ADDRESS_LABEL`]).
pub targets: Vec<LabelSet>,
/// Common labels shared across all targets in this group.
pub labels: LabelSet,
/// Identifier of the discovery source that produced this target group.
pub source: String,
}

impl Serialize for TargetGroup {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
#[derive(Serialize)]
struct TargetGroupWire<'a> {
targets: Vec<&'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
labels: &'a LabelSet,
}

let targets: Vec<&str> = self
.targets
.iter()
.filter_map(|t| t.get(ADDRESS_LABEL).map(String::as_str))
.collect();
let raw = TargetGroupWire {
targets,
labels: &self.labels,
};
raw.serialize(serializer)
}
}

impl<'de> Deserialize<'de> for TargetGroup {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = StaticConfig::deserialize(deserializer)?;
Ok(raw.into_target_group(String::new()))
}
}

/// Resolves a slice of [`StaticConfig`] entries into [`TargetGroup`]s with sequential
/// `"0"`, `"1"`, ... source identifiers matching Prometheus `discovery.NewStaticProvider`.
pub fn resolve_static_configs(configs: &[StaticConfig]) -> Vec<TargetGroup> {
configs
.iter()
.enumerate()
.map(|(idx, cfg)| cfg.clone().into_target_group(idx.to_string()))
.collect()
}

/// Top-level discovery configuration container matching the discovery fields on a
/// Prometheus `scrape_config`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DiscoveryConfigs {
/// Static target group configurations (`static_configs`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub static_configs: Vec<StaticConfig>,
}

impl DiscoveryConfigs {
/// Validates all configured service discovery sections according to Prometheus rules.
pub fn validate(&mut self) -> Result<(), DiscoveryError> {
Ok(())
}

/// Resolves the configured `static_configs` into [`TargetGroup`]s.
pub fn resolve_static(&self) -> Vec<TargetGroup> {
resolve_static_configs(&self.static_configs)
}
}

/// Parses a Prometheus duration string (such as `"30s"`, `"1m"`, `"500ms"`, `"1h30m"`, `"7d"`,
/// `"1w"`, `"1y"`, or `"0"`) into a [`Duration`], matching `prometheus/common/model.ParseDuration`.
pub fn parse_duration(s: &str) -> Result<Duration, DiscoveryError> {
if s == "0" {
return Ok(Duration::ZERO);
}
if s.is_empty() {
return Err(DiscoveryError::InvalidConfig(
"empty duration string".to_owned(),
));
}

let orig = s;
let bytes = s.as_bytes();
let mut idx = 0;
let mut total_ms: u64 = 0;
let mut last_unit_pos: u8 = 0;

while idx < bytes.len() {
if !bytes[idx].is_ascii_digit() {
return Err(DiscoveryError::InvalidConfig(format!(
"not a valid duration string: {orig:?}"
)));
}
let num_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
let val: u64 = s[num_start..idx].parse().map_err(|_| {
DiscoveryError::InvalidConfig(format!("not a valid duration string: {orig:?}"))
})?;

let unit_start = idx;
while idx < bytes.len() && !bytes[idx].is_ascii_digit() {
idx += 1;
}
if unit_start == idx {
return Err(DiscoveryError::InvalidConfig(format!(
"not a valid duration string: {orig:?}"
)));
}
let unit = &s[unit_start..idx];
let (pos, mult_ms): (u8, u64) = match unit {
"y" => (1, 365 * 24 * 60 * 60 * 1000),
"w" => (2, 7 * 24 * 60 * 60 * 1000),
"d" => (3, 24 * 60 * 60 * 1000),
"h" => (4, 60 * 60 * 1000),
"m" => (5, 60 * 1000),
"s" => (6, 1000),
"ms" => (7, 1),
_ => {
return Err(DiscoveryError::InvalidConfig(format!(
"unknown unit {unit:?} in duration {orig:?}"
)));
}
};
if pos <= last_unit_pos {
return Err(DiscoveryError::InvalidConfig(format!(
"not a valid duration string: {orig:?}"
)));
}
last_unit_pos = pos;

let part_ms = val
.checked_mul(mult_ms)
.ok_or_else(|| DiscoveryError::InvalidConfig("duration out of range".to_owned()))?;
total_ms = total_ms
.checked_add(part_ms)
.ok_or_else(|| DiscoveryError::InvalidConfig("duration out of range".to_owned()))?;
}

Ok(Duration::from_millis(total_ms))
}

/// Formats a [`Duration`] as a canonical Prometheus duration string (such as `"30s"` or `"1m30s"`),
/// matching `prometheus/common/model.Duration.String`.
pub fn format_duration(d: Duration) -> String {
if d.is_zero() {
return "0s".to_owned();
}
let mut ms: u128 = d.as_millis().max(1);

let mut out = String::new();
let mut append_unit = |unit: &str, mult: u128, exact: bool| {
if exact && !ms.is_multiple_of(mult) {
return;
}
let v = ms / mult;
if v > 0 {
out.push_str(&v.to_string());
out.push_str(unit);
ms -= v * mult;
}
};

append_unit("y", 1000 * 60 * 60 * 24 * 365, true);
append_unit("w", 1000 * 60 * 60 * 24 * 7, true);
append_unit("d", 1000 * 60 * 60 * 24, false);
append_unit("h", 1000 * 60 * 60, false);
append_unit("m", 1000 * 60, false);
append_unit("s", 1000, false);
append_unit("ms", 1, false);

out
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

#[cfg(feature = "arrow")]
pub mod arrow;
#[cfg(feature = "discovery")]
pub mod discovery;
#[cfg(feature = "dto")]
pub mod dto;
pub mod format;
Expand Down
Loading
Loading