Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 51 additions & 2 deletions src/dev/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
47 changes: 47 additions & 0 deletions src/python_logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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,
Expand All @@ -36,6 +38,7 @@ pub struct LoggingConfig {
}

/// Formatter configuration
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatterConfig {
#[serde(default)]
Expand All @@ -47,6 +50,7 @@ pub struct FormatterConfig {
}

/// Handler configuration
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandlerConfig {
#[serde(rename = "class")]
Expand All @@ -64,6 +68,7 @@ pub struct HandlerConfig {
}

/// Logger configuration
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggerConfig {
#[serde(default)]
Expand All @@ -75,6 +80,7 @@ pub struct LoggerConfig {
}

/// Root logger configuration
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RootLoggerConfig {
#[serde(default)]
Expand Down Expand Up @@ -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
);
}
}
}