Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6e70722
feat(cli): add persistent operational process logging
ericevans-nv Jul 13, 2026
d4a185e
Merge branch 'main' of github.com:NVIDIA/NeMo-Relay into feat/persist…
ericevans-nv Jul 13, 2026
71cd417
chore: refresh Rust attributions for spdlog-rs deps
ericevans-nv Jul 13, 2026
d675d2f
Merge remote-tracking branch 'upstream/main' into feat/persistent-rel…
ericevans-nv Jul 13, 2026
1e54b7e
Merge remote-tracking branch 'upstream/main' into feat/persistent-rel…
ericevans-nv Jul 14, 2026
ba2cdec
fix(cli): restore ffi header drift from merge hooks
ericevans-nv Jul 14, 2026
f424679
docs: remove operational logging section from feature PR
ericevans-nv Jul 14, 2026
c7a2167
refactor(logging): move operational logging into core logging module
ericevans-nv Jul 14, 2026
d949564
fix(logging): remove field redaction and bound file sink queue_capacity
ericevans-nv Jul 14, 2026
60d8662
docs(logging): simplify max_queue_capacity constant comment
ericevans-nv Jul 14, 2026
58893eb
fix(logging): harden file sink configuration
ericevans-nv Jul 14, 2026
19d5c3e
Merge upstream/main into feat/persistent-relay-logging
ericevans-nv Jul 14, 2026
334508c
Merge upstream/main into feat/persistent-relay-logging
ericevans-nv Jul 15, 2026
b91d8d5
Merge branch 'main' into feat/persistent-relay-logging
afourniernv Jul 15, 2026
d782942
feat(logging): add shared runtime configuration sources
ericevans-nv Jul 15, 2026
754c33f
refactor(cli): localize logging config imports
ericevans-nv Jul 15, 2026
8e04578
test(logging): move sink test out of source
ericevans-nv Jul 15, 2026
07d31ee
Merge remote-tracking branch 'upstream/main' into feat/persistent-rel…
ericevans-nv Jul 15, 2026
1e718ab
fix(logging): honor user config scope in discovery
ericevans-nv Jul 15, 2026
c093a40
fix(logging): reject conflicting global logger
ericevans-nv Jul 15, 2026
5ad0ff5
Merge remote-tracking branch 'upstream/main' into feat/persistent-rel…
ericevans-nv Jul 15, 2026
15e6c1d
fix(logging): reject unknown configuration fields
ericevans-nv Jul 15, 2026
697a2cb
fix(logging): serialize logger lifecycle changes
ericevans-nv Jul 15, 2026
6c5e76c
Merge upstream/main into feat/persistent-relay-logging
ericevans-nv Jul 15, 2026
7b1a0cc
Merge upstream/main into feat/persistent-relay-logging
ericevans-nv Jul 16, 2026
a443ada
Merge branch 'main' into feat/persistent-relay-logging
afourniernv Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31,909 changes: 18,669 additions & 13,240 deletions ATTRIBUTIONS-Rust.md

Large diffs are not rendered by default.

303 changes: 303 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

75 changes: 75 additions & 0 deletions crates/cli/src/commands/logging.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Process-wide operational logging arguments and source selection.

use std::path::{Path, PathBuf};

use clap::Args;
use nemo_relay::error::FlowError;
use nemo_relay::logging::{LogFormat, LogLevel, LoggingConfig};

use crate::error::CliError;

#[derive(Debug, Clone, Default, Args)]
pub(super) struct LoggingArgs {
/// Minimum operational log level.
#[arg(
long = "log-level",
value_parser = ["error", "warn", "info", "debug", "trace"],
conflicts_with = "config_path"
)]
level: Option<String>,
/// Format for the mandatory stderr logging sink.
#[arg(
long = "log-stderr-format",
value_parser = ["human", "jsonl"],
conflicts_with = "config_path"
)]
stderr_format: Option<String>,
/// Absolute path to a TOML document containing a `[logging]` section.
#[arg(
long = "log-config-path",
conflicts_with_all = ["level", "stderr_format"]
)]
config_path: Option<PathBuf>,
}

