Skip to content
Open
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
10 changes: 10 additions & 0 deletions cli/src/services/config/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use super::resolver::{
use super::types::DatabaseRetryConfig;
use super::{ConfigPathSource, ReportFormat, ResolvedOptionalValue, ValueSource};

#[allow(clippy::too_many_lines)]
pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> String {
let warnings = build_show_warnings(runtime);
match report_format {
Expand Down Expand Up @@ -76,6 +77,10 @@ pub(super) fn format_show_output(runtime: &RuntimeConfig, report_format: ReportF
runtime.log_format.value.as_str(),
runtime.log_format.source,
),
"log_to_file": format_resolved_value_json(
runtime.log_to_file.value,
runtime.log_to_file.source,
),
"log_dir": format_optional_resolved_value_json(&runtime.log_dir),
"log_file_retention_limit": format_resolved_value_json(
runtime.log_file_retention_limit.value,
Expand Down Expand Up @@ -235,6 +240,11 @@ fn format_observability_text_lines(runtime: &RuntimeConfig) -> Vec<String> {
runtime.log_format.value.as_str(),
runtime.log_format.source,
),
format_resolved_value_text(
"log_to_file",
&runtime.log_to_file.value.to_string(),
runtime.log_to_file.source,
),
format_optional_resolved_value_text("log_dir", &runtime.log_dir),
format_resolved_value_text(
"log_file_retention_limit",
Expand Down
72 changes: 72 additions & 0 deletions cli/src/services/config/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub(super) struct RuntimeConfig {
pub(super) loaded_config_paths: Vec<LoadedConfigPath>,
pub(super) log_level: ResolvedValue<LogLevel>,
pub(super) log_format: ResolvedValue<LogFormat>,
pub(super) log_to_file: ResolvedValue<bool>,
pub(super) log_dir: ResolvedOptionalValue<String>,
pub(super) log_file_retention_limit: ResolvedValue<usize>,
pub(super) timeout_ms: ResolvedValue<u64>,
Expand Down Expand Up @@ -257,6 +258,7 @@ where
Ok(ResolvedObservabilityRuntimeConfig {
log_level: runtime.log_level.value,
log_format: runtime.log_format.value,
log_to_file: runtime.log_to_file.value,
log_dir: runtime.log_dir.value,
log_file_retention_limit: runtime.log_file_retention_limit.value,
loaded_config_paths: runtime.loaded_config_paths,
Expand Down Expand Up @@ -335,6 +337,7 @@ where
let mut file_config = schema::FileConfig {
log_level: None,
log_format: None,
log_to_file: None,
log_dir: None,
log_file_retention_limit: None,
timeout_ms: None,
Expand Down Expand Up @@ -366,6 +369,9 @@ where
if let Some(log_format) = layer.log_format {
file_config.log_format = Some(log_format);
}
if let Some(log_to_file) = layer.log_to_file {
file_config.log_to_file = Some(log_to_file);
}
if let Some(log_dir) = layer.log_dir {
file_config.log_dir = Some(log_dir);
}
Expand Down Expand Up @@ -447,6 +453,17 @@ where
};
}

let resolved_log_to_file = match file_config.log_to_file {
Some(value) => ResolvedValue {
value: value.value,
source: ValueSource::ConfigFile(value.source),
},
None => ResolvedValue {
value: true,
source: ValueSource::Default,
},
};

let resolved_log_dir = if let Some(raw) = env_lookup(ENV_LOG_DIR) {
ResolvedOptionalValue {
value: Some(raw),
Expand Down Expand Up @@ -575,6 +592,7 @@ where
loaded_config_paths,
log_level: resolved_log_level,
log_format: resolved_log_format,
log_to_file: resolved_log_to_file,
log_dir: resolved_log_dir,
log_file_retention_limit: resolved_log_file_retention_limit,
timeout_ms: resolved_timeout_ms,
Expand Down Expand Up @@ -847,6 +865,60 @@ mod tests {
assert_eq!(runtime.agent_trace_repository_id.source, None);
}

#[test]
fn log_to_file_defaults_to_true() {
let runtime = resolve_runtime_with_config(None).unwrap();

assert!(runtime.log_to_file.value);
assert_eq!(runtime.log_to_file.source, ValueSource::Default);
}

#[test]
fn log_to_file_and_log_dir_resolve_independently() {
let enabled_without_log_dir =
resolve_runtime_with_config(Some(r#"{"log_to_file":true}"#)).unwrap();
let log_dir_without_log_to_file =
resolve_runtime_with_config(Some(r#"{"log_dir":"/tmp/sce-logs"}"#)).unwrap();
let disabled_without_log_dir =
resolve_runtime_with_config(Some(r#"{"log_to_file":false}"#)).unwrap();

assert!(enabled_without_log_dir.log_to_file.value);
assert!(enabled_without_log_dir.log_dir.value.is_some());
assert!(enabled_without_log_dir.validation_errors.is_empty());
assert_eq!(
log_dir_without_log_to_file.log_dir.value.as_deref(),
Some("/tmp/sce-logs")
);
assert!(log_dir_without_log_to_file.log_to_file.value);
assert!(disabled_without_log_dir.log_dir.value.is_some());
assert!(disabled_without_log_dir.validation_errors.is_empty());
assert_eq!(
enabled_without_log_dir.log_to_file.source,
ValueSource::ConfigFile(ConfigPathSource::Flag)
);
assert_eq!(
disabled_without_log_dir.log_to_file.source,
ValueSource::ConfigFile(ConfigPathSource::Flag)
);
}

#[test]
fn log_to_file_resolves_both_explicit_boolean_values() {
let enabled = resolve_runtime_with_config(Some(r#"{"log_to_file":true}"#)).unwrap();
let disabled = resolve_runtime_with_config(Some(r#"{"log_to_file":false}"#)).unwrap();

assert!(enabled.log_to_file.value);
assert!(!disabled.log_to_file.value);
assert_eq!(
enabled.log_to_file.source,
ValueSource::ConfigFile(ConfigPathSource::Flag)
);
assert_eq!(
disabled.log_to_file.source,
ValueSource::ConfigFile(ConfigPathSource::Flag)
);
}

#[test]
fn agent_trace_auto_sync_defaults_to_true() {
let runtime = resolve_runtime_with_config(None).unwrap();
Expand Down
28 changes: 27 additions & 1 deletion cli/src/services/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub(crate) const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
CONFIG_SCHEMA_DECLARATION_KEY,
"log_level",
"log_format",
"log_to_file",
"log_dir",
"log_file_retention_limit",
"timeout_ms",
Expand All @@ -45,7 +46,7 @@ pub(crate) const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
];

pub(crate) const TOP_LEVEL_CONFIG_KEYS_DESCRIPTION: &str =
"$schema, log_level, log_format, timeout_ms, workos_client_id, control_plane_base_url, agent_trace, policies, integrations, log_dir, log_file_retention_limit";
"$schema, log_level, log_format, log_to_file, timeout_ms, workos_client_id, control_plane_base_url, agent_trace, policies, integrations, log_dir, log_file_retention_limit";

static CONFIG_SCHEMA_VALIDATOR: OnceLock<Validator> = OnceLock::new();

Expand All @@ -71,6 +72,7 @@ pub(crate) struct ParsedFileConfigDocument {
pub(crate) _schema: Option<String>,
pub(crate) log_level: Option<String>,
pub(crate) log_format: Option<String>,
pub(crate) log_to_file: Option<bool>,
pub(crate) log_dir: Option<String>,
pub(crate) log_file_retention_limit: Option<usize>,
pub(crate) timeout_ms: Option<u64>,
Expand Down Expand Up @@ -158,6 +160,7 @@ pub(crate) struct FileConfigValue<T> {
pub(crate) struct FileConfig {
pub(crate) log_level: Option<FileConfigValue<LogLevel>>,
pub(crate) log_format: Option<FileConfigValue<LogFormat>>,
pub(crate) log_to_file: Option<FileConfigValue<bool>>,
pub(crate) log_dir: Option<FileConfigValue<String>>,
pub(crate) log_file_retention_limit: Option<FileConfigValue<usize>>,
pub(crate) timeout_ms: Option<FileConfigValue<u64>>,
Expand Down Expand Up @@ -302,6 +305,9 @@ pub(crate) fn parse_file_config(
})
})
.transpose()?;
let log_to_file = typed
.log_to_file
.map(|value| FileConfigValue { value, source });
let log_dir = typed.log_dir.map(|value| FileConfigValue { value, source });
let log_file_retention_limit = typed
.log_file_retention_limit
Expand All @@ -324,6 +330,7 @@ pub(crate) fn parse_file_config(
Ok(FileConfig {
log_level,
log_format,
log_to_file,
log_dir,
log_file_retention_limit,
timeout_ms,
Expand Down Expand Up @@ -761,6 +768,25 @@ mod agent_trace_config_tests {
);
}

#[test]
fn parses_log_to_file_boolean() {
let config = parse(r#"{"log_to_file":false}"#).unwrap();

assert_eq!(
config.log_to_file.as_ref().map(|value| value.value),
Some(false)
);
}

#[test]
fn rejects_empty_log_dir_when_file_logging_is_enabled() {
let error = parse(r#"{"log_to_file":true,"log_dir":""}"#)
.unwrap_err()
.to_string();

assert!(error.contains("failed schema validation"), "{error}");
}

#[test]
fn omitted_agent_trace_block_parses_as_unset() {
let config = parse("{}").unwrap();
Expand Down
1 change: 1 addition & 0 deletions cli/src/services/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ pub(crate) struct ResolvedAuthRuntimeConfig {
pub(crate) struct ResolvedObservabilityRuntimeConfig {
pub(crate) log_level: LogLevel,
pub(crate) log_format: LogFormat,
pub(crate) log_to_file: bool,
pub(crate) log_dir: Option<String>,
pub(crate) log_file_retention_limit: usize,
pub(crate) loaded_config_paths: Vec<LoadedConfigPath>,
Expand Down
83 changes: 81 additions & 2 deletions cli/src/services/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ const EMPTY_SESSION_ID_TOKEN: &str = "%EMPTY";
pub struct ObservabilityConfig {
pub level: LogLevel,
pub format: LogFormat,
pub log_to_file: bool,
}

impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
level: LogLevel::Error,
format: LogFormat::Text,
log_to_file: true,
}
}
}
Expand All @@ -61,8 +63,13 @@ impl Logger {
config: ObservabilityConfig {
level: config.log_level,
format: config.log_format,
log_to_file: config.log_to_file,
},
log_dir: config.log_dir.as_deref().map(PathBuf::from),
log_dir: config
.log_to_file
.then_some(config.log_dir.as_deref())
.flatten()
.map(PathBuf::from),
log_file_retention_limit: config.log_file_retention_limit,
})
}
Expand Down Expand Up @@ -181,7 +188,9 @@ impl Logger {

let line = self.render_line(level, event_id, message, fields);
let redacted_line = redact_sensitive_text(&line);
emit_stderr_line(&redacted_line);
if should_emit_to_stderr(level, self.config.log_to_file) {
emit_stderr_line(&redacted_line);
}

if let Err(error) = self.write_log_line(&redacted_line, session_id) {
let diagnostic = redact_sensitive_text(&format!(
Expand All @@ -192,6 +201,10 @@ impl Logger {
}

fn write_log_line(&self, redacted_line: &str, session_id: Option<&str>) -> Result<()> {
if !self.config.log_to_file {
return Ok(());
}

let Some(log_dir) = self.log_dir.as_deref() else {
return Ok(());
};
Expand Down Expand Up @@ -292,6 +305,10 @@ fn cli_error_fields(error: &CliError) -> Vec<(&'static str, String)> {
fields
}

fn should_emit_to_stderr(level: LogLevel, log_to_file: bool) -> bool {
level != LogLevel::Error || !log_to_file
}

fn validate_log_dir(value: &str) -> Result<()> {
if value.is_empty() {
bail!("Invalid {ENV_LOG_DIR} ''. Try: set it to a directory path or unset {ENV_LOG_DIR}.");
Expand Down Expand Up @@ -728,6 +745,68 @@ mod tests {
assert_eq!(field_value(&fields, "error_source"), None);
}

#[test]
fn error_records_route_to_stderr_only_when_file_logging_is_disabled() {
assert!(!should_emit_to_stderr(LogLevel::Error, true));
assert!(should_emit_to_stderr(LogLevel::Error, false));
}

#[test]
fn non_error_records_remain_on_stderr_when_file_logging_is_enabled() {
assert!(should_emit_to_stderr(LogLevel::Warn, true));
assert!(should_emit_to_stderr(LogLevel::Info, true));
assert!(should_emit_to_stderr(LogLevel::Debug, true));
}

#[test]
fn enabled_file_logging_writes_error_records_without_stderr_routing() {
let log_dir =
std::env::temp_dir().join(format!("sce-observability-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&log_dir);
let logger = Logger {
config: ObservabilityConfig {
level: LogLevel::Error,
format: LogFormat::Text,
log_to_file: true,
},
log_dir: Some(log_dir.clone()),
log_file_retention_limit: 10,
};

logger.error("sce.test.error", "test error", &[], None);

let entries = fs::read_dir(&log_dir)
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(entries.len(), 1);
let contents = fs::read_to_string(entries[0].path()).unwrap();
assert!(contents.contains("event_id=sce.test.error"));
let _ = fs::remove_dir_all(log_dir);
}

#[test]
fn disabled_file_logging_keeps_error_records_off_disk() {
let log_dir = std::env::temp_dir().join(format!(
"sce-observability-disabled-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&log_dir);
let logger = Logger {
config: ObservabilityConfig {
level: LogLevel::Error,
format: LogFormat::Text,
log_to_file: false,
},
log_dir: Some(log_dir.clone()),
log_file_retention_limit: 10,
};

logger.error("sce.test.error", "test error", &[], None);

assert!(!log_dir.exists());
}

#[test]
fn observability_fields_for_internal_error_carry_surface_and_full_source_chain() {
let source = anyhow::anyhow!("root cause").context("failed to do the thing");
Expand Down
5 changes: 5 additions & 0 deletions config/pkl/base/sce-config-schema.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ local sceConfigSchema = new JsonSchema {
type = "string"
enum = new { "text"; "json" }
}
["log_to_file"] = new JsonSchema {
type = "boolean"
description = "Write log records to the configured log directory. Defaults to true."
default = true
}
["log_dir"] = new JsonSchema {
type = "string"
minLength = 1
Expand Down
Loading
Loading