diff --git a/Cargo.lock b/Cargo.lock index 63f7dc5..6cba7df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,7 @@ dependencies = [ "schemars 1.2.0", "serde", "serde_json", + "serde_with", "serde_yaml", "similar", "sysinfo", diff --git a/Cargo.toml b/Cargo.toml index 37f8522..2cf6c9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -156,6 +156,7 @@ opentelemetry-proto.workspace = true prost.workspace = true similar.workspace = true tempfile.workspace = true +serde_with = { version = "3.16.1", default-features = false, features = ["macros"] } [dev-dependencies] tempfile.workspace = true diff --git a/src/dev/process.rs b/src/dev/process.rs index 3b9cd1f..83c5557 100644 --- a/src/dev/process.rs +++ b/src/dev/process.rs @@ -24,7 +24,10 @@ use crate::common::{ApxCommand, BunCommand, UvCommand, handle_spawn_error, read_ use crate::dev::common::CLIENT_HOST; use crate::dev::otel::forward_log_to_flux; use crate::dotenv::DotenvFile; -use crate::python_logging::{DevConfig, resolve_log_config}; +use crate::python_logging::{ + DevConfig, LogConfigResult, default_logging_config, resolve_log_config, + write_logging_config_json, +}; #[derive(Debug, Clone, Copy)] enum LogSource { @@ -794,7 +797,53 @@ impl ProcessManager { // Resolve uvicorn logging config (inline TOML, external Python file, or default) let log_config_result = resolve_log_config(dev_config, app_slug, app_dir).await?; - let log_config_str = log_config_result.to_string_path(); + + // Validate JSON configs before use (skip validation for Python file configs) + let log_config_str = match &log_config_result { + LogConfigResult::PythonFile(path) => path.display().to_string(), + LogConfigResult::JsonConfig(config_path) => { + // Validate the JSON config can be loaded by Python's logging.config.dictConfig + let validation_script = format!( + "import json, logging.config; logging.config.dictConfig(json.load(open('{}')))", + config_path.display() + ); + + let mut validation_cmd = UvCommand::new("python").tokio_command(); + validation_cmd + .args(["-c", &validation_script]) + .current_dir(app_dir) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let output = validation_cmd + .output() + .await + .map_err(|e| format!("Failed to validate logging config: {e}"))?; + + if output.status.success() { + config_path.display().to_string() + } else { + // Validation failed - log error and fall back to default config + let stderr = String::from_utf8_lossy(&output.stderr); + warn!( + "Logging config validation failed, falling back to default:\n{}", + stderr + ); + eprintln!( + "⚠️ Custom logging config is invalid, using default config:\n{}", + stderr + ); + + // Generate and write default config + let default_config = default_logging_config(app_slug); + let fallback_path = + write_logging_config_json(&default_config, app_dir) + .await + .map_err(|e| format!("Failed to write fallback logging config: {e}"))?; + fallback_path.display().to_string() + } + } + }; // Run uvicorn via uv to ensure correct Python environment let mut cmd = UvCommand::new("uvicorn").tokio_command(); diff --git a/src/python_logging.rs b/src/python_logging.rs index a1e789a..5915eed 100644 --- a/src/python_logging.rs +++ b/src/python_logging.rs @@ -7,6 +7,7 @@ //! When neither is specified, generates a default logging configuration. use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -20,6 +21,7 @@ pub struct DevConfig { } /// Python logging.dictConfig format +#[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoggingConfig { pub version: i32, @@ -36,6 +38,7 @@ pub struct LoggingConfig { } /// Formatter configuration +#[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FormatterConfig { #[serde(default)] @@ -47,6 +50,7 @@ pub struct FormatterConfig { } /// Handler configuration +#[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HandlerConfig { #[serde(rename = "class")] @@ -64,6 +68,7 @@ pub struct HandlerConfig { } /// Logger configuration +#[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoggerConfig { #[serde(default)] @@ -75,6 +80,7 @@ pub struct LoggerConfig { } /// Root logger configuration +#[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RootLoggerConfig { #[serde(default)] @@ -789,4 +795,45 @@ console = { class = "logging.StreamHandler", formatter = "custom", stream = "ext // User's myapp logger should also be present assert!(merged.loggers.contains_key("myapp")); } + + /// Test that the default logging config can be loaded by Python's logging.config.dictConfig. + /// This ensures the generated JSON is valid and doesn't contain unexpected fields like + /// `filename: null` that would cause StreamHandler to fail. + #[test] + fn test_default_config_python_validation() { + use std::io::Write; + + let config = default_logging_config("testapp"); + let json = serde_json::to_string_pretty(&config).expect("Failed to serialize config"); + + // Write config to temp file + let mut temp_file = tempfile::NamedTempFile::new().expect("Failed to create temp file"); + temp_file + .write_all(json.as_bytes()) + .expect("Failed to write config"); + let config_path = temp_file.path().to_string_lossy().to_string(); + + // Validate using Python's logging.config.dictConfig + let output = std::process::Command::new("uv") + .args([ + "run", + "--no-sync", + "python", + "-c", + &format!( + "import json, logging.config; logging.config.dictConfig(json.load(open('{}')))", + config_path + ), + ]) + .output() + .expect("Failed to run Python validation"); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!( + "Default logging config failed Python validation:\n{}\n\nConfig JSON:\n{}", + stderr, json + ); + } + } }