@@ -45,7 +45,9 @@ const LOG_CONTEXT_ANCHORED_DUPLICATE: &str = "first";
4545#[ serde( rename_all = "camelCase" ) ]
4646pub struct LogContextRequest {
4747 pub dataset : String ,
48- pub context_window : String ,
48+ pub context_window : Option < String > ,
49+ pub context_start_time : Option < String > ,
50+ pub context_end_time : Option < String > ,
4951 pub p_timestamp : String ,
5052 pub log : Option < String > ,
5153 pub body : Option < String > ,
@@ -145,8 +147,13 @@ pub async fn query_context(
145147 let page_size = normalize_log_context_page_size ( context_request. page_size ) ?;
146148 span. record ( "page_size" , page_size) ;
147149 let anchor_timestamp = parse_log_context_timestamp ( & context_request. p_timestamp ) ?;
148- let ( context_start_time, context_end_time) =
149- log_context_bounds ( anchor_timestamp, & context_request. context_window ) ?;
150+ let ( context_start_time, context_end_time) = resolve_log_context_bounds (
151+ anchor_timestamp,
152+ context_request. context_window . as_deref ( ) ,
153+ context_request. context_start_time . as_deref ( ) ,
154+ context_request. context_end_time . as_deref ( ) ,
155+ ) ?;
156+ validate_log_context_anchor_in_bounds ( anchor_timestamp, context_start_time, context_end_time) ?;
150157 debug ! (
151158 page_size,
152159 anchor_timestamp = %anchor_timestamp,
@@ -290,17 +297,23 @@ fn normalize_log_context_page_size(page_size: Option<u64>) -> Result<u64, QueryE
290297}
291298
292299fn parse_log_context_timestamp ( raw : & str ) -> Result < DateTime < Utc > , QueryError > {
300+ parse_log_context_time_field ( raw, "pTimestamp" )
301+ }
302+
303+ fn parse_log_context_time_field ( raw : & str , field_name : & str ) -> Result < DateTime < Utc > , QueryError > {
293304 let raw = raw. trim ( ) ;
294305 let timestamp = DateTime :: parse_from_rfc3339 ( raw)
295306 . map ( |timestamp| timestamp. with_timezone ( & Utc ) )
296307 . or_else ( |rfc3339_err| {
297308 parse_log_context_naive_utc_timestamp ( raw)
298309 . map ( |timestamp| DateTime :: from_naive_utc_and_offset ( timestamp, Utc ) )
299- . map_err ( |_| QueryError :: CustomError ( format ! ( "Invalid pTimestamp: {rfc3339_err}" ) ) )
310+ . map_err ( |_| {
311+ QueryError :: CustomError ( format ! ( "Invalid {field_name}: {rfc3339_err}" ) )
312+ } )
300313 } ) ?;
301314
302315 DateTime :: from_timestamp_millis ( timestamp. timestamp_millis ( ) ) . ok_or_else ( || {
303- QueryError :: CustomError ( "pTimestamp is outside the supported range". to_string ( ) )
316+ QueryError :: CustomError ( format ! ( "{field_name} is outside the supported range") )
304317 } )
305318}
306319
@@ -309,7 +322,33 @@ fn parse_log_context_naive_utc_timestamp(raw: &str) -> Result<NaiveDateTime, chr
309322 . or_else ( |_| NaiveDateTime :: parse_from_str ( raw, "%Y-%m-%d %H:%M:%S%.f" ) )
310323}
311324
312- fn log_context_bounds (
325+ fn resolve_log_context_bounds (
326+ anchor_timestamp : DateTime < Utc > ,
327+ context_window : Option < & str > ,
328+ context_start_time : Option < & str > ,
329+ context_end_time : Option < & str > ,
330+ ) -> Result < ( DateTime < Utc > , DateTime < Utc > ) , QueryError > {
331+ match ( context_window, context_start_time, context_end_time) {
332+ ( Some ( _) , Some ( _) , _) | ( Some ( _) , _, Some ( _) ) => Err ( QueryError :: CustomError (
333+ "Request must include either contextWindow or contextStartTime/contextEndTime, not both"
334+ . to_string ( ) ,
335+ ) ) ,
336+ ( Some ( context_window) , None , None ) => {
337+ log_context_window_bounds ( anchor_timestamp, context_window)
338+ }
339+ ( None , Some ( context_start_time) , Some ( context_end_time) ) => {
340+ log_context_explicit_bounds ( context_start_time, context_end_time)
341+ }
342+ ( None , Some ( _) , None ) | ( None , None , Some ( _) ) => Err ( QueryError :: CustomError (
343+ "contextStartTime and contextEndTime must be provided together" . to_string ( ) ,
344+ ) ) ,
345+ ( None , None , None ) => Err ( QueryError :: CustomError (
346+ "Request must include either contextWindow or contextStartTime/contextEndTime" . to_string ( ) ,
347+ ) ) ,
348+ }
349+ }
350+
351+ fn log_context_window_bounds (
313352 anchor_timestamp : DateTime < Utc > ,
314353 context_window : & str ,
315354) -> Result < ( DateTime < Utc > , DateTime < Utc > ) , QueryError > {
@@ -333,6 +372,37 @@ fn log_context_bounds(
333372 Ok ( ( start, end) )
334373}
335374
375+ fn log_context_explicit_bounds (
376+ context_start_time : & str ,
377+ context_end_time : & str ,
378+ ) -> Result < ( DateTime < Utc > , DateTime < Utc > ) , QueryError > {
379+ let start = parse_log_context_time_field ( context_start_time, "contextStartTime" ) ?;
380+ let end = parse_log_context_time_field ( context_end_time, "contextEndTime" ) ?;
381+
382+ if start >= end {
383+ return Err ( QueryError :: CustomError (
384+ "contextStartTime must be before contextEndTime" . to_string ( ) ,
385+ ) ) ;
386+ }
387+
388+ Ok ( ( start, end) )
389+ }
390+
391+ fn validate_log_context_anchor_in_bounds (
392+ anchor_timestamp : DateTime < Utc > ,
393+ context_start_time : DateTime < Utc > ,
394+ context_end_time : DateTime < Utc > ,
395+ ) -> Result < ( ) , QueryError > {
396+ if anchor_timestamp >= context_start_time && anchor_timestamp < context_end_time {
397+ return Ok ( ( ) ) ;
398+ }
399+
400+ Err ( QueryError :: CustomError (
401+ "pTimestamp must be greater than or equal to contextStartTime and less than contextEndTime"
402+ . to_string ( ) ,
403+ ) )
404+ }
405+
336406fn normalize_log_context_match_fields (
337407 log : & Option < String > ,
338408 body : & Option < String > ,
@@ -942,7 +1012,7 @@ fn log_context_cursor_from_record(
9421012}
9431013
9441014fn format_log_context_api_time ( timestamp : DateTime < Utc > ) -> String {
945- timestamp. to_rfc3339_opts ( SecondsFormat :: Secs , true )
1015+ timestamp. to_rfc3339_opts ( SecondsFormat :: AutoSi , true )
9461016}
9471017
9481018fn timestamp_sql_literal ( timestamp : DateTime < Utc > ) -> String {
@@ -1023,16 +1093,93 @@ mod tests {
10231093 }
10241094
10251095 #[ test]
1026- fn log_context_bounds_apply_window_and_truncate_to_minute ( ) {
1027- let ( start, end) = log_context_bounds ( anchor_timestamp ( ) , "1m" ) . unwrap ( ) ;
1096+ fn log_context_explicit_bounds_accept_start_and_end_times ( ) {
1097+ let ( start, end) =
1098+ log_context_explicit_bounds ( "2026-06-17T10:14:00Z" , "2026-06-17T10:16:00Z" ) . unwrap ( ) ;
10281099 assert_eq ! ( format_log_context_api_time( start) , "2026-06-17T10:14:00Z" ) ;
10291100 assert_eq ! ( format_log_context_api_time( end) , "2026-06-17T10:16:00Z" ) ;
10301101
1031- let ( start, end) = log_context_bounds ( anchor_timestamp ( ) , "5s" ) . unwrap ( ) ;
1102+ let ( start, end) =
1103+ log_context_explicit_bounds ( "2026-06-17T10:15:42.100Z" , "2026-06-17T10:15:42.900Z" )
1104+ . unwrap ( ) ;
1105+ assert_eq ! (
1106+ format_log_context_api_time( start) ,
1107+ "2026-06-17T10:15:42.100Z"
1108+ ) ;
1109+ assert_eq ! ( format_log_context_api_time( end) , "2026-06-17T10:15:42.900Z" ) ;
1110+
1111+ assert ! (
1112+ log_context_explicit_bounds( "2026-06-17T10:16:00Z" , "2026-06-17T10:16:00Z" ) . is_err( )
1113+ ) ;
1114+ assert ! (
1115+ log_context_explicit_bounds( "2026-06-17T10:17:00Z" , "2026-06-17T10:16:00Z" ) . is_err( )
1116+ ) ;
1117+ }
1118+
1119+ #[ test]
1120+ fn log_context_window_bounds_apply_window_and_truncate_to_minute ( ) {
1121+ let ( start, end) = log_context_window_bounds ( anchor_timestamp ( ) , "1m" ) . unwrap ( ) ;
1122+ assert_eq ! ( format_log_context_api_time( start) , "2026-06-17T10:14:00Z" ) ;
1123+ assert_eq ! ( format_log_context_api_time( end) , "2026-06-17T10:16:00Z" ) ;
1124+
1125+ let ( start, end) = log_context_window_bounds ( anchor_timestamp ( ) , "5s" ) . unwrap ( ) ;
10321126 assert_eq ! ( format_log_context_api_time( start) , "2026-06-17T10:15:00Z" ) ;
10331127 assert_eq ! ( format_log_context_api_time( end) , "2026-06-17T10:16:00Z" ) ;
10341128 }
10351129
1130+ #[ test]
1131+ fn log_context_bounds_resolver_accepts_one_mode_only ( ) {
1132+ let anchor = anchor_timestamp ( ) ;
1133+ assert ! ( resolve_log_context_bounds( anchor, Some ( "1m" ) , None , None ) . is_ok( ) ) ;
1134+ assert ! (
1135+ resolve_log_context_bounds(
1136+ anchor,
1137+ None ,
1138+ Some ( "2026-06-17T10:14:00Z" ) ,
1139+ Some ( "2026-06-17T10:16:00Z" ) ,
1140+ )
1141+ . is_ok( )
1142+ ) ;
1143+ assert ! (
1144+ resolve_log_context_bounds( anchor, Some ( "1m" ) , Some ( "2026-06-17T10:14:00Z" ) , None , )
1145+ . is_err( )
1146+ ) ;
1147+ assert ! (
1148+ resolve_log_context_bounds( anchor, None , Some ( "2026-06-17T10:14:00Z" ) , None ) . is_err( )
1149+ ) ;
1150+ assert ! ( resolve_log_context_bounds( anchor, None , None , None ) . is_err( ) ) ;
1151+ }
1152+
1153+ #[ test]
1154+ fn log_context_anchor_must_be_inside_context_bounds ( ) {
1155+ let ( start, end) =
1156+ log_context_explicit_bounds ( "2026-06-17T10:14:00Z" , "2026-06-17T10:16:00Z" ) . unwrap ( ) ;
1157+
1158+ validate_log_context_anchor_in_bounds (
1159+ parse_log_context_timestamp ( "2026-06-17T10:14:00Z" ) . unwrap ( ) ,
1160+ start,
1161+ end,
1162+ )
1163+ . unwrap ( ) ;
1164+ validate_log_context_anchor_in_bounds ( anchor_timestamp ( ) , start, end) . unwrap ( ) ;
1165+ assert ! (
1166+ validate_log_context_anchor_in_bounds(
1167+ parse_log_context_timestamp( "2026-06-17T10:13:59.999Z" ) . unwrap( ) ,
1168+ start,
1169+ end,
1170+ )
1171+ . is_err( )
1172+ ) ;
1173+ assert ! (
1174+ validate_log_context_anchor_in_bounds(
1175+ parse_log_context_timestamp( "2026-06-17T10:16:00Z" ) . unwrap( ) ,
1176+ start,
1177+ end,
1178+ )
1179+ . is_err( )
1180+ ) ;
1181+ }
1182+
10361183 #[ test]
10371184 fn log_context_match_fields_accept_exactly_one_anchor_field ( ) {
10381185 let schema = schema_with ( & [ DEFAULT_TIMESTAMP_KEY , "body" , "log" , "message" ] ) ;
@@ -1130,7 +1277,8 @@ mod tests {
11301277 #[ test]
11311278 fn log_context_anchor_count_sql_only_counts_anchor_duplicates ( ) {
11321279 let anchor = anchor_timestamp ( ) ;
1133- let ( start, end) = log_context_bounds ( anchor, "1m" ) . unwrap ( ) ;
1280+ let ( start, end) =
1281+ log_context_explicit_bounds ( "2026-06-17T10:14:00Z" , "2026-06-17T10:16:00Z" ) . unwrap ( ) ;
11341282 let match_fields = vec ! [ LogContextMatchField {
11351283 name: "message" . to_string( ) ,
11361284 value: "alpha" . to_string( ) ,
@@ -1150,7 +1298,8 @@ mod tests {
11501298 #[ test]
11511299 fn log_context_cursor_sql_builds_previous_and_next_pages ( ) {
11521300 let anchor = anchor_timestamp ( ) ;
1153- let ( start, end) = log_context_bounds ( anchor, "1m" ) . unwrap ( ) ;
1301+ let ( start, end) =
1302+ log_context_explicit_bounds ( "2026-06-17T10:14:00Z" , "2026-06-17T10:16:00Z" ) . unwrap ( ) ;
11541303 let match_fields = vec ! [ LogContextMatchField {
11551304 name: "message" . to_string( ) ,
11561305 value: "alpha" . to_string( ) ,
0 commit comments