Skip to content

Commit c94bd14

Browse files
add log, trace errors as tools (#1823)
* add log, trace errors as tools * deesource error fix * get_dataset_schema tool to return compact TSV response
1 parent c7dfbef commit c94bd14

3 files changed

Lines changed: 250 additions & 15 deletions

File tree

‎src/handlers/http/logstream.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,9 @@ fn schema_response(schema: &Schema, format: SchemaFormat) -> HttpResponse {
205205
}
206206
}
207207

208-
fn compact_schema(schema: &Schema) -> String {
208+
/// Render the token-efficient schema representation used by the compact HTTP
209+
/// response and by agent-facing schema tools.
210+
pub fn compact_schema(schema: &Schema) -> String {
209211
let mut output = String::from("name\ttype\n");
210212
for field in schema.fields() {
211213
append_escaped_compact_field(&mut output, field.name());

‎src/tool_catalog.rs‎

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ impl ToolSpec {
104104
| "explain_query"
105105
| "discover_datasets"
106106
| "get_traces"
107-
| "get_trace" => ToolEffect::ExpensiveRead,
107+
| "get_trace"
108+
| "list_log_errors"
109+
| "list_trace_errors" => ToolEffect::ExpensiveRead,
108110
"create_alert"
109111
| "enable_alert"
110112
| "disable_alert"
@@ -354,7 +356,7 @@ pub fn oss_read_tool_specs() -> Vec<ToolSpec> {
354356
ToolSpec::new(
355357
"get_dataset_schema",
356358
"Get dataset schema",
357-
"Get columns and data types for a dataset.",
359+
"Get all dataset columns and data types as compact tab-separated text with a name/type header.",
358360
dataset_schema(),
359361
),
360362
ToolSpec::new(
@@ -402,6 +404,34 @@ pub fn oss_read_tool_specs() -> Vec<ToolSpec> {
402404
&["dataset", "pTimestamp"],
403405
),
404406
),
407+
ToolSpec::new(
408+
"list_log_errors",
409+
"List log errors",
410+
"List grouped error, fatal, and exception log records from one logs dataset in a bounded time range.",
411+
object_schema(
412+
json!({
413+
"dataset": string_property("One logs dataset."),
414+
"startTime": string_property("RFC3339 range start."),
415+
"endTime": string_property("RFC3339 range end."),
416+
"limit": { "type": "integer", "minimum": 1, "maximum": 500, "default": 50 }
417+
}),
418+
&["dataset", "startTime", "endTime"],
419+
),
420+
),
421+
ToolSpec::new(
422+
"list_trace_errors",
423+
"List trace errors",
424+
"List failed and error spans from one traces dataset in a bounded time range.",
425+
object_schema(
426+
json!({
427+
"dataset": string_property("One traces dataset."),
428+
"startTime": string_property("RFC3339 range start."),
429+
"endTime": string_property("RFC3339 range end."),
430+
"limit": { "type": "integer", "minimum": 1, "maximum": 500, "default": 50 }
431+
}),
432+
&["dataset", "startTime", "endTime"],
433+
),
434+
),
405435
ToolSpec::new(
406436
"query_sql",
407437
"Run SQL query",
@@ -824,7 +854,7 @@ mod tests {
824854
let tools = oss_tool_specs();
825855
let names = tools.iter().map(|tool| tool.name).collect::<HashSet<_>>();
826856

827-
assert_eq!(tools.len(), 47);
857+
assert_eq!(tools.len(), 49);
828858
assert_eq!(tools.len(), names.len());
829859
}
830860

@@ -863,7 +893,7 @@ mod tests {
863893
let read_tools = oss_read_tool_specs();
864894
let action_tools = oss_action_tool_specs();
865895

866-
assert_eq!(read_tools.len(), 34);
896+
assert_eq!(read_tools.len(), 36);
867897
assert_eq!(action_tools.len(), 13);
868898
assert!(read_tools.iter().all(|tool| matches!(
869899
tool.effect(),
@@ -913,6 +943,40 @@ mod tests {
913943
);
914944
}
915945

946+
#[actix_web::test]
947+
async fn oss_catalog_exposes_list_log_errors() {
948+
let tool = oss_read_tool_specs()
949+
.into_iter()
950+
.find(|tool| tool.name == "list_log_errors")
951+
.expect("list_log_errors must be available in OSS");
952+
953+
assert_eq!(tool.effect(), ToolEffect::ExpensiveRead);
954+
assert_eq!(tool.input_schema["properties"]["dataset"]["type"], "string");
955+
assert!(
956+
tool.input_schema["required"]
957+
.as_array()
958+
.unwrap()
959+
.contains(&serde_json::json!("dataset"))
960+
);
961+
}
962+
963+
#[actix_web::test]
964+
async fn oss_catalog_exposes_list_trace_errors() {
965+
let tool = oss_read_tool_specs()
966+
.into_iter()
967+
.find(|tool| tool.name == "list_trace_errors")
968+
.expect("list_trace_errors must be available in OSS");
969+
970+
assert_eq!(tool.effect(), ToolEffect::ExpensiveRead);
971+
assert_eq!(tool.input_schema["properties"]["dataset"]["type"], "string");
972+
assert!(
973+
tool.input_schema["required"]
974+
.as_array()
975+
.unwrap()
976+
.contains(&serde_json::json!("dataset"))
977+
);
978+
}
979+
916980
#[actix_web::test]
917981
async fn tool_call_defaults_to_empty_arguments() {
918982
let call: ToolCallRequest = serde_json::from_value(serde_json::json!({

‎src/tool_executor.rs‎

Lines changed: 179 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -272,12 +272,10 @@ async fn execute_dataset_metadata_tool(
272272
match name {
273273
"get_dataset_schema" => {
274274
authorize(context.session_key, Action::GetSchema, Some(dataset), None)?;
275-
serde_json::to_value(
276-
logstream::get_schema_internal(dataset, context.tenant_id)
277-
.await
278-
.map_err(|error| error.to_string())?,
279-
)
280-
.map_err(|error| error.to_string())
275+
let schema = logstream::get_schema_internal(dataset, context.tenant_id)
276+
.await
277+
.map_err(|error| error.to_string())?;
278+
Ok(Value::String(logstream::compact_schema(&schema)))
281279
}
282280
"get_dataset_info" => {
283281
authorize(
@@ -409,6 +407,152 @@ async fn get_log_context(arguments: &Value, context: OssToolContext<'_>) -> Resu
409407
response_value(response).await
410408
}
411409

410+
fn first_present_field(fields: &[String], candidates: &[&str]) -> Option<String> {
411+
candidates
412+
.iter()
413+
.find(|candidate| fields.iter().any(|field| field == **candidate))
414+
.map(|field| (*field).to_owned())
415+
}
416+
417+
pub fn list_log_errors_query(
418+
dataset: &str,
419+
fields: &[String],
420+
limit: u64,
421+
) -> Result<String, String> {
422+
let service = first_present_field(fields, &["service.name", "service_name"]).map_or_else(
423+
|| "'unknown'".to_owned(),
424+
|field| {
425+
format!(
426+
"COALESCE(CAST({} AS VARCHAR), 'unknown')",
427+
quote_identifier(&field)
428+
)
429+
},
430+
);
431+
let severity_text = first_present_field(fields, &["severity_text", "severity_description"]);
432+
let exception_type = first_present_field(
433+
fields,
434+
&["event_exception.type", "exception.type", "error.type"],
435+
);
436+
let error_type = exception_type
437+
.as_ref()
438+
.or(severity_text.as_ref())
439+
.map_or_else(
440+
|| "'error'".to_owned(),
441+
|field| {
442+
format!(
443+
"COALESCE(CAST({} AS VARCHAR), 'error')",
444+
quote_identifier(field)
445+
)
446+
},
447+
);
448+
let message = first_present_field(
449+
fields,
450+
&[
451+
"event_exception.message",
452+
"exception.message",
453+
"error.message",
454+
"body",
455+
"message",
456+
"log",
457+
],
458+
);
459+
let sample_message = message.as_ref().map_or_else(
460+
|| "CAST(NULL AS VARCHAR)".to_owned(),
461+
|field| format!("MAX(CAST({} AS VARCHAR))", quote_identifier(field)),
462+
);
463+
let last_seen = first_present_field(fields, &["p_timestamp", "time_unix_nano"]).map_or_else(
464+
|| "CAST(NULL AS VARCHAR)".to_owned(),
465+
|field| format!("MAX({})", quote_identifier(&field)),
466+
);
467+
468+
let mut predicates = Vec::new();
469+
if fields.iter().any(|field| field == "severity_number") {
470+
predicates.push("CAST(\"severity_number\" AS BIGINT) >= 17".to_owned());
471+
}
472+
if let Some(field) = &severity_text {
473+
predicates.push(format!(
474+
"UPPER(CAST({} AS VARCHAR)) IN ('ERROR', 'FATAL', 'CRITICAL')",
475+
quote_identifier(field)
476+
));
477+
}
478+
if let Some(field) = &exception_type {
479+
predicates.push(format!(
480+
"{} IS NOT NULL AND CAST({} AS VARCHAR) <> ''",
481+
quote_identifier(field),
482+
quote_identifier(field)
483+
));
484+
}
485+
if predicates.is_empty()
486+
&& let Some(field) = &message
487+
{
488+
let field = quote_identifier(field);
489+
predicates.push(format!(
490+
"(LOWER(CAST({field} AS VARCHAR)) LIKE '%error%' OR LOWER(CAST({field} AS VARCHAR)) LIKE '%exception%' OR LOWER(CAST({field} AS VARCHAR)) LIKE '%failed%' OR LOWER(CAST({field} AS VARCHAR)) LIKE '%fatal%' OR LOWER(CAST({field} AS VARCHAR)) LIKE '%panic%')"
491+
));
492+
}
493+
if predicates.is_empty() {
494+
return Err(
495+
"log dataset has no supported severity, exception, or message field".to_owned(),
496+
);
497+
}
498+
499+
Ok(format!(
500+
"SELECT {service} AS \"serviceName\", {error_type} AS \"errorType\", {sample_message} AS \"sampleMessage\", COUNT(*) AS \"occurrences\", {last_seen} AS \"lastSeen\" FROM {} WHERE ({}) GROUP BY {service}, {error_type} ORDER BY \"occurrences\" DESC LIMIT {}",
501+
quote_identifier(dataset),
502+
predicates.join(" OR "),
503+
limit.clamp(1, 500),
504+
))
505+
}
506+
507+
async fn list_log_errors(arguments: &Value, context: OssToolContext<'_>) -> Result<Value, String> {
508+
let dataset = required_string(arguments, "dataset")?;
509+
authorize(context.session_key, Action::Query, Some(dataset), None)?;
510+
let info = serde_json::to_value(
511+
get_prism_logstream_info(dataset, context.tenant_id)
512+
.await
513+
.map_err(|error| error.to_string())?,
514+
)
515+
.map_err(|error| error.to_string())?;
516+
if !nested_string(&info, "telemetryType").is_some_and(|value| value == "logs") {
517+
return Err(format!(
518+
"tool requires a logs dataset, but `{dataset}` is not logs telemetry"
519+
));
520+
}
521+
let schema = logstream::get_schema_internal(dataset, context.tenant_id)
522+
.await
523+
.map_err(|error| error.to_string())?;
524+
let fields = schema
525+
.fields()
526+
.iter()
527+
.map(|field| field.name().to_owned())
528+
.collect::<Vec<_>>();
529+
let limit = bounded_integer(arguments, "limit", 50, 1, 500);
530+
let query_text = bounded_select(&list_log_errors_query(dataset, &fields, limit)?)?;
531+
let response = query::query_internal(
532+
query::Query {
533+
query: query_text,
534+
start_time: required_string(arguments, "startTime")?.to_owned(),
535+
end_time: required_string(arguments, "endTime")?.to_owned(),
536+
send_null: true,
537+
fields: false,
538+
streaming: false,
539+
filter_tags: None,
540+
},
541+
context.session_key,
542+
context.tenant_id,
543+
)
544+
.await
545+
.map_err(|error| error.to_string())?;
546+
let records = response_value(response).await?;
547+
let total = records.as_array().map_or(0, Vec::len);
548+
Ok(json!({
549+
"total": total,
550+
"offset": 0,
551+
"limit": limit,
552+
"records": records,
553+
}))
554+
}
555+
412556
async fn execute_sql_tool(
413557
name: &str,
414558
arguments: &Value,
@@ -563,7 +707,8 @@ async fn execute_trace_tool(
563707
let datasets = [required_string(arguments, "dataset")?.to_owned()];
564708
let start_time = required_string(arguments, "startTime")?.to_owned();
565709
let end_time = required_string(arguments, "endTime")?.to_owned();
566-
let limit = bounded_integer(arguments, "limit", 100, 1, 500) as usize;
710+
let default_limit = if name == "list_trace_errors" { 50 } else { 100 };
711+
let limit = bounded_integer(arguments, "limit", default_limit, 1, 500) as usize;
567712
let mut results = Vec::new();
568713
let mut errors = Vec::new();
569714
for dataset in datasets {
@@ -596,7 +741,8 @@ async fn execute_trace_tool(
596741
end_time: end_time.clone(),
597742
sort_by: None,
598743
conditions: None,
599-
options: None,
744+
options: (name == "list_trace_errors")
745+
.then_some(crate::handlers::http::traces::TraceListOption::ErrorSpans),
600746
limit: Some(limit),
601747
offset: Some(0),
602748
},
@@ -818,13 +964,16 @@ async fn execute_oss_tool_inner(
818964
}
819965
"sample_events" => sample_events(arguments, context).await,
820966
"get_log_context" => get_log_context(arguments, context).await,
967+
"list_log_errors" => list_log_errors(arguments, context).await,
821968
"query_sql" | "explain_query" => execute_sql_tool(name, arguments, context).await,
822969
"list_alerts" | "get_alert" | "list_alert_tags" | "enable_alert" | "disable_alert"
823970
| "evaluate_alert" | "create_alert" => execute_alert_tool(name, arguments, context).await,
824971
"list_alert_targets" | "get_alert_target" | "create_alert_target" => {
825972
execute_alert_target_tool(name, arguments, context).await
826973
}
827-
"get_traces" | "get_trace" => execute_trace_tool(name, arguments, context).await,
974+
"get_traces" | "get_trace" | "list_trace_errors" => {
975+
execute_trace_tool(name, arguments, context).await
976+
}
828977
"ping"
829978
| "get_cluster_status"
830979
| "get_cluster_metrics"
@@ -894,7 +1043,27 @@ mod tests {
8941043
use chrono::{DateTime, Duration, Utc};
8951044
use serde_json::{Value, json};
8961045

897-
use super::{call_tool_registry, normalized_log_context_bounds};
1046+
use super::{call_tool_registry, list_log_errors_query, normalized_log_context_bounds};
1047+
1048+
#[actix_web::test]
1049+
async fn log_error_query_uses_otel_severity_fields() {
1050+
let query = list_log_errors_query(
1051+
"app-logs",
1052+
&[
1053+
"p_timestamp".to_owned(),
1054+
"service.name".to_owned(),
1055+
"severity_number".to_owned(),
1056+
"severity_text".to_owned(),
1057+
"body".to_owned(),
1058+
],
1059+
5,
1060+
)
1061+
.unwrap();
1062+
1063+
assert!(query.contains("CAST(\"severity_number\" AS BIGINT) >= 17"));
1064+
assert!(query.contains("'ERROR', 'FATAL', 'CRITICAL'"));
1065+
assert!(query.contains("LIMIT 5"));
1066+
}
8981067

8991068
#[actix_web::test]
9001069
async fn log_context_bounds_normalize_relative_times() {

0 commit comments

Comments
 (0)