@@ -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+
412556async 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