@cryptotaxi247 / netdata-1 / commits / 753ab1837

Restructure otel-plugin configuration with layered overrides (#21896)

* Restructure otel-plugin configuration with layered overrides Split plugin_config.rs into a module with submodules (endpoint, metrics, logs, env) for maintainability. Configuration is now resolved in three layers, each overriding the previous: 1. Stock config (otel.yaml shipped with Netdata) 2. User config (partial YAML overrides, unknown fields ignored) 3. Environment variables (NETDATA_OTEL_* prefix, highest priority) Each layer is logged as a single JSON line (stock/user/effective) for easy debugging via journalctl. * Update src/crates/netdata-otel/otel-plugin/src/plugin_config/mod.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Handle explicit null in custom YAML deserializers for override config * Propagate user config errors instead of silently falling back to stock * Validate TLS CA cert requires cert/key and reject non-UTF-8 env vars --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

vkalintiris committed Mar 5, 2026 at 20:32 UTC 753ab18378ce112da66b89a3075ba5c18a58a4fb
8 files changed +1134 -303
src/crates/netdata-otel/otel-plugin/configs/otel.yaml.in
+6 -3
@@ -40,6 +40,12 @@ logs:
40 # Maximum file size for individual journal files (e.g., "100MB", "1.5GB").
41 size_of_journal_file: "100MB"
42
43 + # Maximum number of entries per journal file.
44 + entries_of_journal_file: 50000
45 +
46 + # Maximum time span that entries in a single journal file can cover (e.g., "2 hours", "1h", "30m").
47 + duration_of_journal_file: "2 hours"
48 +
49 # Maximum number of journal files to keep.
50 number_of_journal_files: 10
51
@@ -49,9 +55,6 @@ logs:
55 # Maximum age for journal entries (e.g., "7 days", "1 week", "168h").
56 duration_of_journal_files: "7 days"
57
52 - # Maximum time span that entries in a single journal file can cover (e.g., "2 hours", "1h", "30m").
53 - duration_of_journal_file: "2 hours"
54 -
58 # Store the complete OTLP JSON representation in the OTLP_JSON field.
59 # When enabled, each log entry includes the full original JSON message for debugging and reprocessing.
60 store_otlp_json: false
src/crates/netdata-otel/otel-plugin/metadata.yaml
+32 -2
@@ -83,6 +83,12 @@ modules:
83 options:
84 description: |
85 The plugin is configured via `otel.yaml` in the Netdata configuration directory.
86 + Only the fields you want to change need to be specified.
87 +
88 + Any option can also be overridden via environment variables with the `NETDATA_OTEL_`
89 + prefix (highest priority). The variable name is the config option in all caps with
90 + dots replaced by underscores — e.g. `endpoint.tls_cert_path` becomes
91 + `NETDATA_OTEL_ENDPOINT_TLS_CERT_PATH`.
92 folding:
93 title: Config options
94 enabled: true
@@ -104,7 +110,12 @@ modules:
110 default_value: ""
111 required: false
112 - name: metrics.chart_configs_dir
107 - description: Directory containing YAML files that define how OTLP metrics are mapped to Netdata charts. Each file can match metrics by instrumentation scope and name, set the dimension attribute key, and override timing parameters. The plugin ships stock mappings; user files in this directory take priority.
113 + description: Directory containing metric mapping YAML files.
114 + detailed_description: |
115 + Each file defines how OTLP metrics are mapped to Netdata charts.
116 + Files can match metrics by instrumentation scope and name, set the
117 + dimension attribute key, and override timing parameters. The plugin
118 + ships stock mappings; user files in this directory take priority.
119 default_value: "/etc/netdata/otel.d/v1/metrics/"
120 required: false
121 - name: metrics.interval_secs
@@ -120,7 +131,9 @@ modules:
131 default_value: 900
132 required: false
133 - name: metrics.max_new_charts_per_request
123 - description: Maximum number of new charts that can be created per gRPC request. Limits cardinality explosion from high-cardinality label combinations.
134 + description: Maximum new charts created per gRPC request.
135 + detailed_description: |
136 + Limits cardinality explosion from high-cardinality label combinations.
137 default_value: 100
138 required: false
139 - name: logs.journal_dir
@@ -151,6 +164,12 @@ modules:
164 description: Maximum age of journal files.
165 default_value: "7 days"
166 required: false
167 + - name: logs.store_otlp_json
168 + description: Store the complete OTLP JSON in each log entry.
169 + detailed_description: |
170 + Useful for debugging and reprocessing, but increases storage usage.
171 + default_value: false
172 + required: false
173 examples:
174 folding:
175 title: Config
@@ -171,6 +190,17 @@ modules:
190 max_new_charts_per_request: 100
191 logs:
192 journal_dir: /var/log/netdata/otel-journals
193 + - name: Partial user override
194 + description: |
195 + Override only specific fields in the user config. All other settings
196 + are inherited from the stock config. Unknown fields are ignored for
197 + forward compatibility.
198 + config: |
199 + endpoint:
200 + path: "0.0.0.0:4317"
201 + logs:
202 + number_of_journal_files: 20
203 + duration_of_journal_files: "14 days"
204 - name: Metric mapping file
205 description: |
206 Place YAML files like this in `/etc/netdata/otel.d/v1/metrics/` to control how
src/crates/netdata-otel/otel-plugin/src/plugin_config.rs deleted
-298
@@ -1,298 +0,0 @@
1 -use anyhow::{Context, Result};
2 -use bytesize::ByteSize;
3 -use clap::Parser;
4 -use rt::NetdataEnv;
5 -use serde::{Deserialize, Serialize};
6 -use std::fs;
7 -use std::path::Path;
8 -use std::time::Duration;
9 -
10 -#[derive(Parser, Debug, Clone, Serialize, Deserialize)]
11 -#[serde(deny_unknown_fields)]
12 -pub struct EndpointConfig {
13 - /// gRPC endpoint to listen on
14 - #[arg(long = "otel-endpoint", default_value = "127.0.0.1:4317")]
15 - pub path: String,
16 -
17 - /// Path to TLS certificate file (enables TLS when provided)
18 - #[arg(long = "otel-tls-cert-path")]
19 - pub tls_cert_path: Option<String>,
20 -
21 - /// Path to TLS private key file (required when TLS certificate is provided)
22 - #[arg(long = "otel-tls-key-path")]
23 - pub tls_key_path: Option<String>,
24 -
25 - /// Path to TLS CA certificate file for client authentication (optional)
26 - #[arg(long = "otel-tls-ca-cert-path")]
27 - pub tls_ca_cert_path: Option<String>,
28 -}
29 -
30 -impl Default for EndpointConfig {
31 - fn default() -> Self {
32 - Self {
33 - path: String::from("127.0.0.1:4317"),
34 - tls_cert_path: None,
35 - tls_key_path: None,
36 - tls_ca_cert_path: None,
37 - }
38 - }
39 -}
40 -
41 -#[derive(Parser, Debug, Clone, Default, Serialize, Deserialize)]
42 -#[serde(deny_unknown_fields)]
43 -pub struct MetricsConfig {
44 - /// Directory with configuration files for mapping OTEL metrics to Netdata charts
45 - #[arg(long = "otel-metrics-charts-configs-dir")]
46 - pub chart_configs_dir: Option<String>,
47 -
48 - /// Collection interval in seconds (1–3600). Default: 10.
49 - #[arg(long = "otel-metrics-interval")]
50 - pub interval_secs: Option<u64>,
51 -
52 - /// Grace period in seconds before gap-filling begins. Default: 5 * interval.
53 - #[arg(long = "otel-metrics-grace-period")]
54 - pub grace_period_secs: Option<u64>,
55 -
56 - /// Expiry duration in seconds after which charts with no data are removed. Default: 900.
57 - #[arg(long = "otel-metrics-expiry")]
58 - pub expiry_duration_secs: Option<u64>,
59 -
60 - /// Maximum number of new charts that can be created per gRPC request. Default: 100.
61 - #[arg(
62 - long = "otel-metrics-max-new-charts-per-request",
63 - default_value = "100"
64 - )]
65 - #[serde(default = "default_max_new_charts_per_request")]
66 - pub max_new_charts_per_request: usize,
67 -}
68 -
69 -/// Parse a duration string for clap (e.g., "7 days", "1 week", "168h")
70 -fn parse_duration(s: &str) -> Result<Duration, String> {
71 - humantime::parse_duration(s).map_err(|e| {
72 - format!(
73 - "Invalid duration format: '{}'. Use formats like '7 days', '1 week', '168h'. Error: {}",
74 - s, e
75 - )
76 - })
77 -}
78 -
79 -/// Parse a bytesize string for clap (e.g., "100MB", "1.5GB", "512MiB")
80 -fn parse_bytesize(s: &str) -> Result<ByteSize, String> {
81 - s.parse().map_err(|e| {
82 - format!(
83 - "Invalid size format: '{}'. Use formats like '100MB', '1.5GB', '512MiB'. Error: {}",
84 - s, e
85 - )
86 - })
87 -}
88 -
89 -/// Default value for entries_of_journal_file
90 -fn default_entries_of_journal_file() -> usize {
91 - 50000
92 -}
93 -
94 -fn default_max_new_charts_per_request() -> usize {
95 - 100
96 -}
97 -
98 -#[derive(Parser, Debug, Clone, Serialize, Deserialize)]
99 -#[serde(deny_unknown_fields)]
100 -pub struct LogsConfig {
101 - /// Directory to store journal files for logs
102 - #[arg(long = "otel-logs-journal-dir")]
103 - pub journal_dir: String,
104 -
105 - /// Maximum file size for journal files (accepts human-readable sizes like "100MB", "1.5GB")
106 - #[arg(
107 - long = "otel-logs-rotation-size-of-journal-file",
108 - default_value = "100MB",
109 - value_parser = parse_bytesize
110 - )]
111 - #[serde(with = "bytesize_serde")]
112 - pub size_of_journal_file: ByteSize,
113 -
114 - /// Maximum number of entries in journal files
115 - #[arg(
116 - long = "otel-logs-rotation-entries-of-journal-file",
117 - default_value = "50000"
118 - )]
119 - #[serde(default = "default_entries_of_journal_file")]
120 - pub entries_of_journal_file: usize,
121 -
122 - /// Maximum number of journal files to keep
123 - #[arg(
124 - long = "otel-logs-retention-number-of-journal-files",
125 - default_value = "10"
126 - )]
127 - pub number_of_journal_files: usize,
128 -
129 - /// Maximum total size for all journal files (accepts human-readable sizes like "1GB", "500MB")
130 - #[arg(
131 - long = "otel-logs-retention-size-of-journal-files",
132 - default_value = "1GB",
133 - value_parser = parse_bytesize
134 - )]
135 - #[serde(with = "bytesize_serde")]
136 - pub size_of_journal_files: ByteSize,
137 -
138 - /// Maximum age for journal entries (accepts human-readable durations like "7 days", "1 week", "168h")
139 - #[arg(
140 - long = "otel-logs-retention-duration-of-journal-files",
141 - default_value = "7 days",
142 - value_parser = parse_duration
143 - )]
144 - #[serde(with = "humantime_serde")]
145 - pub duration_of_journal_files: Duration,
146 -
147 - /// Maximum duration that entries in a single journal file can span (accepts human-readable durations like "2 hours", "1h", "30m")
148 - #[arg(
149 - long = "otel-logs-rotation-duration-of-journal-file",
150 - default_value = "2 hours",
151 - value_parser = parse_duration
152 - )]
153 - #[serde(with = "humantime_serde")]
154 - pub duration_of_journal_file: Duration,
155 -
156 - /// Store the complete OTLP JSON representation in the OTLP_JSON field
157 - /// This preserves the full original message for debugging and reprocessing,
158 - /// but increases storage usage and write overhead
159 - #[arg(long = "otel-logs-store-otlp-json", default_value = "false")]
160 - #[serde(default)]
161 - pub store_otlp_json: bool,
162 -}
163 -
164 -impl Default for LogsConfig {
165 - fn default() -> Self {
166 - Self {
167 - journal_dir: String::from("/tmp/netdata-journals"),
168 - size_of_journal_file: ByteSize::mb(100),
169 - entries_of_journal_file: 50000,
170 - number_of_journal_files: 10,
171 - size_of_journal_files: ByteSize::gb(1),
172 - duration_of_journal_files: Duration::from_secs(7 * 24 * 60 * 60), // 7 days
173 - duration_of_journal_file: Duration::from_secs(2 * 60 * 60), // 2 hours
174 - store_otlp_json: false,
175 - }
176 - }
177 -}
178 -
179 -#[derive(Default, Debug, Parser, Clone, Serialize, Deserialize)]
180 -#[command(name = "otel-plugin")]
181 -#[command(about = "OpenTelemetry metrics and logs plugin.")]
182 -#[command(version = "0.1")]
183 -#[serde(deny_unknown_fields)]
184 -pub struct PluginConfig {
185 - // endpoint configuration (includes grpc endpoint and tls)
186 - #[command(flatten)]
187 - #[serde(rename = "endpoint")]
188 - pub endpoint: EndpointConfig,
189 -
190 - // metrics
191 - #[command(flatten)]
192 - #[serde(rename = "metrics")]
193 - pub metrics: MetricsConfig,
194 -
195 - // logs
196 - #[command(flatten)]
197 - #[serde(rename = "logs")]
198 - pub logs: LogsConfig,
199 -
200 - /// Collection interval (ignored)
201 - #[arg(hide = true, help = "Collection interval in seconds (ignored)")]
202 - #[serde(skip)]
203 - pub _update_frequency: Option<u32>,
204 -
205 - // netdata env variables
206 - #[arg(skip)]
207 - #[serde(skip)]
208 - pub _netdata_env: NetdataEnv,
209 -}
210 -
211 -impl PluginConfig {
212 - pub fn new() -> Result<Self> {
213 - let netdata_env = NetdataEnv::from_environment();
214 -
215 - let config = if netdata_env.running_under_netdata() {
216 - // Try user config first, fallback to stock config
217 - let user_config = netdata_env
218 - .user_config_dir
219 - .as_ref()
220 - .map(|path| path.join("otel.yaml"))
221 - .and_then(|path| match Self::from_yaml_file(&path) {
222 - Ok(config) => Some(config),
223 - Err(e) => {
224 - tracing::error!(
225 - "failed to load user config from {}: {:#}. Falling back to stock config.",
226 - path.display(),
227 - e
228 - );
229 - None
230 - }
231 - });
232 -
233 - if let Some(config) = user_config {
234 - config
235 - } else if let Some(stock_path) = netdata_env
236 - .stock_config_dir
237 - .as_ref()
238 - .map(|p| p.join("otel.yaml"))
239 - {
240 - Self::from_yaml_file(&stock_path).with_context(|| {
241 - format!("loading stock config from {}", stock_path.display())
242 - })?
243 - } else {
244 - anyhow::bail!("no configuration directories available");
245 - }
246 - } else {
247 - // load from CLI args
248 - Self::parse()
249 - };
250 -
251 - // Validate endpoint format (basic check)
252 - if !config.endpoint.path.contains(':') {
253 - anyhow::bail!(
254 - "endpoint must be in format host:port, got: {}",
255 - config.endpoint.path
256 - );
257 - }
258 -
259 - // Validate TLS configuration
260 - match (
261 - &config.endpoint.tls_cert_path,
262 - &config.endpoint.tls_key_path,
263 - ) {
264 - (Some(cert_path), Some(key_path)) => {
265 - if cert_path.is_empty() {
266 - anyhow::bail!("TLS certificate path cannot be empty when provided");
267 - }
268 - if key_path.is_empty() {
269 - anyhow::bail!("TLS private key path cannot be empty when provided");
270 - }
271 - }
272 - (Some(_), None) => {
273 - anyhow::bail!(
274 - "TLS private key path must be provided when TLS certificate is provided"
275 - );
276 - }
277 - (None, Some(_)) => {
278 - anyhow::bail!(
279 - "TLS certificate path must be provided when TLS private key is provided"
280 - );
281 - }
282 - (None, None) => {
283 - // TLS disabled, which is fine
284 - }
285 - }
286 -
287 - Ok(config)
288 - }
289 -
290 - pub fn from_yaml_file<P: AsRef<Path>>(path: P) -> Result<Self> {
291 - let path = path.as_ref();
292 - let contents = fs::read_to_string(path)
293 - .with_context(|| format!("failed to read config file: {}", path.display()))?;
294 - let config: PluginConfig = serde_yaml::from_str(&contents)
295 - .with_context(|| format!("failed to parse YAML config file: {}", path.display()))?;
296 - Ok(config)
297 - }
298 -}
src/crates/netdata-otel/otel-plugin/src/plugin_config/endpoint.rs new
+61
@@ -0,0 +1,61 @@
1 +use clap::Parser;
2 +use serde::{Deserialize, Serialize};
3 +
4 +#[derive(Parser, Debug, Clone, Serialize, Deserialize)]
5 +pub struct EndpointConfig {
6 + /// gRPC endpoint to listen on
7 + #[arg(long = "otel-endpoint", default_value = "127.0.0.1:4317")]
8 + pub path: String,
9 +
10 + /// Path to TLS certificate file (enables TLS when provided)
11 + #[arg(long = "otel-tls-cert-path")]
12 + pub tls_cert_path: Option<String>,
13 +
14 + /// Path to TLS private key file (required when TLS certificate is provided)
15 + #[arg(long = "otel-tls-key-path")]
16 + pub tls_key_path: Option<String>,
17 +
18 + /// Path to TLS CA certificate file for client authentication (optional)
19 + #[arg(long = "otel-tls-ca-cert-path")]
20 + pub tls_ca_cert_path: Option<String>,
21 +}
22 +
23 +impl Default for EndpointConfig {
24 + fn default() -> Self {
25 + Self {
26 + path: String::from("127.0.0.1:4317"),
27 + tls_cert_path: None,
28 + tls_key_path: None,
29 + tls_ca_cert_path: None,
30 + }
31 + }
32 +}
33 +
34 +#[derive(Debug, Default, Deserialize)]
35 +pub(super) struct EndpointConfigOverride {
36 + #[serde(default)]
37 + pub(super) path: Option<String>,
38 + #[serde(default)]
39 + pub(super) tls_cert_path: Option<String>,
40 + #[serde(default)]
41 + pub(super) tls_key_path: Option<String>,
42 + #[serde(default)]
43 + pub(super) tls_ca_cert_path: Option<String>,
44 +}
45 +
46 +impl EndpointConfig {
47 + pub(super) fn apply_overrides(&mut self, o: &EndpointConfigOverride) {
48 + if let Some(v) = &o.path {
49 + self.path = v.clone();
50 + }
51 + if let Some(v) = &o.tls_cert_path {
52 + self.tls_cert_path = Some(v.clone());
53 + }
54 + if let Some(v) = &o.tls_key_path {
55 + self.tls_key_path = Some(v.clone());
56 + }
57 + if let Some(v) = &o.tls_ca_cert_path {
58 + self.tls_ca_cert_path = Some(v.clone());
59 + }
60 + }
61 +}
src/crates/netdata-otel/otel-plugin/src/plugin_config/env.rs new
+163
@@ -0,0 +1,163 @@
1 +use anyhow::Result;
2 +use bytesize::ByteSize;
3 +use std::env;
4 +use std::time::Duration;
5 +
6 +use super::endpoint::EndpointConfigOverride;
7 +use super::logs::LogsConfigOverride;
8 +use super::metrics::MetricsConfigOverride;
9 +use super::PluginConfigOverride;
10 +
11 +/// Read an environment variable, returning `None` if not set and an error if not valid UTF-8.
12 +fn read_env(name: &str) -> Result<Option<String>> {
13 + match env::var(name) {
14 + Ok(val) => Ok(Some(val)),
15 + Err(env::VarError::NotPresent) => Ok(None),
16 + Err(env::VarError::NotUnicode(_)) => {
17 + Err(anyhow::anyhow!("{} contains invalid UTF-8", name))
18 + }
19 + }
20 +}
21 +
22 +pub(super) fn env_var(name: &str) -> Result<Option<String>> {
23 + read_env(name)
24 +}
25 +
26 +pub(super) fn parse_env_var<T: std::str::FromStr>(name: &str) -> Result<Option<T>>
27 +where
28 + T::Err: std::fmt::Display,
29 +{
30 + match read_env(name)? {
31 + Some(val) => val
32 + .parse::<T>()
33 + .map(Some)
34 + .map_err(|e| anyhow::anyhow!("invalid value for {}: '{}': {}", name, val, e)),
35 + None => Ok(None),
36 + }
37 +}
38 +
39 +pub(super) fn parse_env_bytesize(name: &str) -> Result<Option<ByteSize>> {
40 + match read_env(name)? {
41 + Some(val) => val
42 + .parse::<ByteSize>()
43 + .map(Some)
44 + .map_err(|e| anyhow::anyhow!("invalid value for {}: '{}': {}", name, val, e)),
45 + None => Ok(None),
46 + }
47 +}
48 +
49 +pub(super) fn parse_env_duration(name: &str) -> Result<Option<Duration>> {
50 + match read_env(name)? {
51 + Some(val) => humantime::parse_duration(&val)
52 + .map(Some)
53 + .map_err(|e| anyhow::anyhow!("invalid value for {}: '{}': {}", name, val, e)),
54 + None => Ok(None),
55 + }
56 +}
57 +
58 +pub(super) fn parse_env_bool(name: &str) -> Result<Option<bool>> {
59 + match read_env(name)? {
60 + Some(val) => match val.to_lowercase().as_str() {
61 + "true" | "1" | "yes" => Ok(Some(true)),
62 + "false" | "0" | "no" => Ok(Some(false)),
63 + _ => Err(anyhow::anyhow!(
64 + "invalid value for {}: '{}': expected true/false, 1/0, or yes/no",
65 + name,
66 + val
67 + )),
68 + },
69 + None => Ok(None),
70 + }
71 +}
72 +
73 +impl PluginConfigOverride {
74 + pub(super) fn from_env() -> Result<Self> {
75 + let endpoint = EndpointConfigOverride::from_env()?;
76 + let metrics = MetricsConfigOverride::from_env()?;
77 + let logs = LogsConfigOverride::from_env()?;
78 +
79 + Ok(Self {
80 + endpoint: if endpoint.has_overrides() { Some(endpoint) } else { None },
81 + metrics: if metrics.has_overrides() { Some(metrics) } else { None },
82 + logs: if logs.has_overrides() { Some(logs) } else { None },
83 + })
84 + }
85 +}
86 +
87 +impl EndpointConfigOverride {
88 + fn from_env() -> Result<Self> {
89 + Ok(Self {
90 + path: env_var("NETDATA_OTEL_ENDPOINT_PATH")?,
91 + tls_cert_path: env_var("NETDATA_OTEL_ENDPOINT_TLS_CERT_PATH")?,
92 + tls_key_path: env_var("NETDATA_OTEL_ENDPOINT_TLS_KEY_PATH")?,
93 + tls_ca_cert_path: env_var("NETDATA_OTEL_ENDPOINT_TLS_CA_CERT_PATH")?,
94 + })
95 + }
96 +
97 + fn has_overrides(&self) -> bool {
98 + self.path.is_some()
99 + || self.tls_cert_path.is_some()
100 + || self.tls_key_path.is_some()
101 + || self.tls_ca_cert_path.is_some()
102 + }
103 +}
104 +
105 +impl MetricsConfigOverride {
106 + fn from_env() -> Result<Self> {
107 + Ok(Self {
108 + chart_configs_dir: env_var("NETDATA_OTEL_METRICS_CHART_CONFIGS_DIR")?,
109 + interval_secs: parse_env_var("NETDATA_OTEL_METRICS_INTERVAL_SECS")?,
110 + grace_period_secs: parse_env_var("NETDATA_OTEL_METRICS_GRACE_PERIOD_SECS")?,
111 + expiry_duration_secs: parse_env_var("NETDATA_OTEL_METRICS_EXPIRY_DURATION_SECS")?,
112 + max_new_charts_per_request: parse_env_var(
113 + "NETDATA_OTEL_METRICS_MAX_NEW_CHARTS_PER_REQUEST",
114 + )?,
115 + })
116 + }
117 +
118 + fn has_overrides(&self) -> bool {
119 + self.chart_configs_dir.is_some()
120 + || self.interval_secs.is_some()
121 + || self.grace_period_secs.is_some()
122 + || self.expiry_duration_secs.is_some()
123 + || self.max_new_charts_per_request.is_some()
124 + }
125 +}
126 +
127 +impl LogsConfigOverride {
128 + fn from_env() -> Result<Self> {
129 + Ok(Self {
130 + journal_dir: env_var("NETDATA_OTEL_LOGS_JOURNAL_DIR")?,
131 + size_of_journal_file: parse_env_bytesize(
132 + "NETDATA_OTEL_LOGS_SIZE_OF_JOURNAL_FILE",
133 + )?,
134 + entries_of_journal_file: parse_env_var(
135 + "NETDATA_OTEL_LOGS_ENTRIES_OF_JOURNAL_FILE",
136 + )?,
137 + number_of_journal_files: parse_env_var(
138 + "NETDATA_OTEL_LOGS_NUMBER_OF_JOURNAL_FILES",
139 + )?,
140 + size_of_journal_files: parse_env_bytesize(
141 + "NETDATA_OTEL_LOGS_SIZE_OF_JOURNAL_FILES",
142 + )?,
143 + duration_of_journal_files: parse_env_duration(
144 + "NETDATA_OTEL_LOGS_DURATION_OF_JOURNAL_FILES",
145 + )?,
146 + duration_of_journal_file: parse_env_duration(
147 + "NETDATA_OTEL_LOGS_DURATION_OF_JOURNAL_FILE",
148 + )?,
149 + store_otlp_json: parse_env_bool("NETDATA_OTEL_LOGS_STORE_OTLP_JSON")?,
150 + })
151 + }
152 +
153 + fn has_overrides(&self) -> bool {
154 + self.journal_dir.is_some()
155 + || self.size_of_journal_file.is_some()
156 + || self.entries_of_journal_file.is_some()
157 + || self.number_of_journal_files.is_some()
158 + || self.size_of_journal_files.is_some()
159 + || self.duration_of_journal_files.is_some()
160 + || self.duration_of_journal_file.is_some()
161 + || self.store_otlp_json.is_some()
162 + }
163 +}
src/crates/netdata-otel/otel-plugin/src/plugin_config/logs.rs new
+181
@@ -0,0 +1,181 @@
1 +use bytesize::ByteSize;
2 +use clap::Parser;
3 +use serde::{Deserialize, Serialize};
4 +use std::time::Duration;
5 +
6 +/// Parse a duration string for clap (e.g., "7 days", "1 week", "168h")
7 +fn parse_duration(s: &str) -> Result<Duration, String> {
8 + humantime::parse_duration(s).map_err(|e| {
9 + format!(
10 + "Invalid duration format: '{}'. Use formats like '7 days', '1 week', '168h'. Error: {}",
11 + s, e
12 + )
13 + })
14 +}
15 +
16 +/// Parse a bytesize string for clap (e.g., "100MB", "1.5GB", "512MiB")
17 +fn parse_bytesize(s: &str) -> Result<ByteSize, String> {
18 + s.parse().map_err(|e| {
19 + format!(
20 + "Invalid size format: '{}'. Use formats like '100MB', '1.5GB', '512MiB'. Error: {}",
21 + s, e
22 + )
23 + })
24 +}
25 +
26 +fn default_entries_of_journal_file() -> usize {
27 + 50000
28 +}
29 +
30 +fn deserialize_opt_bytesize<'de, D>(d: D) -> Result<Option<ByteSize>, D::Error>
31 +where
32 + D: serde::Deserializer<'de>,
33 +{
34 + let opt = Option::<String>::deserialize(d)?;
35 + match opt {
36 + None => Ok(None),
37 + Some(s) => s.parse().map(Some).map_err(serde::de::Error::custom),
38 + }
39 +}
40 +
41 +fn deserialize_opt_duration<'de, D>(d: D) -> Result<Option<Duration>, D::Error>
42 +where
43 + D: serde::Deserializer<'de>,
44 +{
45 + let opt = Option::<String>::deserialize(d)?;
46 + match opt {
47 + None => Ok(None),
48 + Some(s) => humantime::parse_duration(&s)
49 + .map(Some)
50 + .map_err(serde::de::Error::custom),
51 + }
52 +}
53 +
54 +#[derive(Parser, Debug, Clone, Serialize, Deserialize)]
55 +pub struct LogsConfig {
56 + /// Directory to store journal files for logs
57 + #[arg(long = "otel-logs-journal-dir")]
58 + pub journal_dir: String,
59 +
60 + /// Maximum file size for journal files (accepts human-readable sizes like "100MB", "1.5GB")
61 + #[arg(
62 + long = "otel-logs-rotation-size-of-journal-file",
63 + default_value = "100MB",
64 + value_parser = parse_bytesize
65 + )]
66 + #[serde(with = "bytesize_serde")]
67 + pub size_of_journal_file: ByteSize,
68 +
69 + /// Maximum number of entries in journal files
70 + #[arg(
71 + long = "otel-logs-rotation-entries-of-journal-file",
72 + default_value = "50000"
73 + )]
74 + #[serde(default = "default_entries_of_journal_file")]
75 + pub entries_of_journal_file: usize,
76 +
77 + /// Maximum number of journal files to keep
78 + #[arg(
79 + long = "otel-logs-retention-number-of-journal-files",
80 + default_value = "10"
81 + )]
82 + pub number_of_journal_files: usize,
83 +
84 + /// Maximum total size for all journal files (accepts human-readable sizes like "1GB", "500MB")
85 + #[arg(
86 + long = "otel-logs-retention-size-of-journal-files",
87 + default_value = "1GB",
88 + value_parser = parse_bytesize
89 + )]
90 + #[serde(with = "bytesize_serde")]
91 + pub size_of_journal_files: ByteSize,
92 +
93 + /// Maximum age for journal entries (accepts human-readable durations like "7 days", "1 week", "168h")
94 + #[arg(
95 + long = "otel-logs-retention-duration-of-journal-files",
96 + default_value = "7 days",
97 + value_parser = parse_duration
98 + )]
99 + #[serde(with = "humantime_serde")]
100 + pub duration_of_journal_files: Duration,
101 +
102 + /// Maximum duration that entries in a single journal file can span (accepts human-readable durations like "2 hours", "1h", "30m")
103 + #[arg(
104 + long = "otel-logs-rotation-duration-of-journal-file",
105 + default_value = "2 hours",
106 + value_parser = parse_duration
107 + )]
108 + #[serde(with = "humantime_serde")]
109 + pub duration_of_journal_file: Duration,
110 +
111 + /// Store the complete OTLP JSON representation in the OTLP_JSON field
112 + /// This preserves the full original message for debugging and reprocessing,
113 + /// but increases storage usage and write overhead
114 + #[arg(long = "otel-logs-store-otlp-json", default_value = "false")]
115 + #[serde(default)]
116 + pub store_otlp_json: bool,
117 +}
118 +
119 +impl Default for LogsConfig {
120 + fn default() -> Self {
121 + Self {
122 + journal_dir: String::from("/tmp/netdata-journals"),
123 + size_of_journal_file: ByteSize::mb(100),
124 + entries_of_journal_file: 50000,
125 + number_of_journal_files: 10,
126 + size_of_journal_files: ByteSize::gb(1),
127 + duration_of_journal_files: Duration::from_secs(7 * 24 * 60 * 60), // 7 days
128 + duration_of_journal_file: Duration::from_secs(2 * 60 * 60), // 2 hours
129 + store_otlp_json: false,
130 + }
131 + }
132 +}
133 +
134 +#[derive(Debug, Default, Deserialize)]
135 +pub(super) struct LogsConfigOverride {
136 + #[serde(default)]
137 + pub(super) journal_dir: Option<String>,
138 + #[serde(default, deserialize_with = "deserialize_opt_bytesize")]
139 + pub(super) size_of_journal_file: Option<ByteSize>,
140 + #[serde(default)]
141 + pub(super) entries_of_journal_file: Option<usize>,
142 + #[serde(default)]
143 + pub(super) number_of_journal_files: Option<usize>,
144 + #[serde(default, deserialize_with = "deserialize_opt_bytesize")]
145 + pub(super) size_of_journal_files: Option<ByteSize>,
146 + #[serde(default, deserialize_with = "deserialize_opt_duration")]
147 + pub(super) duration_of_journal_files: Option<Duration>,
148 + #[serde(default, deserialize_with = "deserialize_opt_duration")]
149 + pub(super) duration_of_journal_file: Option<Duration>,
150 + #[serde(default)]
151 + pub(super) store_otlp_json: Option<bool>,
152 +}
153 +
154 +impl LogsConfig {
155 + pub(super) fn apply_overrides(&mut self, o: &LogsConfigOverride) {
156 + if let Some(v) = &o.journal_dir {
157 + self.journal_dir = v.clone();
158 + }
159 + if let Some(v) = o.size_of_journal_file {
160 + self.size_of_journal_file = v;
161 + }
162 + if let Some(v) = o.entries_of_journal_file {
163 + self.entries_of_journal_file = v;
164 + }
165 + if let Some(v) = o.number_of_journal_files {
166 + self.number_of_journal_files = v;
167 + }
168 + if let Some(v) = o.size_of_journal_files {
169 + self.size_of_journal_files = v;
170 + }
171 + if let Some(v) = o.duration_of_journal_files {
172 + self.duration_of_journal_files = v;
173 + }
174 + if let Some(v) = o.duration_of_journal_file {
175 + self.duration_of_journal_file = v;
176 + }
177 + if let Some(v) = o.store_otlp_json {
178 + self.store_otlp_json = v;
179 + }
180 + }
181 +}
src/crates/netdata-otel/otel-plugin/src/plugin_config/metrics.rs new
+79
@@ -0,0 +1,79 @@
1 +use clap::Parser;
2 +use serde::{Deserialize, Serialize};
3 +
4 +fn default_max_new_charts_per_request() -> usize {
5 + 100
6 +}
7 +
8 +#[derive(Parser, Debug, Clone, Serialize, Deserialize)]
9 +pub struct MetricsConfig {
10 + /// Directory with configuration files for mapping OTEL metrics to Netdata charts
11 + #[arg(long = "otel-metrics-charts-configs-dir")]
12 + pub chart_configs_dir: Option<String>,
13 +
14 + /// Collection interval in seconds (1–3600). Default: 10.
15 + #[arg(long = "otel-metrics-interval")]
16 + pub interval_secs: Option<u64>,
17 +
18 + /// Grace period in seconds before gap-filling begins. Default: 5 * interval.
19 + #[arg(long = "otel-metrics-grace-period")]
20 + pub grace_period_secs: Option<u64>,
21 +
22 + /// Expiry duration in seconds after which charts with no data are removed. Default: 900.
23 + #[arg(long = "otel-metrics-expiry")]
24 + pub expiry_duration_secs: Option<u64>,
25 +
26 + /// Maximum number of new charts that can be created per gRPC request. Default: 100.
27 + #[arg(
28 + long = "otel-metrics-max-new-charts-per-request",
29 + default_value = "100"
30 + )]
31 + #[serde(default = "default_max_new_charts_per_request")]
32 + pub max_new_charts_per_request: usize,
33 +}
34 +
35 +impl Default for MetricsConfig {
36 + fn default() -> Self {
37 + Self {
38 + chart_configs_dir: None,
39 + interval_secs: None,
40 + grace_period_secs: None,
41 + expiry_duration_secs: None,
42 + max_new_charts_per_request: 100,
43 + }
44 + }
45 +}
46 +
47 +#[derive(Debug, Default, Deserialize)]
48 +pub(super) struct MetricsConfigOverride {
49 + #[serde(default)]
50 + pub(super) chart_configs_dir: Option<String>,
51 + #[serde(default)]
52 + pub(super) interval_secs: Option<u64>,
53 + #[serde(default)]
54 + pub(super) grace_period_secs: Option<u64>,
55 + #[serde(default)]
56 + pub(super) expiry_duration_secs: Option<u64>,
57 + #[serde(default)]
58 + pub(super) max_new_charts_per_request: Option<usize>,
59 +}
60 +
61 +impl MetricsConfig {
62 + pub(super) fn apply_overrides(&mut self, o: &MetricsConfigOverride) {
63 + if let Some(v) = &o.chart_configs_dir {
64 + self.chart_configs_dir = Some(v.clone());
65 + }
66 + if let Some(v) = o.interval_secs {
67 + self.interval_secs = Some(v);
68 + }
69 + if let Some(v) = o.grace_period_secs {
70 + self.grace_period_secs = Some(v);
71 + }
72 + if let Some(v) = o.expiry_duration_secs {
73 + self.expiry_duration_secs = Some(v);
74 + }
75 + if let Some(v) = o.max_new_charts_per_request {
76 + self.max_new_charts_per_request = v;
77 + }
78 + }
79 +}
src/crates/netdata-otel/otel-plugin/src/plugin_config/mod.rs new
+612
@@ -0,0 +1,612 @@
1 +mod endpoint;
2 +mod env;
3 +mod logs;
4 +mod metrics;
5 +
6 +pub use endpoint::EndpointConfig;
7 +pub use logs::LogsConfig;
8 +pub use metrics::MetricsConfig;
9 +
10 +use endpoint::EndpointConfigOverride;
11 +use logs::LogsConfigOverride;
12 +use metrics::MetricsConfigOverride;
13 +
14 +use anyhow::{Context, Result};
15 +use clap::Parser;
16 +use rt::NetdataEnv;
17 +use serde::{Deserialize, Serialize};
18 +use std::fmt;
19 +use std::fs;
20 +use std::path::Path;
21 +
22 +enum ConfigSource {
23 + Stock,
24 + User,
25 + Effective,
26 +}
27 +
28 +impl fmt::Display for ConfigSource {
29 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 + match self {
31 + Self::Stock => write!(f, "stock"),
32 + Self::User => write!(f, "user"),
33 + Self::Effective => write!(f, "effective"),
34 + }
35 + }
36 +}
37 +
38 +#[derive(Default, Debug, Parser, Clone, Serialize, Deserialize)]
39 +#[command(name = "otel-plugin")]
40 +#[command(about = "OpenTelemetry metrics and logs plugin.")]
41 +#[command(version = "0.1")]
42 +pub struct PluginConfig {
43 + // endpoint configuration (includes grpc endpoint and tls)
44 + #[command(flatten)]
45 + #[serde(rename = "endpoint")]
46 + pub endpoint: EndpointConfig,
47 +
48 + // metrics
49 + #[command(flatten)]
50 + #[serde(rename = "metrics")]
51 + pub metrics: MetricsConfig,
52 +
53 + // logs
54 + #[command(flatten)]
55 + #[serde(rename = "logs")]
56 + pub logs: LogsConfig,
57 +
58 + /// Collection interval (ignored)
59 + #[arg(hide = true, help = "Collection interval in seconds (ignored)")]
60 + #[serde(skip)]
61 + pub _update_frequency: Option<u32>,
62 +
63 + // netdata env variables
64 + #[arg(skip)]
65 + #[serde(skip)]
66 + pub _netdata_env: NetdataEnv,
67 +}
68 +
69 +#[derive(Debug, Default, Deserialize)]
70 +struct PluginConfigOverride {
71 + #[serde(default)]
72 + endpoint: Option<EndpointConfigOverride>,
73 + #[serde(default)]
74 + metrics: Option<MetricsConfigOverride>,
75 + #[serde(default)]
76 + logs: Option<LogsConfigOverride>,
77 +}
78 +
79 +impl PluginConfigOverride {
80 + fn has_overrides(&self) -> bool {
81 + self.endpoint.is_some() || self.metrics.is_some() || self.logs.is_some()
82 + }
83 +}
84 +
85 +impl PluginConfig {
86 + pub fn new() -> Result<Self> {
87 + let netdata_env = NetdataEnv::from_environment();
88 +
89 + let config = if netdata_env.running_under_netdata() {
90 + // Always load stock config as the base
91 + let stock_path = netdata_env
92 + .stock_config_dir
93 + .as_ref()
94 + .map(|p| p.join("otel.yaml"));
95 +
96 + let mut config = match &stock_path {
97 + Some(path) => Self::from_yaml_file(path).with_context(|| {
98 + format!("loading stock config from {}", path.display())
99 + })?,
100 + None => anyhow::bail!("no stock configuration directory available"),
101 + };
102 +
103 + config.log_config(ConfigSource::Stock);
104 +
105 + // Merge user overrides on top of stock config
106 + if let Some(user_path) = netdata_env
107 + .user_config_dir
108 + .as_ref()
109 + .map(|path| path.join("otel.yaml"))
110 + {
111 + if let Some(overrides) = Self::load_overrides(&user_path)
112 + .with_context(|| {
113 + format!("loading user config from {}", user_path.display())
114 + })?
115 + {
116 + config.apply_overrides(&overrides);
117 + config.log_config(ConfigSource::User);
118 + }
119 + }
120 +
121 + // Apply environment variable overrides (highest priority)
122 + let env_overrides = PluginConfigOverride::from_env()
123 + .context("reading configuration from environment variables")?;
124 + if env_overrides.has_overrides() {
125 + config.apply_overrides(&env_overrides);
126 + }
127 +
128 + config
129 + } else {
130 + // load from CLI args
131 + Self::parse()
132 + };
133 +
134 + config.validate()?;
135 + config.log_config(ConfigSource::Effective);
136 +
137 + Ok(config)
138 + }
139 +
140 + fn log_config(&self, source: ConfigSource) {
141 + match serde_json::to_string(self) {
142 + Ok(json) => tracing::info!("{source} config: {json}"),
143 + Err(e) => tracing::warn!("failed to serialize {source} config: {e}"),
144 + }
145 + }
146 +
147 + fn validate(&self) -> Result<()> {
148 + // Validate endpoint format (basic check)
149 + if !self.endpoint.path.contains(':') {
150 + anyhow::bail!(
151 + "endpoint must be in format host:port, got: {}",
152 + self.endpoint.path
153 + );
154 + }
155 +
156 + // Validate TLS configuration
157 + let tls_enabled = match (
158 + &self.endpoint.tls_cert_path,
159 + &self.endpoint.tls_key_path,
160 + ) {
161 + (Some(cert_path), Some(key_path)) => {
162 + if cert_path.is_empty() {
163 + anyhow::bail!("TLS certificate path cannot be empty when provided");
164 + }
165 + if key_path.is_empty() {
166 + anyhow::bail!("TLS private key path cannot be empty when provided");
167 + }
168 + true
169 + }
170 + (Some(_), None) => {
171 + anyhow::bail!(
172 + "TLS private key path must be provided when TLS certificate is provided"
173 + );
174 + }
175 + (None, Some(_)) => {
176 + anyhow::bail!(
177 + "TLS certificate path must be provided when TLS private key is provided"
178 + );
179 + }
180 + (None, None) => false,
181 + };
182 +
183 + if self.endpoint.tls_ca_cert_path.is_some() && !tls_enabled {
184 + anyhow::bail!(
185 + "TLS CA certificate path requires both TLS certificate and key to be configured"
186 + );
187 + }
188 +
189 + Ok(())
190 + }
191 +
192 + fn apply_overrides(&mut self, o: &PluginConfigOverride) {
193 + if let Some(endpoint) = &o.endpoint {
194 + self.endpoint.apply_overrides(endpoint);
195 + }
196 + if let Some(metrics) = &o.metrics {
197 + self.metrics.apply_overrides(metrics);
198 + }
199 + if let Some(logs) = &o.logs {
200 + self.logs.apply_overrides(logs);
201 + }
202 + }
203 +
204 + fn load_overrides<P: AsRef<Path>>(path: P) -> Result<Option<PluginConfigOverride>> {
205 + let path = path.as_ref();
206 + let contents = match fs::read_to_string(path) {
207 + Ok(contents) => contents,
208 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
209 + Err(e) => {
210 + return Err(
211 + anyhow::Error::new(e).context(format!("reading {}", path.display()))
212 + );
213 + }
214 + };
215 + let overrides: PluginConfigOverride = serde_yaml::from_str(&contents)
216 + .with_context(|| format!("failed to parse user config file: {}", path.display()))?;
217 + Ok(Some(overrides))
218 + }
219 +
220 + pub fn from_yaml_file<P: AsRef<Path>>(path: P) -> Result<Self> {
221 + let path = path.as_ref();
222 + let contents = fs::read_to_string(path)
223 + .with_context(|| format!("failed to read config file: {}", path.display()))?;
224 + let config: PluginConfig = serde_yaml::from_str(&contents)
225 + .with_context(|| format!("failed to parse YAML config file: {}", path.display()))?;
226 + Ok(config)
227 + }
228 +}
229 +
230 +#[cfg(test)]
231 +mod tests {
232 + use super::*;
233 + use bytesize::ByteSize;
234 + use std::env;
235 + use std::time::Duration;
236 +
237 + fn stock_config() -> PluginConfig {
238 + let yaml = r#"
239 +endpoint:
240 + path: "127.0.0.1:4317"
241 + tls_cert_path: null
242 + tls_key_path: null
243 + tls_ca_cert_path: null
244 +metrics:
245 + chart_configs_dir: /etc/netdata/otel.d/v1/metrics
246 + interval_secs: 10
247 + grace_period_secs: 60
248 + expiry_duration_secs: 900
249 + max_new_charts_per_request: 100
250 +logs:
251 + journal_dir: /var/log/netdata/otel/v1
252 + size_of_journal_file: "100MB"
253 + entries_of_journal_file: 50000
254 + number_of_journal_files: 10
255 + size_of_journal_files: "1GB"
256 + duration_of_journal_files: "7 days"
257 + duration_of_journal_file: "2 hours"
258 + store_otlp_json: false
259 +"#;
260 + serde_yaml::from_str(yaml).unwrap()
261 + }
262 +
263 + fn apply_user_yaml(config: &mut PluginConfig, yaml: &str) {
264 + let overrides: PluginConfigOverride = serde_yaml::from_str(yaml).unwrap();
265 + config.apply_overrides(&overrides);
266 + }
267 +
268 + #[test]
269 + fn override_single_field_in_endpoint() {
270 + let mut config = stock_config();
271 + apply_user_yaml(
272 + &mut config,
273 + r#"
274 +endpoint:
275 + path: "0.0.0.0:4317"
276 +"#,
277 + );
278 + assert_eq!(config.endpoint.path, "0.0.0.0:4317");
279 + assert!(config.endpoint.tls_cert_path.is_none());
280 + }
281 +
282 + #[test]
283 + fn override_single_field_in_logs() {
284 + let mut config = stock_config();
285 + apply_user_yaml(
286 + &mut config,
287 + r#"
288 +logs:
289 + store_otlp_json: true
290 +"#,
291 + );
292 + assert!(config.logs.store_otlp_json);
293 + assert_eq!(config.logs.journal_dir, "/var/log/netdata/otel/v1");
294 + assert_eq!(config.logs.size_of_journal_file, ByteSize::mb(100));
295 + assert_eq!(config.logs.number_of_journal_files, 10);
296 + }
297 +
298 + #[test]
299 + fn override_metrics_interval_only() {
300 + let mut config = stock_config();
301 + apply_user_yaml(
302 + &mut config,
303 + r#"
304 +metrics:
305 + interval_secs: 30
306 +"#,
307 + );
308 + assert_eq!(config.metrics.interval_secs, Some(30));
309 + assert_eq!(config.metrics.grace_period_secs, Some(60));
310 + assert_eq!(config.metrics.expiry_duration_secs, Some(900));
311 + assert_eq!(config.metrics.max_new_charts_per_request, 100);
312 + }
313 +
314 + #[test]
315 + fn override_across_multiple_sections() {
316 + let mut config = stock_config();
317 + apply_user_yaml(
318 + &mut config,
319 + r#"
320 +endpoint:
321 + path: "0.0.0.0:4317"
322 +logs:
323 + number_of_journal_files: 20
324 +"#,
325 + );
326 + assert_eq!(config.endpoint.path, "0.0.0.0:4317");
327 + assert_eq!(config.logs.number_of_journal_files, 20);
328 + assert_eq!(config.logs.journal_dir, "/var/log/netdata/otel/v1");
329 + assert_eq!(config.metrics.interval_secs, Some(10));
330 + }
331 +
332 + #[test]
333 + fn empty_user_config_changes_nothing() {
334 + let mut config = stock_config();
335 + let original_path = config.endpoint.path.clone();
336 + apply_user_yaml(&mut config, "{}");
337 + assert_eq!(config.endpoint.path, original_path);
338 + }
339 +
340 + #[test]
341 + fn unknown_fields_are_ignored() {
342 + let mut config = stock_config();
343 + apply_user_yaml(
344 + &mut config,
345 + r#"
346 +some_future_option: true
347 +endpoint:
348 + path: "0.0.0.0:9999"
349 + some_removed_field: "whatever"
350 +"#,
351 + );
352 + assert_eq!(config.endpoint.path, "0.0.0.0:9999");
353 + assert_eq!(config.logs.journal_dir, "/var/log/netdata/otel/v1");
354 + }
355 +
356 + #[test]
357 + fn override_bytesize_field() {
358 + let mut config = stock_config();
359 + apply_user_yaml(
360 + &mut config,
361 + r#"
362 +logs:
363 + size_of_journal_file: "200MB"
364 + size_of_journal_files: "2GB"
365 +"#,
366 + );
367 + assert_eq!(config.logs.size_of_journal_file, ByteSize::mb(200));
368 + assert_eq!(config.logs.size_of_journal_files, ByteSize::gb(2));
369 + }
370 +
371 + #[test]
372 + fn override_duration_field() {
373 + let mut config = stock_config();
374 + apply_user_yaml(
375 + &mut config,
376 + r#"
377 +logs:
378 + duration_of_journal_files: "14 days"
379 + duration_of_journal_file: "4 hours"
380 +"#,
381 + );
382 + assert_eq!(
383 + config.logs.duration_of_journal_files,
384 + Duration::from_secs(14 * 24 * 60 * 60)
385 + );
386 + assert_eq!(
387 + config.logs.duration_of_journal_file,
388 + Duration::from_secs(4 * 60 * 60)
389 + );
390 + }
391 +
392 + #[test]
393 + fn override_tls_fields() {
394 + let mut config = stock_config();
395 + apply_user_yaml(
396 + &mut config,
397 + r#"
398 +endpoint:
399 + tls_cert_path: "/etc/ssl/cert.pem"
400 + tls_key_path: "/etc/ssl/key.pem"
401 +"#,
402 + );
403 + assert_eq!(
404 + config.endpoint.tls_cert_path.as_deref(),
405 + Some("/etc/ssl/cert.pem")
406 + );
407 + assert_eq!(
408 + config.endpoint.tls_key_path.as_deref(),
409 + Some("/etc/ssl/key.pem")
410 + );
411 + assert_eq!(config.endpoint.path, "127.0.0.1:4317");
412 + }
413 +
414 + #[test]
415 + fn invalid_yaml_syntax_is_rejected() {
416 + let result: Result<PluginConfigOverride, _> = serde_yaml::from_str("{{invalid yaml");
417 + assert!(result.is_err());
418 + }
419 +
420 + #[test]
421 + fn type_mismatch_in_override_is_rejected() {
422 + let result: Result<PluginConfigOverride, _> = serde_yaml::from_str(
423 + r#"
424 +metrics:
425 + interval_secs: "not a number"
426 +"#,
427 + );
428 + assert!(result.is_err());
429 + }
430 +
431 + #[test]
432 + fn invalid_bytesize_format_is_rejected() {
433 + let result: Result<PluginConfigOverride, _> = serde_yaml::from_str(
434 + r#"
435 +logs:
436 + size_of_journal_file: "not a size"
437 +"#,
438 + );
439 + assert!(result.is_err());
440 + }
441 +
442 + #[test]
443 + fn invalid_duration_format_is_rejected() {
444 + let result: Result<PluginConfigOverride, _> = serde_yaml::from_str(
445 + r#"
446 +logs:
447 + duration_of_journal_files: "not a duration"
448 +"#,
449 + );
450 + assert!(result.is_err());
451 + }
452 +
453 + #[test]
454 + fn validation_rejects_invalid_endpoint() {
455 + let mut config = stock_config();
456 + config.endpoint.path = "no-port".to_string();
457 + assert!(config.validate().is_err());
458 + }
459 +
460 + #[test]
461 + fn validation_rejects_mismatched_tls() {
462 + let mut config = stock_config();
463 + config.endpoint.tls_cert_path = Some("/cert.pem".to_string());
464 + config.endpoint.tls_key_path = None;
465 + assert!(config.validate().is_err());
466 + }
467 +
468 + // Use a mutex to prevent env var tests from interfering with each other,
469 + // since env vars are process-global state.
470 + static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
471 +
472 + /// Set env vars for the duration of a closure, then clean them up.
473 + /// SAFETY: The ENV_MUTEX ensures only one test modifies env vars at a time.
474 + fn with_env_vars<F: FnOnce()>(vars: &[(&str, &str)], f: F) {
475 + let _lock = ENV_MUTEX.lock().unwrap();
476 + for (key, val) in vars {
477 + unsafe { env::set_var(key, val) };
478 + }
479 + f();
480 + for (key, _) in vars {
481 + unsafe { env::remove_var(key) };
482 + }
483 + }
484 +
485 + #[test]
486 + fn env_override_endpoint_path() {
487 + with_env_vars(&[("NETDATA_OTEL_ENDPOINT_PATH", "0.0.0.0:9999")], || {
488 + let overrides = PluginConfigOverride::from_env().unwrap();
489 + assert!(overrides.has_overrides());
490 + assert_eq!(
491 + overrides.endpoint.as_ref().unwrap().path.as_deref(),
492 + Some("0.0.0.0:9999")
493 + );
494 + });
495 + }
496 +
497 + #[test]
498 + fn env_override_metrics_interval() {
499 + with_env_vars(
500 + &[("NETDATA_OTEL_METRICS_INTERVAL_SECS", "30")],
501 + || {
502 + let overrides = PluginConfigOverride::from_env().unwrap();
503 + assert_eq!(overrides.metrics.as_ref().unwrap().interval_secs, Some(30));
504 + },
505 + );
506 + }
507 +
508 + #[test]
509 + fn env_override_logs_bytesize() {
510 + with_env_vars(
511 + &[("NETDATA_OTEL_LOGS_SIZE_OF_JOURNAL_FILE", "200MB")],
512 + || {
513 + let overrides = PluginConfigOverride::from_env().unwrap();
514 + assert_eq!(
515 + overrides.logs.as_ref().unwrap().size_of_journal_file,
516 + Some(ByteSize::mb(200))
517 + );
518 + },
519 + );
520 + }
521 +
522 + #[test]
523 + fn env_override_logs_duration() {
524 + with_env_vars(
525 + &[("NETDATA_OTEL_LOGS_DURATION_OF_JOURNAL_FILES", "14 days")],
526 + || {
527 + let overrides = PluginConfigOverride::from_env().unwrap();
528 + assert_eq!(
529 + overrides.logs.as_ref().unwrap().duration_of_journal_files,
530 + Some(Duration::from_secs(14 * 24 * 60 * 60))
531 + );
532 + },
533 + );
534 + }
535 +
536 + #[test]
537 + fn env_override_bool_values() {
538 + with_env_vars(
539 + &[("NETDATA_OTEL_LOGS_STORE_OTLP_JSON", "true")],
540 + || {
541 + let overrides = PluginConfigOverride::from_env().unwrap();
542 + assert_eq!(
543 + overrides.logs.as_ref().unwrap().store_otlp_json,
544 + Some(true)
545 + );
546 + },
547 + );
548 + }
549 +
550 + #[test]
551 + fn env_override_bool_accepts_yes_no() {
552 + with_env_vars(
553 + &[("NETDATA_OTEL_LOGS_STORE_OTLP_JSON", "yes")],
554 + || {
555 + let overrides = PluginConfigOverride::from_env().unwrap();
556 + assert_eq!(
557 + overrides.logs.as_ref().unwrap().store_otlp_json,
558 + Some(true)
559 + );
560 + },
561 + );
562 + }
563 +
564 + #[test]
565 + fn env_override_invalid_number_is_rejected() {
566 + with_env_vars(
567 + &[("NETDATA_OTEL_METRICS_INTERVAL_SECS", "not_a_number")],
568 + || {
569 + assert!(PluginConfigOverride::from_env().is_err());
570 + },
571 + );
572 + }
573 +
574 + #[test]
575 + fn env_override_invalid_bool_is_rejected() {
576 + with_env_vars(
577 + &[("NETDATA_OTEL_LOGS_STORE_OTLP_JSON", "maybe")],
578 + || {
579 + assert!(PluginConfigOverride::from_env().is_err());
580 + },
581 + );
582 + }
583 +
584 + #[test]
585 + fn env_no_vars_set_produces_no_overrides() {
586 + with_env_vars(&[], || {
587 + let overrides = PluginConfigOverride::from_env().unwrap();
588 + assert!(!overrides.has_overrides());
589 + });
590 + }
591 +
592 + #[test]
593 + fn env_overrides_applied_on_top_of_user_config() {
594 + let mut config = stock_config();
595 + // User sets endpoint path
596 + apply_user_yaml(
597 + &mut config,
598 + r#"
599 +endpoint:
600 + path: "192.168.1.1:4317"
601 +"#,
602 + );
603 + assert_eq!(config.endpoint.path, "192.168.1.1:4317");
604 +
605 + // Env var overrides it (highest priority)
606 + with_env_vars(&[("NETDATA_OTEL_ENDPOINT_PATH", "0.0.0.0:4317")], || {
607 + let env_overrides = PluginConfigOverride::from_env().unwrap();
608 + config.apply_overrides(&env_overrides);
609 + assert_eq!(config.endpoint.path, "0.0.0.0:4317");
610 + });
611 + }
612 +}