Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions src/handlers/http/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use actix_web::web::{self, Json};
use actix_web::{Either, FromRequest, HttpRequest, HttpResponse, Responder};
use arrow_array::{ArrayRef, RecordBatch, StringArray, UInt64Array};
use bytes::Bytes;
use chrono::{DateTime, Utc};
use chrono::Utc;
use datafusion::error::DataFusionError;
use datafusion::execution::context::SessionState;
use datafusion::sql::sqlparser::parser::ParserError;
Expand All @@ -55,7 +55,7 @@ use crate::rbac::Users;
use crate::response::QueryResponse;
use crate::storage::ObjectStorageError;
use crate::utils::actix::extract_session_key_from_req;
use crate::utils::time::{TimeParseError, TimeRange};
use crate::utils::time::{TimeParseError, TimeRange, parse_time_expression};
use crate::utils::{get_tenant_id_from_request, user_auth_for_datasets};

pub const TIME_ELAPSED_HEADER: &str = "p-time-elapsed";
Expand Down Expand Up @@ -658,13 +658,7 @@ fn transform_query_for_ingestor(query: &Query) -> Option<Query> {
return None;
}

let end_time: DateTime<Utc> = if query.end_time == "now" {
Utc::now()
} else {
DateTime::parse_from_rfc3339(&query.end_time)
.ok()?
.with_timezone(&Utc)
};
let end_time = parse_time_expression(&query.end_time, Utc::now()).ok()?;

let start_time = end_time - chrono::Duration::minutes(1);
// when transforming the query, the ingestors are forced to return an array of values
Expand Down
69 changes: 66 additions & 3 deletions src/handlers/http/query_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ use crate::parseable::{DEFAULT_TENANT, PARSEABLE};
use crate::rbac::Users;
use crate::utils::actix::extract_session_key_from_req;
use crate::utils::arrow::record_batches_to_json;
use crate::utils::time::truncate_to_minute;
use crate::utils::time::{
TimeExpressionError, parse_start_time_expression, parse_time_expression, truncate_to_minute,
};
use crate::utils::{get_tenant_id_from_request, user_auth_for_datasets};

const DEFAULT_LOG_CONTEXT_PAGE_SIZE: u64 = 500;
Expand Down Expand Up @@ -376,8 +378,26 @@ fn log_context_explicit_bounds(
context_start_time: &str,
context_end_time: &str,
) -> Result<(DateTime<Utc>, DateTime<Utc>), QueryError> {
let start = parse_log_context_time_field(context_start_time, "contextStartTime")?;
let end = parse_log_context_time_field(context_end_time, "contextEndTime")?;
log_context_explicit_bounds_at(context_start_time, context_end_time, Utc::now())
}

