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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ tracing-actix-web = "0.7"

# Time and Date
chrono = "0.4"
chrono-tz = "0.10"
chrono-humanize = "0.2"
humantime = "2.1.0"
humantime-serde = "1.1"
Expand Down
131 changes: 131 additions & 0 deletions docs/tool-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ Response:
}
```

A listed `inputSchema` never has a root-level `oneOf`, `anyOf` or `allOf`,
because several LLM tool APIs reject tool input schemas with root combinators.
Where the catalog schema has one, the server still enforces it when the tool is
called, and the affected property descriptions state the constraint.

The response includes an `ETag` and
`Cache-Control: private, max-age=300, must-revalidate`. A request with a
matching `If-None-Match` receives `304 Not Modified`.
Expand Down Expand Up @@ -130,3 +135,129 @@ and last-seen value, and coalesces supported aliases per row. Its `total` is the
number of returned groups, not an unbounded count of all matching groups. The
native Errors page additionally separates issues by message, so its grouping
and pagination are not identical to this summary tool.

## SQL tool limits

`query_sql`, `explain_query` and other tools that run SQL internally parse the
statement with DataFusion's SQL parser and accept exactly one `SELECT` or `WITH`
query. A trailing semicolon or trailing comments are allowed, but a semicolon
followed by a comment passes validation and then fails when the query runs.
The outermost projection must name at most 10 explicit columns, and `*` and
`table.*` are rejected there. The same limit applies to each branch of a
top-level set operation such as `UNION` and to a parenthesized outer query.
Projections inside CTE definitions, derived tables and subqueries are not
checked. Casts to `INT64`, `UINT64` or `FLOAT64` anywhere in the statement are
rejected with a hint to use `BIGINT` or `DOUBLE`. The statement is wrapped with
an outer `LIMIT 500`. `sample_events`
returns at most 10 columns, choosing common observability fields such as
`p_timestamp`, `service.name`, `severity_text`, `body` and `trace_id` before
other fields.

## Dataset and time resolution

`list_datasets`, `resolve_dataset` and `discover_datasets` omit internal
datasets.

`resolve_dataset` accepts an optional `telemetryType` (`logs`, `metrics` or
`traces`) and `limit` (1–20, default 5). Its `status` is `resolved`,
`ambiguous`, `not_found` or `error`. Dataset names are matched
case-insensitively.

- A `selectedDataset` or `contextDataset` resolves to the visible dataset with
that name. If no visible dataset has that name, the result has
`status: "error"`. If its telemetry type does not match `telemetryType`, the
result is `not_found`.
- Otherwise, if exactly one dataset is visible, it resolves to that dataset, or
returns `not_found` if its telemetry type does not match.
- Otherwise it scores the visible datasets that match `telemetryType` by name
against `query`: 1000 if the name equals the query, 750 if the query contains
the name, or 500 if the name contains the query, plus 25 for each query term
longer than two characters that the name contains, plus 15 when
`telemetryType` is given. It resolves only a unique top score of at least
500. In every other case it returns `ambiguous` with up to `limit` scored
`candidates`, or `not_found` if no visible dataset matches `telemetryType`.

It never falls back to an arbitrary dataset.

`discover_datasets` counts events in the requested range for each visible
dataset of the requested telemetry type that the caller can query. It returns
active datasets ordered by event count as `{ name, eventsInRange }`, together
with `selectedDatasets`, `activeDatasetCount`, `truncated` and up to 20
`unavailableDatasets`. If no dataset is active and at least one could not be
counted, the result has `status: "error"` and no datasets.

`resolve_time_range` accepts any IANA `timezone` (default `UTC`), and uses it
for `today` and `yesterday` day boundaries. It also accepts `last <duration>`,
`past <duration>`, `now-<duration>`, `<start> to <end>` and RFC3339 start
times. An unknown timezone is an error.

`sample_events` accepts an explicit `startTime` and `endTime`, which must be
provided together. They take precedence over `minutes`.

## Log context

`get_log_context` uses the same implementation as `POST /api/v1/query/context`.
Arguments must include exactly one of `log`, `body` or `message`, holding the
anchor row's exact value. The catalog schema enforces this with a root `oneOf`,
so calls with none or more than one are rejected with `400`. The listed
`inputSchema` omits that `oneOf` (see [List tools](#list-tools)) and states the
rule in the property descriptions.

Context bounds are either `contextWindow` or both `contextStartTime` and
`contextEndTime`. Giving only one explicit bound, or a window together with an
explicit bound, is an error. If no bounds are given, the tool uses
`contextWindow: "5 minutes"`. A window extends that duration on each side of
`pTimestamp`, and both ends are rounded down to the minute; if they round to the
same minute, the end is one minute after the start. Explicit bounds are used at
millisecond precision without rounding, and `pTimestamp` must be at or after
the start and before the end. `pageSize` is 1–500, default 100.

Returned records keep at most 10 fields per row, preferring `p_timestamp`,
service, severity, message and trace identifiers. The response also includes
the anchor index, scope and the executed queries.

## Trace activity

`get_trace_activity` returns a bucketed span and trace activity series for one
traces dataset:

```json
{
"name": "get_trace_activity",
"arguments": {
"dataset": "otel-traces",
"startTime": "2026-10-22T00:00:00Z",
"endTime": "2026-10-22T01:00:00Z",
"groupBy": ["service.name"],
"filters": { "span_status_code": 2 },
"bucketCount": 60
}
}
```

`dataset`, `startTime` and `endTime` are required. `groupBy` takes up to 5
exact field names. `filters` maps exact field names to JSON scalars: strings,
numbers and booleans test equality, and `null` tests `IS NULL`. `bucketCount`
is 1–100, default 60. It approximates the number of `DATE_BIN` buckets over the
dataset's time partition, or `p_timestamp` if none is configured. The dataset
must have traces telemetry and contain `span_span_id` and `span_trace_id`.
Unknown fields are rejected. `span_count` is `COUNT(DISTINCT span_span_id)` and
`trace_count` is `COUNT(DISTINCT span_trace_id)`, so duplicate span rows are
not double counted. Rows are ordered by bucket. The result contains `query`,
`bucketInterval`, `semantics` and up to 500 `rows`.

`query` is the aggregation SQL before the tool wraps it with `LIMIT 500`. It
has no time predicate: the time range comes from the request's `startTime` and
`endTime`, which the query API rounds down to the minute. To reproduce the
result, for example in a dashboard tile, run `query` through the query API with
the same `startTime` and `endTime`.

## Argument schema validation

`inputSchema` is enforced server-side for `type` (a single type or a list of types), `required`,
`additionalProperties`, `enum`, `const`, `minLength`/`maxLength`,
`minimum`/`maximum`, `minItems`/`maxItems`, `items`, `anyOf` and `oneOf`.
`oneOf` passes only when exactly one variant matches. Combinators are checked
in addition to sibling keywords; they do not replace them. Validation uses the
catalog schema, including any root combinators omitted from the listed
`inputSchema`.
57 changes: 47 additions & 10 deletions src/handlers/http/query_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,26 @@ pub async fn query_context(
let permissions = Users.get_permissions(&creds);

create_streams_for_distributed(vec![context_request.dataset.clone()], &tenant_id).await?;
let authorized_datasets = vec![context_request.dataset.clone()];
user_auth_for_datasets(
&permissions,
std::slice::from_ref(&context_request.dataset),
&tenant_id,
)
.await?;

query_context_for_authorized_dataset(context_request, &tenant_id)
.await
.map(web::Json)
}

/// Fetches log context after the caller has authorized the dataset and made it
/// available on the local query node.
pub(crate) async fn query_context_for_authorized_dataset(
context_request: LogContextRequest,
tenant_id: &Option<String>,
) -> Result<LogContextResponse, QueryError> {
let span = Span::current();
let authorized_datasets = vec![context_request.dataset.clone()];
let page_size = normalize_log_context_page_size(context_request.page_size)?;
span.record("page_size", page_size);
let anchor_timestamp = parse_log_context_timestamp(&context_request.p_timestamp)?;
Expand All @@ -164,7 +176,7 @@ pub async fn query_context(
"query context request normalized"
);

let dataset = PARSEABLE.get_stream(&context_request.dataset, &tenant_id)?;
let dataset = PARSEABLE.get_stream(&context_request.dataset, tenant_id)?;
let schema = dataset.get_schema();
validate_log_context_schema(schema.as_ref(), DEFAULT_TIMESTAMP_KEY)?;
let match_fields = normalize_log_context_match_fields(
Expand All @@ -191,7 +203,7 @@ pub async fn query_context(
&authorized_datasets,
&context_start_time_str,
&context_end_time_str,
&tenant_id,
tenant_id,
)
.await?;
debug!(
Expand Down Expand Up @@ -242,8 +254,8 @@ pub async fn query_context(
);

let (newer_records, older_records) = tokio::try_join!(
execute_log_context_rows(&newer_payload, &authorized_datasets, &tenant_id),
execute_log_context_rows(&older_payload, &authorized_datasets, &tenant_id),
execute_log_context_rows(&newer_payload, &authorized_datasets, tenant_id),
execute_log_context_rows(&older_payload, &authorized_datasets, tenant_id),
)?;
let (records, anchor_index) =
build_log_context_records_window(newer_records, older_records, page_size)?;
Expand Down Expand Up @@ -274,7 +286,7 @@ pub async fn query_context(
tenant_id.as_deref().unwrap_or(DEFAULT_TENANT),
);

Ok(web::Json(LogContextResponse {
Ok(LogContextResponse {
scope,
context_start_time: context_start_time_str,
context_end_time: context_end_time_str,
Expand All @@ -284,7 +296,7 @@ pub async fn query_context(
anchored_duplicate: LOG_CONTEXT_ANCHORED_DUPLICATE,
records,
queries,
}))
})
}

fn normalize_log_context_page_size(page_size: Option<u64>) -> Result<u64, QueryError> {
Expand Down Expand Up @@ -364,11 +376,23 @@ fn log_context_window_bounds(

let duration = chrono::Duration::from_std(duration)
.map_err(|err| QueryError::CustomError(format!("Invalid contextWindow: {err}")))?;
let start = truncate_to_minute(anchor_timestamp - duration);
let mut end = truncate_to_minute(anchor_timestamp + duration);
let out_of_range =
|| QueryError::CustomError("contextWindow is outside the supported range".to_string());
let start = truncate_to_minute(
anchor_timestamp
.checked_sub_signed(duration)
.ok_or_else(out_of_range)?,
);
let mut end = truncate_to_minute(
anchor_timestamp
.checked_add_signed(duration)
.ok_or_else(out_of_range)?,
);

if start >= end {
end = start + chrono::Duration::minutes(1);
end = start
.checked_add_signed(chrono::Duration::minutes(1))
.ok_or_else(out_of_range)?;
}

Ok((start, end))
Expand Down Expand Up @@ -1190,6 +1214,19 @@ mod tests {
assert_eq!(format_log_context_api_time(end), "2026-06-17T10:16:00Z");
}

#[test]
fn log_context_window_bounds_reject_windows_beyond_the_supported_range() {
for window in ["300000y", "1000000 years"] {
let error = log_context_window_bounds(anchor_timestamp(), window)
.unwrap_err()
.to_string();
assert!(
error.contains("contextWindow is outside the supported range"),
"{window}: {error}"
);
}
}

#[test]
fn log_context_bounds_resolver_accepts_one_mode_only() {
let anchor = anchor_timestamp();
Expand Down
Loading
Loading