1+ use std:: collections:: BTreeMap ;
12use std:: collections:: VecDeque ;
3+ use std:: collections:: btree_map:: Entry ;
24use std:: fs;
35use std:: io:: Write ;
46use std:: io:: { self } ;
@@ -11,12 +13,20 @@ use anyhow::Result;
1113use anyhow:: anyhow;
1214use codex_protocol:: ConversationId ;
1315use codex_protocol:: protocol:: SessionSource ;
16+ use tracing:: Event ;
17+ use tracing:: Level ;
18+ use tracing:: field:: Visit ;
19+ use tracing_subscriber:: Layer ;
20+ use tracing_subscriber:: filter:: Targets ;
1421use tracing_subscriber:: fmt:: writer:: MakeWriter ;
22+ use tracing_subscriber:: registry:: LookupSpan ;
1523
1624const DEFAULT_MAX_BYTES : usize = 4 * 1024 * 1024 ; // 4 MiB
1725const SENTRY_DSN : & str =
1826 "https://[email protected] /4510195390611458" ; 1927const UPLOAD_TIMEOUT_SECS : u64 = 10 ;
28+ const FEEDBACK_TAGS_TARGET : & str = "feedback_tags" ;
29+ const MAX_FEEDBACK_TAGS : usize = 64 ;
2030
2131#[ derive( Clone ) ]
2232pub struct CodexFeedback {
@@ -46,13 +56,50 @@ impl CodexFeedback {
4656 }
4757 }
4858
59+ /// Returns a [`tracing_subscriber`] layer that captures full-fidelity logs into this feedback
60+ /// ring buffer.
61+ ///
62+ /// This is intended for initialization code so call sites don't have to duplicate the exact
63+ /// `fmt::layer()` configuration and filter logic.
64+ pub fn logger_layer < S > ( & self ) -> impl Layer < S > + Send + Sync + ' static
65+ where
66+ S : tracing:: Subscriber + for < ' a > LookupSpan < ' a > ,
67+ {
68+ tracing_subscriber:: fmt:: layer ( )
69+ . with_writer ( self . make_writer ( ) )
70+ . with_ansi ( false )
71+ . with_target ( false )
72+ // Capture everything, regardless of the caller's `RUST_LOG`, so feedback includes the
73+ // full trace when the user uploads a report.
74+ . with_filter ( Targets :: new ( ) . with_default ( Level :: TRACE ) )
75+ }
76+
77+ /// Returns a [`tracing_subscriber`] layer that collects structured metadata for feedback.
78+ ///
79+ /// Events with `target: "feedback_tags"` are treated as key/value tags to attach to feedback
80+ /// uploads later.
81+ pub fn metadata_layer < S > ( & self ) -> impl Layer < S > + Send + Sync + ' static
82+ where
83+ S : tracing:: Subscriber + for < ' a > LookupSpan < ' a > ,
84+ {
85+ FeedbackMetadataLayer {
86+ inner : self . inner . clone ( ) ,
87+ }
88+ . with_filter ( Targets :: new ( ) . with_target ( FEEDBACK_TAGS_TARGET , Level :: TRACE ) )
89+ }
90+
4991 pub fn snapshot ( & self , session_id : Option < ConversationId > ) -> CodexLogSnapshot {
5092 let bytes = {
5193 let guard = self . inner . ring . lock ( ) . expect ( "mutex poisoned" ) ;
5294 guard. snapshot_bytes ( )
5395 } ;
96+ let tags = {
97+ let guard = self . inner . tags . lock ( ) . expect ( "mutex poisoned" ) ;
98+ guard. clone ( )
99+ } ;
54100 CodexLogSnapshot {
55101 bytes,
102+ tags,
56103 thread_id : session_id
57104 . map ( |id| id. to_string ( ) )
58105 . unwrap_or ( "no-active-thread-" . to_string ( ) + & ConversationId :: new ( ) . to_string ( ) ) ,
@@ -62,12 +109,14 @@ impl CodexFeedback {
62109
63110struct FeedbackInner {
64111 ring : Mutex < RingBuffer > ,
112+ tags : Mutex < BTreeMap < String , String > > ,
65113}
66114
67115impl FeedbackInner {
68116 fn new ( max_bytes : usize ) -> Self {
69117 Self {
70118 ring : Mutex :: new ( RingBuffer :: new ( max_bytes) ) ,
119+ tags : Mutex :: new ( BTreeMap :: new ( ) ) ,
71120 }
72121 }
73122}
@@ -152,6 +201,7 @@ impl RingBuffer {
152201
153202pub struct CodexLogSnapshot {
154203 bytes : Vec < u8 > ,
204+ tags : BTreeMap < String , String > ,
155205 pub thread_id : String ,
156206}
157207
@@ -212,6 +262,22 @@ impl CodexLogSnapshot {
212262 tags. insert ( String :: from ( "reason" ) , r. to_string ( ) ) ;
213263 }
214264
265+ let reserved = [
266+ "thread_id" ,
267+ "classification" ,
268+ "cli_version" ,
269+ "session_source" ,
270+ "reason" ,
271+ ] ;
272+ for ( key, value) in & self . tags {
273+ if reserved. contains ( & key. as_str ( ) ) {
274+ continue ;
275+ }
276+ if let Entry :: Vacant ( entry) = tags. entry ( key. clone ( ) ) {
277+ entry. insert ( value. clone ( ) ) ;
278+ }
279+ }
280+
215281 let level = match classification {
216282 "bug" | "bad_result" => Level :: Error ,
217283 _ => Level :: Info ,
@@ -280,9 +346,80 @@ fn display_classification(classification: &str) -> String {
280346 }
281347}
282348
349+ #[ derive( Clone ) ]
350+ struct FeedbackMetadataLayer {
351+ inner : Arc < FeedbackInner > ,
352+ }
353+
354+ impl < S > Layer < S > for FeedbackMetadataLayer
355+ where
356+ S : tracing:: Subscriber + for < ' a > LookupSpan < ' a > ,
357+ {
358+ fn on_event ( & self , event : & Event < ' _ > , _ctx : tracing_subscriber:: layer:: Context < ' _ , S > ) {
359+ // This layer is filtered by `Targets`, but keep the guard anyway in case it is used without
360+ // the filter.
361+ if event. metadata ( ) . target ( ) != FEEDBACK_TAGS_TARGET {
362+ return ;
363+ }
364+
365+ let mut visitor = FeedbackTagsVisitor :: default ( ) ;
366+ event. record ( & mut visitor) ;
367+ if visitor. tags . is_empty ( ) {
368+ return ;
369+ }
370+
371+ let mut guard = self . inner . tags . lock ( ) . expect ( "mutex poisoned" ) ;
372+ for ( key, value) in visitor. tags {
373+ if guard. len ( ) >= MAX_FEEDBACK_TAGS && !guard. contains_key ( & key) {
374+ continue ;
375+ }
376+ guard. insert ( key, value) ;
377+ }
378+ }
379+ }
380+
381+ #[ derive( Default ) ]
382+ struct FeedbackTagsVisitor {
383+ tags : BTreeMap < String , String > ,
384+ }
385+
386+ impl Visit for FeedbackTagsVisitor {
387+ fn record_i64 ( & mut self , field : & tracing:: field:: Field , value : i64 ) {
388+ self . tags
389+ . insert ( field. name ( ) . to_string ( ) , value. to_string ( ) ) ;
390+ }
391+
392+ fn record_u64 ( & mut self , field : & tracing:: field:: Field , value : u64 ) {
393+ self . tags
394+ . insert ( field. name ( ) . to_string ( ) , value. to_string ( ) ) ;
395+ }
396+
397+ fn record_bool ( & mut self , field : & tracing:: field:: Field , value : bool ) {
398+ self . tags
399+ . insert ( field. name ( ) . to_string ( ) , value. to_string ( ) ) ;
400+ }
401+
402+ fn record_f64 ( & mut self , field : & tracing:: field:: Field , value : f64 ) {
403+ self . tags
404+ . insert ( field. name ( ) . to_string ( ) , value. to_string ( ) ) ;
405+ }
406+
407+ fn record_str ( & mut self , field : & tracing:: field:: Field , value : & str ) {
408+ self . tags
409+ . insert ( field. name ( ) . to_string ( ) , value. to_string ( ) ) ;
410+ }
411+
412+ fn record_debug ( & mut self , field : & tracing:: field:: Field , value : & dyn std:: fmt:: Debug ) {
413+ self . tags
414+ . insert ( field. name ( ) . to_string ( ) , format ! ( "{value:?}" ) ) ;
415+ }
416+ }
417+
283418#[ cfg( test) ]
284419mod tests {
285420 use super :: * ;
421+ use tracing_subscriber:: layer:: SubscriberExt ;
422+ use tracing_subscriber:: util:: SubscriberInitExt ;
286423
287424 #[ test]
288425 fn ring_buffer_drops_front_when_full ( ) {
@@ -296,4 +433,18 @@ mod tests {
296433 // Capacity 8: after writing 10 bytes, we should keep the last 8.
297434 pretty_assertions:: assert_eq!( std:: str :: from_utf8( snap. as_bytes( ) ) . unwrap( ) , "cdefghij" ) ;
298435 }
436+
437+ #[ test]
438+ fn metadata_layer_records_tags_from_feedback_target ( ) {
439+ let fb = CodexFeedback :: new ( ) ;
440+ let _guard = tracing_subscriber:: registry ( )
441+ . with ( fb. metadata_layer ( ) )
442+ . set_default ( ) ;
443+
444+ tracing:: info!( target: FEEDBACK_TAGS_TARGET , model = "gpt-5" , cached = true , "tags" ) ;
445+
446+ let snap = fb. snapshot ( None ) ;
447+ pretty_assertions:: assert_eq!( snap. tags. get( "model" ) . map( String :: as_str) , Some ( "gpt-5" ) ) ;
448+ pretty_assertions:: assert_eq!( snap. tags. get( "cached" ) . map( String :: as_str) , Some ( "true" ) ) ;
449+ }
299450}
0 commit comments