@ -4,12 +4,14 @@ use std::{
sync ::OnceLock ,
} ;
use chrono ::{ DateTime , Utc } ;
use chrono_tz ::Tz ;
use tracing ::Level ;
use tracing_appender ::non_blocking ::{ ErrorCounter , NonBlocking , NonBlockingBuilder , WorkerGuard } ;
use tracing_appender ::rolling ::{ RollingFileAppender , Rotation } ;
use tracing_subscriber ::{
filter ::FilterFn ,
fmt ::{ self , format ::FmtSpan , writer ::BoxMakeWriter } ,
fmt ::{ self , format ::Writer, time ::FormatTime , writer ::BoxMakeWriter } ,
layer ::{ Layer , Layered , SubscriberExt } ,
registry ::Registry ,
util ::SubscriberInitExt ,
@ -17,6 +19,7 @@ use tracing_subscriber::{
const SQLX_POSTGRES_NOTICE_TARGET : & str = "sqlx::postgres::notice" ;
const LOG_BUFFERED_LINES_LIMIT : usize = 128_000 ;
const LOG_TIMESTAMP_FORMAT : & str = "%Y-%m-%dT%H:%M:%S%.6f%:z" ;
static LOGGING_ERROR_COUNTERS : OnceLock < Vec < ComponentErrorCounter > > = OnceLock ::new ( ) ;
#[ derive(Debug, Clone) ]
@ -39,6 +42,12 @@ pub enum LogRotation {
Never ,
}
#[ derive(Debug, Clone, Copy, PartialEq, Eq) ]
pub enum LogStyle {
Diagnostic ,
Access ,
}
#[ derive(Debug, Clone, PartialEq, Eq) ]
pub enum LogOutput {
Stdout ,
@ -53,6 +62,7 @@ pub enum LogOutput {
#[ derive(Debug, Clone) ]
pub struct ComponentLoggingOptions {
pub name : String ,
pub style : LogStyle ,
pub targets : Vec < String > ,
pub level : String ,
pub format : String ,
@ -62,6 +72,7 @@ pub struct ComponentLoggingOptions {
#[ derive(Debug, Clone) ]
pub struct LoggingOptions {
pub timezone : String ,
pub global : ComponentLoggingOptions ,
pub components : Vec < ComponentLoggingOptions > ,
}
@ -69,8 +80,10 @@ pub struct LoggingOptions {
impl Default for LoggingOptions {
fn default ( ) -> Self {
Self {
timezone : "UTC" . to_string ( ) ,
global : ComponentLoggingOptions {
name : "global" . to_string ( ) ,
style : LogStyle ::Diagnostic ,
targets : Vec ::new ( ) ,
level : "info" . to_string ( ) ,
format : "text" . to_string ( ) ,
@ -82,6 +95,39 @@ impl Default for LoggingOptions {
}
}
#[ derive(Debug, Clone, Copy) ]
struct ConfiguredTimezoneTimer {
timezone : Tz ,
}
impl FormatTime for ConfiguredTimezoneTimer {
fn format_time ( & self , writer : & mut Writer < ' _ > ) -> std ::fmt ::Result {
write_log_timestamp ( writer , self . timezone , Utc ::now ( ) )
}
}
fn write_log_timestamp (
writer : & mut impl std ::fmt ::Write ,
timezone : Tz ,
timestamp : DateTime < Utc > ,
) -> std ::fmt ::Result {
write! (
writer ,
"{}" ,
timestamp
. with_timezone ( & timezone )
. format ( LOG_TIMESTAMP_FORMAT )
)
}
#[ cfg(test) ]
fn format_log_timestamp ( timezone : Tz , timestamp : DateTime < Utc > ) -> String {
let mut output = String ::new ( ) ;
write_log_timestamp ( & mut output , timezone , timestamp )
. expect ( "writing a timestamp to a string should succeed" ) ;
output
}
/// Keeps every non-blocking logging worker alive until application shutdown.
#[ derive(Debug) ]
pub struct LoggingGuards {
@ -136,6 +182,11 @@ type LoggingSubscriber = Layered<Vec<Box<dyn Layer<Registry> + Send + Sync>>, Re
fn build_subscriber ( config : & LoggingOptions ) -> anyhow ::Result < ( LoggingSubscriber , LoggingGuards ) > {
validate_component_routes ( config ) ? ;
let timer = ConfiguredTimezoneTimer {
timezone : synctv_common ::time ::parse_timezone_name ( & config . timezone ) . map_err ( | error | {
anyhow ::anyhow ! ( "invalid logging timezone '{}': {error}" , config . timezone )
} ) ? ,
} ;
let component_targets : Vec < String > = config
. components
@ -175,10 +226,24 @@ fn build_subscriber(config: &LoggingOptions) -> anyhow::Result<(LoggingSubscribe
} ) ;
let writer = writers . writer_for ( component ) ? ;
let layer = if component . format . eq_ignore_ascii_case ( "json" ) {
let is_access_log = component . style = = LogStyle ::Access ;
let layer = if component . format . eq_ignore_ascii_case ( "json" ) & & is_access_log {
fmt ::layer ( )
. with_timer ( timer )
. json ( )
. with_current_span ( false )
. with_span_list ( false )
. with_target ( false )
. with_line_number ( false )
. with_file ( false )
. with_ansi ( false )
. with_writer ( writer )
. with_filter ( filter )
. boxed ( )
} else if component . format . eq_ignore_ascii_case ( "json" ) {
fmt ::layer ( )
. with_timer ( timer )
. json ( )
. with_span_events ( FmtSpan ::CLOSE )
. with_current_span ( true )
. with_span_list ( true )
. with_target ( true )
@ -188,10 +253,21 @@ fn build_subscriber(config: &LoggingOptions) -> anyhow::Result<(LoggingSubscribe
. with_writer ( writer )
. with_filter ( filter )
. boxed ( )
} else if component . format . eq_ignore_ascii_case ( "text" ) & & is_access_log {
fmt ::layer ( )
. with_timer ( timer )
. compact ( )
. with_target ( false )
. with_line_number ( false )
. with_file ( false )
. with_ansi ( ansi_enabled ( component ) )
. with_writer ( writer )
. with_filter ( filter )
. boxed ( )
} else if component . format . eq_ignore_ascii_case ( "text" ) {
fmt ::layer ( )
. with_timer ( timer )
. compact ( )
. with_span_events ( FmtSpan ::CLOSE )
. with_target ( true )
. with_line_number ( true )
. with_file ( false )
@ -463,14 +539,54 @@ mod tests {
#[ test ]
fn default_logging_has_a_global_output ( ) {
let config = LoggingOptions ::default ( ) ;
assert_eq! ( config . timezone , "UTC" ) ;
assert_eq! ( config . global . name , "global" ) ;
assert! ( config . global . targets . is_empty ( ) ) ;
assert! ( config . components . is_empty ( ) ) ;
}
#[ test ]
fn log_timestamp_uses_configured_timezone_and_daylight_saving_offset ( ) {
let timezone = synctv_common ::time ::parse_timezone_name ( "America/New_York" )
. expect ( "timezone should be valid" ) ;
let winter = DateTime ::parse_from_rfc3339 ( "2026-01-15T12:00:00Z" )
. expect ( "timestamp should be valid" )
. to_utc ( ) ;
let summer = DateTime ::parse_from_rfc3339 ( "2026-07-15T12:00:00Z" )
. expect ( "timestamp should be valid" )
. to_utc ( ) ;
assert_eq! (
format_log_timestamp ( timezone , winter ) ,
"2026-01-15T07:00:00.000000-05:00"
) ;
assert_eq! (
format_log_timestamp ( timezone , summer ) ,
"2026-07-15T08:00:00.000000-04:00"
) ;
assert_eq! (
format_log_timestamp ( chrono_tz ::UTC , summer ) ,
"2026-07-15T12:00:00.000000+00:00"
) ;
}
#[ test ]
fn invalid_logging_timezone_is_rejected ( ) {
let config = LoggingOptions {
timezone : "Invalid/Timezone" . to_string ( ) ,
.. LoggingOptions ::default ( )
} ;
let Err ( error ) = build_subscriber ( & config ) else {
panic! ( "invalid timezone should be rejected" ) ;
} ;
assert! ( error . to_string ( ) . contains ( "invalid logging timezone" ) ) ;
}
#[ test ]
fn effective_level_comes_from_global_output ( ) {
let config = LoggingOptions {
timezone : "UTC" . to_string ( ) ,
global : ComponentLoggingOptions {
level : "debug" . to_string ( ) ,
.. default_component ( "global" )
@ -514,6 +630,7 @@ mod tests {
#[ test ]
fn overlapping_specialized_routes_are_rejected ( ) {
let config = LoggingOptions {
timezone : "UTC" . to_string ( ) ,
global : default_component ( "global" ) ,
components : vec ! [
ComponentLoggingOptions {
@ -532,6 +649,7 @@ mod tests {
#[ test ]
fn components_sharing_standard_output_have_independent_workers ( ) {
let config = LoggingOptions {
timezone : "UTC" . to_string ( ) ,
global : default_component ( "global" ) ,
components : vec ! [ ComponentLoggingOptions {
targets : vec ! [ "synctv::health" . to_string ( ) ] ,
@ -574,6 +692,7 @@ mod tests {
#[ test ]
fn zero_file_retention_is_rejected ( ) {
let config = LoggingOptions {
timezone : "UTC" . to_string ( ) ,
global : ComponentLoggingOptions {
output : LogOutput ::File {
path : PathBuf ::from ( "global.log" ) ,
@ -621,6 +740,7 @@ mod tests {
fn component_layers_use_independent_routes_levels_formats_and_files ( ) {
let dir = tempdir ( ) . expect ( "temporary log directory should be created" ) ;
let config = LoggingOptions {
timezone : "Asia/Shanghai" . to_string ( ) ,
global : ComponentLoggingOptions {
format : "json" . to_string ( ) ,
output : LogOutput ::File {
@ -649,21 +769,68 @@ mod tests {
tracing ::info ! ( target : "synctv_core::cache" , "global-only-event" ) ;
tracing ::info ! ( target : "synctv::health" , "filtered-health-event" ) ;
tracing ::warn ! ( target : "synctv::health" , "health-only-event" ) ;
let span = tracing ::info_span ! ( "silent-span" ) ;
drop ( span . enter ( ) ) ;
} ) ;
drop ( guards ) ;
let global_log = read_log_with_prefix ( dir . path ( ) , "global" ) ;
let health_log = read_log_with_prefix ( dir . path ( ) , "health" ) ;
assert! ( global_log . contains ( "global-only-event" ) ) ;
assert! ( global_log . contains ( "+08:00" ) ) ;
assert! ( global_log . contains ( "\"target\":\"synctv_core::cache\"" ) ) ;
assert! ( ! global_log . contains ( "health-only-event" ) ) ;
assert! ( ! global_log . contains ( "filtered-health-event" ) ) ;
assert! ( ! global_log . contains ( "silent-span" ) ) ;
assert! ( health_log . contains ( "health-only-event" ) ) ;
assert! ( health_log . contains ( "+08:00" ) ) ;
assert! ( ! health_log . contains ( "filtered-health-event" ) ) ;
assert! ( ! health_log . contains ( "global-only-event" ) ) ;
assert! ( ! health_log . trim_start ( ) . starts_with ( '{' ) ) ;
}
#[ test ]
fn access_component_uses_compact_context_free_text ( ) {
let dir = tempdir ( ) . expect ( "temporary log directory should be created" ) ;
let config = LoggingOptions {
timezone : "UTC" . to_string ( ) ,
global : default_component ( "global" ) ,
components : vec ! [ ComponentLoggingOptions {
name : "access" . to_string ( ) ,
style : LogStyle ::Access ,
targets : vec ! [ "synctv::access" . to_string ( ) ] ,
output : LogOutput ::File {
path : dir . path ( ) . join ( "access.log" ) ,
rotation : LogRotation ::Never ,
max_files : 2 ,
} ,
.. default_component ( "access" )
} ] ,
} ;
let ( subscriber , guards ) =
build_subscriber ( & config ) . expect ( "logging subscriber should build" ) ;
tracing ::subscriber ::with_default ( subscriber , | | {
let span = tracing ::info_span ! ( "request" , secret = "must-not-be-inherited" ) ;
let _guard = span . enter ( ) ;
tracing ::info ! (
target : "synctv::access" ,
protocol = "http" ,
status = 200 ,
"request completed"
) ;
} ) ;
drop ( guards ) ;
let access_log = read_log_with_prefix ( dir . path ( ) , "access" ) ;
assert! ( access_log . contains ( "request completed" ) ) ;
assert! ( access_log . contains ( "protocol=\"http\"" ) ) ;
assert! ( access_log . contains ( "status=200" ) ) ;
assert! ( ! access_log . contains ( "synctv::access" ) ) ;
assert! ( ! access_log . contains ( "must-not-be-inherited" ) ) ;
assert! ( ! access_log . contains ( "logging.rs" ) ) ;
}
fn read_log_with_prefix ( dir : & Path , prefix : & str ) -> String {
let path = fs ::read_dir ( dir )
. expect ( "log directory should be readable" )
@ -684,6 +851,7 @@ mod tests {
fn default_component ( name : & str ) -> ComponentLoggingOptions {
ComponentLoggingOptions {
name : name . to_string ( ) ,
style : LogStyle ::Diagnostic ,
targets : Vec ::new ( ) ,
level : "info" . to_string ( ) ,
format : "text" . to_string ( ) ,