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
+42 -8
View File
@@ -22,10 +22,13 @@ TLS is not terminated or inspected. HTTPS remains end-to-end encrypted between t
```sh ```sh
cargo build cargo build
HERMES_CONFIG_PATH=examples/hermes.yaml cargo run
# The CLI flag takes precedence over HERMES_CONFIG_PATH:
cargo run -- --config examples/hermes.yaml cargo run -- --config examples/hermes.yaml
``` ```
The default configuration path is `hermes.yaml`. The included example listens on `127.0.0.1:8080` and allows only localhost clients. Configuration path precedence is `--config`, then `HERMES_CONFIG_PATH`, then `hermes.yaml`. This is the only environment variable read by Hermes; logging and OpenTelemetry are also configured in YAML. The included example listens on `127.0.0.1:8080` and allows only localhost clients.
```sh ```sh
# Plain HTTP # Plain HTTP
@@ -42,6 +45,22 @@ listen_addr: "127.0.0.1:8080"
connect_timeout_ms: 10000 connect_timeout_ms: 10000
upstream_timeout_ms: 30000 upstream_timeout_ms: 30000
telemetry:
service_name: "hermes"
log_filter: "info"
resource_attributes:
deployment.environment.name: "development"
otlp:
endpoint: "http://127.0.0.1:4318"
timeout_ms: 10000
headers: {}
traces:
sample_ratio: 1.0
metrics:
export_interval_ms: 60000
temporality: "cumulative"
logs: {}
acl: acl:
default_action: deny default_action: deny
@@ -68,13 +87,14 @@ acl:
| `listen_addr` | `127.0.0.1:8080` | Downstream listen address | | `listen_addr` | `127.0.0.1:8080` | Downstream listen address |
| `connect_timeout_ms` | `10000` | TCP connection timeout for `CONNECT` and the HTTP connector | | `connect_timeout_ms` | `10000` | TCP connection timeout for `CONNECT` and the HTTP connector |
| `upstream_timeout_ms` | `30000` | Time limit for receiving upstream HTTP response headers | | `upstream_timeout_ms` | `30000` | Time limit for receiving upstream HTTP response headers |
| `telemetry` | see OpenTelemetry below | Logging, resource, and OTLP configuration |
| `acl.default_action` | `deny` | Action when no rule matches | | `acl.default_action` | `deny` | Action when no rule matches |
| `acl.users` | empty | Usernames and salted SHA-256 password hashes | | `acl.users` | empty | Usernames and salted SHA-256 password hashes |
| `acl.rules` | empty | Ordered ACL rules | | `acl.rules` | empty | Ordered ACL rules |
Configuration is loaded and validated at startup. Unknown fields, invalid CIDRs or domain patterns, duplicate or empty rules, references to unknown users, and unsupported password hashes prevent startup. Restart Hermes to apply configuration changes. Configuration is loaded and validated at startup. Unknown fields, invalid CIDRs or domain patterns, duplicate or empty rules, references to unknown users, and unsupported password hashes prevent startup. Restart Hermes to apply configuration changes.
`RUST_LOG` controls log filtering and defaults to `info`, for example `RUST_LOG=hermes=debug`. `telemetry.log_filter` controls log filtering and defaults to `info`, for example `log_filter: "hermes=debug"`. `RUST_LOG` is not read.
### ACL rules ### ACL rules
@@ -157,15 +177,29 @@ Password verification is synchronous and inexpensive, and digest comparisons are
## OpenTelemetry ## OpenTelemetry
Hermes exports traces, metrics, and logs independently to an OpenTelemetry Collector using OTLP/HTTP (protobuf). Exporters use the standard OpenTelemetry environment variables. Hermes exports traces, metrics, and logs independently to an OpenTelemetry Collector using OTLP/HTTP (protobuf). All settings come from `telemetry` in YAML; `OTEL_*` environment variables are not read.
```sh ```yaml
export OTEL_SERVICE_NAME=hermes telemetry:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 service_name: "hermes"
cargo run -- --config examples/hermes.yaml log_filter: "info"
resource_attributes:
deployment.environment.name: "production"
otlp:
endpoint: "http://127.0.0.1:4318"
timeout_ms: 10000
headers: {}
traces:
sample_ratio: 1.0
metrics:
export_interval_ms: 60000
temporality: "cumulative"
logs: {}
``` ```
Signal-specific settings such as `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, and `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` are also supported. Standard OTLP headers, timeouts, and trace sampling environment variables can be used. The common `otlp.endpoint` is a base URL; Hermes appends `/v1/traces`, `/v1/metrics`, and `/v1/logs`. Set `otlp.traces.endpoint`, `otlp.metrics.endpoint`, or `otlp.logs.endpoint` to override a signal with an exact full URL. OTLP headers may contain credentials, so protect the configuration file and never log its contents.
Trace `sample_ratio` must be between 0 and 1. Metric `temporality` accepts `cumulative` or `delta`. Timeouts and export intervals must be greater than zero.
Traces and logs are batched, metrics are exported periodically, and all providers are flushed during shutdown. An unavailable collector does not prevent the proxy from forwarding traffic, although exporter errors may appear in stdout logs. Traces and logs are batched, metrics are exported periodically, and all providers are flushed during shutdown. An unavailable collector does not prevent the proxy from forwarding traffic, although exporter errors may appear in stdout logs.
+17
View File
@@ -2,6 +2,23 @@ listen_addr: "127.0.0.1:8080"
connect_timeout_ms: 10000 connect_timeout_ms: 10000
upstream_timeout_ms: 30000 upstream_timeout_ms: 30000
telemetry:
service_name: "hermes"
log_filter: "info"
resource_attributes:
deployment.environment.name: "development"
otlp:
endpoint: "http://127.0.0.1:4318"
timeout_ms: 10000
headers: {}
traces:
# Set an endpoint here to override the common OTLP endpoint.
sample_ratio: 1.0
metrics:
export_interval_ms: 60000
temporality: "cumulative"
logs: {}
acl: acl:
default_action: deny default_action: deny
users: {} users: {}
+274
View File
@@ -1,7 +1,9 @@
use std::{collections::BTreeMap, fs, net::SocketAddr, path::Path, sync::Arc, time::Duration}; use std::{collections::BTreeMap, fs, net::SocketAddr, path::Path, sync::Arc, time::Duration};
use http::{HeaderName, HeaderValue, Uri};
use serde::Deserialize; use serde::Deserialize;
use thiserror::Error; use thiserror::Error;
use tracing_subscriber::EnvFilter;
use crate::acl::{Acl, AclError}; use crate::acl::{Acl, AclError};
@@ -13,6 +15,7 @@ pub struct Config {
pub listen_addr: SocketAddr, pub listen_addr: SocketAddr,
pub connect_timeout: Duration, pub connect_timeout: Duration,
pub upstream_timeout: Duration, pub upstream_timeout: Duration,
pub telemetry: TelemetryConfig,
pub(crate) acl: Arc<Acl>, pub(crate) acl: Arc<Acl>,
} }
@@ -34,11 +37,13 @@ impl Config {
if file.upstream_timeout_ms == 0 { if file.upstream_timeout_ms == 0 {
return Err(ConfigError::ZeroDuration("upstream_timeout_ms")); return Err(ConfigError::ZeroDuration("upstream_timeout_ms"));
} }
file.telemetry.validate()?;
Ok(Self { Ok(Self {
listen_addr: file.listen_addr, listen_addr: file.listen_addr,
connect_timeout: Duration::from_millis(file.connect_timeout_ms), connect_timeout: Duration::from_millis(file.connect_timeout_ms),
upstream_timeout: Duration::from_millis(file.upstream_timeout_ms), upstream_timeout: Duration::from_millis(file.upstream_timeout_ms),
telemetry: file.telemetry,
acl: Arc::new(Acl::from_config(file.acl)?), acl: Arc::new(Acl::from_config(file.acl)?),
}) })
} }
@@ -53,6 +58,8 @@ struct FileConfig {
connect_timeout_ms: u64, connect_timeout_ms: u64,
#[serde(default = "default_upstream_timeout_ms")] #[serde(default = "default_upstream_timeout_ms")]
upstream_timeout_ms: u64, upstream_timeout_ms: u64,
#[serde(default)]
telemetry: TelemetryConfig,
acl: AclConfig, acl: AclConfig,
} }
@@ -68,6 +75,161 @@ const fn default_upstream_timeout_ms() -> u64 {
DEFAULT_UPSTREAM_TIMEOUT_MS 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)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub(crate) struct AclConfig { pub(crate) struct AclConfig {
@@ -118,6 +280,16 @@ pub enum ConfigError {
Yaml(#[from] serde_norway::Error), Yaml(#[from] serde_norway::Error),
#[error("{0} must be greater than zero")] #[error("{0} must be greater than zero")]
ZeroDuration(&'static str), 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)] #[error(transparent)]
Acl(#[from] AclError), Acl(#[from] AclError),
} }
@@ -142,6 +314,108 @@ acl:
assert_eq!(config.listen_addr, default_listen_addr()); assert_eq!(config.listen_addr, default_listen_addr());
assert_eq!(config.connect_timeout, Duration::from_secs(10)); assert_eq!(config.connect_timeout, Duration::from_secs(10));
assert_eq!(config.upstream_timeout, Duration::from_secs(30)); 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] #[test]
+15 -9
View File
@@ -1,4 +1,4 @@
use std::path::PathBuf; use std::{env, path::PathBuf};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use hermes::{BoxError, acl, config::Config, telemetry}; use hermes::{BoxError, acl, config::Config, telemetry};
@@ -10,8 +10,13 @@ use zeroize::Zeroizing;
about = "A minimal HTTP forward proxy with OpenTelemetry observability" about = "A minimal HTTP forward proxy with OpenTelemetry observability"
)] )]
struct Cli { struct Cli {
#[arg(short, long, default_value = "hermes.yaml", value_name = "PATH")] #[arg(
config: PathBuf, short,
long,
value_name = "PATH",
help = "Configuration file path (overrides HERMES_CONFIG_PATH)"
)]
config: Option<PathBuf>,
#[command(subcommand)] #[command(subcommand)]
command: Option<Command>, command: Option<Command>,
} }
@@ -35,12 +40,13 @@ async fn main() -> Result<(), BoxError> {
return Ok(()); return Ok(());
} }
let telemetry = telemetry::init()?; let config_path = cli
let result: Result<(), BoxError> = async { .config
let config = Config::load(&cli.config)?; .or_else(|| env::var_os("HERMES_CONFIG_PATH").map(PathBuf::from))
hermes::run(config).await .unwrap_or_else(|| PathBuf::from("hermes.yaml"));
} let config = Config::load(config_path)?;
.await; let telemetry = telemetry::init(&config.telemetry)?;
let result = hermes::run(config).await;
telemetry.shutdown(); telemetry.shutdown();
result result
+2 -1
View File
@@ -617,7 +617,7 @@ mod tests {
}; };
use super::*; use super::*;
use crate::config::{AclConfig, Action, RuleConfig, UserConfig}; use crate::config::{AclConfig, Action, RuleConfig, TelemetryConfig, UserConfig};
#[test] #[test]
fn validates_absolute_http_targets() { fn validates_absolute_http_targets() {
@@ -871,6 +871,7 @@ mod tests {
listen_addr: address, listen_addr: address,
connect_timeout: Duration::from_secs(2), connect_timeout: Duration::from_secs(2),
upstream_timeout: Duration::from_secs(2), upstream_timeout: Duration::from_secs(2),
telemetry: TelemetryConfig::default(),
acl: Arc::new(acl), acl: Arc::new(acl),
}); });
let task = tokio::spawn(async move { 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 http::{HeaderMap, HeaderName, HeaderValue, header};
use opentelemetry::{ use opentelemetry::{
global, KeyValue, global,
propagation::{Extractor, Injector}, propagation::{Extractor, Injector},
}; };
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; 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::{ use opentelemetry_sdk::{
Resource, logs::SdkLoggerProvider, metrics::SdkMeterProvider, Resource,
propagation::TraceContextPropagator, trace::SdkTracerProvider, 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 tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
use crate::BoxError; use crate::{
BoxError,
config::{MetricTemporality, OtlpConfig, TelemetryConfig},
};
pub struct TelemetryGuard { pub struct TelemetryGuard {
tracer_provider: SdkTracerProvider, 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()); 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_empty()
let resource = Resource::builder().with_service_name(service_name).build(); .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() let metric_exporter = MetricExporter::builder()
.with_http() .with_http()
.with_protocol(Protocol::HttpBinary) .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()?; .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() let meter_provider = SdkMeterProvider::builder()
.with_periodic_exporter(metric_exporter) .with_reader(metric_reader)
.with_resource(resource.clone()) .with_resource(resource.clone())
.build(); .build();
global::set_meter_provider(meter_provider.clone()); global::set_meter_provider(meter_provider.clone());
@@ -54,24 +90,55 @@ pub fn init() -> Result<TelemetryGuard, BoxError> {
let log_exporter = LogExporter::builder() let log_exporter = LogExporter::builder()
.with_http() .with_http()
.with_protocol(Protocol::HttpBinary) .with_protocol(Protocol::HttpBinary)
.with_endpoint(endpoint(&config.otlp, &config.otlp.logs.endpoint, "logs"))
.with_timeout(timeout)
.with_headers(headers.clone())
.build()?; .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() let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter) .with_log_processor(log_processor)
.with_resource(resource.clone()) .with_resource(resource.clone())
.build(); .build();
let span_exporter = SpanExporter::builder() let span_exporter = SpanExporter::builder()
.with_http() .with_http()
.with_protocol(Protocol::HttpBinary) .with_protocol(Protocol::HttpBinary)
.with_endpoint(endpoint(
&config.otlp,
&config.otlp.traces.endpoint,
"traces",
))
.with_timeout(timeout)
.with_headers(headers)
.build()?; .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() 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) .with_resource(resource)
.build(); .build();
global::set_tracer_provider(tracer_provider.clone()); global::set_tracer_provider(tracer_provider.clone());
let fmt_filter = env_filter(); let fmt_filter = EnvFilter::try_new(&config.log_filter)?;
let otel_log_filter = exporter_log_filter(env_filter()); let otel_log_filter = exporter_log_filter(EnvFilter::try_new(&config.log_filter)?);
let tracer = global::tracer("hermes"); let tracer = global::tracer("hermes");
tracing_subscriber::registry() tracing_subscriber::registry()
@@ -87,8 +154,18 @@ pub fn init() -> Result<TelemetryGuard, BoxError> {
}) })
} }
fn env_filter() -> EnvFilter { fn endpoint(config: &OtlpConfig, override_endpoint: &Option<String>, signal: &str) -> String {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")) 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 { fn exporter_log_filter(filter: EnvFilter) -> EnvFilter {
@@ -177,4 +254,28 @@ mod tests {
assert!(!headers.contains_key("proxy-authorization")); assert!(!headers.contains_key("proxy-authorization"));
assert_eq!(headers["x-keep"], "value"); 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"
);
}
} }