use std::path::{Path, PathBuf};
use autumn_web::config::Env;
use serde::{Deserialize, Serialize};
pub const DEFAULT_SECTION: &str = "otel";
pub const DEFAULT_ENDPOINT: &str = "http://localhost:4317";
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct ConfigError(pub(crate) String);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum OtelProtocol {
#[default]
Grpc,
Http,
}
impl OtelProtocol {
#[must_use]
pub const fn is_available(self) -> bool {
match self {
Self::Grpc => cfg!(feature = "grpc"),
Self::Http => cfg!(feature = "http"),
}
}
#[must_use]
pub const fn feature(self) -> &'static str {
match self {
Self::Grpc => "grpc",
Self::Http => "http",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
#[allow(clippy::struct_excessive_bools, reason = "each bool is one TOML key")]
pub struct OtelConfig {
pub enabled: bool,
pub endpoint: String,
pub service_name: String,
pub service_version: String,
pub service_namespace: Option<String>,
pub environment: String,
pub sample_ratio: f64,
pub protocol: OtelProtocol,
pub traces: bool,
pub metrics: bool,
pub logs: bool,
pub health_check: bool,
}
impl Default for OtelConfig {
fn default() -> Self {
Self {
enabled: false,
endpoint: DEFAULT_ENDPOINT.to_owned(),
service_name: "autumn-app".to_owned(),
service_version: "unknown".to_owned(),
service_namespace: None,
environment: "development".to_owned(),
sample_ratio: 1.0,
protocol: OtelProtocol::Grpc,
traces: true,
metrics: true,
logs: true,
health_check: true,
}
}
}
#[derive(Clone, Copy)]
enum Kind {
Text,
Float,
Bool,
}
const LEAVES: &[(&str, Kind)] = &[
("enabled", Kind::Bool),
("endpoint", Kind::Text),
("service_name", Kind::Text),
("service_version", Kind::Text),
("service_namespace", Kind::Text),
("environment", Kind::Text),
("sample_ratio", Kind::Float),
("protocol", Kind::Text),
("traces", Kind::Bool),
("metrics", Kind::Bool),
("logs", Kind::Bool),
("health_check", Kind::Bool),
];
impl OtelConfig {
pub fn resolve(section: &str) -> Result<Self, ConfigError> {
autumn_web::dotenv::os_env_with_dotenv().map_or_else(
|_| Self::resolve_with_env(section, &autumn_web::config::OsEnv),
|env| Self::resolve_with_env(section, &env),
)
}
pub fn resolve_with_env(section: &str, env: &dyn Env) -> Result<Self, ConfigError> {
let (selected, profile) = active_profile(env);
let mut merged = toml::Table::new();
if let Some(base) = read_toml(&config_file("autumn.toml", env))? {
merge_section(&mut merged, base.get(section), section)?;
for name in inline_profile_names(&profile) {
let inline = base
.get("profile")
.and_then(|p| p.get(name))
.and_then(|p| p.get(section));
merge_section(&mut merged, inline, section)?;
}
}
for name in autumn_web::config::profile_override_file_lookup_names(&profile, &selected) {
if let Some(file) = read_toml(&config_file(&format!("autumn-{name}.toml"), env))? {
merge_section(&mut merged, file.get(section), section)?;
break;
}
}
apply_env(&mut merged, section, env)?;
let config: Self = toml::Value::Table(merged)
.try_into()
.map_err(|err| ConfigError(format!("[{section}]: {err}")))?;
config.validate_section(section)?;
Ok(config)
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.validate_section(DEFAULT_SECTION)
}
pub(crate) fn validate_section(&self, section: &str) -> Result<(), ConfigError> {
let fail = |key: &str, rule: &str| Err(ConfigError(format!("{section}.{key} {rule}")));
if !self.enabled {
return Ok(());
}
if self.service_name.trim().is_empty() {
return fail("service_name", "must not be empty");
}
if let Err(reason) = check_endpoint(&self.endpoint) {
return fail("endpoint", &reason);
}
if !(0.0..=1.0).contains(&self.sample_ratio) {
return fail("sample_ratio", "must be from 0.0 to 1.0");
}
if !self.protocol.is_available() {
return fail(
"protocol",
&format!(
"needs the `{}` crate feature: rebuild with `--features {}`",
self.protocol.feature(),
self.protocol.feature()
),
);
}
if self.protocol == OtelProtocol::Grpc
&& !cfg!(feature = "tls")
&& is_https_endpoint(&self.endpoint)
{
return fail(
"endpoint",
"uses `https` with the gRPC transport: enable the `tls` crate feature",
);
}
if !self.traces && !self.metrics && !self.logs {
return fail(
"traces",
"enable at least one of `traces`, `metrics`, `logs`",
);
}
Ok(())
}
#[must_use]
pub fn enabled_signals(&self) -> Vec<&'static str> {
let mut signals = Vec::with_capacity(3);
if self.traces {
signals.push("traces");
}
if self.metrics {
signals.push("metrics");
}
if self.logs {
signals.push("logs");
}
signals
}
}
fn check_endpoint(endpoint: &str) -> Result<(), String> {
let uri: http::Uri = endpoint
.parse()
.map_err(|err: http::uri::InvalidUri| err.to_string())?;
if uri.scheme_str().is_none_or(str::is_empty) {
return Err("must be an absolute URI with a scheme".to_owned());
}
if uri.authority().is_none() {
return Err("must have an authority, for example a host and a port".to_owned());
}
Ok(())
}
pub(crate) fn is_https_endpoint(endpoint: &str) -> bool {
endpoint
.parse::<http::Uri>()
.ok()
.and_then(|uri| uri.scheme_str().map(|scheme| scheme == "https"))
.unwrap_or(false)
}
fn active_profile(env: &dyn Env) -> (String, String) {
let selected = ["AUTUMN_ENV", "AUTUMN_PROFILE"]
.iter()
.filter_map(|key| env.var(key).ok())
.map(|value| value.trim().to_owned())
.find(|value| !value.is_empty())
.unwrap_or_else(|| {
let release = env.var("AUTUMN_IS_DEBUG").is_ok_and(|v| v == "0");
if release { "prod" } else { "dev" }.to_owned()
});
let profile =
autumn_web::config::normalize_profile_name(&selected).unwrap_or_else(|| "dev".to_owned());
(selected, profile)
}
fn inline_profile_names(profile: &str) -> Vec<&str> {
match profile {
"prod" => vec!["production", "prod"],
"dev" => vec!["development", "dev"],
other => vec![other],
}
}
fn config_file(name: &str, env: &dyn Env) -> PathBuf {
env.var("AUTUMN_MANIFEST_DIR")
.ok()
.map(|dir| Path::new(&dir).join(name))
.filter(|path| path.exists())
.unwrap_or_else(|| PathBuf::from(name))
}
fn read_toml(path: &Path) -> Result<Option<toml::Table>, ConfigError> {
match std::fs::read_to_string(path) {
Ok(text) => text
.parse::<toml::Table>()
.map(Some)
.map_err(|err| ConfigError(format!("{}: {err}", path.display()))),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(ConfigError(format!("{}: {err}", path.display()))),
}
}
fn merge_section(
into: &mut toml::Table,
layer: Option<&toml::Value>,
section: &str,
) -> Result<(), ConfigError> {
match layer {
None => Ok(()),
Some(toml::Value::Table(table)) => {
deep_merge(into, table);
Ok(())
}
Some(_) => Err(ConfigError(format!("[{section}] must be a table"))),
}
}
fn deep_merge(into: &mut toml::Table, layer: &toml::Table) {
for (key, value) in layer {
match (into.get_mut(key), value) {
(Some(toml::Value::Table(old)), toml::Value::Table(new)) => deep_merge(old, new),
_ => {
into.insert(key.clone(), value.clone());
}
}
}
}
fn apply_env(into: &mut toml::Table, section: &str, env: &dyn Env) -> Result<(), ConfigError> {
let name: String = section
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect();
for (key, kind) in LEAVES {
let name = format!("AUTUMN_{name}__{}", key.to_ascii_uppercase());
let Ok(raw) = env.var(&name) else {
continue;
};
let bad = || ConfigError(format!("{name}: can not read {raw:?}"));
let value = match kind {
Kind::Text => toml::Value::String(raw.clone()),
Kind::Float => {
let value: f64 = raw.trim().parse().map_err(|_| bad())?;
toml::Value::Float(value)
}
Kind::Bool => match raw.trim() {
"true" | "1" => toml::Value::Boolean(true),
"false" | "0" => toml::Value::Boolean(false),
_ => return Err(bad()),
},
};
into.insert((*key).to_owned(), value);
}
Ok(())
}
#[cfg(test)]
mod tests;