impl LoggingArgs {
/// Selects one logging source: direct CLI settings, environment, file configuration, or
/// built-in defaults. Sources are not merged with one another.
pub(super) fn resolve(
&self,
explicit_config: Option<&Path>,
user_only: bool,
) -> Result<LoggingConfig, CliError> {
if let Some(path) = &self.config_path {
return LoggingConfig::from_file_path(path).map_err(logging_config_error);
}

if self.level.is_some() || self.stderr_format.is_some() {
let mut config = LoggingConfig::default();
if let Some(level) = self.level.as_deref() {
config.level = LogLevel::parse(level).map_err(logging_config_error)?;
}
if let Some(stderr_format) = self.stderr_format.as_deref() {
config.stderr_format =
LogFormat::parse(stderr_format).map_err(logging_config_error)?;
}
return Ok(config);
}

if let Some(config) = LoggingConfig::from_environment().map_err(logging_config_error)? {
return Ok(config);
}

crate::configuration::resolve_logging_config(explicit_config, user_only)
}
}

fn logging_config_error(error: FlowError) -> CliError {
match error {
FlowError::InvalidArgument(message) => CliError::Config(message),
other => CliError::Flow(other),
}
}
28 changes: 28 additions & 0 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod configure;
mod diagnostics;
mod hook_forward;
mod install;
mod logging;
mod mcp;
mod model_pricing;
mod plugins;
Expand Down Expand Up @@ -54,6 +55,33 @@ pub(crate) async fn run(bootstrap_shutdown_token: Option<String>) -> ExitCode {
// top-level server flags so transparent launch can share config parsing with daemon startup.
async fn dispatch(bootstrap_shutdown_token: Option<String>) -> Result<ExitCode, error::CliError> {
let cli = Cli::parse();

let initialize_logging = match cli.command.as_ref() {
Some(command) => !command.skips_logging(),
None => {
cli.server.to_runtime().requested_daemon_mode()
|| runtime_configuration::any_config_file_exists()
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let _logging = if initialize_logging {
let user_only = matches!(cli.command.as_ref(), Some(Command::Mcp));
let explicit_config = if user_only {
None
} else {
match cli.command.as_ref() {
Some(Command::Run(command)) => {
command.config.as_deref().or(cli.server.config.as_deref())
}
_ => cli.server.config.as_deref(),
}
};
let config = cli.logging.resolve(explicit_config, user_only)?;
let runtime = nemo_relay::logging::LoggingRuntime::configure(config)?;
Some(runtime)
} else {
None
};

match cli.command {
Some(command) => run_command(command, &cli.server).await,
None => run_default(&cli.server, bootstrap_shutdown_token).await,
Expand Down
6 changes: 6 additions & 0 deletions crates/cli/src/commands/plugins/subcommands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ pub(crate) struct PluginsCommand {
pub(crate) command: PluginsSubcommand,
}

impl PluginsCommand {
pub(crate) fn is_edit(&self) -> bool {
matches!(self.command, PluginsSubcommand::Edit(_))
}
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct PluginJsonContext<'a> {
pub(crate) command: &'static str,
Expand Down
12 changes: 12 additions & 0 deletions crates/cli/src/commands/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use super::configure::ConfigCommand;
use super::diagnostics::{AgentsCommand, DoctorCommand};
use super::hook_forward::HookForwardCommand;
use super::install::{InstallCommand, UninstallCommand};
use super::logging::LoggingArgs;
use super::model_pricing::PricingCommand;
use super::plugins::PluginsCommand;
use super::run::{EasyPathCommand, RunCommand};
Expand Down Expand Up @@ -40,6 +41,8 @@ impl From<AgentArg> for CodingAgent {
pub(crate) struct Cli {
#[command(flatten)]
pub(crate) server: ServerArgs,
#[command(flatten)]
pub(super) logging: LoggingArgs,
#[command(subcommand)]
pub(crate) command: Option<Command>,
}
Expand Down Expand Up @@ -121,3 +124,12 @@ pub(crate) enum Command {
#[command(hide = true)]
HookForward(HookForwardCommand),
}

impl Command {
/// Configuration-editing commands remain available even when operational logging settings are
/// invalid, so users can repair their configuration.
pub(crate) fn skips_logging(&self) -> bool {
matches!(self, Self::Config(_))
|| matches!(self, Self::Plugins(command) if command.is_edit())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
116 changes: 116 additions & 0 deletions crates/cli/src/configuration/logging.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! TOML input, validation, and default inheritance for operational logging.

use std::path::PathBuf;

use nemo_relay::error::FlowError;
use nemo_relay::logging::{
DEFAULT_FILE_FLUSH_INTERVAL_MILLIS, DEFAULT_FILE_SINK_QUEUE_ENTRIES, FileLogSinkConfig,
LogFormat, LogLevel, LogSinkConfig, LoggingConfig, MAX_FILE_SINK_QUEUE_ENTRIES,
};
use serde::Deserialize;

use super::CliError;

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct FileLoggingConfig {
level: Option<String>,
stderr_format: Option<String>,
/// Optional advanced: periodic flush interval in ms applied to all file sinks (default
/// [`DEFAULT_FILE_FLUSH_INTERVAL_MILLIS`]). `0` means flush only on shutdown.
flush_interval_millis: Option<u64>,
#[serde(default)]
sinks: Vec<RawFileLogSinkConfig>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFileLogSinkConfig {
/// Required. Every `[[logging.sinks]]` entry is a file sink.
path: Option<PathBuf>,
level: Option<String>,
format: Option<String>,
/// Optional advanced: pending async queue entries per file sink (default
/// [`DEFAULT_FILE_SINK_QUEUE_ENTRIES`]).
queue_capacity: Option<usize>,
}

pub(super) fn apply_file_logging_config(
logging: &mut LoggingConfig,
config: Option<FileLoggingConfig>,
) -> Result<(), CliError> {
let Some(config) = config else {
return Ok(());
};
if let Some(level) = config.level.as_deref() {
logging.level = LogLevel::parse(level).map_err(logging_parse_error)?;
}
if let Some(stderr_format) = config.stderr_format.as_deref() {
logging.stderr_format = LogFormat::parse(stderr_format).map_err(logging_parse_error)?;
}
logging.flush_interval_millis = config
.flush_interval_millis
.unwrap_or(DEFAULT_FILE_FLUSH_INTERVAL_MILLIS);
if !config.sinks.is_empty() {
let default_sink_level = logging.level;
logging.sinks = config
.sinks
.into_iter()
.map(|sink| parse_file_log_sink(sink, default_sink_level))
.collect::<Result<Vec<_>, _>>()?;
}
Ok(())
}

fn parse_file_log_sink(
config: RawFileLogSinkConfig,
default_level: LogLevel,
) -> Result<LogSinkConfig, CliError> {
let path = config
.path
.ok_or_else(|| CliError::Config("logging sink requires path".into()))?;
if path.as_os_str().is_empty() {
return Err(CliError::Config(
"logging sink path must not be empty".into(),
));
}
let level = match config.level.as_deref() {
Some(raw) => LogLevel::parse(raw).map_err(logging_parse_error)?,
None => default_level,
};
let format = match config.format.as_deref() {
Some(raw) => LogFormat::parse(raw).map_err(logging_parse_error)?,
None => LogFormat::Jsonl,
};
let queue_capacity = match config.queue_capacity {
Some(0) => {
return Err(CliError::Config(
"logging sink queue_capacity must be greater than 0".into(),
));
}
Some(capacity) if capacity > MAX_FILE_SINK_QUEUE_ENTRIES => {
return Err(CliError::Config(format!(
"logging sink queue_capacity {capacity} exceeds maximum \
{MAX_FILE_SINK_QUEUE_ENTRIES} entries per file sink"
)));
}
Some(capacity) => capacity,
None => DEFAULT_FILE_SINK_QUEUE_ENTRIES,
};
Ok(LogSinkConfig::File(FileLogSinkConfig {
path,
level,
format,
queue_capacity,
}))
}

fn logging_parse_error(error: FlowError) -> CliError {
match error {
FlowError::InvalidArgument(message) => CliError::Config(message),
other => CliError::Flow(other),
}
}
40 changes: 40 additions & 0 deletions crates/cli/src/configuration/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

mod logging;
mod types;

pub(crate) use types::*;
Expand All @@ -14,6 +15,7 @@ use std::thread;
use std::time::{Duration, Instant};

use axum::http::HeaderMap;
use nemo_relay::logging::LoggingConfig;
use nemo_relay::plugin::dynamic::{
DYNAMIC_PLUGIN_MANIFEST_FILENAME, DynamicPluginManifest, DynamicPluginManifestLoad,
};
Expand Down Expand Up @@ -51,6 +53,7 @@ struct FileConfig {
gateway: Option<FileGatewayConfig>,
upstream: Option<FileUpstreamConfig>,
agents: Option<FileAgentsConfig>,
logging: Option<logging::FileLoggingConfig>,
}

#[derive(Debug, Clone, Default, Deserialize)]
Expand Down Expand Up @@ -92,6 +95,42 @@ pub(crate) fn resolve_server_config(args: &GatewayOverrides) -> Result<ResolvedC
Ok(resolved)
}

/// Resolves only operational logging from the normal config discovery scope.
///
/// This intentionally avoids plugin discovery and activation so logging can be initialized before
/// operational command dispatch. Missing `[logging]` configuration resolves to built-in defaults.
pub(crate) fn resolve_logging_config(
explicit: Option<&Path>,
user_only: bool,
) -> Result<LoggingConfig, CliError> {
let explicit = explicit.map(Path::to_path_buf);
let user_only = user_only || user_config_scope();
let mut merged = toml::Value::Table(toml::map::Map::new());
for path in config_paths_scoped(explicit.as_ref(), user_only) {
let Some(raw) = read_config_file(&path, explicit.is_some(), "configuration")? else {
continue;
};
let parsed = raw
.parse::<toml::Table>()
.map(toml::Value::Table)
.map_err(|error| {
CliError::Config(format!("invalid TOML in {}: {error}", path.display()))
})?;
merge_toml(&mut merged, parsed);
}

if merged.get("logging").is_none() {
return Ok(LoggingConfig::default());
}
let document = toml::to_string(&merged).map_err(|error| {
CliError::Config(format!("failed to resolve logging configuration: {error}"))
})?;
LoggingConfig::from_toml_document(&document).map_err(|error| match error {
nemo_relay::error::FlowError::InvalidArgument(message) => CliError::Config(message),
other => CliError::Flow(other),
})
}

/// Resolves the shared plugin MCP gateway from system and user layers only.
pub(crate) fn resolve_persistent_server_config(
args: &GatewayOverrides,
Expand Down Expand Up @@ -1119,6 +1158,7 @@ fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Resul
apply_file_gateway_config(&mut resolved.gateway, config.gateway)?;
apply_file_upstream_config(&mut resolved.gateway, config.upstream);
apply_file_agents_config(&mut resolved.agents, config.agents);
logging::apply_file_logging_config(&mut resolved.logging, config.logging)?;
Comment thread
willkill07 marked this conversation as resolved.
Ok(())
}

Expand Down
2 changes: 2 additions & 0 deletions crates/cli/src/configuration/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::net::SocketAddr;
use std::path::PathBuf;

use axum::http::HeaderMap;
use nemo_relay::logging::LoggingConfig;
use serde::Serialize;
use serde_json::{Map, Value};
use strum::{Display, IntoStaticStr};
Expand Down Expand Up @@ -57,6 +58,7 @@ impl GatewayConfig {
pub(crate) struct ResolvedConfig {
pub(crate) gateway: GatewayConfig,
pub(crate) agents: AgentConfigs,
pub(crate) logging: LoggingConfig,
pub(crate) dynamic_plugins: Vec<ResolvedDynamicPluginConfig>,
pub(crate) dynamic_plugin_policy: DynamicPluginHostPolicy,
pub(crate) bootstrap_fingerprint: Option<String>,
Expand Down
Loading
Loading