From c9af42a9777b6e535d3aa59f75c239f03e0243a9 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Mon, 12 Oct 2026 14:36:50 -0700 Subject: [PATCH] feat(server): record the turn ID in the routing log Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-server/src/lib.rs | 15 ++++++++++----- crates/switchyard-server/src/routing_log.rs | 5 +++++ crates/switchyard-server/tests/server.rs | 5 +++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/switchyard-server/src/lib.rs b/crates/switchyard-server/src/lib.rs index f619c0152f..faf73e116a 100644 --- a/crates/switchyard-server/src/lib.rs +++ b/crates/switchyard-server/src/lib.rs @@ -1774,16 +1774,17 @@ targets = ["only"] ); } - /// A successful judge call lands in the per-session routing snapshot under its - /// model id with the classifier tier, while routed calls stay off the observer's - /// log path — they are logged with terminal usage when the served response is - /// observed, so an append here would double count them. + /// The observer logs judge calls with their model IDs, classifier tier, and + /// recorded session and turn IDs. The served response logs routed calls with + /// their final usage, so logging them here would count each routed call twice. #[test] - fn stats_observer_logs_judge_calls_to_the_routing_log() { + fn stats_observer_logs_judge_calls_to_the_routing_log() -> Result<(), Box> + { let dir = tempfile::tempdir().expect("temp dir"); let log = SharedRoutingLog::new(dir.path().join("routing.jsonl")).expect("routing log"); let mut headers = HeaderMap::new(); headers.insert("proxy_x_session_id", "session-1".parse().expect("header")); + headers.insert("x-switchyard-turn-id", "turn-1".parse()?); let metadata = metadata_from_headers(headers); let context = routing_log::RoutingLogContext::from_metadata(&metadata); let observer = stats_observer(StatsAccumulator::default(), Some((log.clone(), context))); @@ -1817,6 +1818,10 @@ targets = ["only"] assert_eq!(snapshot["models"]["judge-model"]["prompt_tokens"], 100); assert_eq!(snapshot["models"]["judge-model"]["completion_tokens"], 7); assert!(snapshot["models"].get("routed-model").is_none()); + let record: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&log.path)?)?; + assert_eq!(record["session_id"], "session-1"); + assert_eq!(record["turn_id"], "turn-1"); + Ok(()) } #[derive(Clone)] diff --git a/crates/switchyard-server/src/routing_log.rs b/crates/switchyard-server/src/routing_log.rs index cd1a21f737..674db1b6c5 100644 --- a/crates/switchyard-server/src/routing_log.rs +++ b/crates/switchyard-server/src/routing_log.rs @@ -58,6 +58,7 @@ impl RoutingLog { task: context.task.map(Cow::Owned), trial_id: context.trial_id.map(Cow::Owned), session_id: context.session_id.map(Cow::Owned), + turn_id: context.turn_id.map(Cow::Owned), model: model.into(), tier: tier.unwrap_or("").into(), prompt_tokens: usage.prompt_tokens, @@ -108,6 +109,7 @@ pub(crate) struct RoutingLogContext { task: Option, trial_id: Option, session_id: Option, + turn_id: Option, } impl RoutingLogContext { @@ -126,6 +128,7 @@ impl RoutingLogContext { trial_id: headers .and_then(|headers| nonempty_header(headers, TRIAL_ID_HEADER)) .map(str::to_string), + turn_id: metadata.turn_id.clone(), session_id: metadata.session_id.clone().or_else(|| { headers .and_then(|headers| nonempty_header(headers, LEGACY_SESSION_ID_HEADER)) @@ -159,6 +162,8 @@ struct RoutingRecord<'a> { trial_id: Option>, #[serde(borrow)] session_id: Option>, + #[serde(borrow)] + turn_id: Option>, model: Cow<'a, str>, tier: Cow<'a, str>, prompt_tokens: u64, diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index 8f07935066..d075fb2b4a 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -4136,6 +4136,7 @@ async fn routing_log_prefers_canonical_and_preserves_legacy_fallback() -> TestRe .uri("/v1/chat/completions") .header("content-type", "application/json") .header("x-switchyard-session-id", "canonical-session") + .header("x-switchyard-turn-id", "canonical-turn") .header("proxy_x_session_id", "legacy-session") .header("x-switchyard-origin", r#"custom-agent/"quoted"\path"#) .body(Body::from(serde_json::to_vec(&json!({ @@ -4195,8 +4196,10 @@ async fn routing_log_prefers_canonical_and_preserves_legacy_fallback() -> TestRe let first: Value = serde_json::from_str(records.lines().next().ok_or("routing log was empty")?)?; assert_eq!(first["session_id"], "canonical-session"); + assert_eq!(first["turn_id"], "canonical-turn"); assert_eq!(first["origin"], r#"custom-agent/"quoted"\path"#); let second: Value = serde_json::from_str(records.lines().nth(1).ok_or("missing record")?)?; + assert_eq!(second["turn_id"], Value::Null); assert_eq!(second.get("origin"), Some(&Value::Null)); assert!( first["ts"] @@ -4304,6 +4307,7 @@ async fn routing_log_keeps_the_canonical_session_id_until_a_stream_drains() -> T })), &[ ("x-switchyard-session-id", "streaming-session"), + ("x-switchyard-turn-id", "streaming-turn"), ("x-switchyard-origin", "codex-cli"), ], ) @@ -4328,6 +4332,7 @@ async fn routing_log_keeps_the_canonical_session_id_until_a_stream_drains() -> T let record: Value = serde_json::from_str(&std::fs::read_to_string(log_path)?)?; assert_eq!(record["route_id"], ROUTE_MODEL); + assert_eq!(record["turn_id"], "streaming-turn"); assert_eq!(record["algorithm"], "random"); assert_eq!(record["origin"], "codex-cli"); Ok(())