From d5a2fb5a8867396cd420c7ded9cbb672f72bca9a Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Mon, 3 Aug 2026 10:15:51 -0600 Subject: [PATCH 1/3] fix(observability): prioritize programmatic destinations Signed-off-by: Bryan Bednarski --- crates/core/src/plugin.rs | 219 ++++++++++++++- crates/core/tests/unit/plugin_tests.rs | 251 ++++++++++++++++++ docs/about-nemo-relay/release-notes/index.mdx | 13 +- .../observability/configuration.mdx | 30 ++- .../plugin-configuration-files.mdx | 37 ++- 5 files changed, 527 insertions(+), 23 deletions(-) diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 5bcb380dc..fb6d08afb 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -1285,6 +1285,16 @@ fn merge_plugin_config_value( } } +const OBSERVABILITY_DESTINATION_FIELDS: [(&str, &str, &str); 3] = [ + ("atof", "sinks", "config.atof.sinks"), + ( + "opentelemetry", + "endpoints", + "config.opentelemetry.endpoints", + ), + ("atif", "storage", "config.atif.storage"), +]; + fn plugin_config_list_concatenates(path: &[String], is_observability: bool) -> bool { path.len() == 1 || (is_observability @@ -1613,10 +1623,12 @@ async fn initialize_plugin_components_catching_panics( /// layering as the gateway. Each file's schema version is validated before /// layering. Declaring a component in `config` applies its `enabled` value, /// while default policy values inherit from discovered files and component -/// `config` bodies merge field-by-field. The resolved configuration and -/// diagnostics are passed to the shared `initialize_plugins_with_diagnostics` -/// helper. Call [`initialize_plugins_exact`] directly when `config` is already -/// fully resolved and every value must be applied exactly. +/// `config` bodies merge field-by-field. Nonempty programmatic observability +/// destination arrays replace their corresponding discovered-file arrays. +/// The resolved configuration and diagnostics are passed to the shared +/// `initialize_plugins_with_diagnostics` helper. Call +/// [`initialize_plugins_exact`] directly when `config` is already fully +/// resolved and every value must be applied exactly. pub async fn initialize_plugins(config: PluginConfig) -> Result { let resolved = resolve_plugin_config(config)?; initialize_plugins_with_diagnostics(resolved.config, resolved.diagnostics).await @@ -1628,13 +1640,26 @@ pub async fn initialize_plugins(config: PluginConfig) -> Result { /// one-time configuration resolution as regular harness-native initialization. pub(crate) fn resolve_plugin_config(config: PluginConfig) -> Result { let discovered = resolve_default_file_plugin_config()?; - let diagnostics = programmatic_enable_override_diagnostics( + resolve_programmatic_plugin_config(discovered, config) +} + +fn resolve_programmatic_plugin_config( + mut discovered: DiscoveredPluginConfig, + config: PluginConfig, +) -> Result { + let mut diagnostics = programmatic_enable_override_diagnostics( &discovered.value, &discovered.enabled_sources, &config, ); + replace_discovered_observability_destinations(&mut discovered.value, &config); let mut base = discovered.value; layer_config(&mut base, plugin_config_overlay_value(&config)?); + diagnostics.extend(programmatic_observability_destination_diagnostics( + &discovered.observability_destination_sources, + &config, + &base, + )); Ok(ResolvedPluginConfig { config: serde_json::from_value(base)?, diagnostics, @@ -1703,19 +1728,35 @@ fn resolve_default_file_plugin_config() -> Result { let paths = default_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()); let documents = read_plugin_config_files(paths)?; + resolve_discovered_plugin_config(documents) +} + +fn resolve_discovered_plugin_config( + documents: Vec<(PathBuf, Json)>, +) -> Result { let enabled_sources = component_enabled_sources(&documents); + // Keep per-file provenance before the canonical file merge concatenates destinations. + let observability_destination_sources = observability_destination_sources(&documents); let value = merge_plugin_config_documents(documents)? .map(|(value, _sources)| value) .unwrap_or_else(|| Json::Object(Map::new())); Ok(DiscoveredPluginConfig { value, enabled_sources, + observability_destination_sources, }) } struct DiscoveredPluginConfig { value: Json, enabled_sources: HashMap, + observability_destination_sources: HashMap<&'static str, DestinationProvenance>, +} + +#[derive(Default)] +struct DestinationProvenance { + entry_count: usize, + sources: Vec, } use std::path::{Path, PathBuf}; @@ -1769,6 +1810,174 @@ fn component_enabled_sources(documents: &[(PathBuf, Json)]) -> HashMap HashMap<&'static str, DestinationProvenance> { + let mut destinations = HashMap::<_, DestinationProvenance>::new(); + for (source, document) in documents { + let Some(component) = observability_component(document) else { + continue; + }; + for (section, field, dotted_path) in OBSERVABILITY_DESTINATION_FIELDS { + let Some(entries) = json_destination_array(component, section, field) else { + continue; + }; + if entries.is_empty() { + continue; + } + let provenance = destinations.entry(dotted_path).or_default(); + provenance.entry_count += entries.len(); + provenance.sources.push(source.clone()); + } + } + destinations +} + +fn replace_discovered_observability_destinations( + discovered: &mut Json, + programmatic: &PluginConfig, +) { + let Some(programmatic_component) = programmatic_observability_component(programmatic) else { + return; + }; + let Some(discovered_component) = observability_component_mut(discovered) else { + return; + }; + for (section, field, _) in OBSERVABILITY_DESTINATION_FIELDS { + if programmatic_destination_array(programmatic_component, section, field) + .is_none_or(Vec::is_empty) + { + continue; + } + if let Some(section_config) = discovered_component + .get_mut("config") + .and_then(Json::as_object_mut) + .and_then(|config| config.get_mut(section)) + .and_then(Json::as_object_mut) + { + section_config.remove(field); + } + } +} + +fn programmatic_observability_destination_diagnostics( + sources: &HashMap<&'static str, DestinationProvenance>, + programmatic: &PluginConfig, + effective: &Json, +) -> Vec { + let Some(programmatic_component) = programmatic_observability_component(programmatic) else { + return Vec::new(); + }; + let effective_component = observability_component(effective); + let mut diagnostics = Vec::new(); + for (section, field, dotted_path) in OBSERVABILITY_DESTINATION_FIELDS { + let Some(provenance) = sources.get(dotted_path) else { + continue; + }; + let replaced = programmatic_destination_array(programmatic_component, section, field) + .is_some_and(|entries| !entries.is_empty()); + if !effective_component + .is_some_and(|component| observability_destination_is_active(component, section, field)) + { + continue; + } + + let source_paths = provenance + .sources + .iter() + .map(|source| source.display().to_string()) + .collect::>() + .join(", "); + let entry_label = if provenance.entry_count == 1 { + "entry" + } else { + "entries" + }; + let source_label = if provenance.sources.len() == 1 { + "file" + } else { + "files" + }; + let (code, action) = if replaced { + ("plugin.observability_destinations_replaced", "replaced") + } else { + ("plugin.observability_destinations_inherited", "inherited") + }; + diagnostics.push(ConfigDiagnostic { + level: DiagnosticLevel::Warning, + code: code.to_string(), + component: Some("observability".to_string()), + field: Some(dotted_path.to_string()), + message: format!( + "programmatic observability configuration {action} {} discovered destination {entry_label} for '{dotted_path}' from {} {source_label}: {source_paths}", + provenance.entry_count, + provenance.sources.len(), + ), + }); + } + diagnostics +} + +fn programmatic_observability_component(config: &PluginConfig) -> Option<&PluginComponentSpec> { + config + .components + .iter() + .find(|component| component.kind == "observability") +} + +fn observability_component(document: &Json) -> Option<&Json> { + document + .get("components")? + .as_array()? + .iter() + .find(|component| component_kind(component) == Some("observability")) +} + +fn observability_component_mut(document: &mut Json) -> Option<&mut Json> { + document + .get_mut("components")? + .as_array_mut()? + .iter_mut() + .find(|component| component_kind(component) == Some("observability")) +} + +fn json_destination_array<'a>( + component: &'a Json, + section: &str, + field: &str, +) -> Option<&'a Vec> { + component + .get("config")? + .get(section)? + .get(field)? + .as_array() +} + +fn programmatic_destination_array<'a>( + component: &'a PluginComponentSpec, + section: &str, + field: &str, +) -> Option<&'a Vec> { + component.config.get(section)?.get(field)?.as_array() +} + +fn observability_destination_is_active(component: &Json, section: &str, field: &str) -> bool { + component + .get("enabled") + .and_then(Json::as_bool) + .unwrap_or(true) + && component + .get("config") + .and_then(|config| config.get(section)) + .is_some_and(|section| { + section.get("enabled").and_then(Json::as_bool) == Some(true) + && section + .get(field) + .and_then(Json::as_array) + .is_some_and(|entries| !entries.is_empty()) + }) +} + fn programmatic_enable_override_diagnostics( discovered: &Json, enabled_sources: &HashMap, diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index 5be548cbb..76ba98aa8 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -89,6 +89,26 @@ fn set_conflicting_runtime_owner_for_tests() { }; } +fn observability_destination_document(destination: &str) -> Json { + json!({ + "components": [{ + "kind": "observability", + "config": { + "atof": {"enabled": true, "sinks": [destination]}, + "opentelemetry": {"enabled": true, "endpoints": [destination]}, + "atif": {"enabled": true, "storage": [destination]} + } + }] + }) +} + +fn programmatic_observability_config(config: Json) -> PluginConfig { + serde_json::from_value(json!({ + "components": [{"kind": "observability", "config": config}] + })) + .unwrap() +} + impl Plugin for TestPlugin { fn plugin_kind(&self) -> &str { "test.plugin" @@ -649,6 +669,48 @@ fn test_layer_config_concatenates_nested_observability_lists() { ); } +#[test] +fn test_file_layers_concatenate_all_observability_destination_lists() { + let lower = PathBuf::from("lower/plugins.toml"); + let higher = PathBuf::from("higher/plugins.toml"); + let (merged, sources) = merge_plugin_config_documents(vec![ + ( + lower.clone(), + json!({ + "components": [{ + "kind": "observability", + "config": { + "atof": {"sinks": ["lower"]}, + "opentelemetry": {"endpoints": ["lower"]}, + "atif": {"storage": ["lower"]} + } + }] + }), + ), + ( + higher.clone(), + json!({ + "components": [{ + "kind": "observability", + "config": { + "atof": {"sinks": ["higher"]}, + "opentelemetry": {"endpoints": ["higher"]}, + "atif": {"storage": ["higher"]} + } + }] + }), + ), + ]) + .unwrap() + .expect("documents exist"); + + assert_eq!(sources, vec![lower, higher]); + let config = &merged["components"][0]["config"]; + for (section, field, _) in OBSERVABILITY_DESTINATION_FIELDS { + assert_eq!(config[section][field], json!(["higher", "lower"])); + } +} + #[test] fn test_layer_config_replaces_observability_named_nested_lists_for_other_plugins() { let mut merged = json!({ @@ -2461,6 +2523,195 @@ fn test_plugin_config_overlay_enables_programmatically_declared_components() { assert!(!typed.components[1].enabled); } +#[test] +fn test_nonempty_programmatic_observability_destinations_replace_file_entries() { + let lower = PathBuf::from("lower/plugins.toml"); + let higher = PathBuf::from("higher/plugins.toml"); + let discovered = resolve_discovered_plugin_config(vec![ + ( + lower.clone(), + observability_destination_document("lower-secret-destination"), + ), + ( + higher.clone(), + observability_destination_document("higher-secret-destination"), + ), + ]) + .unwrap(); + let programmatic = programmatic_observability_config(json!({ + "atof": {"enabled": true, "sinks": ["programmatic"]}, + "opentelemetry": {"enabled": true, "endpoints": ["programmatic"]}, + "atif": {"enabled": true, "storage": ["programmatic"]} + })); + + let resolved = resolve_programmatic_plugin_config(discovered, programmatic).unwrap(); + let config = &resolved.config.components[0].config; + for (section, field, dotted_path) in OBSERVABILITY_DESTINATION_FIELDS { + assert_eq!(config[section][field], json!(["programmatic"])); + let diagnostic = resolved + .diagnostics + .iter() + .find(|diagnostic| diagnostic.field.as_deref() == Some(dotted_path)) + .expect("a destination replacement diagnostic"); + assert_eq!( + diagnostic.code, + "plugin.observability_destinations_replaced" + ); + assert_eq!(diagnostic.component.as_deref(), Some("observability")); + assert!( + diagnostic + .message + .contains("2 discovered destination entries") + ); + assert!(diagnostic.message.contains(&lower.display().to_string())); + assert!(diagnostic.message.contains(&higher.display().to_string())); + assert!(!diagnostic.message.contains("secret-destination")); + } + assert_eq!(resolved.diagnostics.len(), 3); +} + +#[test] +fn test_empty_programmatic_observability_destinations_inherit_file_entries() { + let source = PathBuf::from("project/plugins.toml"); + let discovered = resolve_discovered_plugin_config(vec![( + source.clone(), + observability_destination_document("file-destination"), + )]) + .unwrap(); + let programmatic = programmatic_observability_config(json!({ + "atof": {"enabled": true, "sinks": []}, + "opentelemetry": {"enabled": true, "endpoints": []}, + "atif": {"enabled": true, "storage": []} + })); + + let resolved = resolve_programmatic_plugin_config(discovered, programmatic).unwrap(); + let config = &resolved.config.components[0].config; + for (section, field, dotted_path) in OBSERVABILITY_DESTINATION_FIELDS { + assert_eq!(config[section][field], json!(["file-destination"])); + let diagnostic = resolved + .diagnostics + .iter() + .find(|diagnostic| diagnostic.field.as_deref() == Some(dotted_path)) + .expect("a destination inheritance diagnostic"); + assert_eq!( + diagnostic.code, + "plugin.observability_destinations_inherited" + ); + assert!(diagnostic.message.contains(&source.display().to_string())); + } + assert_eq!(resolved.diagnostics.len(), 3); +} + +#[test] +fn test_omitted_programmatic_observability_destinations_inherit_file_entries() { + let source = PathBuf::from("project/plugins.toml"); + let discovered = resolve_discovered_plugin_config(vec![( + source, + observability_destination_document("file-destination"), + )]) + .unwrap(); + let programmatic = programmatic_observability_config(json!({ + "atof": {"enabled": true}, + "opentelemetry": {"enabled": true}, + "atif": {"enabled": true} + })); + + let resolved = resolve_programmatic_plugin_config(discovered, programmatic).unwrap(); + let config = &resolved.config.components[0].config; + for (section, field, dotted_path) in OBSERVABILITY_DESTINATION_FIELDS { + assert_eq!(config[section][field], json!(["file-destination"])); + assert!(resolved.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "plugin.observability_destinations_inherited" + && diagnostic.field.as_deref() == Some(dotted_path) + })); + } + assert_eq!(resolved.diagnostics.len(), 3); +} + +#[test] +fn test_inherited_destination_warnings_respect_effective_enablement() { + let source = PathBuf::from("project/plugins.toml"); + let documents = || { + vec![( + source.clone(), + observability_destination_document("file-destination"), + )] + }; + + let disabled_component: PluginConfig = serde_json::from_value(json!({ + "components": [{ + "kind": "observability", + "enabled": false, + "config": { + "atof": {"enabled": true, "sinks": ["programmatic"]}, + "opentelemetry": {"enabled": true, "endpoints": ["programmatic"]}, + "atif": {"enabled": true, "storage": ["programmatic"]} + } + }] + })) + .unwrap(); + let resolved = resolve_programmatic_plugin_config( + resolve_discovered_plugin_config(documents()).unwrap(), + disabled_component, + ) + .unwrap(); + assert!(resolved.diagnostics.is_empty()); + + let partially_disabled = programmatic_observability_config(json!({ + "atof": {"enabled": false}, + "opentelemetry": {"enabled": true}, + "atif": {"enabled": false} + })); + let resolved = resolve_programmatic_plugin_config( + resolve_discovered_plugin_config(documents()).unwrap(), + partially_disabled, + ) + .unwrap(); + assert_eq!(resolved.diagnostics.len(), 1); + assert_eq!( + resolved.diagnostics[0].field.as_deref(), + Some("config.opentelemetry.endpoints") + ); + assert_eq!( + resolved.diagnostics[0].code, + "plugin.observability_destinations_inherited" + ); +} + +#[test] +fn test_destination_warnings_require_declared_observability_and_file_entries() { + let source = PathBuf::from("project/plugins.toml"); + let discovered = resolve_discovered_plugin_config(vec![( + source.clone(), + observability_destination_document("file-destination"), + )]) + .unwrap(); + let resolved = resolve_programmatic_plugin_config(discovered, PluginConfig::default()).unwrap(); + assert!(resolved.diagnostics.is_empty()); + + let discovered = resolve_discovered_plugin_config(vec![( + source, + json!({ + "components": [{ + "kind": "observability", + "config": { + "atof": {"enabled": true, "sinks": []}, + "opentelemetry": {"enabled": true, "endpoints": []}, + "atif": {"enabled": true, "storage": []} + } + }] + }), + )]) + .unwrap(); + let programmatic = programmatic_observability_config(json!({ + "atof": {"enabled": true}, + "opentelemetry": {"enabled": true}, + "atif": {"enabled": true} + })); + let resolved = resolve_programmatic_plugin_config(discovered, programmatic).unwrap(); + assert!(resolved.diagnostics.is_empty()); +} + #[test] fn test_programmatic_enable_override_diagnostic_matches_positionally_and_names_source() { let source = PathBuf::from("/etc/nemo-relay/plugins.toml"); diff --git a/docs/about-nemo-relay/release-notes/index.mdx b/docs/about-nemo-relay/release-notes/index.mdx index 8f80bf24f..a86eb54e4 100644 --- a/docs/about-nemo-relay/release-notes/index.mdx +++ b/docs/about-nemo-relay/release-notes/index.mdx @@ -52,10 +52,10 @@ compatibility information applies to the current 0.7 prerelease. - OpenTelemetry exporter dependencies, including OpenInference semantic support, are always enabled instead of being controlled by Cargo features. - OpenTelemetry Rust is upgraded to `0.32`. -- Top-level plugin component `config` lists and the observability destination - lists `atof.sinks`, `opentelemetry.endpoints`, and `atif.storage` - concatenate across configuration layers, with higher-precedence entries - first. +- Top-level plugin component `config` lists concatenate across configuration + layers. The observability destination lists `atof.sinks`, + `opentelemetry.endpoints`, and `atif.storage` concatenate across discovered + files, with higher-precedence entries first. - `object_store` is upgraded to `0.14.1`, which removes the temporary `RUSTSEC-2026-0194` and `RUSTSEC-2026-0195` advisory exceptions. @@ -102,6 +102,11 @@ their values cannot be isolated between endpoints. over discovered file configuration. When code re-enables a component that a discovered file disabled, initialization reports a warning that names the component and source file. +- A nonempty programmatic Observability destination array now replaces the + corresponding destinations contributed by discovered files. Omitted or empty + arrays inherit active file destinations. Initialization reports replacement + or inheritance warnings with source provenance without exposing destination + values or credentials. - LLM payload redaction now follows the codec active for each call instead of a codec captured from plugin configuration. Codec-dependent policies omit the observability payload and annotation when Relay cannot safely normalize the diff --git a/docs/configure-plugins/observability/configuration.mdx b/docs/configure-plugins/observability/configuration.mdx index 8552b2538..892d58e5c 100644 --- a/docs/configure-plugins/observability/configuration.mdx +++ b/docs/configure-plugins/observability/configuration.mdx @@ -92,12 +92,30 @@ lossless; refer to [OpenTelemetry](/configure-plugins/observability/opentelemetr for queue sizing and drop-warning behavior. Top-level component `config` lists concatenate across configuration layers, -with higher-precedence entries first. The observability destination lists -`atof.sinks`, `opentelemetry.endpoints`, and `atif.storage` follow the same -rule, so explicit-or-user, project, system, and programmatic layers can -contribute destinations. Arbitrary lists nested inside structured values -retain replacement semantics. List entries are not merged item by item. -To change or remove an inherited endpoint, edit the layer that declares it. +with higher-precedence entries first. Across discovered files, the +observability destination lists `atof.sinks`, `opentelemetry.endpoints`, and +`atif.storage` follow the same rule, so system, project, and +explicit-or-user files can contribute destinations. + +Programmatic initialization treats these three destination arrays differently. +A nonempty programmatic array replaces all entries from the merged file layer. +An omitted or empty programmatic array inherits the file entries; an empty +array is not a clear operation. This behavior avoids accidental destination +loss when typed Rust or Go configuration omits empty lists during serialization +or Python and Node.js helpers emit empty defaults. To remove inherited +destinations, edit the file that declares them or disable the exporter section. + +When programmatic Observability configuration encounters active destinations +from files, Relay reports one warning per affected field. The +`plugin.observability_destinations_replaced` code identifies a nonempty +programmatic replacement, and `plugin.observability_destinations_inherited` +identifies omission or an empty list. Warnings include source file paths and +entry counts, but never destination values or credentials. Disabled components +and exporter sections do not emit these routing warnings. + +Arbitrary lists nested inside other structured values retain replacement +semantics. List entries are not merged item by item. + For complete layering rules, refer to [Plugin Configuration Files](/configure-plugins/plugin-configuration-files#precedence-and-merge-behavior). diff --git a/docs/configure-plugins/plugin-configuration-files.mdx b/docs/configure-plugins/plugin-configuration-files.mdx index fb086efa0..2af85273d 100644 --- a/docs/configure-plugins/plugin-configuration-files.mdx +++ b/docs/configure-plugins/plugin-configuration-files.mdx @@ -278,10 +278,10 @@ precedence. System config overrides project config, and project config overrides the selected explicit-or-user config. TOML tables merge recursively. Top-level lists inside a component's `config` -concatenate, as do the declared observability destination lists. Entries from -the higher-precedence layer are placed before entries from the lower-precedence -layer. Other nested lists replace. For example, the following user -configuration enables one ATOF file sink: +concatenate across files, as do the declared observability destination lists. +Entries from the higher-precedence file are placed before entries from the +lower-precedence file. Other nested lists replace. For example, the following +user configuration enables one ATOF file sink: ```toml # user plugins.toml @@ -355,8 +355,27 @@ follows: setting it specifies overrides the file value, and the result is the effective config that Relay validates and activates. +The observability destination arrays have a specific rule at the file-to-code +boundary. A nonempty programmatic `config.atof.sinks`, +`config.opentelemetry.endpoints`, or `config.atif.storage` array replaces every +entry contributed by discovered files. An omitted or empty programmatic array +inherits the merged file entries. Other component `config` lists retain the +general concatenation behavior. + +When code declares the Observability component and discovered files contributed +active destinations, initialization and the active plugin report include one +warning per affected field. The +`plugin.observability_destinations_replaced` warning identifies a nonempty +programmatic replacement. The `plugin.observability_destinations_inherited` +warning identifies destinations inherited because the programmatic array was +omitted or empty. Each warning reports only the field, entry count, and source +file paths; it does not include destination values or credentials. Relay +suppresses these routing warnings when the effective Observability component or +corresponding exporter section is disabled. + +Aside from the destination-array rule, files and code differ in how they treat +a setting you **omit**: -Files and code differ only in how they treat a setting you **omit**: | You omit | In a file | In code | |---|---|---| | `version`, `policy`, or the `enabled` flag of a component you declare | Inherited from a lower-precedence file | **Always taken from code** — its default if you did not set it | @@ -398,9 +417,11 @@ the runtime ignores the section because `enabled = false`. To override an inherited non-default scalar field with its default value, write the default explicitly in the higher-precedence file. List entries are not -merged item by item: higher-precedence entries are added before inherited -entries. To change or remove an inherited sink, profile, source, or other list -entry, edit the layer that declares it. +merged item by item: higher-precedence file entries are added before inherited +file entries. A nonempty programmatic observability destination array replaces +the corresponding merged file array, but an empty array is not a clear +operation. To remove every inherited destination, edit the file that declares +it or disable the corresponding exporter section. There is no tombstone syntax for deleting an inherited nested field while keeping the rest of the lower-precedence component. To remove inherited settings From 4f44ddcbe93b2f04e09ebd13e96fbf4ffea41e3f Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Mon, 3 Aug 2026 14:53:03 -0600 Subject: [PATCH 2/3] fix(plugin): preserve inherited configuration layering Signed-off-by: Bryan Bednarski --- crates/core/src/plugin.rs | 232 +++--------------- crates/core/tests/unit/plugin_tests.rs | 218 ++-------------- .../integration/plugin_activation_tests.rs | 13 +- crates/node/tests/dynamic_plugin_tests.mjs | 7 +- docs/about-nemo-relay/release-notes/index.mdx | 17 +- .../observability/configuration.mdx | 36 +-- .../plugin-configuration-files.mdx | 50 ++-- go/nemo_relay/plugin_activation_test.go | 11 +- python/tests/test_dynamic_plugin_host.py | 12 +- 9 files changed, 131 insertions(+), 465 deletions(-) diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index fb6d08afb..601cb5559 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -1285,16 +1285,6 @@ fn merge_plugin_config_value( } } -const OBSERVABILITY_DESTINATION_FIELDS: [(&str, &str, &str); 3] = [ - ("atof", "sinks", "config.atof.sinks"), - ( - "opentelemetry", - "endpoints", - "config.opentelemetry.endpoints", - ), - ("atif", "storage", "config.atif.storage"), -]; - fn plugin_config_list_concatenates(path: &[String], is_observability: bool) -> bool { path.len() == 1 || (is_observability @@ -1623,12 +1613,10 @@ async fn initialize_plugin_components_catching_panics( /// layering as the gateway. Each file's schema version is validated before /// layering. Declaring a component in `config` applies its `enabled` value, /// while default policy values inherit from discovered files and component -/// `config` bodies merge field-by-field. Nonempty programmatic observability -/// destination arrays replace their corresponding discovered-file arrays. -/// The resolved configuration and diagnostics are passed to the shared -/// `initialize_plugins_with_diagnostics` helper. Call -/// [`initialize_plugins_exact`] directly when `config` is already fully -/// resolved and every value must be applied exactly. +/// `config` bodies merge field-by-field. The resolved configuration and +/// diagnostics are passed to the shared `initialize_plugins_with_diagnostics` +/// helper. Call [`initialize_plugins_exact`] directly when `config` is already +/// fully resolved and every value must be applied exactly. pub async fn initialize_plugins(config: PluginConfig) -> Result { let resolved = resolve_plugin_config(config)?; initialize_plugins_with_diagnostics(resolved.config, resolved.diagnostics).await @@ -1644,22 +1632,17 @@ pub(crate) fn resolve_plugin_config(config: PluginConfig) -> Result Result { - let mut diagnostics = programmatic_enable_override_diagnostics( + let mut diagnostics = inherited_plugin_config_diagnostics(&discovered.sources); + diagnostics.extend(programmatic_enable_override_diagnostics( &discovered.value, &discovered.enabled_sources, &config, - ); - replace_discovered_observability_destinations(&mut discovered.value, &config); + )); let mut base = discovered.value; layer_config(&mut base, plugin_config_overlay_value(&config)?); - diagnostics.extend(programmatic_observability_destination_diagnostics( - &discovered.observability_destination_sources, - &config, - &base, - )); Ok(ResolvedPluginConfig { config: serde_json::from_value(base)?, diagnostics, @@ -1735,27 +1718,18 @@ fn resolve_discovered_plugin_config( documents: Vec<(PathBuf, Json)>, ) -> Result { let enabled_sources = component_enabled_sources(&documents); - // Keep per-file provenance before the canonical file merge concatenates destinations. - let observability_destination_sources = observability_destination_sources(&documents); - let value = merge_plugin_config_documents(documents)? - .map(|(value, _sources)| value) - .unwrap_or_else(|| Json::Object(Map::new())); + let (value, sources) = merge_plugin_config_documents(documents)? + .unwrap_or_else(|| (Json::Object(Map::new()), Vec::new())); Ok(DiscoveredPluginConfig { value, enabled_sources, - observability_destination_sources, + sources, }) } struct DiscoveredPluginConfig { value: Json, enabled_sources: HashMap, - observability_destination_sources: HashMap<&'static str, DestinationProvenance>, -} - -#[derive(Default)] -struct DestinationProvenance { - entry_count: usize, sources: Vec, } @@ -1810,172 +1784,26 @@ fn component_enabled_sources(documents: &[(PathBuf, Json)]) -> HashMap HashMap<&'static str, DestinationProvenance> { - let mut destinations = HashMap::<_, DestinationProvenance>::new(); - for (source, document) in documents { - let Some(component) = observability_component(document) else { - continue; - }; - for (section, field, dotted_path) in OBSERVABILITY_DESTINATION_FIELDS { - let Some(entries) = json_destination_array(component, section, field) else { - continue; - }; - if entries.is_empty() { - continue; - } - let provenance = destinations.entry(dotted_path).or_default(); - provenance.entry_count += entries.len(); - provenance.sources.push(source.clone()); - } - } - destinations -} - -fn replace_discovered_observability_destinations( - discovered: &mut Json, - programmatic: &PluginConfig, -) { - let Some(programmatic_component) = programmatic_observability_component(programmatic) else { - return; - }; - let Some(discovered_component) = observability_component_mut(discovered) else { - return; - }; - for (section, field, _) in OBSERVABILITY_DESTINATION_FIELDS { - if programmatic_destination_array(programmatic_component, section, field) - .is_none_or(Vec::is_empty) - { - continue; - } - if let Some(section_config) = discovered_component - .get_mut("config") - .and_then(Json::as_object_mut) - .and_then(|config| config.get_mut(section)) - .and_then(Json::as_object_mut) - { - section_config.remove(field); - } - } -} - -fn programmatic_observability_destination_diagnostics( - sources: &HashMap<&'static str, DestinationProvenance>, - programmatic: &PluginConfig, - effective: &Json, -) -> Vec { - let Some(programmatic_component) = programmatic_observability_component(programmatic) else { - return Vec::new(); - }; - let effective_component = observability_component(effective); - let mut diagnostics = Vec::new(); - for (section, field, dotted_path) in OBSERVABILITY_DESTINATION_FIELDS { - let Some(provenance) = sources.get(dotted_path) else { - continue; - }; - let replaced = programmatic_destination_array(programmatic_component, section, field) - .is_some_and(|entries| !entries.is_empty()); - if !effective_component - .is_some_and(|component| observability_destination_is_active(component, section, field)) - { - continue; - } - - let source_paths = provenance - .sources - .iter() - .map(|source| source.display().to_string()) - .collect::>() - .join(", "); - let entry_label = if provenance.entry_count == 1 { - "entry" - } else { - "entries" - }; - let source_label = if provenance.sources.len() == 1 { - "file" - } else { - "files" - }; - let (code, action) = if replaced { - ("plugin.observability_destinations_replaced", "replaced") - } else { - ("plugin.observability_destinations_inherited", "inherited") - }; - diagnostics.push(ConfigDiagnostic { - level: DiagnosticLevel::Warning, - code: code.to_string(), - component: Some("observability".to_string()), - field: Some(dotted_path.to_string()), - message: format!( - "programmatic observability configuration {action} {} discovered destination {entry_label} for '{dotted_path}' from {} {source_label}: {source_paths}", - provenance.entry_count, - provenance.sources.len(), - ), - }); - } - diagnostics -} - -fn programmatic_observability_component(config: &PluginConfig) -> Option<&PluginComponentSpec> { - config - .components - .iter() - .find(|component| component.kind == "observability") -} - -fn observability_component(document: &Json) -> Option<&Json> { - document - .get("components")? - .as_array()? +fn inherited_plugin_config_diagnostics(sources: &[PathBuf]) -> Vec { + sources .iter() - .find(|component| component_kind(component) == Some("observability")) -} - -fn observability_component_mut(document: &mut Json) -> Option<&mut Json> { - document - .get_mut("components")? - .as_array_mut()? - .iter_mut() - .find(|component| component_kind(component) == Some("observability")) -} - -fn json_destination_array<'a>( - component: &'a Json, - section: &str, - field: &str, -) -> Option<&'a Vec> { - component - .get("config")? - .get(section)? - .get(field)? - .as_array() -} - -fn programmatic_destination_array<'a>( - component: &'a PluginComponentSpec, - section: &str, - field: &str, -) -> Option<&'a Vec> { - component.config.get(section)?.get(field)?.as_array() -} - -fn observability_destination_is_active(component: &Json, section: &str, field: &str) -> bool { - component - .get("enabled") - .and_then(Json::as_bool) - .unwrap_or(true) - && component - .get("config") - .and_then(|config| config.get(section)) - .is_some_and(|section| { - section.get("enabled").and_then(Json::as_bool) == Some(true) - && section - .get(field) - .and_then(Json::as_array) - .is_some_and(|entries| !entries.is_empty()) - }) + .map(|source| { + let source = source.display().to_string(); + log::warn!( + target: "nemo_relay.plugin", + event = "plugin_configuration_inherited", + config_path = source.as_str(); + "Inherited plugin configuration from discovered file" + ); + ConfigDiagnostic { + level: DiagnosticLevel::Warning, + code: "plugin.configuration_inherited".to_string(), + component: None, + field: None, + message: format!("inherited plugin configuration from discovered file: {source}"), + } + }) + .collect() } fn programmatic_enable_override_diagnostics( diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index 76ba98aa8..b39f6f934 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -669,48 +669,6 @@ fn test_layer_config_concatenates_nested_observability_lists() { ); } -#[test] -fn test_file_layers_concatenate_all_observability_destination_lists() { - let lower = PathBuf::from("lower/plugins.toml"); - let higher = PathBuf::from("higher/plugins.toml"); - let (merged, sources) = merge_plugin_config_documents(vec![ - ( - lower.clone(), - json!({ - "components": [{ - "kind": "observability", - "config": { - "atof": {"sinks": ["lower"]}, - "opentelemetry": {"endpoints": ["lower"]}, - "atif": {"storage": ["lower"]} - } - }] - }), - ), - ( - higher.clone(), - json!({ - "components": [{ - "kind": "observability", - "config": { - "atof": {"sinks": ["higher"]}, - "opentelemetry": {"endpoints": ["higher"]}, - "atif": {"storage": ["higher"]} - } - }] - }), - ), - ]) - .unwrap() - .expect("documents exist"); - - assert_eq!(sources, vec![lower, higher]); - let config = &merged["components"][0]["config"]; - for (section, field, _) in OBSERVABILITY_DESTINATION_FIELDS { - assert_eq!(config[section][field], json!(["higher", "lower"])); - } -} - #[test] fn test_layer_config_replaces_observability_named_nested_lists_for_other_plugins() { let mut merged = json!({ @@ -2524,7 +2482,7 @@ fn test_plugin_config_overlay_enables_programmatically_declared_components() { } #[test] -fn test_nonempty_programmatic_observability_destinations_replace_file_entries() { +fn test_programmatic_observability_destinations_concatenate_with_discovered_files_and_warn() { let lower = PathBuf::from("lower/plugins.toml"); let higher = PathBuf::from("higher/plugins.toml"); let discovered = resolve_discovered_plugin_config(vec![ @@ -2546,170 +2504,36 @@ fn test_nonempty_programmatic_observability_destinations_replace_file_entries() let resolved = resolve_programmatic_plugin_config(discovered, programmatic).unwrap(); let config = &resolved.config.components[0].config; - for (section, field, dotted_path) in OBSERVABILITY_DESTINATION_FIELDS { - assert_eq!(config[section][field], json!(["programmatic"])); - let diagnostic = resolved - .diagnostics - .iter() - .find(|diagnostic| diagnostic.field.as_deref() == Some(dotted_path)) - .expect("a destination replacement diagnostic"); + for (section, field) in [ + ("atof", "sinks"), + ("opentelemetry", "endpoints"), + ("atif", "storage"), + ] { assert_eq!( - diagnostic.code, - "plugin.observability_destinations_replaced" - ); - assert_eq!(diagnostic.component.as_deref(), Some("observability")); - assert!( - diagnostic - .message - .contains("2 discovered destination entries") + config[section][field], + json!([ + "programmatic", + "higher-secret-destination", + "lower-secret-destination" + ]) ); - assert!(diagnostic.message.contains(&lower.display().to_string())); - assert!(diagnostic.message.contains(&higher.display().to_string())); - assert!(!diagnostic.message.contains("secret-destination")); } - assert_eq!(resolved.diagnostics.len(), 3); -} - -#[test] -fn test_empty_programmatic_observability_destinations_inherit_file_entries() { - let source = PathBuf::from("project/plugins.toml"); - let discovered = resolve_discovered_plugin_config(vec![( - source.clone(), - observability_destination_document("file-destination"), - )]) - .unwrap(); - let programmatic = programmatic_observability_config(json!({ - "atof": {"enabled": true, "sinks": []}, - "opentelemetry": {"enabled": true, "endpoints": []}, - "atif": {"enabled": true, "storage": []} - })); - - let resolved = resolve_programmatic_plugin_config(discovered, programmatic).unwrap(); - let config = &resolved.config.components[0].config; - for (section, field, dotted_path) in OBSERVABILITY_DESTINATION_FIELDS { - assert_eq!(config[section][field], json!(["file-destination"])); - let diagnostic = resolved - .diagnostics - .iter() - .find(|diagnostic| diagnostic.field.as_deref() == Some(dotted_path)) - .expect("a destination inheritance diagnostic"); - assert_eq!( - diagnostic.code, - "plugin.observability_destinations_inherited" - ); + assert_eq!(resolved.diagnostics.len(), 2); + for (diagnostic, source) in resolved.diagnostics.iter().zip([lower, higher]) { + assert_eq!(diagnostic.level, DiagnosticLevel::Warning); + assert_eq!(diagnostic.code, "plugin.configuration_inherited"); + assert!(diagnostic.component.is_none()); + assert!(diagnostic.field.is_none()); assert!(diagnostic.message.contains(&source.display().to_string())); + assert!(!diagnostic.message.contains("secret-destination")); } - assert_eq!(resolved.diagnostics.len(), 3); -} - -#[test] -fn test_omitted_programmatic_observability_destinations_inherit_file_entries() { - let source = PathBuf::from("project/plugins.toml"); - let discovered = resolve_discovered_plugin_config(vec![( - source, - observability_destination_document("file-destination"), - )]) - .unwrap(); - let programmatic = programmatic_observability_config(json!({ - "atof": {"enabled": true}, - "opentelemetry": {"enabled": true}, - "atif": {"enabled": true} - })); - - let resolved = resolve_programmatic_plugin_config(discovered, programmatic).unwrap(); - let config = &resolved.config.components[0].config; - for (section, field, dotted_path) in OBSERVABILITY_DESTINATION_FIELDS { - assert_eq!(config[section][field], json!(["file-destination"])); - assert!(resolved.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "plugin.observability_destinations_inherited" - && diagnostic.field.as_deref() == Some(dotted_path) - })); - } - assert_eq!(resolved.diagnostics.len(), 3); -} - -#[test] -fn test_inherited_destination_warnings_respect_effective_enablement() { - let source = PathBuf::from("project/plugins.toml"); - let documents = || { - vec![( - source.clone(), - observability_destination_document("file-destination"), - )] - }; - - let disabled_component: PluginConfig = serde_json::from_value(json!({ - "components": [{ - "kind": "observability", - "enabled": false, - "config": { - "atof": {"enabled": true, "sinks": ["programmatic"]}, - "opentelemetry": {"enabled": true, "endpoints": ["programmatic"]}, - "atif": {"enabled": true, "storage": ["programmatic"]} - } - }] - })) - .unwrap(); - let resolved = resolve_programmatic_plugin_config( - resolve_discovered_plugin_config(documents()).unwrap(), - disabled_component, - ) - .unwrap(); - assert!(resolved.diagnostics.is_empty()); - - let partially_disabled = programmatic_observability_config(json!({ - "atof": {"enabled": false}, - "opentelemetry": {"enabled": true}, - "atif": {"enabled": false} - })); - let resolved = resolve_programmatic_plugin_config( - resolve_discovered_plugin_config(documents()).unwrap(), - partially_disabled, - ) - .unwrap(); - assert_eq!(resolved.diagnostics.len(), 1); - assert_eq!( - resolved.diagnostics[0].field.as_deref(), - Some("config.opentelemetry.endpoints") - ); - assert_eq!( - resolved.diagnostics[0].code, - "plugin.observability_destinations_inherited" - ); } #[test] -fn test_destination_warnings_require_declared_observability_and_file_entries() { - let source = PathBuf::from("project/plugins.toml"); - let discovered = resolve_discovered_plugin_config(vec![( - source.clone(), - observability_destination_document("file-destination"), - )]) - .unwrap(); +fn test_no_inherited_configuration_warning_without_discovered_files() { + let discovered = resolve_discovered_plugin_config(Vec::new()).unwrap(); let resolved = resolve_programmatic_plugin_config(discovered, PluginConfig::default()).unwrap(); assert!(resolved.diagnostics.is_empty()); - - let discovered = resolve_discovered_plugin_config(vec![( - source, - json!({ - "components": [{ - "kind": "observability", - "config": { - "atof": {"enabled": true, "sinks": []}, - "opentelemetry": {"enabled": true, "endpoints": []}, - "atif": {"enabled": true, "storage": []} - } - }] - }), - )]) - .unwrap(); - let programmatic = programmatic_observability_config(json!({ - "atof": {"enabled": true}, - "opentelemetry": {"enabled": true}, - "atif": {"enabled": true} - })); - let resolved = resolve_programmatic_plugin_config(discovered, programmatic).unwrap(); - assert!(resolved.diagnostics.is_empty()); } #[test] diff --git a/crates/ffi/tests/integration/plugin_activation_tests.rs b/crates/ffi/tests/integration/plugin_activation_tests.rs index a8658d8ae..d4f9b8adf 100644 --- a/crates/ffi/tests/integration/plugin_activation_tests.rs +++ b/crates/ffi/tests/integration/plugin_activation_tests.rs @@ -187,7 +187,18 @@ fn assert_empty_dynamic_specs_rejected(config: &CString, empty_specs: &CString) #[track_caller] fn write_and_assert_discovered_activation(report: &Json, plugins_toml: &Path) { // The file-only component and its config must survive the merge. - assert_eq!(report["diagnostics"], json!([])); + let diagnostics = report["diagnostics"].as_array().expect("diagnostics array"); + assert_eq!(diagnostics.len(), 1); + let diagnostic = &diagnostics[0]; + assert_eq!(diagnostic["level"], "warning"); + assert_eq!(diagnostic["code"], "plugin.configuration_inherited"); + assert!(diagnostic.get("component").is_none()); + assert!(diagnostic.get("field").is_none()); + assert!( + diagnostic["message"] + .as_str() + .is_some_and(|message| message.contains(&plugins_toml.display().to_string())) + ); assert_eq!(DISCOVERED_STATIC_REGISTRATIONS.load(Ordering::SeqCst), 1); assert_eq!( DISCOVERED_STATIC_CONFIG.lock().unwrap().as_ref(), diff --git a/crates/node/tests/dynamic_plugin_tests.mjs b/crates/node/tests/dynamic_plugin_tests.mjs index bf96f4387..e72517e6a 100644 --- a/crates/node/tests/dynamic_plugin_tests.mjs +++ b/crates/node/tests/dynamic_plugin_tests.mjs @@ -209,10 +209,11 @@ describe('dynamic plugin host', () => { const projectRoot = path.join(tempRoot, 'file-static-base-project'); const projectConfigDirectory = path.join(projectRoot, '.nemo-relay'); const isolatedUserConfig = path.join(projectRoot, 'xdg'); + const pluginsToml = path.join(projectConfigDirectory, 'plugins.toml'); mkdirSync(projectConfigDirectory, { recursive: true }); mkdirSync(isolatedUserConfig, { recursive: true }); writeFileSync( - path.join(projectConfigDirectory, 'plugins.toml'), + pluginsToml, `version = 1 [[components]] @@ -237,6 +238,10 @@ enabled = true activation = await plugin.initializeWithDynamicPlugins({ version: 1, components: [] }, [ activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), ]); + assert.equal(activation.report.diagnostics.length, 1); + assert.equal(activation.report.diagnostics[0].level, 'warning'); + assert.equal(activation.report.diagnostics[0].code, 'plugin.configuration_inherited'); + assert.match(activation.report.diagnostics[0].message, /plugins\.toml$/); const result = await executeTool('node_static_and_dynamic_tool'); assert.equal(result.staticBase, true); assert.equal(result.native_plugin_tool_execution, true); diff --git a/docs/about-nemo-relay/release-notes/index.mdx b/docs/about-nemo-relay/release-notes/index.mdx index a86eb54e4..408d20151 100644 --- a/docs/about-nemo-relay/release-notes/index.mdx +++ b/docs/about-nemo-relay/release-notes/index.mdx @@ -52,10 +52,10 @@ compatibility information applies to the current 0.7 prerelease. - OpenTelemetry exporter dependencies, including OpenInference semantic support, are always enabled instead of being controlled by Cargo features. - OpenTelemetry Rust is upgraded to `0.32`. -- Top-level plugin component `config` lists concatenate across configuration - layers. The observability destination lists `atof.sinks`, - `opentelemetry.endpoints`, and `atif.storage` concatenate across discovered - files, with higher-precedence entries first. +- Top-level plugin component `config` lists and the observability destination + lists `atof.sinks`, `opentelemetry.endpoints`, and `atif.storage` + concatenate across configuration layers, with higher-precedence entries + first. - `object_store` is upgraded to `0.14.1`, which removes the temporary `RUSTSEC-2026-0194` and `RUSTSEC-2026-0195` advisory exceptions. @@ -102,11 +102,10 @@ their values cannot be isolated between endpoints. over discovered file configuration. When code re-enables a component that a discovered file disabled, initialization reports a warning that names the component and source file. -- A nonempty programmatic Observability destination array now replaces the - corresponding destinations contributed by discovered files. Omitted or empty - arrays inherit active file destinations. Initialization reports replacement - or inheritance warnings with source provenance without exposing destination - values or credentials. +- Library plugin initialization now emits a warning for each discovered + `plugins.toml` file inherited by the caller configuration. The warning is + available in operational logs, initialization results, and the active plugin + report without exposing configuration values or credentials. - LLM payload redaction now follows the codec active for each call instead of a codec captured from plugin configuration. Codec-dependent policies omit the observability payload and annotation when Relay cannot safely normalize the diff --git a/docs/configure-plugins/observability/configuration.mdx b/docs/configure-plugins/observability/configuration.mdx index 892d58e5c..72cdb8513 100644 --- a/docs/configure-plugins/observability/configuration.mdx +++ b/docs/configure-plugins/observability/configuration.mdx @@ -92,29 +92,19 @@ lossless; refer to [OpenTelemetry](/configure-plugins/observability/opentelemetr for queue sizing and drop-warning behavior. Top-level component `config` lists concatenate across configuration layers, -with higher-precedence entries first. Across discovered files, the -observability destination lists `atof.sinks`, `opentelemetry.endpoints`, and -`atif.storage` follow the same rule, so system, project, and -explicit-or-user files can contribute destinations. - -Programmatic initialization treats these three destination arrays differently. -A nonempty programmatic array replaces all entries from the merged file layer. -An omitted or empty programmatic array inherits the file entries; an empty -array is not a clear operation. This behavior avoids accidental destination -loss when typed Rust or Go configuration omits empty lists during serialization -or Python and Node.js helpers emit empty defaults. To remove inherited -destinations, edit the file that declares them or disable the exporter section. - -When programmatic Observability configuration encounters active destinations -from files, Relay reports one warning per affected field. The -`plugin.observability_destinations_replaced` code identifies a nonempty -programmatic replacement, and `plugin.observability_destinations_inherited` -identifies omission or an empty list. Warnings include source file paths and -entry counts, but never destination values or credentials. Disabled components -and exporter sections do not emit these routing warnings. - -Arbitrary lists nested inside other structured values retain replacement -semantics. List entries are not merged item by item. +with higher-precedence entries first. The observability destination lists +`atof.sinks`, `opentelemetry.endpoints`, and `atif.storage` follow the same +rule, so explicit-or-user, project, system, and programmatic layers can +contribute destinations. Arbitrary lists nested inside structured values +retain replacement semantics. List entries are not merged item by item. + +When library initialization discovers `plugins.toml` files, Relay emits one +`plugin.configuration_inherited` warning per file. Each warning is written to +the operational log and included in the initialization result and active plugin +report. It names only the source path, never destination values or credentials. +Relay continues with the layered destination set; validation or activation +errors in that effective configuration still fail normally. To change or +remove an inherited endpoint, edit the layer that declares it. For complete layering rules, refer to [Plugin Configuration Files](/configure-plugins/plugin-configuration-files#precedence-and-merge-behavior). diff --git a/docs/configure-plugins/plugin-configuration-files.mdx b/docs/configure-plugins/plugin-configuration-files.mdx index 2af85273d..73db0435c 100644 --- a/docs/configure-plugins/plugin-configuration-files.mdx +++ b/docs/configure-plugins/plugin-configuration-files.mdx @@ -278,10 +278,10 @@ precedence. System config overrides project config, and project config overrides the selected explicit-or-user config. TOML tables merge recursively. Top-level lists inside a component's `config` -concatenate across files, as do the declared observability destination lists. -Entries from the higher-precedence file are placed before entries from the -lower-precedence file. Other nested lists replace. For example, the following -user configuration enables one ATOF file sink: +concatenate, as do the declared observability destination lists. Entries from +the higher-precedence layer are placed before entries from the lower-precedence +layer. Other nested lists replace. For example, the following user +configuration enables one ATOF file sink: ```toml # user plugins.toml @@ -355,26 +355,20 @@ follows: setting it specifies overrides the file value, and the result is the effective config that Relay validates and activates. -The observability destination arrays have a specific rule at the file-to-code -boundary. A nonempty programmatic `config.atof.sinks`, -`config.opentelemetry.endpoints`, or `config.atif.storage` array replaces every -entry contributed by discovered files. An omitted or empty programmatic array -inherits the merged file entries. Other component `config` lists retain the -general concatenation behavior. - -When code declares the Observability component and discovered files contributed -active destinations, initialization and the active plugin report include one -warning per affected field. The -`plugin.observability_destinations_replaced` warning identifies a nonempty -programmatic replacement. The `plugin.observability_destinations_inherited` -warning identifies destinations inherited because the programmatic array was -omitted or empty. Each warning reports only the field, entry count, and source -file paths; it does not include destination values or credentials. Relay -suppresses these routing warnings when the effective Observability component or -corresponding exporter section is disabled. - -Aside from the destination-array rule, files and code differ in how they treat -a setting you **omit**: +Programmatic lists participate in the same concatenation rules. For example, a +programmatic `config.opentelemetry.endpoints` list appears before endpoints +inherited from system, project, and explicit-or-user files; it does not remove +those file entries. + +When library initialization discovers `plugins.toml` files, Relay emits one +`plugin.configuration_inherited` warning per file. Each warning is written to +the operational log and included in the initialization result and active plugin +report. It names only the source path and does not include configuration values +or credentials. Discovery itself does not block initialization, but validation +or activation errors in the effective layered configuration still fail +normally. + +Files and code differ only in how they treat a setting you **omit**: | You omit | In a file | In code | |---|---|---| @@ -417,11 +411,9 @@ the runtime ignores the section because `enabled = false`. To override an inherited non-default scalar field with its default value, write the default explicitly in the higher-precedence file. List entries are not -merged item by item: higher-precedence file entries are added before inherited -file entries. A nonempty programmatic observability destination array replaces -the corresponding merged file array, but an empty array is not a clear -operation. To remove every inherited destination, edit the file that declares -it or disable the corresponding exporter section. +merged item by item: higher-precedence entries are added before inherited +entries. To change or remove an inherited sink, profile, source, or other list +entry, edit the layer that declares it. There is no tombstone syntax for deleting an inherited nested field while keeping the rest of the lower-precedence component. To remove inherited settings diff --git a/go/nemo_relay/plugin_activation_test.go b/go/nemo_relay/plugin_activation_test.go index 3d31d347c..67ce0089f 100644 --- a/go/nemo_relay/plugin_activation_test.go +++ b/go/nemo_relay/plugin_activation_test.go @@ -603,8 +603,15 @@ func TestInitializeWithDynamicPluginsLoadsNativePluginThroughCgo(t *testing.T) { t.Errorf("deferred Close() error = %v", err) } }() - if len(report.Diagnostics) != 0 { - t.Fatalf("activation diagnostics = %#v, want none", report.Diagnostics) + if len(report.Diagnostics) != 1 { + t.Fatalf("activation diagnostics = %#v, want one inherited-configuration warning", report.Diagnostics) + } + diagnostic := report.Diagnostics[0] + if diagnostic.Level != DiagnosticLevelWarning || + diagnostic.Code != "plugin.configuration_inherited" || + diagnostic.Component != nil || diagnostic.Field != nil || + !strings.Contains(diagnostic.Message, pluginsTOML) { + t.Fatalf("activation diagnostic = %#v, want source-only inherited-configuration warning", diagnostic) } if staticRegistrations.Load() != 1 { t.Fatalf("static registrations = %d, want 1", staticRegistrations.Load()) diff --git a/python/tests/test_dynamic_plugin_host.py b/python/tests/test_dynamic_plugin_host.py index 61f4be9ae..97340cec5 100644 --- a/python/tests/test_dynamic_plugin_host.py +++ b/python/tests/test_dynamic_plugin_host.py @@ -323,7 +323,8 @@ def register(self, _plugin_config, context): project_config = tmp_path / ".nemo-relay" project_config.mkdir() - (project_config / "plugins.toml").write_text( + plugins_toml = project_config / "plugins.toml" + plugins_toml.write_text( textwrap.dedent( f""" version = 1 @@ -343,6 +344,15 @@ def register(self, _plugin_config, context): activation = None try: activation = await plugin.initialize_with_dynamic_plugins(plugin.PluginConfig(), [native_dynamic_plugin.spec()]) + assert activation.report == { + "diagnostics": [ + { + "level": "warning", + "code": "plugin.configuration_inherited", + "message": f"inherited plugin configuration from discovered file: {plugins_toml.resolve()}", + } + ] + } result = await tools.execute("python-file-static-base", {"input": True}, lambda args: args) assert result["file_static_base"] is True assert result["native_plugin_tool_execution"] is True From 00be0e7f9808f67110e786bb1a3923b0c0593ae1 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Mon, 3 Aug 2026 15:43:24 -0600 Subject: [PATCH 3/3] test(node): strengthen inherited warning assertions Signed-off-by: Bryan Bednarski --- crates/node/tests/dynamic_plugin_tests.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/node/tests/dynamic_plugin_tests.mjs b/crates/node/tests/dynamic_plugin_tests.mjs index e72517e6a..a321020df 100644 --- a/crates/node/tests/dynamic_plugin_tests.mjs +++ b/crates/node/tests/dynamic_plugin_tests.mjs @@ -239,9 +239,12 @@ enabled = true activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), ]); assert.equal(activation.report.diagnostics.length, 1); - assert.equal(activation.report.diagnostics[0].level, 'warning'); - assert.equal(activation.report.diagnostics[0].code, 'plugin.configuration_inherited'); - assert.match(activation.report.diagnostics[0].message, /plugins\.toml$/); + const diagnostic = activation.report.diagnostics[0]; + assert.equal(diagnostic.level, 'warning'); + assert.equal(diagnostic.code, 'plugin.configuration_inherited'); + assert.ok(diagnostic.message.endsWith(path.resolve(pluginsToml))); + assert.equal(diagnostic.component, undefined); + assert.equal(diagnostic.field, undefined); const result = await executeTool('node_static_and_dynamic_tool'); assert.equal(result.staticBase, true); assert.equal(result.native_plugin_tool_execution, true);