feat(server): support stdout log destination

Add configurable server log destinations with an environment override so containers can stream foreground server logs to stdout while local installs keep file logging by default. Validate configured log filters at load time and reject stdout logging for daemon mode.
This commit is contained in:
Bryan Helmkamp 2026-04-26 14:52:15 -04:00
parent 9f61c942cb
commit d07596ca80
No known key found for this signature in database
30 changed files with 667 additions and 68 deletions

2
Cargo.lock generated
View file

@ -1690,6 +1690,7 @@ dependencies = [
"serde_yaml",
"sha2",
"shlex",
"temp-env",
"tempfile",
"thiserror 2.0.18",
"tokio",
@ -1755,6 +1756,7 @@ dependencies = [
"thiserror 2.0.18",
"toml 0.8.23",
"tracing",
"tracing-subscriber",
"ulid",
]

View file

@ -31,7 +31,8 @@ COPY --chmod=0755 tmp/docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro
COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/fabro-entrypoint
ENV FABRO_HOME=/storage/.home \
FABRO_STORAGE_DIR=/storage
FABRO_STORAGE_DIR=/storage \
FABRO_LOG_DESTINATION=stdout
VOLUME ["/storage"]
EXPOSE 32276

View file

@ -5978,10 +5978,16 @@ components:
ServerLoggingSettings:
type: object
required: [level]
required: [level, destination]
properties:
level:
type: ["string", "null"]
destination:
$ref: "#/components/schemas/LogDestination"
LogDestination:
type: string
enum: [file, stdout]
ServerIntegrationsSettings:
type: object

View file

@ -263,6 +263,11 @@ fn main() {
"fabro_types::settings::server::ServerLoggingSettings",
&[],
),
(
"LogDestination",
"fabro_types::settings::server::LogDestination",
&[],
),
(
"ServerIntegrationsSettings",
"fabro_types::settings::server::ServerIntegrationsSettings",

View file

@ -16,12 +16,12 @@ mod generated {
pub mod types {
pub use fabro_types::settings::server::{
DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy,
IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreSettings, ServerApiSettings,
ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings,
ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings,
ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
TeamsIntegrationSettings, WebhookStrategy,
IntegrationWebhooksSettings, IpAllowEntry, LogDestination, ObjectStoreSettings,
ServerApiSettings, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod,
ServerAuthSettings, ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings,
ServerIpAllowlistSettings, ServerListenSettings, ServerLoggingSettings,
ServerSchedulerSettings, ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings,
SlackIntegrationSettings, TeamsIntegrationSettings, WebhookStrategy,
};
pub use fabro_types::settings::{FeaturesNamespace, ServerNamespace};
pub use fabro_types::status::{

View file

@ -1,12 +1,13 @@
use std::any::{TypeId, type_name};
use fabro_api::types::{
FeaturesNamespace as ApiFeaturesNamespace, ObjectStoreSettings as ApiObjectStoreSettings,
ServerNamespace as ApiServerNamespace, ServerSettings as ApiServerSettings,
FeaturesNamespace as ApiFeaturesNamespace, LogDestination as ApiLogDestination,
ObjectStoreSettings as ApiObjectStoreSettings, ServerNamespace as ApiServerNamespace,
ServerSettings as ApiServerSettings,
};
use fabro_config::ServerSettingsBuilder;
use fabro_types::ServerSettings;
use fabro_types::settings::server::ObjectStoreSettings;
use fabro_types::settings::server::{LogDestination, ObjectStoreSettings};
use fabro_types::settings::{FeaturesNamespace, ServerNamespace};
#[test]
@ -15,6 +16,7 @@ fn server_settings_family_reuses_domain_types() {
assert_same_type::<ApiServerNamespace, ServerNamespace>();
assert_same_type::<ApiFeaturesNamespace, FeaturesNamespace>();
assert_same_type::<ApiObjectStoreSettings, ObjectStoreSettings>();
assert_same_type::<ApiLogDestination, LogDestination>();
}
#[test]
@ -43,6 +45,9 @@ allowed_usernames = ["alice"]
[server.storage]
root = "/srv/fabro"
[server.logging]
destination = "stdout"
[server.integrations.github]
enabled = true
strategy = "app"
@ -60,6 +65,7 @@ session_sandboxes = true
assert_eq!(json["server"]["listen"]["type"], "tcp");
assert_eq!(json["server"]["listen"]["address"], "127.0.0.1:32276");
assert_eq!(json["server"]["storage"]["root"], "/srv/fabro");
assert_eq!(json["server"]["logging"]["destination"], "stdout");
assert_eq!(json["features"]["session_sandboxes"], true);
let round_trip: ApiServerSettings =

View file

@ -114,6 +114,7 @@ paste = "1"
predicates = "3"
serde_json.workspace = true
tempfile = "3"
temp-env = "0.3"
httpmock = "0.8"
fabro-test = { workspace = true }
fabro-macros = { path = "../fabro-macros" }

View file

@ -15,7 +15,7 @@ use fabro_server::jwt_auth::auth_method_name;
use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs, resolve_runtime_server_settings_for_start};
use fabro_server::{process_env_snapshot, validate_startup};
use fabro_static::EnvVars;
use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::{LogDestination, ServerAuthMethod};
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use tokio::net::{TcpStream, UnixStream};
@ -24,6 +24,7 @@ use tokio::task::spawn_blocking;
use tokio::time;
use crate::local_server;
use crate::logging::{self, ServerLogDestination};
pub(crate) struct ForegroundServerLogBootstrap {
#[expect(dead_code, reason = "held for its Drop to release the server lock")]
@ -67,6 +68,7 @@ pub(crate) async fn execute(
pub(crate) async fn prepare_foreground_server_log(
runtime_directory: &RuntimeDirectory,
destination: &ServerLogDestination,
) -> Result<ForegroundServerLogBootstrap> {
let lock_file = acquire_lock(runtime_directory).await?;
if let Some(existing) = ServerDaemon::load_running(runtime_directory)? {
@ -77,13 +79,14 @@ pub(crate) async fn prepare_foreground_server_log(
);
}
let log_path = runtime_directory.log_path();
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating log directory {}", parent.display()))?;
if let ServerLogDestination::File(log_path) = destination {
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating log directory {}", parent.display()))?;
}
std::fs::File::create(log_path)
.with_context(|| format!("creating server log file {}", log_path.display()))?;
}
std::fs::File::create(&log_path)
.with_context(|| format!("creating server log file {}", log_path.display()))?;
Ok(ForegroundServerLogBootstrap { lock_file })
}
@ -265,6 +268,12 @@ async fn execute_daemon(
}
let resolved_settings = resolve_runtime_server_settings_for_start(serve_args, storage_dir)?;
let destination = logging::resolve_log_destination(resolved_settings.logging.destination)?;
if matches!(destination, LogDestination::Stdout) {
bail!(
"[server.logging].destination = \"stdout\" is incompatible with daemon mode; use `fabro server start --foreground`"
);
}
validate_startup(
runtime_directory.env_path().as_path(),
process_env_snapshot(),
@ -317,7 +326,7 @@ async fn execute_daemon(
cmd.arg("--storage-dir").arg(storage_dir);
cmd.env_remove("FABRO_JSON");
cmd.env_remove(EnvVars::FABRO_JSON);
cmd.stdout(stdout_log)
.stderr(log_file)
.stdin(std::process::Stdio::null());
@ -486,9 +495,55 @@ fn read_log_tail(log_path: &Path, lines: usize) -> String {
#[cfg(test)]
mod tests {
use fabro_config::bind::BindRequest;
use fabro_server::serve::ServeArgs;
use fabro_static::EnvVars;
use fabro_util::Home;
use fabro_util::printer::Printer;
use temp_env::with_var;
use tokio::runtime::Runtime;
use super::ensure_storage_server_autostart_allowed;
use super::{
ensure_storage_server_autostart_allowed, execute_daemon, prepare_foreground_server_log,
};
use crate::logging::ServerLogDestination;
fn runtime() -> Runtime {
Runtime::new().expect("runtime should build")
}
fn write_server_settings(path: &std::path::Path, destination: &str) {
std::fs::write(
path,
format!(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[server.logging]
destination = "{destination}"
"#
),
)
.expect("settings fixture should write");
}
fn serve_args_with_config(config_path: &std::path::Path) -> ServeArgs {
ServeArgs {
bind: Some("127.0.0.1:0".to_string()),
web: false,
no_web: false,
model: None,
provider: None,
sandbox: None,
max_concurrent_runs: None,
config: Some(config_path.to_path_buf()),
#[cfg(debug_assertions)]
watch_web: false,
}
}
#[test]
fn ensure_server_running_for_storage_errors_when_install_mode_is_required() {
@ -516,4 +571,87 @@ mod tests {
"unexpected error: {message}"
);
}
#[test]
fn prepare_foreground_server_log_with_stdout_does_not_create_server_log() {
let storage_dir = tempfile::tempdir().unwrap();
let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.path());
let _bootstrap = runtime()
.block_on(prepare_foreground_server_log(
&runtime_directory,
&ServerLogDestination::Stdout,
))
.expect("stdout foreground bootstrap should succeed");
assert!(
!runtime_directory.log_path().exists(),
"stdout destination should not create server.log"
);
}
#[test]
fn execute_daemon_rejects_configured_stdout_before_creating_server_log() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_server_settings(&config_path, "stdout");
let bind = BindRequest::Tcp("127.0.0.1:0".parse().unwrap());
let serve_args = serve_args_with_config(&config_path);
let err = runtime()
.block_on(execute_daemon(
&bind,
&serve_args,
storage_dir.path(),
false,
None,
Printer::Silent,
))
.expect_err("daemon mode should reject stdout logging");
assert!(
err.to_string().contains("incompatible with daemon mode"),
"unexpected error: {err}"
);
let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.path());
assert!(
!runtime_directory.log_path().exists(),
"daemon rejection should happen before server.log is created"
);
}
#[test]
fn execute_daemon_rejects_invalid_env_destination_before_creating_server_log() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_server_settings(&config_path, "file");
let bind = BindRequest::Tcp("127.0.0.1:0".parse().unwrap());
let serve_args = serve_args_with_config(&config_path);
with_var(EnvVars::FABRO_LOG_DESTINATION, Some("stdot"), || {
let err = runtime()
.block_on(execute_daemon(
&bind,
&serve_args,
storage_dir.path(),
false,
None,
Printer::Silent,
))
.expect_err("daemon mode should reject invalid env destination");
let message = err.to_string();
assert!(message.contains(EnvVars::FABRO_LOG_DESTINATION));
assert!(message.contains("stdot"));
});
let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.path());
assert!(
!runtime_directory.log_path().exists(),
"invalid env rejection should happen before server.log is created"
);
}
}

View file

@ -7,15 +7,17 @@ use fabro_config::bind::BindRequest;
use fabro_config::user::default_storage_dir;
use fabro_server::serve::resolve_bind_request_from_server_settings;
use fabro_types::ServerSettings;
use fabro_types::settings::server::LogDestination;
use fabro_types::settings::{InterpString, ServerAuthMethod};
use crate::user_config;
pub(crate) struct LocalServerConfig {
storage_dir: PathBuf,
auth_methods: Vec<ServerAuthMethod>,
config_log_level: Option<String>,
server_settings: std::result::Result<ServerSettings, String>,
storage_dir: PathBuf,
auth_methods: Vec<ServerAuthMethod>,
config_log_level: Option<fabro_config::LogFilter>,
config_log_destination: Option<LogDestination>,
server_settings: std::result::Result<ServerSettings, String>,
}
impl LocalServerConfig {
@ -39,6 +41,7 @@ impl LocalServerConfig {
storage_dir: settings.storage_dir,
auth_methods,
config_log_level: settings.config_log_level,
config_log_destination: settings.config_log_destination,
server_settings,
}
}
@ -52,7 +55,13 @@ impl LocalServerConfig {
}
pub(crate) fn config_log_level(&self) -> Option<&str> {
self.config_log_level.as_deref()
self.config_log_level
.as_ref()
.map(fabro_config::LogFilter::as_str)
}
pub(crate) fn config_log_destination(&self) -> Option<LogDestination> {
self.config_log_destination
}
pub(crate) fn bind_request(&self, cli_override: Option<&str>) -> Result<BindRequest> {

View file

@ -3,9 +3,11 @@
reason = "CLI logging setup: sync directory scan during startup"
)]
use std::fs::{File, OpenOptions};
use std::path::Path;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use fabro_static::EnvVars;
use fabro_types::settings::server::LogDestination;
use fabro_util::run_log;
use tracing_appender::rolling;
use tracing_subscriber::fmt::writer::MakeWriter;
@ -18,7 +20,13 @@ const LOG_RETENTION_DAYS: u32 = 7;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum InternalLogSink {
Cli,
Server { path: std::path::PathBuf },
Server { destination: ServerLogDestination },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ServerLogDestination {
File(PathBuf),
Stdout,
}
pub(crate) fn init_tracing(
@ -31,8 +39,8 @@ pub(crate) fn init_tracing(
} else {
config_log_level.unwrap_or("info")
};
let filter =
EnvFilter::try_from_env("FABRO_LOG").unwrap_or_else(|_| EnvFilter::new(default_level));
let filter = EnvFilter::try_from_env(EnvVars::FABRO_LOG)
.unwrap_or_else(|_| EnvFilter::new(default_level));
match sink {
InternalLogSink::Cli => {
@ -52,14 +60,51 @@ pub(crate) fn init_tracing(
cleanup_old_logs(&log_dir, "cli", LOG_RETENTION_DAYS);
init_subscriber(filter, file_appender);
}
InternalLogSink::Server { path } => {
init_subscriber(filter, FixedFileAppender::open(path)?);
}
InternalLogSink::Server { destination } => match destination {
ServerLogDestination::File(path) => {
init_subscriber(filter, FixedFileAppender::open(path)?);
}
ServerLogDestination::Stdout => {
init_subscriber(filter, std::io::stdout);
}
},
}
Ok(())
}
pub(crate) fn resolve_log_destination(
config_destination: LogDestination,
) -> Result<LogDestination> {
let env_value = std::env::var(EnvVars::FABRO_LOG_DESTINATION).ok();
resolve_log_destination_with_env(config_destination, env_value.as_deref())
}
pub(crate) fn resolve_log_destination_with_env(
config_destination: LogDestination,
env_value: Option<&str>,
) -> Result<LogDestination> {
match env_value {
Some(value) => value.parse::<LogDestination>().with_context(|| {
format!(
"invalid {} value `{value}`; expected `file` or `stdout`",
EnvVars::FABRO_LOG_DESTINATION
)
}),
None => Ok(config_destination),
}
}
pub(crate) fn server_log_destination(
destination: LogDestination,
log_path: PathBuf,
) -> ServerLogDestination {
match destination {
LogDestination::File => ServerLogDestination::File(log_path),
LogDestination::Stdout => ServerLogDestination::Stdout,
}
}
fn cleanup_old_logs(log_dir: &Path, prefix: &str, max_age_days: u32) {
let cutoff = chrono::Utc::now().date_naive() - chrono::Duration::days(i64::from(max_age_days));
let Ok(entries) = std::fs::read_dir(log_dir) else {

View file

@ -461,15 +461,26 @@ async fn prepare_server_bootstrap(
let local_config = local_server::LocalServerConfig::load(config_path, storage_dir)?;
let storage_dir = local_config.storage_dir().to_path_buf();
let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.clone());
let log_destination = logging::resolve_log_destination(
local_config.config_log_destination().unwrap_or_default(),
)?;
let server_log_destination =
logging::server_log_destination(log_destination, runtime_directory.log_path());
let foreground_server_log_bootstrap = if foreground {
Some(commands::server::start::prepare_foreground_server_log(&runtime_directory).await?)
Some(
commands::server::start::prepare_foreground_server_log(
&runtime_directory,
&server_log_destination,
)
.await?,
)
} else {
None
};
Ok(PreTracingBootstrap {
sink: logging::InternalLogSink::Server {
path: runtime_directory.log_path(),
destination: server_log_destination,
},
config_log_level: local_config.config_log_level().map(str::to_owned),
foreground_server_log_bootstrap,
@ -486,6 +497,7 @@ mod tests {
AuthCommand, AuthNamespace, Commands, InstallGitHubStrategyArg, ModelsCommand,
ProviderCommand, ProviderNamespace,
};
use temp_env::with_var;
use tokio::runtime::Runtime;
use super::*;
@ -495,18 +507,32 @@ mod tests {
}
fn write_test_settings(path: &std::path::Path) {
write_test_settings_with_logging(path, "warn", "stdout");
}
fn write_test_settings_with_logging(path: &std::path::Path, level: &str, destination: &str) {
std::fs::write(
path,
r#"
format!(
r#"
_version = 1
[server.logging]
level = "warn"
"#,
level = "{level}"
destination = "{destination}"
"#
),
)
.unwrap();
}
fn expect_bootstrap_err(result: Result<PreTracingBootstrap>) -> anyhow::Error {
match result {
Ok(_) => panic!("bootstrap should have failed"),
Err(err) => err,
}
}
#[test]
fn pre_tracing_bootstrap_uses_cli_sink_for_normal_cli_command() {
let cli = Cli::try_parse_from(["fabro", "uninstall"]).expect("should parse");
@ -665,7 +691,7 @@ level = "warn"
.expect("bootstrap should resolve");
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
path: storage_dir.path().join("logs").join("server.log"),
destination: logging::ServerLogDestination::Stdout,
});
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
assert!(bootstrap.foreground_server_log_bootstrap.is_some());
@ -696,7 +722,7 @@ level = "warn"
.expect("bootstrap should resolve");
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
path: storage_dir.path().join("logs").join("server.log"),
destination: logging::ServerLogDestination::Stdout,
});
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
assert!(bootstrap.foreground_server_log_bootstrap.is_some());
@ -725,12 +751,125 @@ level = "warn"
.expect("bootstrap should resolve");
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
path: storage_dir.path().join("logs").join("server.log"),
destination: logging::ServerLogDestination::Stdout,
});
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
}
#[test]
fn pre_tracing_bootstrap_env_destination_overrides_config_file() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_test_settings_with_logging(&config_path, "warn", "file");
let cli = Cli::try_parse_from([
"fabro",
"server",
"start",
"--foreground",
"--storage-dir",
storage_dir.path().to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])
.expect("should parse");
let command = cli.command.as_deref().unwrap();
with_var(EnvVars::FABRO_LOG_DESTINATION, Some("stdout"), || {
let bootstrap = runtime()
.block_on(pre_tracing_bootstrap(command))
.expect("bootstrap should resolve");
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
destination: logging::ServerLogDestination::Stdout,
});
});
}
#[test]
fn pre_tracing_bootstrap_rejects_invalid_env_destination() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_test_settings_with_logging(&config_path, "warn", "file");
let cli = Cli::try_parse_from([
"fabro",
"server",
"start",
"--foreground",
"--storage-dir",
storage_dir.path().to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])
.expect("should parse");
let command = cli.command.as_deref().unwrap();
with_var(EnvVars::FABRO_LOG_DESTINATION, Some("stdot"), || {
let err = expect_bootstrap_err(runtime().block_on(pre_tracing_bootstrap(command)));
let message = err.to_string();
assert!(message.contains(EnvVars::FABRO_LOG_DESTINATION));
assert!(message.contains("stdot"));
});
}
#[test]
fn pre_tracing_bootstrap_rejects_invalid_config_log_level() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_test_settings_with_logging(&config_path, "definitely not a filter", "file");
let cli = Cli::try_parse_from([
"fabro",
"server",
"start",
"--foreground",
"--storage-dir",
storage_dir.path().to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])
.expect("should parse");
let command = cli.command.as_deref().unwrap();
let err = expect_bootstrap_err(runtime().block_on(pre_tracing_bootstrap(command)));
assert!(
err.to_string().contains("server.logging.level"),
"unexpected error: {err}"
);
}
#[test]
fn pre_tracing_bootstrap_rejects_invalid_config_destination() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_test_settings_with_logging(&config_path, "warn", "stdot");
let cli = Cli::try_parse_from([
"fabro",
"server",
"start",
"--foreground",
"--storage-dir",
storage_dir.path().to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])
.expect("should parse");
let command = cli.command.as_deref().unwrap();
let err = expect_bootstrap_err(runtime().block_on(pre_tracing_bootstrap(command)));
assert!(
err.to_string().contains("server.logging.destination"),
"unexpected error: {err}"
);
}
#[test]
fn pre_tracing_bootstrap_uses_cli_sink_for_server_start_daemon_wrapper() {
let storage_dir = tempfile::tempdir().unwrap();

View file

@ -1,15 +1,16 @@
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::Result;
use anyhow::{Context, Result, anyhow};
pub(crate) use fabro_client::ServerTarget;
pub(crate) use fabro_config::user::{active_settings_path, default_storage_dir};
use fabro_config::user::{default_settings_path, default_socket_path};
use fabro_config::{
CliLayer, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder,
CliLayer, LogFilter, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder,
};
use fabro_static::EnvVars;
use fabro_types::settings::cli::CliTargetSettings;
use fabro_types::settings::server::LogDestination;
use fabro_types::settings::{CliNamespace, InterpString, RunNamespace};
use fabro_types::{ServerSettings, UserSettings};
use fabro_util::version::FABRO_VERSION;
@ -18,11 +19,12 @@ use tracing::debug;
use crate::args::ServerTargetArgs;
pub(crate) struct LoadedSettings {
pub(crate) storage_dir: PathBuf,
pub(crate) config_log_level: Option<String>,
pub(crate) run_settings: std::result::Result<RunNamespace, String>,
pub(crate) server_settings: std::result::Result<ServerSettings, String>,
pub(crate) user_settings: UserSettings,
pub(crate) storage_dir: PathBuf,
pub(crate) config_log_level: Option<LogFilter>,
pub(crate) config_log_destination: Option<LogDestination>,
pub(crate) run_settings: std::result::Result<RunNamespace, String>,
pub(crate) server_settings: std::result::Result<ServerSettings, String>,
pub(crate) user_settings: UserSettings,
}
pub(crate) fn load_resolved_settings(
@ -33,7 +35,7 @@ pub(crate) fn load_resolved_settings(
let document = load_settings_document(config_path)?;
let storage_override = storage_dir.map(Path::to_path_buf);
let storage_dir = storage_dir_from_document(&document, storage_dir)?;
let config_log_level = config_log_level_from_document(&document);
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
let run_settings = load_run_settings(config_path).map_err(|err| err.to_string());
let server_settings = load_server_settings(config_path)
.map(|settings| match storage_override.as_deref() {
@ -45,7 +47,8 @@ pub(crate) fn load_resolved_settings(
Ok(LoadedSettings {
storage_dir,
config_log_level,
config_log_level: pre_tracing_config.log_level,
config_log_destination: pre_tracing_config.log_destination,
run_settings,
server_settings,
user_settings,
@ -118,8 +121,48 @@ fn load_user_settings(
})
}
fn config_log_level_from_document(document: &toml::Value) -> Option<String> {
string_at_path(document, &["server", "logging", "level"])
struct PreTracingConfig {
log_level: Option<LogFilter>,
log_destination: Option<LogDestination>,
}
fn pre_tracing_config_from_document(document: &toml::Value) -> Result<PreTracingConfig> {
Ok(PreTracingConfig {
log_level: log_filter_at_path(document, &["server", "logging", "level"])?,
log_destination: log_destination_at_path(document, &["server", "logging", "destination"])?,
})
}
fn log_filter_at_path(document: &toml::Value, path: &[&str]) -> Result<Option<LogFilter>> {
let Some(value) = value_at_path(document, path) else {
return Ok(None);
};
let raw = value
.as_str()
.ok_or_else(|| anyhow!("{} must be a string", path.join(".")))?;
LogFilter::parse(raw)
.with_context(|| format!("invalid {} `{raw}`", path.join(".")))
.map(Some)
}
fn log_destination_at_path(
document: &toml::Value,
path: &[&str],
) -> Result<Option<LogDestination>> {
let Some(value) = value_at_path(document, path) else {
return Ok(None);
};
let raw = value
.as_str()
.ok_or_else(|| anyhow!("{} must be a string", path.join(".")))?;
raw.parse::<LogDestination>()
.with_context(|| {
format!(
"invalid {} `{raw}`; expected `file` or `stdout`",
path.join(".")
)
})
.map(Some)
}
fn storage_dir_from_document(
@ -163,11 +206,15 @@ fn storage_dir_from_document_with_lookup(
}
fn string_at_path(document: &toml::Value, path: &[&str]) -> Option<String> {
value_at_path(document, path).and_then(|value| value.as_str().map(str::to_owned))
}
fn value_at_path<'a>(document: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Value> {
let mut current = document;
for segment in path {
current = current.get(*segment)?;
}
current.as_str().map(str::to_owned)
Some(current)
}
/// Pull the resolved CLI target configuration out of `[cli.target]`.
@ -233,7 +280,7 @@ pub(crate) fn load_resolved_settings_from_toml(
.map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?;
let storage_override = storage_dir.map(Path::to_path_buf);
let storage_dir = storage_dir_from_document(&document, storage_dir)?;
let config_log_level = config_log_level_from_document(&document);
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
let run_settings = RunSettingsBuilder::from_toml(source).map_err(|err| err.to_string());
let server_settings = ServerSettingsBuilder::from_toml(source)
.map(|settings| match storage_override.as_deref() {
@ -248,7 +295,8 @@ pub(crate) fn load_resolved_settings_from_toml(
Ok(LoadedSettings {
storage_dir,
config_log_level,
config_log_level: pre_tracing_config.log_level,
config_log_destination: pre_tracing_config.log_destination,
run_settings,
server_settings,
user_settings,

View file

@ -34,6 +34,7 @@ strsim = "0.11"
tempfile = "3"
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
thiserror.workspace = true
ulid.workspace = true

View file

@ -5,10 +5,12 @@ use fabro_types::settings::run::{
AgentPermissions, ApprovalMode, DaytonaNetworkLayer, MergeStrategy, RunMode, WorktreeMode,
};
use fabro_types::settings::server::{
GithubIntegrationStrategy, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy,
GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, ServerAuthMethod,
WebhookStrategy,
};
use fabro_types::settings::{Duration, InterpString, Size};
use super::LogFilter;
use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer};
use super::features::FeaturesLayer;
use super::run::{
@ -18,7 +20,7 @@ use super::run::{
};
use super::server::{
ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerAuthGithubLayer,
ServerListenLayer, ServerLoggingLayer,
ServerListenLayer,
};
/// Internal merge trait used by sparse config layers inside `fabro-config`.
@ -74,9 +76,11 @@ impl_combine_or_option!(
RunMode,
WorktreeMode,
GithubIntegrationStrategy,
LogDestination,
ObjectStoreProvider,
ServerAuthMethod,
WebhookStrategy,
LogFilter,
);
impl Combine for Option<Vec<String>> {
@ -128,7 +132,6 @@ impl_combine_self!(
ServerApiLayer,
ServerAuthGithubLayer,
ServerListenLayer,
ServerLoggingLayer,
);
impl Combine for RunCheckpointLayer {
@ -284,8 +287,13 @@ mod tests {
GithubIntegrationStrategy::Token,
);
assert_option_leaf(ObjectStoreProvider::S3, ObjectStoreProvider::Local);
assert_option_leaf(LogDestination::Stdout, LogDestination::File);
assert_option_leaf(ServerAuthMethod::Github, ServerAuthMethod::DevToken);
assert_option_leaf(WebhookStrategy::ServerUrl, WebhookStrategy::TailscaleFunnel);
assert_option_leaf(
LogFilter::parse("debug").unwrap(),
LogFilter::parse("info").unwrap(),
);
assert_option_leaf(vec!["this".to_string()], vec!["fallback".to_string()]);
assert_option_leaf(vec![ServerAuthMethod::Github], vec![
ServerAuthMethod::DevToken,

View file

@ -0,0 +1,45 @@
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use tracing_subscriber::EnvFilter;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogFilter(String);
impl LogFilter {
pub fn parse(value: &str) -> anyhow::Result<Self> {
if value.chars().any(char::is_whitespace) {
anyhow::bail!("filter must not contain whitespace");
}
EnvFilter::builder()
.parse(value)
.map(|_| Self(value.to_owned()))
.map_err(Into::into)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Serialize for LogFilter {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for LogFilter {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).map_err(|err| {
D::Error::custom(format!("invalid server.logging.level `{value}`: {err}"))
})
}
}

View file

@ -1,6 +1,7 @@
mod cli;
mod combine;
mod features;
mod log_filter;
mod maps;
mod project;
mod run;
@ -15,6 +16,7 @@ pub use cli::{
};
pub(crate) use combine::Combine;
pub use features::FeaturesLayer;
pub use log_filter::LogFilter;
pub use maps::{MergeMap, ReplaceMap, StickyMap};
pub use project::ProjectLayer;
pub use run::{

View file

@ -1,11 +1,13 @@
//! Sparse `[server]` settings layer definitions.
use fabro_types::settings::server::{
GithubIntegrationStrategy, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy,
GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, ServerAuthMethod,
WebhookStrategy,
};
use fabro_types::settings::{Duration, InterpString};
use serde::{Deserialize, Serialize};
use super::LogFilter;
use super::maps::StickyMap;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
@ -179,11 +181,13 @@ pub struct ServerSchedulerLayer {
}
/// `[server.logging]` — process-owned logging configuration for the server.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerLoggingLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
pub level: Option<LogFilter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub destination: Option<LogDestination>,
}
/// `[server.integrations.<provider>]` — cohesive integration surface for chat

View file

@ -39,7 +39,7 @@ pub use layers::{
CliOutputLayer, CliTargetLayer, CliUpdatesLayer, DaytonaDockerfileLayer, DaytonaSandboxLayer,
DaytonaSnapshotLayer, DiscordIntegrationLayer, FeaturesLayer, GitAuthorLayer,
GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer,
InterviewProviderLayer, InterviewsLayer, LocalSandboxLayer, McpEntryLayer, MergeMap,
InterviewProviderLayer, InterviewsLayer, LocalSandboxLayer, LogFilter, McpEntryLayer, MergeMap,
ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer,
ObjectStoreS3Layer, PrepareStep, ProjectLayer, ReplaceMap, RunAgentLayer, RunArtifactsLayer,
RunCheckpointLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunLayer, RunModelLayer,

View file

@ -49,10 +49,16 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
.expect("defaults.toml should provide server.scheduler.max_concurrent_runs"),
},
logging: ServerLoggingSettings {
level: layer
level: layer
.logging
.as_ref()
.and_then(|logging| logging.level.clone()),
.and_then(|logging| logging.level.as_ref())
.map(|level| level.as_str().to_owned()),
destination: layer
.logging
.as_ref()
.and_then(|logging| logging.destination)
.unwrap_or_default(),
},
integrations,
}

View file

@ -1,5 +1,6 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_types::settings::server::LogDestination;
use crate::{Combine, SettingsLayer, StringOrSplice};
@ -259,6 +260,30 @@ level = "debug"
assert_eq!(updates.check, Some(true));
}
#[test]
fn server_logging_merges_by_field() {
let lower = parse(
r#"
[server.logging]
level = "warn"
"#,
);
let higher = parse(
r#"
[server.logging]
destination = "stdout"
"#,
);
let merged = higher.combine(lower);
let logging = merged.server.unwrap().logging.unwrap();
assert_eq!(
logging.level.as_ref().map(fabro_config::LogFilter::as_str),
Some("warn")
);
assert_eq!(logging.destination, Some(LogDestination::Stdout));
}
#[test]
fn whole_replace_option_subtable_does_not_inherit_fallback_fields() {
let lower = parse(

View file

@ -0,0 +1,28 @@
use fabro_config::LogFilter;
#[test]
fn log_filter_accepts_env_filter_directives() {
let filter = LogFilter::parse("info,fabro_server=debug")
.expect("valid env filter directive should parse");
assert_eq!(filter.as_str(), "info,fabro_server=debug");
}
#[test]
fn log_filter_rejects_invalid_directives() {
LogFilter::parse("definitely not a filter")
.expect_err("whitespace phrase should not be accepted as a filter");
LogFilter::parse("fabro_server=definitelynotalevel")
.expect_err("unknown level should not be accepted as a filter");
}
#[test]
fn log_filter_round_trips_through_serde() {
let filter = LogFilter::parse("warn,fabro_cli=debug").unwrap();
let json = serde_json::to_value(&filter).expect("filter should serialize");
assert_eq!(json, "warn,fabro_cli=debug");
let round_trip: LogFilter = serde_json::from_value(json).expect("filter should deserialize");
assert_eq!(round_trip, filter);
}

View file

@ -1,5 +1,6 @@
mod combine;
mod defaults;
mod log_filter;
mod resolve_cli;
mod resolve_features;
mod resolve_project;

View file

@ -5,7 +5,7 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerAuthMethod,
GithubIntegrationStrategy, IpAllowEntry, LogDestination, ObjectStoreSettings, ServerAuthMethod,
ServerListenSettings, ServerNamespace,
};
use fabro_util::Home;
@ -67,6 +67,7 @@ fn resolves_server_defaults_from_empty_settings() {
assert!(settings.web.enabled);
assert_eq!(settings.web.url.as_source(), "http://localhost:3000");
assert_eq!(settings.scheduler.max_concurrent_runs, 5);
assert_eq!(settings.logging.destination, LogDestination::File);
match settings.listen {
ServerListenSettings::Unix { path } => {
@ -108,6 +109,39 @@ fn resolves_server_defaults_from_empty_settings() {
assert!(!settings.slatedb.disk_cache);
}
#[test]
fn resolves_server_logging_destination_from_settings() {
let file = parse(
r#"
_version = 1
[server.logging]
destination = "stdout"
"#,
);
let settings = resolve_server(&file);
assert_eq!(settings.logging.destination, LogDestination::Stdout);
}
#[test]
fn parsing_rejects_invalid_server_log_filter() {
let err = r#"
_version = 1
[server.logging]
level = "definitely not a filter"
"#
.parse::<SettingsLayer>()
.expect_err("invalid log filters should be rejected at parse time");
assert!(
err.to_string().contains("server.logging.level"),
"unexpected error: {err}"
);
}
#[test]
fn server_settings_from_layer_matches_namespace_resolvers() {
let settings = parse(

View file

@ -19,6 +19,8 @@ impl EnvVars {
pub const FABRO_HOME: &'static str = "FABRO_HOME";
pub const FABRO_HTTP_PROXY_POLICY: &'static str = "FABRO_HTTP_PROXY_POLICY";
pub const FABRO_JSON: &'static str = "FABRO_JSON";
pub const FABRO_LOG: &'static str = "FABRO_LOG";
pub const FABRO_LOG_DESTINATION: &'static str = "FABRO_LOG_DESTINATION";
pub const FABRO_NO_UPGRADE_CHECK: &'static str = "FABRO_NO_UPGRADE_CHECK";
pub const FABRO_QUIET: &'static str = "FABRO_QUIET";
pub const FABRO_SERVER: &'static str = "FABRO_SERVER";
@ -134,6 +136,8 @@ mod tests {
#[test]
fn env_var_constants_match_their_names() {
assert_eq!(EnvVars::FABRO_CONFIG, "FABRO_CONFIG");
assert_eq!(EnvVars::FABRO_LOG, "FABRO_LOG");
assert_eq!(EnvVars::FABRO_LOG_DESTINATION, "FABRO_LOG_DESTINATION");
}
#[test]
@ -151,6 +155,8 @@ mod tests {
EnvVars::FABRO_HOME,
EnvVars::FABRO_HTTP_PROXY_POLICY,
EnvVars::FABRO_JSON,
EnvVars::FABRO_LOG,
EnvVars::FABRO_LOG_DESTINATION,
EnvVars::FABRO_NO_UPGRADE_CHECK,
EnvVars::FABRO_QUIET,
EnvVars::FABRO_SERVER,

View file

@ -45,7 +45,7 @@ pub use run::{
};
pub use server::{
DiscordIntegrationSettings, GithubIntegrationSettings, IntegrationWebhooksSettings,
IpAllowEntry, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings,
IpAllowEntry, LogDestination, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings,
ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings,
ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerListenSettings,
ServerLoggingSettings, ServerNamespace, ServerSchedulerSettings, ServerSlateDbSettings,

View file

@ -230,9 +230,20 @@ pub struct ServerSchedulerSettings {
pub max_concurrent_runs: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, strum::EnumString)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum LogDestination {
#[default]
File,
Stdout,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerLoggingSettings {
pub level: Option<String>,
pub level: Option<String>,
#[serde(default)]
pub destination: LogDestination,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -103,6 +103,7 @@ models/integration-webhooks-settings.ts
models/internal-stage-status.ts
models/ip-allow-entry.ts
models/literal-ip-allow-entry.ts
models/log-destination.ts
models/manifest-args.ts
models/manifest-config.ts
models/manifest-file-entry.ts

View file

@ -82,6 +82,7 @@ export * from './integration-webhooks-settings';
export * from './internal-stage-status';
export * from './ip-allow-entry';
export * from './literal-ip-allow-entry';
export * from './log-destination';
export * from './manifest-args';
export * from './manifest-config';
export * from './manifest-file-entry';

View file

@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export const LogDestination = {
FILE: 'file',
STDOUT: 'stdout'
} as const;
export type LogDestination = typeof LogDestination[keyof typeof LogDestination];

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -13,8 +13,11 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { LogDestination } from './log-destination';
export interface ServerLoggingSettings {
'level': string | null;
'destination': LogDestination;
}