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
+}