Move telemetry configuration into YAML

This commit is contained in:
2026-10-28 14:50:06 +00:00
parent 467624b747
commit b8ee10e7b2
6 changed files with 467 additions and 34 deletions
+274
View File
@@ -1,7 +1,9 @@
use std::{collections::BTreeMap, fs, net::SocketAddr, path::Path, sync::Arc, time::Duration};
use http::{HeaderName, HeaderValue, Uri};
use serde::Deserialize;
use thiserror::Error;
use tracing_subscriber::EnvFilter;
use crate::acl::{Acl, AclError};
@@ -13,6 +15,7 @@ pub struct Config {
pub listen_addr: SocketAddr,
pub connect_timeout: Duration,
pub upstream_timeout: Duration,
pub telemetry: TelemetryConfig,
pub(crate) acl: Arc<Acl>,
}
@@ -34,11 +37,13 @@ impl Config {
if file.upstream_timeout_ms == 0 {
return Err(ConfigError::ZeroDuration("upstream_timeout_ms"));
}
file.telemetry.validate()?;
Ok(Self {
listen_addr: file.listen_addr,
connect_timeout: Duration::from_millis(file.connect_timeout_ms),
upstream_timeout: Duration::from_millis(file.upstream_timeout_ms),
telemetry: file.telemetry,
acl: Arc::new(Acl::from_config(file.acl)?),
})
}
@@ -53,6 +58,8 @@ struct FileConfig {
connect_timeout_ms: u64,
#[serde(default = "default_upstream_timeout_ms")]
upstream_timeout_ms: u64,
#[serde(default)]
telemetry: TelemetryConfig,
acl: AclConfig,
}
@@ -68,6 +75,161 @@ const fn default_upstream_timeout_ms() -> u64 {
DEFAULT_UPSTREAM_TIMEOUT_MS
}
#[derive(Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TelemetryConfig {
pub(crate) service_name: String,
pub(crate) log_filter: String,
pub(crate) resource_attributes: BTreeMap<String, String>,
pub(crate) otlp: OtlpConfig,
}
impl Default for TelemetryConfig {
fn default() -> Self {
Self {
service_name: "hermes".to_owned(),
log_filter: "info".to_owned(),
resource_attributes: BTreeMap::new(),
otlp: OtlpConfig::default(),
}
}
}
impl TelemetryConfig {
fn validate(&self) -> Result<(), ConfigError> {
if self.service_name.trim().is_empty() {
return Err(ConfigError::EmptyServiceName);
}
EnvFilter::try_new(&self.log_filter)
.map_err(|error| ConfigError::InvalidLogFilter(error.to_string()))?;
validate_endpoint(&self.otlp.endpoint, "telemetry.otlp.endpoint")?;
validate_endpoint_override(&self.otlp.traces.endpoint, "telemetry.otlp.traces.endpoint")?;
validate_endpoint_override(
&self.otlp.metrics.endpoint,
"telemetry.otlp.metrics.endpoint",
)?;
validate_endpoint_override(&self.otlp.logs.endpoint, "telemetry.otlp.logs.endpoint")?;
if self.otlp.timeout_ms == 0 {
return Err(ConfigError::ZeroDuration("telemetry.otlp.timeout_ms"));
}
if self.otlp.metrics.export_interval_ms == 0 {
return Err(ConfigError::ZeroDuration(
"telemetry.otlp.metrics.export_interval_ms",
));
}
if !self.otlp.traces.sample_ratio.is_finite()
|| !(0.0..=1.0).contains(&self.otlp.traces.sample_ratio)
{
return Err(ConfigError::InvalidSampleRatio(
self.otlp.traces.sample_ratio,
));
}
for (name, value) in &self.otlp.headers {
if HeaderName::from_bytes(name.as_bytes()).is_err()
|| HeaderValue::from_str(value).is_err()
{
return Err(ConfigError::InvalidHeader(name.clone()));
}
}
Ok(())
}
}
fn validate_endpoint_override(
endpoint: &Option<String>,
field: &'static str,
) -> Result<(), ConfigError> {
if let Some(endpoint) = endpoint {
validate_endpoint(endpoint, field)?;
}
Ok(())
}
fn validate_endpoint(endpoint: &str, field: &'static str) -> Result<(), ConfigError> {
let uri = endpoint
.parse::<Uri>()
.map_err(|_| ConfigError::InvalidEndpoint(field))?;
if !matches!(uri.scheme_str(), Some("http" | "https")) || uri.authority().is_none() {
return Err(ConfigError::InvalidEndpoint(field));
}
Ok(())
}
#[derive(Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct OtlpConfig {
pub(crate) endpoint: String,
pub(crate) timeout_ms: u64,
pub(crate) headers: BTreeMap<String, String>,
pub(crate) traces: TraceTelemetryConfig,
pub(crate) metrics: MetricTelemetryConfig,
pub(crate) logs: LogTelemetryConfig,
}
impl Default for OtlpConfig {
fn default() -> Self {
Self {
endpoint: "http://127.0.0.1:4318".to_owned(),
timeout_ms: 10_000,
headers: BTreeMap::new(),
traces: TraceTelemetryConfig::default(),
metrics: MetricTelemetryConfig::default(),
logs: LogTelemetryConfig::default(),
}
}
}
#[derive(Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct TraceTelemetryConfig {
pub endpoint: Option<String>,
pub sample_ratio: f64,
}
impl Default for TraceTelemetryConfig {
fn default() -> Self {
Self {
endpoint: None,
sample_ratio: 1.0,
}
}
}
#[derive(Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct MetricTelemetryConfig {
pub endpoint: Option<String>,
pub export_interval_ms: u64,
pub temporality: MetricTemporality,
}
impl Default for MetricTelemetryConfig {
fn default() -> Self {
Self {
endpoint: None,
export_interval_ms: 60_000,
temporality: MetricTemporality::Cumulative,
}
}
}
#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct LogTelemetryConfig {
pub endpoint: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum MetricTemporality {
#[default]
Cumulative,
Delta,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct AclConfig {
@@ -118,6 +280,16 @@ pub enum ConfigError {
Yaml(#[from] serde_norway::Error),
#[error("{0} must be greater than zero")]
ZeroDuration(&'static str),
#[error("{0} must be an absolute http:// or https:// endpoint")]
InvalidEndpoint(&'static str),
#[error("telemetry.otlp.traces.sample_ratio must be between 0 and 1, got {0}")]
InvalidSampleRatio(f64),
#[error("invalid telemetry.log_filter: {0}")]
InvalidLogFilter(String),
#[error("invalid OTLP header {0:?}")]
InvalidHeader(String),
#[error("telemetry.service_name must not be empty")]
EmptyServiceName,
#[error(transparent)]
Acl(#[from] AclError),
}
@@ -142,6 +314,108 @@ acl:
assert_eq!(config.listen_addr, default_listen_addr());
assert_eq!(config.connect_timeout, Duration::from_secs(10));
assert_eq!(config.upstream_timeout, Duration::from_secs(30));
assert_eq!(config.telemetry.service_name, "hermes");
assert_eq!(config.telemetry.log_filter, "info");
assert_eq!(config.telemetry.otlp.endpoint, "http://127.0.0.1:4318");
assert_eq!(config.telemetry.otlp.traces.sample_ratio, 1.0);
assert_eq!(
config.telemetry.otlp.metrics.temporality,
MetricTemporality::Cumulative
);
}
#[test]
fn loads_telemetry_configuration() {
let config = Config::from_yaml(
r#"
telemetry:
service_name: "hermes-test"
log_filter: "hermes=debug"
resource_attributes:
deployment.environment.name: "test"
otlp:
endpoint: "https://collector.example:4318"
timeout_ms: 5000
headers:
Authorization: "Bearer secret"
traces:
endpoint: "https://traces.example:4318"
sample_ratio: 0.25
metrics:
export_interval_ms: 1000
temporality: "delta"
logs:
endpoint: "https://logs.example:4318"
acl: {}
"#,
)
.unwrap();
assert_eq!(config.telemetry.service_name, "hermes-test");
assert_eq!(config.telemetry.log_filter, "hermes=debug");
assert_eq!(
config.telemetry.resource_attributes["deployment.environment.name"],
"test"
);
assert_eq!(config.telemetry.otlp.timeout_ms, 5000);
assert_eq!(
config.telemetry.otlp.headers["Authorization"],
"Bearer secret"
);
assert_eq!(
config.telemetry.otlp.traces.endpoint.as_deref(),
Some("https://traces.example:4318")
);
assert_eq!(config.telemetry.otlp.traces.sample_ratio, 0.25);
assert_eq!(config.telemetry.otlp.metrics.export_interval_ms, 1000);
assert_eq!(
config.telemetry.otlp.metrics.temporality,
MetricTemporality::Delta
);
assert_eq!(
config.telemetry.otlp.logs.endpoint.as_deref(),
Some("https://logs.example:4318")
);
}
#[test]
fn rejects_invalid_telemetry_configuration() {
for yaml in [
"telemetry:\n otlp:\n endpoint: \"collector:4318\"\nacl: {}\n",
"telemetry:\n otlp:\n endpoint: \"ftp://collector.example\"\nacl: {}\n",
"telemetry:\n otlp:\n traces:\n endpoint: \"not-a-url\"\nacl: {}\n",
] {
assert!(matches!(
Config::from_yaml(yaml),
Err(ConfigError::InvalidEndpoint(_))
));
}
for yaml in [
"telemetry:\n otlp:\n timeout_ms: 0\nacl: {}\n",
"telemetry:\n otlp:\n metrics:\n export_interval_ms: 0\nacl: {}\n",
] {
assert!(matches!(
Config::from_yaml(yaml),
Err(ConfigError::ZeroDuration(_))
));
}
let ratio = Config::from_yaml(
"telemetry:\n otlp:\n traces:\n sample_ratio: 1.1\nacl: {}\n",
);
assert!(matches!(ratio, Err(ConfigError::InvalidSampleRatio(_))));
let filter = Config::from_yaml("telemetry:\n log_filter: \"hermes=verbose\"\nacl: {}\n");
assert!(matches!(filter, Err(ConfigError::InvalidLogFilter(_))));
let header = Config::from_yaml(
"telemetry:\n otlp:\n headers:\n Authorization: \"bad\\nvalue\"\nacl: {}\n",
);
assert!(matches!(header, Err(ConfigError::InvalidHeader(_))));
let service = Config::from_yaml("telemetry:\n service_name: \"\"\nacl: {}\n");
assert!(matches!(service, Err(ConfigError::EmptyServiceName)));
}
#[test]
+15 -9
View File
@@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::{env, path::PathBuf};
use clap::{Parser, Subcommand};
use hermes::{BoxError, acl, config::Config, telemetry};
@@ -10,8 +10,13 @@ use zeroize::Zeroizing;
about = "A minimal HTTP forward proxy with OpenTelemetry observability"
)]
struct Cli {
#[arg(short, long, default_value = "hermes.yaml", value_name = "PATH")]
config: PathBuf,
#[arg(
short,
long,
value_name = "PATH",
help = "Configuration file path (overrides HERMES_CONFIG_PATH)"
)]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
@@ -35,12 +40,13 @@ async fn main() -> Result<(), BoxError> {
return Ok(());
}
let telemetry = telemetry::init()?;
let result: Result<(), BoxError> = async {
let config = Config::load(&cli.config)?;
hermes::run(config).await
}
.await;
let config_path = cli
.config
.or_else(|| env::var_os("HERMES_CONFIG_PATH").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("hermes.yaml"));
let config = Config::load(config_path)?;
let telemetry = telemetry::init(&config.telemetry)?;
let result = hermes::run(config).await;
telemetry.shutdown();
result
+2 -1
View File
@@ -617,7 +617,7 @@ mod tests {
};
use super::*;
use crate::config::{AclConfig, Action, RuleConfig, UserConfig};
use crate::config::{AclConfig, Action, RuleConfig, TelemetryConfig, UserConfig};
#[test]
fn validates_absolute_http_targets() {
@@ -871,6 +871,7 @@ mod tests {
listen_addr: address,
connect_timeout: Duration::from_secs(2),
upstream_timeout: Duration::from_secs(2),
telemetry: TelemetryConfig::default(),
acl: Arc::new(acl),
});
let task = tokio::spawn(async move {
+117 -16
View File
@@ -1,19 +1,30 @@
use std::env;
use std::{collections::HashMap, time::Duration};
use http::{HeaderMap, HeaderName, HeaderValue, header};
use opentelemetry::{
global,
KeyValue, global,
propagation::{Extractor, Injector},
};
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::{LogExporter, MetricExporter, Protocol, SpanExporter, WithExportConfig};
use opentelemetry_otlp::{
LogExporter, MetricExporter, Protocol, SpanExporter, WithExportConfig, WithHttpConfig,
};
use opentelemetry_sdk::{
Resource, logs::SdkLoggerProvider, metrics::SdkMeterProvider,
propagation::TraceContextPropagator, trace::SdkTracerProvider,
Resource,
logs::{BatchConfigBuilder as LogBatchConfigBuilder, BatchLogProcessor, SdkLoggerProvider},
metrics::{PeriodicReader, SdkMeterProvider, Temporality},
propagation::TraceContextPropagator,
trace::{
BatchConfigBuilder as TraceBatchConfigBuilder, BatchSpanProcessor, Sampler,
SdkTracerProvider,
},
};
use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
use crate::BoxError;
use crate::{
BoxError,
config::{MetricTemporality, OtlpConfig, TelemetryConfig},
};
pub struct TelemetryGuard {
tracer_provider: SdkTracerProvider,
@@ -35,18 +46,43 @@ impl TelemetryGuard {
}
}
pub fn init() -> Result<TelemetryGuard, BoxError> {
pub fn init(config: &TelemetryConfig) -> Result<TelemetryGuard, BoxError> {
global::set_text_map_propagator(TraceContextPropagator::new());
let service_name = env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "hermes".to_owned());
let resource = Resource::builder().with_service_name(service_name).build();
let resource = Resource::builder_empty()
.with_service_name(config.service_name.clone())
.with_attributes(
config
.resource_attributes
.iter()
.map(|(key, value)| KeyValue::new(key.clone(), value.clone())),
)
.build();
let timeout = Duration::from_millis(config.otlp.timeout_ms);
let headers = otlp_headers(&config.otlp);
let metric_exporter = MetricExporter::builder()
.with_http()
.with_protocol(Protocol::HttpBinary)
.with_endpoint(endpoint(
&config.otlp,
&config.otlp.metrics.endpoint,
"metrics",
))
.with_timeout(timeout)
.with_headers(headers.clone())
.with_temporality(match config.otlp.metrics.temporality {
MetricTemporality::Cumulative => Temporality::Cumulative,
MetricTemporality::Delta => Temporality::Delta,
})
.build()?;
let metric_reader = PeriodicReader::builder(metric_exporter)
.with_interval(Duration::from_millis(
config.otlp.metrics.export_interval_ms,
))
.build();
let meter_provider = SdkMeterProvider::builder()
.with_periodic_exporter(metric_exporter)
.with_reader(metric_reader)
.with_resource(resource.clone())
.build();
global::set_meter_provider(meter_provider.clone());
@@ -54,24 +90,55 @@ pub fn init() -> Result<TelemetryGuard, BoxError> {
let log_exporter = LogExporter::builder()
.with_http()
.with_protocol(Protocol::HttpBinary)
.with_endpoint(endpoint(&config.otlp, &config.otlp.logs.endpoint, "logs"))
.with_timeout(timeout)
.with_headers(headers.clone())
.build()?;
let log_processor = BatchLogProcessor::builder(log_exporter)
.with_batch_config(
LogBatchConfigBuilder::default()
.with_max_queue_size(2048)
.with_scheduled_delay(Duration::from_secs(1))
.with_max_export_batch_size(512)
.build(),
)
.build();
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter)
.with_log_processor(log_processor)
.with_resource(resource.clone())
.build();
let span_exporter = SpanExporter::builder()
.with_http()
.with_protocol(Protocol::HttpBinary)
.with_endpoint(endpoint(
&config.otlp,
&config.otlp.traces.endpoint,
"traces",
))
.with_timeout(timeout)
.with_headers(headers)
.build()?;
let span_processor = BatchSpanProcessor::builder(span_exporter)
.with_batch_config(
TraceBatchConfigBuilder::default()
.with_max_queue_size(2048)
.with_scheduled_delay(Duration::from_secs(5))
.with_max_export_batch_size(512)
.build(),
)
.build();
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(span_exporter)
.with_span_processor(span_processor)
.with_sampler(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(
config.otlp.traces.sample_ratio,
))))
.with_resource(resource)
.build();
global::set_tracer_provider(tracer_provider.clone());
let fmt_filter = env_filter();
let otel_log_filter = exporter_log_filter(env_filter());
let fmt_filter = EnvFilter::try_new(&config.log_filter)?;
let otel_log_filter = exporter_log_filter(EnvFilter::try_new(&config.log_filter)?);
let tracer = global::tracer("hermes");
tracing_subscriber::registry()
@@ -87,8 +154,18 @@ pub fn init() -> Result<TelemetryGuard, BoxError> {
})
}
fn env_filter() -> EnvFilter {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
fn endpoint(config: &OtlpConfig, override_endpoint: &Option<String>, signal: &str) -> String {
override_endpoint
.clone()
.unwrap_or_else(|| format!("{}/v1/{signal}", config.endpoint.trim_end_matches('/')))
}
fn otlp_headers(config: &OtlpConfig) -> HashMap<String, String> {
config
.headers
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
fn exporter_log_filter(filter: EnvFilter) -> EnvFilter {
@@ -177,4 +254,28 @@ mod tests {
assert!(!headers.contains_key("proxy-authorization"));
assert_eq!(headers["x-keep"], "value");
}
#[test]
fn signal_endpoints_inherit_the_common_endpoint() {
let config = OtlpConfig::default();
assert_eq!(
endpoint(&config, &None, "traces"),
"http://127.0.0.1:4318/v1/traces"
);
assert_eq!(
endpoint(
&config,
&Some("http://signals.example:4318/custom/traces".to_owned()),
"traces",
),
"http://signals.example:4318/custom/traces"
);
let mut with_slash = OtlpConfig::default();
with_slash.endpoint.push('/');
assert_eq!(
endpoint(&with_slash, &None, "metrics"),
"http://127.0.0.1:4318/v1/metrics"
);
}
}