Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit f0e5a9d

Browse files
committed
feat(telemetry): add lane.open and lane.close events
Add typed lane lifecycle telemetry entries and mirror them into session trace and JSONL output using the dotted lane.open/lane.close wire names. This keeps lane lifecycle data queryable without routing it through generic analytics events. Constraint: Keep telemetry crate changes backward-compatible for existing HTTP and analytics event consumers Rejected: Reuse generic analytics events for lane lifecycle | loses dedicated typed telemetry variants Rejected: Keep snake_case lane_open/lane_close wire names | does not match the requested lane.open/lane.close event names Confidence: high Scope-risk: narrow Reversibility: clean Directive: Preserve lane.open/lane.close wire names and the lane_id attribute key unless downstream consumers are migrated together Tested: cargo build --workspace; cargo test --workspace Not-tested: Runtime wiring that emits lane open/close events from higher-level crates
1 parent 476b03e commit f0e5a9d

1 file changed

Lines changed: 248 additions & 5 deletions

File tree

  • rust/crates/telemetry/src

rust/crates/telemetry/src/lib.rs

Lines changed: 248 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
421561
fn 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

Comments
 (0)