@@ -198,6 +198,41 @@ pub enum TelemetryEvent {
198198 #[ serde( default , skip_serializing_if = "Map::is_empty" ) ]
199199 attributes : Map < String , Value > ,
200200 } ,
201+ #[ serde( rename = "worker.init" ) ]
202+ WorkerInit {
203+ session_id : String ,
204+ worker_id : String ,
205+ cwd : String ,
206+ boot_duration_ms : u64 ,
207+ #[ serde( default , skip_serializing_if = "Map::is_empty" ) ]
208+ attributes : Map < String , Value > ,
209+ } ,
210+ #[ serde( rename = "worker.done" ) ]
211+ WorkerDone {
212+ session_id : String ,
213+ worker_id : String ,
214+ status : String ,
215+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
216+ boot_duration_ms : Option < u64 > ,
217+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
218+ error : Option < String > ,
219+ #[ serde( default , skip_serializing_if = "Map::is_empty" ) ]
220+ attributes : Map < String , Value > ,
221+ } ,
222+ #[ serde( rename = "lane.open" ) ]
223+ LaneOpen {
224+ session_id : String ,
225+ lane_id : String ,
226+ #[ serde( default , skip_serializing_if = "Map::is_empty" ) ]
227+ attributes : Map < String , Value > ,
228+ } ,
229+ #[ serde( rename = "lane.close" ) ]
230+ LaneClose {
231+ session_id : String ,
232+ lane_id : String ,
233+ #[ serde( default , skip_serializing_if = "Map::is_empty" ) ]
234+ attributes : Map < String , Value > ,
235+ } ,
201236 Analytics ( AnalyticsEvent ) ,
202237 SessionTrace ( SessionTraceRecord ) ,
203238}
@@ -394,6 +429,80 @@ impl SessionTracer {
394429 self . record ( "http_request_failed" , trace_attributes) ;
395430 }
396431
432+ pub fn record_worker_init (
433+ & self ,
434+ worker_id : impl Into < String > ,
435+ cwd : impl Into < String > ,
436+ boot_duration_ms : u64 ,
437+ attributes : Map < String , Value > ,
438+ ) {
439+ let worker_id = worker_id. into ( ) ;
440+ let cwd = cwd. into ( ) ;
441+ self . sink . record ( TelemetryEvent :: WorkerInit {
442+ session_id : self . session_id . clone ( ) ,
443+ worker_id : worker_id. clone ( ) ,
444+ cwd : cwd. clone ( ) ,
445+ boot_duration_ms,
446+ attributes : attributes. clone ( ) ,
447+ } ) ;
448+ self . record (
449+ "worker.init" ,
450+ merge_worker_trace_fields (
451+ worker_id,
452+ Some ( cwd) ,
453+ Some ( boot_duration_ms) ,
454+ None ,
455+ attributes,
456+ ) ,
457+ ) ;
458+ }
459+
460+ pub fn record_worker_done (
461+ & self ,
462+ worker_id : impl Into < String > ,
463+ status : impl Into < String > ,
464+ boot_duration_ms : Option < u64 > ,
465+ error : Option < String > ,
466+ attributes : Map < String , Value > ,
467+ ) {
468+ let worker_id = worker_id. into ( ) ;
469+ let status = status. into ( ) ;
470+ self . sink . record ( TelemetryEvent :: WorkerDone {
471+ session_id : self . session_id . clone ( ) ,
472+ worker_id : worker_id. clone ( ) ,
473+ status : status. clone ( ) ,
474+ boot_duration_ms,
475+ error : error. clone ( ) ,
476+ attributes : attributes. clone ( ) ,
477+ } ) ;
478+ let mut trace_attributes =
479+ merge_worker_trace_fields ( worker_id, None , boot_duration_ms, Some ( status) , attributes) ;
480+ if let Some ( error) = error {
481+ trace_attributes. insert ( "error" . to_string ( ) , Value :: String ( error) ) ;
482+ }
483+ self . record ( "worker.done" , trace_attributes) ;
484+ }
485+
486+ pub fn record_lane_open ( & self , lane_id : impl Into < String > , attributes : Map < String , Value > ) {
487+ let lane_id = lane_id. into ( ) ;
488+ self . sink . record ( TelemetryEvent :: LaneOpen {
489+ session_id : self . session_id . clone ( ) ,
490+ lane_id : lane_id. clone ( ) ,
491+ attributes : attributes. clone ( ) ,
492+ } ) ;
493+ self . record ( "lane.open" , merge_lane_trace_fields ( lane_id, attributes) ) ;
494+ }
495+
496+ pub fn record_lane_close ( & self , lane_id : impl Into < String > , attributes : Map < String , Value > ) {
497+ let lane_id = lane_id. into ( ) ;
498+ self . sink . record ( TelemetryEvent :: LaneClose {
499+ session_id : self . session_id . clone ( ) ,
500+ lane_id : lane_id. clone ( ) ,
501+ attributes : attributes. clone ( ) ,
502+ } ) ;
503+ self . record ( "lane.close" , merge_lane_trace_fields ( lane_id, attributes) ) ;
504+ }
505+
397506 pub fn record_analytics ( & self , event : AnalyticsEvent ) {
398507 let mut attributes = event. properties . clone ( ) ;
399508 attributes. insert (
@@ -418,6 +527,37 @@ fn merge_trace_fields(
418527 attributes
419528}
420529
530+ fn merge_lane_trace_fields (
531+ lane_id : String ,
532+ mut attributes : Map < String , Value > ,
533+ ) -> Map < String , Value > {
534+ attributes. insert ( "lane_id" . to_string ( ) , Value :: String ( lane_id) ) ;
535+ attributes
536+ }
537+
538+ fn merge_worker_trace_fields (
539+ worker_id : String ,
540+ cwd : Option < String > ,
541+ boot_duration_ms : Option < u64 > ,
542+ status : Option < String > ,
543+ mut attributes : Map < String , Value > ,
544+ ) -> Map < String , Value > {
545+ attributes. insert ( "worker_id" . to_string ( ) , Value :: String ( worker_id) ) ;
546+ if let Some ( cwd) = cwd {
547+ attributes. insert ( "cwd" . to_string ( ) , Value :: String ( cwd) ) ;
548+ }
549+ if let Some ( boot_duration_ms) = boot_duration_ms {
550+ attributes. insert (
551+ "boot_duration_ms" . to_string ( ) ,
552+ Value :: from ( boot_duration_ms) ,
553+ ) ;
554+ }
555+ if let Some ( status) = status {
556+ attributes. insert ( "status" . to_string ( ) , Value :: String ( status) ) ;
557+ }
558+ attributes
559+ }
560+
421561fn current_timestamp_ms ( ) -> u64 {
422562 SystemTime :: now ( )
423563 . duration_since ( UNIX_EPOCH )
@@ -477,6 +617,12 @@ mod tests {
477617 let sink = Arc :: new ( MemoryTelemetrySink :: default ( ) ) ;
478618 let tracer = SessionTracer :: new ( "session-123" , sink. clone ( ) ) ;
479619
620+ let mut lane_open_attributes = Map :: new ( ) ;
621+ lane_open_attributes. insert ( "worker" . to_string ( ) , Value :: String ( "worker-1" . to_string ( ) ) ) ;
622+ tracer. record_lane_open ( "lane-42" , lane_open_attributes) ;
623+ let mut lane_close_attributes = Map :: new ( ) ;
624+ lane_close_attributes. insert ( "status" . to_string ( ) , Value :: String ( "completed" . to_string ( ) ) ) ;
625+ tracer. record_lane_close ( "lane-42" , lane_close_attributes) ;
480626 tracer. record_http_request_started ( 1 , "POST" , "/v1/messages" , Map :: new ( ) ) ;
481627 tracer. record_analytics (
482628 AnalyticsEvent :: new ( "cli" , "prompt_sent" )
@@ -486,6 +632,32 @@ mod tests {
486632 let events = sink. events ( ) ;
487633 assert ! ( matches!(
488634 & events[ 0 ] ,
635+ TelemetryEvent :: LaneOpen {
636+ session_id,
637+ lane_id,
638+ ..
639+ } if session_id == "session-123" && lane_id == "lane-42"
640+ ) ) ;
641+ assert ! ( matches!(
642+ & events[ 1 ] ,
643+ TelemetryEvent :: SessionTrace ( SessionTraceRecord { sequence: 0 , name, attributes, .. } )
644+ if name == "lane.open" && attributes. get( "lane_id" ) == Some ( & Value :: String ( "lane-42" . to_string( ) ) )
645+ ) ) ;
646+ assert ! ( matches!(
647+ & events[ 2 ] ,
648+ TelemetryEvent :: LaneClose {
649+ session_id,
650+ lane_id,
651+ ..
652+ } if session_id == "session-123" && lane_id == "lane-42"
653+ ) ) ;
654+ assert ! ( matches!(
655+ & events[ 3 ] ,
656+ TelemetryEvent :: SessionTrace ( SessionTraceRecord { sequence: 1 , name, attributes, .. } )
657+ if name == "lane.close" && attributes. get( "lane_id" ) == Some ( & Value :: String ( "lane-42" . to_string( ) ) )
658+ ) ) ;
659+ assert ! ( matches!(
660+ & events[ 4 ] ,
489661 TelemetryEvent :: HttpRequestStarted {
490662 session_id,
491663 attempt: 1 ,
@@ -495,18 +667,89 @@ mod tests {
495667 } if session_id == "session-123" && method == "POST" && path == "/v1/messages"
496668 ) ) ;
497669 assert ! ( matches!(
498- & events[ 1 ] ,
499- TelemetryEvent :: SessionTrace ( SessionTraceRecord { sequence: 0 , name, .. } )
670+ & events[ 5 ] ,
671+ TelemetryEvent :: SessionTrace ( SessionTraceRecord { sequence: 2 , name, .. } )
500672 if name == "http_request_started"
501673 ) ) ;
502- assert ! ( matches!( & events[ 2 ] , TelemetryEvent :: Analytics ( _) ) ) ;
674+ assert ! ( matches!( & events[ 6 ] , TelemetryEvent :: Analytics ( _) ) ) ;
503675 assert ! ( matches!(
504- & events[ 3 ] ,
505- TelemetryEvent :: SessionTrace ( SessionTraceRecord { sequence: 1 , name, .. } )
676+ & events[ 7 ] ,
677+ TelemetryEvent :: SessionTrace ( SessionTraceRecord { sequence: 3 , name, .. } )
506678 if name == "analytics"
507679 ) ) ;
508680 }
509681
682+ #[ test]
683+ fn session_tracer_records_worker_events_with_boot_duration ( ) {
684+ let sink = Arc :: new ( MemoryTelemetrySink :: default ( ) ) ;
685+ let tracer = SessionTracer :: new ( "session-worker" , sink. clone ( ) ) ;
686+
687+ tracer. record_worker_init ( "worker-1" , "/tmp/project" , 125 , Map :: new ( ) ) ;
688+ tracer. record_worker_done ( "worker-1" , "finished" , Some ( 125 ) , None , Map :: new ( ) ) ;
689+
690+ let events = sink. events ( ) ;
691+ assert ! ( matches!(
692+ & events[ 0 ] ,
693+ TelemetryEvent :: WorkerInit {
694+ session_id,
695+ worker_id,
696+ cwd,
697+ boot_duration_ms: 125 ,
698+ ..
699+ } if session_id == "session-worker" && worker_id == "worker-1" && cwd == "/tmp/project"
700+ ) ) ;
701+ assert ! ( matches!(
702+ & events[ 1 ] ,
703+ TelemetryEvent :: SessionTrace ( SessionTraceRecord { sequence: 0 , name, attributes, .. } )
704+ if name == "worker.init"
705+ && attributes. get( "worker_id" ) == Some ( & Value :: String ( "worker-1" . to_string( ) ) )
706+ && attributes. get( "boot_duration_ms" ) == Some ( & Value :: from( 125 ) )
707+ ) ) ;
708+ assert ! ( matches!(
709+ & events[ 2 ] ,
710+ TelemetryEvent :: WorkerDone {
711+ session_id,
712+ worker_id,
713+ status,
714+ boot_duration_ms: Some ( 125 ) ,
715+ ..
716+ } if session_id == "session-worker" && worker_id == "worker-1" && status == "finished"
717+ ) ) ;
718+ assert ! ( matches!(
719+ & events[ 3 ] ,
720+ TelemetryEvent :: SessionTrace ( SessionTraceRecord { sequence: 1 , name, attributes, .. } )
721+ if name == "worker.done"
722+ && attributes. get( "worker_id" ) == Some ( & Value :: String ( "worker-1" . to_string( ) ) )
723+ && attributes. get( "status" ) == Some ( & Value :: String ( "finished" . to_string( ) ) )
724+ ) ) ;
725+ }
726+
727+ #[ test]
728+ fn jsonl_sink_persists_lane_events ( ) {
729+ let path = std:: env:: temp_dir ( ) . join ( format ! (
730+ "telemetry-jsonl-lane-{}.log" ,
731+ current_timestamp_ms( )
732+ ) ) ;
733+ let sink = JsonlTelemetrySink :: new ( & path) . expect ( "sink should create file" ) ;
734+
735+ sink. record ( TelemetryEvent :: LaneOpen {
736+ session_id : "session-123" . to_string ( ) ,
737+ lane_id : "lane-42" . to_string ( ) ,
738+ attributes : Map :: new ( ) ,
739+ } ) ;
740+ sink. record ( TelemetryEvent :: LaneClose {
741+ session_id : "session-123" . to_string ( ) ,
742+ lane_id : "lane-42" . to_string ( ) ,
743+ attributes : Map :: new ( ) ,
744+ } ) ;
745+
746+ let contents = std:: fs:: read_to_string ( & path) . expect ( "telemetry log should be readable" ) ;
747+ assert ! ( contents. contains( "\" type\" :\" lane.open\" " ) ) ;
748+ assert ! ( contents. contains( "\" type\" :\" lane.close\" " ) ) ;
749+
750+ let _ = std:: fs:: remove_file ( path) ;
751+ }
752+
510753 #[ test]
511754 fn jsonl_sink_persists_events ( ) {
512755 let path =
0 commit comments