fn log_context_explicit_bounds_at(
context_start_time: &str,
context_end_time: &str,
now: DateTime<Utc>,
) -> Result<(DateTime<Utc>, DateTime<Utc>), QueryError> {
let start = parse_log_context_time_bound(
context_start_time,
"contextStartTime",
now,
parse_start_time_expression,
)?;
let end = parse_log_context_time_bound(
context_end_time,
"contextEndTime",
now,
parse_time_expression,
)?;

if start >= end {
return Err(QueryError::CustomError(
Expand All @@ -388,6 +408,29 @@ fn log_context_explicit_bounds(
Ok((start, end))
}

fn parse_log_context_time_bound<F>(
raw: &str,
field_name: &str,
now: DateTime<Utc>,
parse_expression: F,
) -> Result<DateTime<Utc>, QueryError>
where
F: FnOnce(&str, DateTime<Utc>) -> Result<DateTime<Utc>, TimeExpressionError>,
{
let timestamp = match parse_expression(raw, now) {
Ok(timestamp) => timestamp,
Err(expression_error) => {
return parse_log_context_time_field(raw, field_name).map_err(|_| {
QueryError::CustomError(format!("Invalid {field_name}: {expression_error}"))
});
}
};

DateTime::from_timestamp_millis(timestamp.timestamp_millis()).ok_or_else(|| {
QueryError::CustomError(format!("{field_name} is outside the supported range"))
})
}

fn validate_log_context_anchor_in_bounds(
anchor_timestamp: DateTime<Utc>,
context_start_time: DateTime<Utc>,
Expand Down Expand Up @@ -1116,6 +1159,26 @@ mod tests {
);
}

#[test]
fn log_context_explicit_bounds_accept_mixed_relative_and_rfc3339_times() {
let now = parse_log_context_timestamp("2026-08-26T12:00:00Z").unwrap();

let (start, end) =
log_context_explicit_bounds_at("now-15m", "2026-08-26T13:00:00Z", now).unwrap();
assert_eq!(format_log_context_api_time(start), "2026-08-26T11:45:00Z");
assert_eq!(format_log_context_api_time(end), "2026-08-26T13:00:00Z");

let (start, end) =
log_context_explicit_bounds_at("15m", "2026-08-26T13:00:00Z", now).unwrap();
assert_eq!(format_log_context_api_time(start), "2026-08-26T11:45:00Z");
assert_eq!(format_log_context_api_time(end), "2026-08-26T13:00:00Z");

let (start, end) =
log_context_explicit_bounds_at("2026-08-26T10:00:00Z", "now-2m", now).unwrap();
assert_eq!(format_log_context_api_time(start), "2026-08-26T10:00:00Z");
assert_eq!(format_log_context_api_time(end), "2026-08-26T11:58:00Z");
}

#[test]
fn log_context_window_bounds_apply_window_and_truncate_to_minute() {
let (start, end) = log_context_window_bounds(anchor_timestamp(), "1m").unwrap();
Expand Down
184 changes: 167 additions & 17 deletions src/utils/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,22 @@ pub enum TimeParseError {
OutOfRange(#[from] chrono::OutOfRangeError),
#[error("Error parsing time: {0}")]
Chrono(#[from] chrono::ParseError),
#[error("Invalid start time: {0}")]
InvalidStartTime(#[source] TimeExpressionError),
#[error("Invalid end time: {0}")]
InvalidEndTime(#[source] TimeExpressionError),
#[error("Start time cannot be greater than the end time")]
StartTimeAfterEndTime,
}

#[derive(Debug, thiserror::Error)]
pub enum TimeExpressionError {
#[error("unsupported time expression `{0}`; expected RFC 3339, `now`, or `now+/-<duration>`")]
Invalid(String),
#[error("time expression `{0}` is outside the supported range")]
OutOfRange(String),
}

#[derive(Debug, thiserror::Error)]
pub enum TimeBinError {
#[error("num_bins must be greater than 0")]
Expand Down Expand Up @@ -174,10 +186,10 @@ impl TimeRange {
/// Parses human-readable time strings into a `TimeRange` object.
///
/// # Arguments
/// - `start_time`: A string representing the start of the time range. This can either be
/// a human-readable duration (e.g., `"2 hours"`) or an RFC 3339 formatted timestamp.
/// - `end_time`: A string representing the end of the time range. This can either be
/// the keyword `"now"` (to represent the current time) or an RFC 3339 formatted timestamp.
/// - `start_time`: A human-readable duration (e.g., `"15m"`, equivalent to `"now-15m"`),
/// a relative expression, or an RFC 3339 formatted timestamp.
/// - `end_time`: `"now"`, a relative expression (e.g., `"now-2m"`), or an RFC 3339
/// formatted timestamp.
///
/// # Errors
/// - `TimeParseError::StartTimeAfterEndTime`: Returned when the parsed start time is later than the end time.
Expand All @@ -186,19 +198,24 @@ impl TimeRange {
/// # Example
/// ```ignore
/// let range = TimeRange::parse_human_time("2 hours", "now");
/// let range = TimeRange::parse_human_time("15m", "2023-01-01T15:00:00Z");
/// let range = TimeRange::parse_human_time("now-15m", "2023-01-01T15:00:00Z");
/// let range = TimeRange::parse_human_time("2023-01-01T12:00:00Z", "now-2m");
/// let range = TimeRange::parse_human_time("2023-01-01T12:00:00Z", "2023-01-01T15:00:00Z");
/// ```
pub fn parse_human_time(start_time: &str, end_time: &str) -> Result<Self, TimeParseError> {
let mut start: DateTime<Utc>;
let mut end: DateTime<Utc>;

if end_time == "now" {
end = Utc::now();
start = end - chrono::Duration::from_std(humantime::parse_duration(start_time)?)?;
} else {
start = DateTime::parse_from_rfc3339(start_time)?.into();
end = DateTime::parse_from_rfc3339(end_time)?.into();
};
Self::parse_human_time_at(start_time, end_time, Utc::now())
}

fn parse_human_time_at(
start_time: &str,
end_time: &str,
now: DateTime<Utc>,
) -> Result<Self, TimeParseError> {
let mut start = parse_start_time_expression(start_time, now)
.map_err(TimeParseError::InvalidStartTime)?;
let mut end =
parse_time_expression(end_time, now).map_err(TimeParseError::InvalidEndTime)?;

// Truncate seconds, milliseconds, and nanoseconds to zero
// to ensure that the time range is aligned to the minute
Expand Down Expand Up @@ -403,6 +420,82 @@ impl TimeRange {
}
}

/// Parses an RFC 3339 timestamp or a `now` expression against a fixed reference time.
///
/// Supported relative forms are `now`, `now-<duration>`, and `now+<duration>`.
/// Both range bounds should use the same `now` value so they resolve consistently.
pub fn parse_time_expression(
value: &str,
now: DateTime<Utc>,
) -> Result<DateTime<Utc>, TimeExpressionError> {
let value = value.trim();
if value == "now" {
return Ok(now);
}

if let Some(math) = value.strip_prefix("now") {
return parse_now_math(value, math, now);
}

DateTime::parse_from_rfc3339(value)
.map(|timestamp| timestamp.with_timezone(&Utc))
.map_err(|_| TimeExpressionError::Invalid(value.to_string()))
}

/// Parses a start-time expression. A bare duration is shorthand for that duration before `now`.
pub fn parse_start_time_expression(
value: &str,
now: DateTime<Utc>,
) -> Result<DateTime<Utc>, TimeExpressionError> {
match parse_time_expression(value, now) {
Ok(timestamp) => Ok(timestamp),
Err(TimeExpressionError::Invalid(_)) => {
let value = value.trim();
let duration = humantime::parse_duration(value)
.map_err(|_| TimeExpressionError::Invalid(value.to_string()))?;
apply_time_offset(value, now, duration, false)
}
Err(error) => Err(error),
}
}

fn parse_now_math(
expression: &str,
math: &str,
now: DateTime<Utc>,
) -> Result<DateTime<Utc>, TimeExpressionError> {
let math = math.trim_start();
let (add, duration) = match math.as_bytes().first() {
Some(b'+') => (true, math[1..].trim()),
Some(b'-') => (false, math[1..].trim()),
_ => return Err(TimeExpressionError::Invalid(expression.to_string())),
};
if duration.is_empty() {
return Err(TimeExpressionError::Invalid(expression.to_string()));
}

let duration = humantime::parse_duration(duration)
.map_err(|_| TimeExpressionError::Invalid(expression.to_string()))?;
apply_time_offset(expression, now, duration, add)
}

fn apply_time_offset(
expression: &str,
base: DateTime<Utc>,
duration: std::time::Duration,
add: bool,
) -> Result<DateTime<Utc>, TimeExpressionError> {
let duration = chrono::Duration::from_std(duration)
.map_err(|_| TimeExpressionError::OutOfRange(expression.to_string()))?;
let timestamp = if add {
base.checked_add_signed(duration)
} else {
base.checked_sub_signed(duration)
};

timestamp.ok_or_else(|| TimeExpressionError::OutOfRange(expression.to_string()))
}

pub fn truncate_to_minute(dt: DateTime<Utc>) -> DateTime<Utc> {
// Get the date and time components we want to keep
let year = dt.year();
Expand Down Expand Up @@ -517,6 +610,47 @@ mod tests {
assert_eq!(parsed.end - parsed.start, Duration::minutes(30));
}

#[test]
fn end_time_now_with_rfc3339_start_time() {
let start_time = truncate_to_minute(Utc::now() - Duration::hours(1));

let parsed = TimeRange::parse_human_time(&start_time.to_rfc3339(), "now").unwrap();

assert_eq!(parsed.start, start_time);
assert!(parsed.end <= Utc::now());
assert!(parsed.start <= parsed.end);
}

#[test]
fn mixed_relative_and_rfc3339_bounds() {
let now = Utc.with_ymd_and_hms(2026, 8, 26, 12, 0, 0).unwrap();

let relative_start =
TimeRange::parse_human_time_at("now-15m", "2026-08-26T13:00:00Z", now).unwrap();
assert_eq!(relative_start.start, now - Duration::minutes(15));
assert_eq!(
relative_start.end,
Utc.with_ymd_and_hms(2026, 8, 26, 13, 0, 0).unwrap()
);

let duration_start =
TimeRange::parse_human_time_at("15m", "2026-08-26T13:00:00Z", now).unwrap();
assert_eq!(duration_start.start, relative_start.start);
assert_eq!(duration_start.end, relative_start.end);

let relative_end =
TimeRange::parse_human_time_at("2026-08-26T10:00:00Z", "now-2m", now).unwrap();
assert_eq!(
relative_end.start,
Utc.with_ymd_and_hms(2026, 8, 26, 10, 0, 0).unwrap()
);
assert_eq!(relative_end.end, now - Duration::minutes(2));

let future_end =
TimeRange::parse_human_time_at("2026-08-26T10:00:00Z", "now+2m", now).unwrap();
assert_eq!(future_end.end, now + Duration::minutes(2));
}

#[test]
fn start_time_after_end_time() {
let start_time = "2023-01-01T14:00:00Z";
Expand All @@ -532,7 +666,7 @@ mod tests {
let end_time = "2023-01-01T13:00:00Z";

let result = TimeRange::parse_human_time(start_time, end_time);
assert!(matches!(result, Err(TimeParseError::Chrono(_))));
assert!(matches!(result, Err(TimeParseError::InvalidStartTime(_))));
}

#[test]
Expand All @@ -541,7 +675,7 @@ mod tests {
let end_time = "not-a-valid-time";

let result = TimeRange::parse_human_time(start_time, end_time);
assert!(matches!(result, Err(TimeParseError::Chrono(_))));
assert!(matches!(result, Err(TimeParseError::InvalidEndTime(_))));
}

#[test]
Expand All @@ -550,7 +684,23 @@ mod tests {
let end_time = "now";

let result = TimeRange::parse_human_time(start_time, end_time);
assert!(matches!(result, Err(TimeParseError::HumanTime(_))));
assert!(matches!(result, Err(TimeParseError::InvalidStartTime(_))));
}

#[test]
fn time_expression_arithmetic_is_checked() {
assert!(matches!(
parse_time_expression("now-1s", DateTime::<Utc>::MIN_UTC),
Err(TimeExpressionError::OutOfRange(_))
));
assert!(matches!(
parse_time_expression("now+1s", DateTime::<Utc>::MAX_UTC),
Err(TimeExpressionError::OutOfRange(_))
));
assert!(matches!(
parse_time_expression("15m", Utc::now()),
Err(TimeExpressionError::Invalid(_))
));
}

#[test]
Expand Down
Loading