1- use tracing_subscriber:: { EnvFilter , Layer , fmt, layer:: SubscriberExt , util:: SubscriberInitExt } ;
1+ //! Structured logging utilities for InferaDB Management
2+ //!
3+ //! Provides enhanced logging with contextual fields and formatting options,
4+ //! matching the server's logging architecture for consistent developer experience.
5+
6+ use std:: io:: IsTerminal ;
7+
8+ use tracing_subscriber:: {
9+ EnvFilter , Layer , fmt, fmt:: format:: FmtSpan , layer:: SubscriberExt , util:: SubscriberInitExt ,
10+ } ;
211
312use crate :: config:: ObservabilityConfig ;
413
5- /// Initialize structured logging based on configuration
14+ /// Log output format options
15+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
16+ pub enum LogFormat {
17+ /// Standard single-line format (matches server default)
18+ /// Output: `2025-01-15T10:30:45.123456Z INFO target: message key=value`
19+ Full ,
20+ /// Human-readable multi-line format with colors (for development debugging)
21+ Pretty ,
22+ /// Compact single-line format without timestamp details
23+ Compact ,
24+ /// JSON format (for production log aggregation)
25+ Json ,
26+ }
27+
28+ #[ allow( clippy:: derivable_impls) ]
29+ impl Default for LogFormat {
30+ fn default ( ) -> Self {
31+ #[ cfg( debug_assertions) ]
32+ {
33+ LogFormat :: Full // Match server's default format in development
34+ }
35+ #[ cfg( not( debug_assertions) ) ]
36+ {
37+ LogFormat :: Json
38+ }
39+ }
40+ }
41+
42+ /// Configuration for logging behavior
43+ #[ derive( Debug , Clone ) ]
44+ pub struct LogConfig {
45+ /// Output format
46+ pub format : LogFormat ,
47+ /// Whether to include file/line numbers
48+ pub include_location : bool ,
49+ /// Whether to include target module
50+ pub include_target : bool ,
51+ /// Whether to include thread IDs
52+ pub include_thread_id : bool ,
53+ /// Whether to log span events (enter/exit/close)
54+ pub log_spans : bool ,
55+ /// Whether to use ANSI colors (None = auto-detect based on TTY)
56+ pub ansi : Option < bool > ,
57+ /// Environment filter (e.g., "info,inferadb_management=debug")
58+ pub filter : Option < String > ,
59+ }
60+
61+ impl Default for LogConfig {
62+ fn default ( ) -> Self {
63+ Self {
64+ format : LogFormat :: default ( ) ,
65+ include_location : cfg ! ( debug_assertions) ,
66+ include_target : true ,
67+ include_thread_id : false ,
68+ log_spans : cfg ! ( debug_assertions) ,
69+ ansi : None , // Auto-detect
70+ filter : None ,
71+ }
72+ }
73+ }
74+
75+ /// Initialize structured logging with configuration
76+ ///
77+ /// This is the primary logging initialization function that provides full control
78+ /// over log format and behavior, matching the server's logging API.
79+ ///
80+ /// # Arguments
81+ ///
82+ /// * `config` - Logging configuration options
83+ ///
84+ /// # Examples
85+ ///
86+ /// ```no_run
87+ /// use inferadb_management_core::logging::{LogConfig, LogFormat, init_logging};
88+ ///
89+ /// // Development: Pretty format with colors
90+ /// let config = LogConfig {
91+ /// format: LogFormat::Pretty,
92+ /// ..Default::default()
93+ /// };
94+ /// init_logging(config).unwrap();
95+ ///
96+ /// // Production: JSON format
97+ /// let config = LogConfig {
98+ /// format: LogFormat::Json,
99+ /// filter: Some("info".to_string()),
100+ /// ..Default::default()
101+ /// };
102+ /// init_logging(config).unwrap();
103+ /// ```
104+ pub fn init_logging ( config : LogConfig ) -> Result < ( ) , Box < dyn std:: error:: Error + Send + Sync > > {
105+ let env_filter = if let Some ( filter) = & config. filter {
106+ EnvFilter :: try_new ( filter) ?
107+ } else {
108+ EnvFilter :: try_from_default_env ( )
109+ . unwrap_or_else ( |_| EnvFilter :: new ( "info,inferadb_management=debug" ) )
110+ } ;
111+
112+ // Auto-detect ANSI support based on TTY, or use explicit setting
113+ let ansi = config. ansi . unwrap_or_else ( || std:: io:: stdout ( ) . is_terminal ( ) ) ;
114+
115+ let fmt_span = if config. log_spans { FmtSpan :: NEW | FmtSpan :: CLOSE } else { FmtSpan :: NONE } ;
116+
117+ match config. format {
118+ LogFormat :: Full => {
119+ // Standard format matching server's default output style
120+ let fmt_layer = fmt:: layer ( ) . with_target ( config. include_target ) . with_filter ( env_filter) ;
121+
122+ tracing_subscriber:: registry ( ) . with ( fmt_layer) . try_init ( ) ?;
123+ } ,
124+ LogFormat :: Pretty => {
125+ let fmt_layer = fmt:: layer ( )
126+ . pretty ( )
127+ . with_ansi ( ansi)
128+ . with_target ( config. include_target )
129+ . with_thread_ids ( config. include_thread_id )
130+ . with_file ( config. include_location )
131+ . with_line_number ( config. include_location )
132+ . with_span_events ( fmt_span)
133+ . with_filter ( env_filter) ;
134+
135+ tracing_subscriber:: registry ( ) . with ( fmt_layer) . try_init ( ) ?;
136+ } ,
137+ LogFormat :: Compact => {
138+ let fmt_layer = fmt:: layer ( )
139+ . compact ( )
140+ . with_ansi ( ansi)
141+ . with_target ( config. include_target )
142+ . with_thread_ids ( config. include_thread_id )
143+ . with_file ( config. include_location )
144+ . with_line_number ( config. include_location )
145+ . with_span_events ( fmt_span)
146+ . with_filter ( env_filter) ;
147+
148+ tracing_subscriber:: registry ( ) . with ( fmt_layer) . try_init ( ) ?;
149+ } ,
150+ LogFormat :: Json => {
151+ let fmt_layer = fmt:: layer ( )
152+ . json ( )
153+ . with_target ( config. include_target )
154+ . with_current_span ( true )
155+ . with_span_list ( true )
156+ . with_thread_ids ( config. include_thread_id )
157+ . with_thread_names ( config. include_thread_id )
158+ . with_filter ( env_filter) ;
159+
160+ tracing_subscriber:: registry ( ) . with ( fmt_layer) . try_init ( ) ?;
161+ } ,
162+ }
163+
164+ tracing:: debug!(
165+ format = ?config. format,
166+ location = config. include_location,
167+ target = config. include_target,
168+ ansi = ansi,
169+ "Logging initialized"
170+ ) ;
171+
172+ Ok ( ( ) )
173+ }
174+
175+ /// Initialize structured logging based on ObservabilityConfig (backward compatible)
6176///
7177/// Sets up tracing-subscriber with either JSON or compact formatting based on environment.
8178/// In production (when `json` is true), logs are emitted as JSON for structured ingestion.
@@ -32,34 +202,18 @@ use crate::config::ObservabilityConfig;
32202/// logging::init(&config, false);
33203/// ```
34204pub fn init ( config : & ObservabilityConfig , json : bool ) {
35- let env_filter = EnvFilter :: try_from_default_env ( )
36- . or_else ( |_| EnvFilter :: try_new ( & config. log_level ) )
37- . unwrap_or_else ( |_| EnvFilter :: new ( "info" ) ) ;
38-
39- if json {
40- // Production: JSON structured logging
41- let fmt_layer = fmt:: layer ( )
42- . json ( )
43- . with_target ( true )
44- . with_current_span ( true )
45- . with_span_list ( true )
46- . with_thread_ids ( true )
47- . with_thread_names ( true )
48- . with_filter ( env_filter) ;
49-
50- tracing_subscriber:: registry ( ) . with ( fmt_layer) . init ( ) ;
51- } else {
52- // Development: Compact single-line logging (matches server format)
53- let fmt_layer = fmt:: layer ( )
54- . compact ( )
55- . with_target ( true )
56- . with_thread_ids ( false )
57- . with_thread_names ( false )
58- . with_file ( false )
59- . with_line_number ( false )
60- . with_filter ( env_filter) ;
205+ let log_config = LogConfig {
206+ format : if json { LogFormat :: Json } else { LogFormat :: Full } ,
207+ filter : Some ( config. log_level . clone ( ) ) ,
208+ include_location : false ,
209+ include_target : true ,
210+ include_thread_id : json, // Include thread info in JSON mode
211+ log_spans : false ,
212+ ansi : None , // Auto-detect
213+ } ;
61214
62- tracing_subscriber:: registry ( ) . with ( fmt_layer) . init ( ) ;
215+ if let Err ( e) = init_logging ( log_config) {
216+ eprintln ! ( "Failed to initialize logging: {}" , e) ;
63217 }
64218}
65219
@@ -89,7 +243,7 @@ pub fn init_with_tracing(
89243
90244 let env_filter = EnvFilter :: try_from_default_env ( )
91245 . or_else ( |_| EnvFilter :: try_new ( & config. log_level ) )
92- . unwrap_or_else ( |_| EnvFilter :: new ( "info" ) ) ;
246+ . unwrap_or_else ( |_| EnvFilter :: new ( "info,inferadb_management=debug " ) ) ;
93247
94248 // Build the base logging layer
95249 let fmt_layer = if json {
@@ -104,16 +258,8 @@ pub fn init_with_tracing(
104258 . with_filter ( env_filter. clone ( ) )
105259 . boxed ( )
106260 } else {
107- // Development: Compact single-line logging (matches server format)
108- fmt:: layer ( )
109- . compact ( )
110- . with_target ( true )
111- . with_thread_ids ( false )
112- . with_thread_names ( false )
113- . with_file ( false )
114- . with_line_number ( false )
115- . with_filter ( env_filter. clone ( ) )
116- . boxed ( )
261+ // Development: Standard format (matches server default)
262+ fmt:: layer ( ) . with_target ( true ) . with_filter ( env_filter. clone ( ) ) . boxed ( )
117263 } ;
118264
119265 let subscriber = tracing_subscriber:: registry ( ) . with ( fmt_layer) ;
@@ -164,14 +310,76 @@ pub fn init_with_tracing(
164310
165311#[ cfg( test) ]
166312mod tests {
313+ use std:: sync:: Once ;
314+
167315 use super :: * ;
168316
169- // Note: We cannot test init() directly in unit tests because
170- // tracing-subscriber only allows setting the global default subscriber once per process.
171- // The logging initialization is tested through integration tests.
317+ static INIT : Once = Once :: new ( ) ;
318+
319+ fn init_test_logging ( ) {
320+ INIT . call_once ( || {
321+ let _ = init_logging ( LogConfig {
322+ format : LogFormat :: Compact ,
323+ include_location : false ,
324+ include_target : false ,
325+ include_thread_id : false ,
326+ log_spans : true ,
327+ ansi : Some ( false ) ,
328+ filter : Some ( "debug" . to_string ( ) ) ,
329+ } ) ;
330+ } ) ;
331+ }
332+
333+ #[ test]
334+ fn test_log_config_default ( ) {
335+ let config = LogConfig :: default ( ) ;
336+ assert_eq ! ( config. format, LogFormat :: default ( ) ) ;
337+ assert ! ( config. include_target) ;
338+ assert ! ( !config. include_thread_id) ;
339+ assert ! ( config. ansi. is_none( ) ) ; // Auto-detect
340+ }
172341
173342 #[ test]
174- fn test_config_creation ( ) {
343+ fn test_log_format_default ( ) {
344+ let format = LogFormat :: default ( ) ;
345+ #[ cfg( debug_assertions) ]
346+ assert_eq ! ( format, LogFormat :: Full ) ;
347+ #[ cfg( not( debug_assertions) ) ]
348+ assert_eq ! ( format, LogFormat :: Json ) ;
349+ }
350+
351+ #[ test]
352+ fn test_log_format_variants ( ) {
353+ assert_eq ! ( LogFormat :: Full , LogFormat :: Full ) ;
354+ assert_eq ! ( LogFormat :: Pretty , LogFormat :: Pretty ) ;
355+ assert_eq ! ( LogFormat :: Compact , LogFormat :: Compact ) ;
356+ assert_eq ! ( LogFormat :: Json , LogFormat :: Json ) ;
357+ assert_ne ! ( LogFormat :: Full , LogFormat :: Json ) ;
358+ }
359+
360+ #[ test]
361+ fn test_log_config_custom ( ) {
362+ let config = LogConfig {
363+ format : LogFormat :: Json ,
364+ include_location : true ,
365+ include_target : false ,
366+ include_thread_id : true ,
367+ log_spans : true ,
368+ ansi : Some ( false ) ,
369+ filter : Some ( "warn" . to_string ( ) ) ,
370+ } ;
371+
372+ assert_eq ! ( config. format, LogFormat :: Json ) ;
373+ assert ! ( config. include_location) ;
374+ assert ! ( !config. include_target) ;
375+ assert ! ( config. include_thread_id) ;
376+ assert ! ( config. log_spans) ;
377+ assert_eq ! ( config. ansi, Some ( false ) ) ;
378+ assert_eq ! ( config. filter, Some ( "warn" . to_string( ) ) ) ;
379+ }
380+
381+ #[ test]
382+ fn test_observability_config_creation ( ) {
175383 let config = ObservabilityConfig {
176384 log_level : "debug" . to_string ( ) ,
177385 metrics_enabled : true ,
@@ -184,6 +392,12 @@ mod tests {
184392 assert ! ( !config. tracing_enabled) ;
185393 }
186394
395+ #[ test]
396+ fn test_init_logging_does_not_panic ( ) {
397+ init_test_logging ( ) ;
398+ // If we get here without panicking, the test passes
399+ }
400+
187401 #[ cfg( feature = "opentelemetry" ) ]
188402 #[ test]
189403 fn test_init_with_tracing_disabled ( ) {
0 commit comments