From ea3580fe2ea54e7d6110d3f19c4c97cb0955d4a9 Mon Sep 17 00:00:00 2001 From: whit3rabbit Date: Sat, 28 Mar 2026 18:29:05 -0500 Subject: [PATCH] fix: retain millisecond precision in Langfuse start-time calculation iso8601_to_epoch returned seconds, losing sub-second precision. The start_time was always off by up to 999ms. New iso8601_to_epoch_ms parses the fractional-seconds component (.SSS or .SSSSSS) and returns epoch milliseconds directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/proxy/src/integrations/langfuse.rs | 55 +++++++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/proxy/src/integrations/langfuse.rs b/crates/proxy/src/integrations/langfuse.rs index 5e1a291..b3c9f75 100644 --- a/crates/proxy/src/integrations/langfuse.rs +++ b/crates/proxy/src/integrations/langfuse.rs @@ -78,10 +78,8 @@ impl LangfuseClient { /// Build the Langfuse batch ingestion payload for a single generation. pub(crate) fn build_generation_payload(entry: &RequestLogEntry) -> serde_json::Value { let end_time = &entry.timestamp; - let start_time = iso8601_to_epoch(end_time) - .map(|epoch_secs| { - // Compute start in milliseconds for sub-second precision. - let end_ms = epoch_secs.saturating_mul(1000); + let start_time = iso8601_to_epoch_ms(end_time) + .map(|end_ms| { let start_ms = end_ms.saturating_sub(entry.latency_ms); crate::admin::db::epoch_to_iso8601_ms(start_ms) }) @@ -186,6 +184,33 @@ pub(crate) fn iso8601_to_epoch(s: &str) -> Option { u64::try_from(total).ok() } +/// Parse ISO 8601 UTC timestamp to Unix epoch milliseconds, retaining sub-second precision. +/// Handles "2026-03-27T10:15:30Z" (returns seconds * 1000) and +/// "2026-03-27T10:15:30.750Z" (retains the fractional ms component). +pub(crate) fn iso8601_to_epoch_ms(s: &str) -> Option { + let epoch_secs = iso8601_to_epoch(s)?; + let base_ms = epoch_secs.saturating_mul(1000); + + // Look for fractional seconds: '.' at position 19 (after "...SS."). + if s.len() > 20 && s.as_bytes().get(19) == Some(&b'.') { + let frac_start = 20; + let frac_end = s[frac_start..] + .find(|c: char| !c.is_ascii_digit()) + .map(|i| frac_start + i) + .unwrap_or(s.len()); + let frac_str = &s[frac_start..frac_end]; + if !frac_str.is_empty() { + let ms_digits = match frac_str.len() { + 1 => frac_str.parse::().ok()?.saturating_mul(100), + 2 => frac_str.parse::().ok()?.saturating_mul(10), + _ => frac_str[..3].parse::().ok()?, + }; + return Some(base_ms + ms_digits); + } + } + Some(base_ms) +} + /// Days from 1970-01-01 to the given date (may be negative for dates before epoch). /// Algorithm: http://howardhinnant.github.io/date_algorithms.html fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { @@ -301,6 +326,28 @@ mod tests { assert!(iso8601_to_epoch("").is_none()); } + #[test] + fn iso8601_to_epoch_ms_retains_milliseconds() { + let ms = iso8601_to_epoch_ms("2026-03-27T10:15:30.750Z").unwrap(); + let base_ms = iso8601_to_epoch("2026-03-27T10:15:30Z").unwrap() * 1000; + assert_eq!(ms, base_ms + 750); + } + + #[test] + fn iso8601_to_epoch_ms_no_fractional() { + let ms = iso8601_to_epoch_ms("2026-03-27T10:15:30Z").unwrap(); + let secs_ms = iso8601_to_epoch("2026-03-27T10:15:30Z").unwrap() * 1000; + assert_eq!(ms, secs_ms); + } + + #[test] + fn iso8601_to_epoch_ms_microsecond_input() { + // 6-digit fractional: should truncate to ms + let ms = iso8601_to_epoch_ms("2026-03-27T10:15:30.123456Z").unwrap(); + let base_ms = iso8601_to_epoch("2026-03-27T10:15:30Z").unwrap() * 1000; + assert_eq!(ms, base_ms + 123); + } + #[test] fn build_payload_structure() { let entry = RequestLogEntry {