diff --git a/internal/mcp/panel_examples_test.go b/internal/mcp/panel_examples_test.go index 9df362cd..f65286b2 100644 --- a/internal/mcp/panel_examples_test.go +++ b/internal/mcp/panel_examples_test.go @@ -3,6 +3,7 @@ package mcp import ( "encoding/json" "math" + "reflect" "strings" "testing" "time" @@ -15,6 +16,31 @@ import ( telemetrystore "github.com/labstack/fanout/internal/telemetry/store" ) +func TestSQLTimestampRuleAppearsOnceInAgentGuidance(t *testing.T) { + field, ok := reflect.TypeOf(panel.Panel{}).FieldByName("SQL") + if !ok || strings.Count(field.Tag.Get("jsonschema"), panel.SQLTimestampGuide) != 1 { + t.Fatalf("SQL field guidance=%s", field.Tag.Get("jsonschema")) + } + docs, err := DescribeTools(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"create_dashboard", "get_telemetry_schema"} { + found := false + for _, doc := range docs { + if doc.Name == name { + found = true + if strings.Count(doc.Description, panel.SQLTimestampGuide) != 1 { + t.Fatalf("%s must state the timestamp rule once", name) + } + } + } + if !found { + t.Fatalf("missing %s", name) + } + } +} + func TestExamplesAndPreviewCoverNewTypes(t *testing.T) { s := newPanelServer(t) coverage := map[string]int{} diff --git a/internal/mcp/panels.go b/internal/mcp/panels.go index 27258479..ae72f565 100644 --- a/internal/mcp/panels.go +++ b/internal/mcp/panels.go @@ -44,7 +44,7 @@ type PreviewOutput struct { Problems []panel.Problem `json:"problems,omitempty"` } -const baseSpecGuide = `A dashboard is {name, description, time:{range}, variables, panels}. Each panel: {id (lowercase), title, viz, width 1-12, query or sql}. viz: stat or gauge (one measure; gauge needs min and max), timeseries (bucket auto, at most one by), bar (one or two by), table (up to three by), text (content in Markdown), heatmap (bucket auto, no by), histogram (at most one by), scatter (exactly two measures in x then y order, one item dimension and an optional colour dimension), state_timeline (one measure, one item dimension, bucket auto and thresholds required). Rollup panel types are service_map (dependency graph) and health (overview metrics, error trend and health distribution). They require query.from=spans, no measures, by, bucket or sort, and only namespace/service equality filters with a literal or single-value variable; explicit casts and values with surrounding whitespace are rejected. Reads default to 400 services/edges and are capped at 400 even when query.limit is larger. Service scope applies before the limit; map nodes carry p95_ms and edges carry average_ms; error_rate is percent. Row panel types are logs, log_patterns and traces. Logs and traces take no measures, by or bucket. Patterns use count() by body_template. Patterns group only by body_template. Severity is the most frequent severity, ties choose the higher severity. Service is the most frequent service, ties choose ascending service name. Both describe the scoped, redacted events, not extra grouping dimensions. Each pattern returns dominant severity, top service, count and a dense trend, computed in the same bounded query after redaction and scoped filters. Redaction applies before filtering, grouping, display and drill-down. Row panels cannot use SQL. "erroring" means slowest erroring traces: filter to erroring traces, then rank by root duration. Trace candidate ranking is approximate when filters exclude the root span, as with exemplars. Categorical charts use fixed-order colour slots. Categorical options.top defaults to 6, maximum 6. Structured panels: the server computes Other; series are chosen worst-first by confidence (Wilson lower bound for error rates; at least 20 samples for latency); the rest fold into Other (N). Count, rate, sum and share rank largest first. Error rates require at least 20 requests for eligible Wilson ranking (z=1.96). Percentiles, quantile, avg, max and min require at least 20 non-null numeric samples for eligible value ranking and use better (explicit or inferred). Ineligible series follow eligible ones by sample count descending. Ties use sample count descending, then name ascending. SQL panels: series past six are left out, with a note. state_timeline defaults to 8 rows, maximum 20; other noncategorical panels use the same limits. Scatter supports options.x_scale and options.y_scale (log or linear), with distinct x_unit for x and unit for y, each inferred independently when omitted. Rows returned use the count unit. State timelines preserve null and absent buckets as unknown and keep the top items without merging them into Other. Scatter requires a structured query. SQL state_timeline requires timestamp time, a string item, one numeric measure and optional string state; its interval is one minute and thresholds are required. Items rank by volume (row count) for top N. Previous-period comparison is not drawn for scatter or state_timeline. Distribution panels use a structured query with one count() measure and histogram: {field: duration_ms, buckets: log2} for spans or {field: value, buckets: explicit} for metrics. Negative span durations clamp to [0,1); +Inf goes to overflow; NaN is excluded because it is not a measurement. Metric histograms use stored temporality and shared per-series increases; resets contribute current counts. Delta histograms sum observation weights. query: {from: spans|logs|metrics, where: [filter expressions], measures: [fn(field) as alias], by: [fields], bucket, sort, limit}. Measures: count(), rate() per second, error_rate() percent, share() percent, avg/min/max/sum(field), last(value) for metrics, p50/p75/p90/p95/p99(field), quantile(field, 0.999), count_distinct(field). Fields are columns from get_telemetry_schema or attributes['key'] / resource['key'] with the key written literally. Filters are SQL boolean expressions: service = $service, kind = 'SPAN_KIND_SERVER', status = 'STATUS_CODE_ERROR', http_route IN $routes, attributes['http.response.status_code']::INTEGER >= 500. Variables: {name, kind: query|custom|constant|text, from, field, default, include_all}; $__all selects All and removes filters that use the variable. sql: one SELECT over spans, logs, metrics, service_rollup, edge_rollup or resource_inventory that filters time with $__window(column). Use values exactly as get_telemetry_schema lists them. Example panel: {"id":"latency","title":"Checkout latency","viz":"timeseries","width":8,"query":{"from":"spans","where":["service = 'checkout'","kind = 'SPAN_KIND_SERVER'"],"measures":["p50(duration_ms)","p95(duration_ms)","p99(duration_ms)"],"bucket":"auto"},"unit":"ms","thresholds":[{"value":1500,"status":"bad"}]}.` + " " + metricMeasureGuide + " " + resourceInventoryGuide +const baseSpecGuide = `A dashboard is {name, description, time:{range}, variables, panels}. Each panel: {id (lowercase), title, viz, width 1-12, query or sql}. viz: stat or gauge (one measure; gauge needs min and max), timeseries (bucket auto, at most one by), bar (one or two by), table (up to three by), text (content in Markdown), heatmap (bucket auto, no by), histogram (at most one by), scatter (exactly two measures in x then y order, one item dimension and an optional colour dimension), state_timeline (one measure, one item dimension, bucket auto and thresholds required). Rollup panel types are service_map (dependency graph) and health (overview metrics, error trend and health distribution). They require query.from=spans, no measures, by, bucket or sort, and only namespace/service equality filters with a literal or single-value variable; explicit casts and values with surrounding whitespace are rejected. Reads default to 400 services/edges and are capped at 400 even when query.limit is larger. Service scope applies before the limit; map nodes carry p95_ms and edges carry average_ms; error_rate is percent. Row panel types are logs, log_patterns and traces. Logs and traces take no measures, by or bucket. Patterns use count() by body_template. Patterns group only by body_template. Severity is the most frequent severity, ties choose the higher severity. Service is the most frequent service, ties choose ascending service name. Both describe the scoped, redacted events, not extra grouping dimensions. Each pattern returns dominant severity, top service, count and a dense trend, computed in the same bounded query after redaction and scoped filters. Redaction applies before filtering, grouping, display and drill-down. Row panels cannot use SQL. "erroring" means slowest erroring traces: filter to erroring traces, then rank by root duration. Trace candidate ranking is approximate when filters exclude the root span, as with exemplars. Categorical charts use fixed-order colour slots. Categorical options.top defaults to 6, maximum 6. Structured panels: the server computes Other; series are chosen worst-first by confidence (Wilson lower bound for error rates; at least 20 samples for latency); the rest fold into Other (N). Count, rate, sum and share rank largest first. Error rates require at least 20 requests for eligible Wilson ranking (z=1.96). Percentiles, quantile, avg, max and min require at least 20 non-null numeric samples for eligible value ranking and use better (explicit or inferred). Ineligible series follow eligible ones by sample count descending. Ties use sample count descending, then name ascending. SQL panels: series past six are left out, with a note. state_timeline defaults to 8 rows, maximum 20; other noncategorical panels use the same limits. Scatter supports options.x_scale and options.y_scale (log or linear), with distinct x_unit for x and unit for y, each inferred independently when omitted. Rows returned use the count unit. State timelines preserve null and absent buckets as unknown and keep the top items without merging them into Other. Scatter requires a structured query. SQL state_timeline requires timestamp time, a string item, one numeric measure and optional string state; its interval is one minute and thresholds are required. Items rank by volume (row count) for top N. Previous-period comparison is not drawn for scatter or state_timeline. Distribution panels use a structured query with one count() measure and histogram: {field: duration_ms, buckets: log2} for spans or {field: value, buckets: explicit} for metrics. Negative span durations clamp to [0,1); +Inf goes to overflow; NaN is excluded because it is not a measurement. Metric histograms use stored temporality and shared per-series increases; resets contribute current counts. Delta histograms sum observation weights. query: {from: spans|logs|metrics, where: [filter expressions], measures: [fn(field) as alias], by: [fields], bucket, sort, limit}. Measures: count(), rate() per second, error_rate() percent, share() percent, avg/min/max/sum(field), last(value) for metrics, p50/p75/p90/p95/p99(field), quantile(field, 0.999), count_distinct(field). Fields are columns from get_telemetry_schema or attributes['key'] / resource['key'] with the key written literally. Filters are SQL boolean expressions: service = $service, kind = 'SPAN_KIND_SERVER', status = 'STATUS_CODE_ERROR', http_route IN $routes, attributes['http.response.status_code']::INTEGER >= 500. Variables: {name, kind: query|custom|constant|text, from, field, default, include_all}; $__all selects All and removes filters that use the variable. sql: one SELECT over spans, logs, metrics, service_rollup, edge_rollup or resource_inventory that filters time with $__window(column).` + " " + panel.SQLTimestampGuide + ` Use values exactly as get_telemetry_schema lists them. Example panel: {"id":"latency","title":"Checkout latency","viz":"timeseries","width":8,"query":{"from":"spans","where":["service = 'checkout'","kind = 'SPAN_KIND_SERVER'"],"measures":["p50(duration_ms)","p95(duration_ms)","p99(duration_ms)"],"bucket":"auto"},"unit":"ms","thresholds":[{"value":1500,"status":"bad"}]}.` + " " + metricMeasureGuide + " " + resourceInventoryGuide func (s *Server) registerPanelTools() { readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)} @@ -57,7 +57,7 @@ func (s *Server) registerPanelTools() { mcp.AddTool(s.mcp, &mcp.Tool{ Name: "get_telemetry_schema", Title: "Telemetry schema", - Description: "List the signals, columns with their common values, attribute keys seen per service, metric names, services, measure functions, units and SQL-only relations in sql_relations. Read this before drafting panels so filters use values that exist. See create_dashboard for the spec guide.", + Description: "List the signals, columns with their common values, attribute keys seen per service, metric names, services, measure functions, units and SQL-only relations in sql_relations. Read this before drafting panels so filters use values that exist. " + panel.SQLTimestampGuide + " See create_dashboard for the spec guide.", Annotations: readOnly, }, s.telemetrySchema) mcp.AddTool(s.mcp, &mcp.Tool{ diff --git a/internal/mcp/presentation_test.go b/internal/mcp/presentation_test.go index 8e9cdfc1..1217735f 100644 --- a/internal/mcp/presentation_test.go +++ b/internal/mcp/presentation_test.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" "testing" + "time" "github.com/labstack/fanout/internal/dashboard" "github.com/labstack/fanout/internal/panel" @@ -14,6 +15,65 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" ) +func TestTimestampLiteralHintSurvivesMCPPreviewAndDashboardRejection(t *testing.T) { + now := time.Date(2026, 10, 20, 18, 6, 0, 0, time.UTC) + s := panelServerFixtureAt(t, true, now) + from := now.Add(-time.Hour) + window := panel.Time{From: &from, To: &now} + for _, tc := range []struct { + name, predicate string + invalid bool + }{ + {"timestamptz", "start_time < TIMESTAMPTZ '2026-10-20 17:36:15+00'", true}, + {"between_timestamptz", "start_time BETWEEN TIMESTAMPTZ '2026-10-20 17:36:00+00' AND TIMESTAMPTZ '2026-10-20 17:36:14+00'", true}, + {"timestamp", "start_time < TIMESTAMP '2026-10-20 17:36:15'", false}, + {"between_timestamp", "start_time BETWEEN TIMESTAMP '2026-10-20 17:36:00' AND TIMESTAMP '2026-10-20 17:36:14'", false}, + {"explicit_cast", "start_time < TIMESTAMPTZ '2026-10-20 17:36:15+00'::TIMESTAMPTZ_NS", false}, + } { + t.Run(tc.name, func(t *testing.T) { + p := panel.Panel{ID: "calls", Title: "Calls", Viz: "table", SQL: "SELECT count(*) AS n FROM spans WHERE $__window(start_time) AND " + tc.predicate} + _, out, err := s.previewPanels(t.Context(), nil, PreviewInput{Panels: []panel.Panel{p}, Time: &window}) + if err != nil || len(out.Panels) != 1 { + t.Fatalf("preview=%+v err=%v", out, err) + } + got := out.Panels[0] + if !tc.invalid { + if got.Status != "ok" || got.Rows != 1 || len(got.Sample) != 1 || len(got.Sample[0]) != 1 || got.Sample[0][0] != float64(15) { + t.Fatalf("preview=%+v, want count 15", got) + } + return + } + if got.Status != "invalid" || len(got.Problems) != 1 || got.Problems[0].Path != "panels[0].sql" || got.Problems[0].Hint != panel.SQLTimestampGuide { + t.Fatalf("preview lost hint: %+v", got) + } + session := connectTestClient(t, s, nil) + result, err := session.CallTool(t.Context(), &mcpsdk.CallToolParams{ + Name: "create_dashboard", Arguments: DashboardCreateInput{Dashboard: panel.Dashboard{Name: "Time literals", Time: window, Panels: []panel.Panel{p}}}, + Meta: mcpsdk.Meta{dashboard.OwnerMetaKey: "owner"}, + }) + if err != nil || !result.IsError { + t.Fatalf("result=%+v err=%v", result, err) + } + check := func(raw []byte) { + t.Helper() + var failure dashboardOutput + if err := json.Unmarshal(raw, &failure); err != nil || failure.Error == nil || failure.Error.Code != "invalid_spec" || !strings.Contains(failure.Error.Message, panel.SQLTimestampGuide) { + t.Fatalf("failure=%s err=%v", raw, err) + } + if len(failure.Problems) != 1 || failure.Problems[0].Path != "panels[0].sql" || failure.Problems[0].Hint != panel.SQLTimestampGuide { + t.Fatalf("lost problem hint: %+v", failure.Problems) + } + } + check([]byte(result.Content[0].(*mcpsdk.TextContent).Text)) + raw, err := json.Marshal(result.StructuredContent) + if err != nil { + t.Fatal(err) + } + check(raw) + }) + } +} + type presentationExecutor struct { receiptExecutor guidance string diff --git a/internal/panel/check.go b/internal/panel/check.go index d18234c1..e0ca3fe6 100644 --- a/internal/panel/check.go +++ b/internal/panel/check.go @@ -150,7 +150,7 @@ func Check(ctx context.Context, parser Parser, d *Dashboard) (*Checked, Problems if isOperational(err) { return nil, nil, err } - problems.add(path+".sql", PresentError(err, SQLError)) + problems = append(problems, sqlProblem(path+".sql", err)) } } } diff --git a/internal/panel/check_test.go b/internal/panel/check_test.go index ece03391..10f148ea 100644 --- a/internal/panel/check_test.go +++ b/internal/panel/check_test.go @@ -2,10 +2,13 @@ package panel import ( "context" + "errors" "strings" "testing" + "time" duckdb "github.com/duckdb/duckdb-go/v2" + "github.com/labstack/fanout/internal/telemetry" ) type interruptingParser struct{ Parser } @@ -45,6 +48,57 @@ func TestCheckCollectsFiltersAndSQLProblems(t *testing.T) { } } +func TestSQLTimestampComparisonsReturnRepairHintOrExactResults(t *testing.T) { + engine, repo := newTestEngine(t) + cutoff := fixtureStart.Add(30 * time.Minute) + var spans []telemetry.Span + for i, offset := range []time.Duration{-time.Nanosecond, 0, time.Nanosecond} { + n := cutoff.Add(offset).UnixNano() + spans = append(spans, telemetry.Span{TraceID: "trace", SpanID: string(rune('a' + i)), StartUnixNanos: n, EndUnixNanos: n + 1, IngestedAt: n}) + } + commit(t, repo, spans, nil) + executor := NewExecutor(engine, 30) + executor.now = func() time.Time { return fixtureStart.Add(time.Hour) } + for _, tc := range []struct { + name, predicate string + invalid bool + }{ + {"timestamptz", "start_time < TIMESTAMPTZ '2026-10-01 12:30:00+00'", true}, + {"timestamptz_reversed", "TIMESTAMPTZ '2026-10-01 12:30:00+00' > start_time", true}, + {"between_timestamptz", "start_time BETWEEN TIMESTAMPTZ '2026-10-01 12:30:00+00' AND TIMESTAMPTZ '2026-10-01 12:30:00+00'", true}, + {"between_timestamptz_reversed", "TIMESTAMPTZ '2026-10-01 12:30:00+00' BETWEEN start_time AND start_time", true}, + {"timestamp_utc", "start_time < TIMESTAMP '2026-10-01 12:30:00'", false}, + {"between_timestamp_utc", "start_time BETWEEN TIMESTAMP '2026-10-01 12:30:00' AND TIMESTAMP '2026-10-01 12:30:00'", false}, + {"explicit_cast", "start_time < TIMESTAMPTZ '2026-10-01 12:30:00+00'::TIMESTAMPTZ_NS", false}, + {"explicit_cast_offset", "start_time < TIMESTAMPTZ '2026-10-01 14:30:00+02'::TIMESTAMPTZ_NS", false}, + } { + t.Run(tc.name, func(t *testing.T) { + d := Dashboard{Name: "Time literals", Panels: []Panel{{ID: "p", Title: "Calls", Viz: "table", SQL: "SELECT count(*) AS n FROM spans WHERE $__window(start_time) AND " + tc.predicate}}} + results, err := executor.Run(t.Context(), RunRequest{Dashboard: d}) + if tc.invalid { + var problems Problems + if !errors.As(err, &problems) || len(problems) != 1 { + t.Fatalf("expected one SQL problem, got %v", err) + } + // Each publication boundary may present the same problem again. + for range 3 { + problems = PresentProblems(problems) + if p := problems[0]; p.Path != "panels[0].sql" || !strings.Contains(p.Message, "explicit cast is required") || p.Hint != SQLTimestampGuide || !strings.Contains(problems.Error(), SQLTimestampGuide) { + t.Fatalf("lost repair hint: %+v", p) + } + } + return + } + if err != nil || len(results) != 1 || results[0].Status != StatusOK { + t.Fatalf("results=%+v err=%v", results, err) + } + if got := metricValues(t, results[0], "n"); len(got) != 1 || got[0] != float64(1) { + t.Fatalf("nanosecond boundary count=%v, want [1]", got) + } + }) + } +} + // TestCheckBindsFilters pins that a filter which parses and passes the // allowlist but cannot bind against its signal is a Check problem, not an // error on every run. diff --git a/internal/panel/errors.go b/internal/panel/errors.go index e6edb1db..523c9d41 100644 --- a/internal/panel/errors.go +++ b/internal/panel/errors.go @@ -22,6 +22,11 @@ const ( const ReadFailure = "Could not read the requested data. Try again." +// SQLTimestampGuide is the time-literal rule shared by diagnostics and MCP guidance. +const SQLTimestampGuide = "Compare time columns with TIMESTAMP 'YYYY-MM-DD HH:MM:SS' (UTC), or cast TIMESTAMPTZ literals with ::TIMESTAMPTZ_NS." + +var timestampComparison = regexp.MustCompile(`^Binder Error: Cannot (?:compare values of type (?:TIMESTAMPTZ_NS and type TIMESTAMP WITH TIME ZONE|TIMESTAMP WITH TIME ZONE and type TIMESTAMPTZ_NS)|mix values of type (?:TIMESTAMPTZ_NS and TIMESTAMP WITH TIME ZONE|TIMESTAMP WITH TIME ZONE and TIMESTAMPTZ_NS) in BETWEEN clause) - an explicit cast is required$`) + var ( engineClass = regexp.MustCompile(`(?i)(?:[a-z ]+ Error):\s*`) operationalClass = regexp.MustCompile(`(?i)(?:IO|Out of Memory|Internal|Fatal|Interrupt|Connection|Serialization|Transaction) Error:`) @@ -34,6 +39,20 @@ var ( candidateBinding = regexp.MustCompile(`^"?([\pL_][\pL\pN_$.]*)"?$`) ) +// sqlProblem sanitizes the engine message before marking the fixed repair hint +// as product prose, so repeated presentation preserves the literal's cast. +func sqlProblem(path string, err error) Problem { + message := PresentError(err, SQLError) + var engineError *duckdb.Error + if errors.As(err, &engineError) && engineError.Type == duckdb.ErrorTypeBinder { + first, _, _ := strings.Cut(engineError.Msg, "\n") + if timestampComparison.MatchString(first) { + return DiagnosisProblem(path, message, SQLTimestampGuide) + } + } + return Problem{Path: path, Message: message} +} + // PresentError logs the complete cause and publishes only the chosen audience's // message. It never changes cancellation identity at the calling boundary. func PresentError(err error, kind ErrorKind) string { diff --git a/internal/panel/errors_test.go b/internal/panel/errors_test.go index 20ade8ea..4b4c3aa5 100644 --- a/internal/panel/errors_test.go +++ b/internal/panel/errors_test.go @@ -13,6 +13,19 @@ import ( var forbiddenPresentation = regexp.MustCompile(`(?i)duckdb|parquet|sqlite|(?:binder|catalog|conversion|io|parser) error|variant|timestamptz_ns|timestamp_ns|telemetry\.|parquet_index|/srv/|cast\(\$`) +func TestSQLTimestampHintOnlyAppliesToTheEngineComparisonError(t *testing.T) { + for _, cause := range []error{ + &duckdb.Error{Type: duckdb.ErrorTypeBinder, Msg: "Binder Error: Cannot compare values of type TIMESTAMPTZ_NS and type VARCHAR - an explicit cast is required"}, + &duckdb.Error{Type: duckdb.ErrorTypeBinder, Msg: "Binder Error: Referenced column TIMESTAMPTZ_NS not found"}, + errors.New("Binder Error: Cannot compare values of type TIMESTAMPTZ_NS and type TIMESTAMP WITH TIME ZONE - an explicit cast is required"), + &duckdb.Error{Type: duckdb.ErrorTypeIO, Msg: "IO Error: Cannot compare values of type TIMESTAMPTZ_NS and type TIMESTAMP WITH TIME ZONE - an explicit cast is required"}, + } { + if got := sqlProblem("panels[0].sql", cause); got.Hint != "" { + t.Fatalf("unrelated error received timestamp hint: %+v", got) + } + } +} + func TestAuthorDiagnosticsPreserveSQLVocabulary(t *testing.T) { for _, tc := range []struct{ message, want string }{ {`Catalog Error: Table with name telemetry.service_rollup does not exist!`, `Table with name service_rollup does not exist!`}, diff --git a/internal/panel/problems.go b/internal/panel/problems.go index 500a8988..af3dd696 100644 --- a/internal/panel/problems.go +++ b/internal/panel/problems.go @@ -15,8 +15,8 @@ type Problem struct { productDiagnosis bool } -// DiagnosisProblem marks product-authored no-data guidance so subsequent error -// adapters never reinterpret observed values as engine or filesystem failures. +// DiagnosisProblem marks product-authored guidance so subsequent error adapters +// never reinterpret it as engine or filesystem failures. func DiagnosisProblem(path, message, hint string) Problem { return Problem{Path: path, Message: PresentDiagnosis(message), Hint: PresentDiagnosis(hint), productDiagnosis: true} } diff --git a/internal/panel/spec.go b/internal/panel/spec.go index 5197b2ee..38dee827 100644 --- a/internal/panel/spec.go +++ b/internal/panel/spec.go @@ -58,7 +58,7 @@ type Panel struct { Width int `json:"width,omitempty" jsonschema:"Grid columns from 1 to 12; default depends on viz"` Height string `json:"height,omitempty" jsonschema:"s, m or l; default depends on viz"` Query *Query `json:"query,omitempty" jsonschema:"Structured query; exactly one of query or sql, except text panels"` - SQL string `json:"sql,omitempty" jsonschema:"One read-only SELECT over spans, logs, metrics, service_rollup or edge_rollup; must use $__window(time_column)"` + SQL string `json:"sql,omitempty" jsonschema:"One read-only SELECT over spans, logs, metrics, service_rollup or edge_rollup; must use $__window(time_column). Compare time columns with TIMESTAMP 'YYYY-MM-DD HH:MM:SS' (UTC), or cast TIMESTAMPTZ literals with ::TIMESTAMPTZ_NS."` Unit string `json:"unit,omitempty" jsonschema:"ms, s, ns, percent, ratio, count, per_second, per_minute, bytes or none; inferred when omitted"` XUnit string `json:"x_unit,omitempty" jsonschema:"scatter x axis unit; unit describes y"` Reduce string `json:"reduce,omitempty" jsonschema:"stat and gauge: window (default), last, mean, min, max or sum"` diff --git a/site/src/content/docs/reference/mcp-tools.mdx b/site/src/content/docs/reference/mcp-tools.mdx index 5b828dd8..9a41c7d6 100644 --- a/site/src/content/docs/reference/mcp-tools.mdx +++ b/site/src/content/docs/reference/mcp-tools.mdx @@ -65,7 +65,7 @@ AG-UI `mcp-app` activity content uses exactly `resource_uri`, `tool_name`, ## create_dashboard -**Create dashboard** — Create a dashboard for the authenticated user from a complete spec. Read get_telemetry_schema first and preview_panels until every non-text panel returns data. Empty or failing panels return error.code invalid_spec with their path, title and diagnosis, plus structured problems. Successful saves return a receipt for that committed version: base_version, version, changes, layout_changed, dashboard_fields and save_check. Changes describe authored panel fields and position_changed; physical packing is only layout_changed. Order chips mark the minimal moved set. save_check reports checked, reason when unchecked, total elapsed_ms and one status/rows result per saved panel with elapsed_ms when executed. checked means every panel was executed, not that error or empty panels are healthy. Text rows identify nonempty content, not telemetry. Use the structured receipt rather than prose warnings as check evidence. Create receipts list added panels without layout or dashboard edit chips. No-op saves still create a new version with no change chips. Per-panel elapsed_ms is omitted when the panel did not run. A dashboard is \{name, description, time:\{range}, variables, panels}. Each panel: \{id (lowercase), title, viz, width 1-12, query or sql}. viz: stat or gauge (one measure; gauge needs min and max), timeseries (bucket auto, at most one by), bar (one or two by), table (up to three by), text (content in Markdown), heatmap (bucket auto, no by), histogram (at most one by), scatter (exactly two measures in x then y order, one item dimension and an optional colour dimension), state_timeline (one measure, one item dimension, bucket auto and thresholds required). Rollup panel types are service_map (dependency graph) and health (overview metrics, error trend and health distribution). They require query.from=spans, no measures, by, bucket or sort, and only namespace/service equality filters with a literal or single-value variable; explicit casts and values with surrounding whitespace are rejected. Reads default to 400 services/edges and are capped at 400 even when query.limit is larger. Service scope applies before the limit; map nodes carry p95_ms and edges carry average_ms; error_rate is percent. Row panel types are logs, log_patterns and traces. Logs and traces take no measures, by or bucket. Patterns use count() by body_template. Patterns group only by body_template. Severity is the most frequent severity, ties choose the higher severity. Service is the most frequent service, ties choose ascending service name. Both describe the scoped, redacted events, not extra grouping dimensions. Each pattern returns dominant severity, top service, count and a dense trend, computed in the same bounded query after redaction and scoped filters. Redaction applies before filtering, grouping, display and drill-down. Row panels cannot use SQL. "erroring" means slowest erroring traces: filter to erroring traces, then rank by root duration. Trace candidate ranking is approximate when filters exclude the root span, as with exemplars. Categorical charts use fixed-order colour slots. Categorical options.top defaults to 6, maximum 6. Structured panels: the server computes Other; series are chosen worst-first by confidence (Wilson lower bound for error rates; at least 20 samples for latency); the rest fold into Other (N). Count, rate, sum and share rank largest first. Error rates require at least 20 requests for eligible Wilson ranking (z=1.96). Percentiles, quantile, avg, max and min require at least 20 non-null numeric samples for eligible value ranking and use better (explicit or inferred). Ineligible series follow eligible ones by sample count descending. Ties use sample count descending, then name ascending. SQL panels: series past six are left out, with a note. state_timeline defaults to 8 rows, maximum 20; other noncategorical panels use the same limits. Scatter supports options.x_scale and options.y_scale (log or linear), with distinct x_unit for x and unit for y, each inferred independently when omitted. Rows returned use the count unit. State timelines preserve null and absent buckets as unknown and keep the top items without merging them into Other. Scatter requires a structured query. SQL state_timeline requires timestamp time, a string item, one numeric measure and optional string state; its interval is one minute and thresholds are required. Items rank by volume (row count) for top N. Previous-period comparison is not drawn for scatter or state_timeline. Distribution panels use a structured query with one count() measure and histogram: \{field: duration_ms, buckets: log2} for spans or \{field: value, buckets: explicit} for metrics. Negative span durations clamp to [0,1); +Inf goes to overflow; NaN is excluded because it is not a measurement. Metric histograms use stored temporality and shared per-series increases; resets contribute current counts. Delta histograms sum observation weights. query: \{from: spans|logs|metrics, where: [filter expressions], measures: [fn(field) as alias], by: [fields], bucket, sort, limit}. Measures: count(), rate() per second, error_rate() percent, share() percent, avg/min/max/sum(field), last(value) for metrics, p50/p75/p90/p95/p99(field), quantile(field, 0.999), count_distinct(field). Fields are columns from get_telemetry_schema or attributes['key'] / resource['key'] with the key written literally. Filters are SQL boolean expressions: service = $service, kind = 'SPAN_KIND_SERVER', status = 'STATUS_CODE_ERROR', http_route IN $routes, attributes['http.response.status_code']::INTEGER >= 500. Variables: \{name, kind: query|custom|constant|text, from, field, default, include_all}; $__all selects All and removes filters that use the variable. sql: one SELECT over spans, logs, metrics, service_rollup, edge_rollup or resource_inventory that filters time with $__window(column). Use values exactly as get_telemetry_schema lists them. Example panel: \{"id":"latency","title":"Checkout latency","viz":"timeseries","width":8,"query":\{"from":"spans","where":["service = 'checkout'","kind = 'SPAN_KIND_SERVER'"],"measures":["p50(duration_ms)","p95(duration_ms)","p99(duration_ms)"],"bucket":"auto"},"unit":"ms","thresholds":[\{"value":1500,"status":"bad"}]}. Metrics: gauge/updown | counter | histogram. count(): points; count_distinct(): distinct. rate(): undefined | series increase/elapsed summed | count rate summed. sum(value): series means summed | increases | sum increases. avg(value): mean series means | undefined | sum/count increases. Gauge/updown sum/avg carry earlier points within 5m. Deltas spread across covered buckets; rate alone carries within 5m. rate() is per-second increase over covered elapsed time; auto >=1m. min/max: points | points | undefined. last(value): latest strictly before bucket/window end, reported within 5m, summed | same | undefined. pNN/quantile: points | undefined | interpolated bucket increases. summary: no value aggregates. Undefined: NULL. Fix name in its own where entry: one literal or single-value variable without All or multi-value; or name in by. Read type/temporality from get_telemetry_schema. Panel SQL also supports resource_inventory: signal, namespace, service, full typed resource, first_seen and last_seen. One row per distinct full resource per signal/namespace/service observed inside the logical panel window; first_seen/last_seen are actual in-window event times. Select signal='spans', 'logs' or 'metrics' and use resource['key'] with literal dotted keys. count(*) counts resource identities per signal, not events or instances. Declare $__window(first_seen). This relation is SQL-only: query.from and variables remain spans|logs|metrics; unscoped SQL cannot read it. state_timeline needs at least one threshold marked warn or bad, rather than only ok. Annotations attach only to timeseries, heatmap and state_timeline panels, scoped by service and namespace filters. drill: logs needs query.from=logs. Drill is not available on metrics. Every table format's field must exist in the returned frame; formatting cannot reference an absent column. A structured-table sparkline field must be a measure alias from that table's query. service_link needs variable to name the query or custom variable selected by the link. highlight applies to logs and log_patterns, at most 200 characters. split: deploy needs a service equality filter, as a literal or a single-value variable; other filters are allowed. When the service is All, a split: deploy panel shows the plain bar with a note explaining the missing service scope. Use service_map for dependency context, health for service health; neither proves causality. Example 1: Reference shapes; use only the types the question needs. \{"name":"Investigation shapes","annotations":\{"deploys":true,"anomalies":true},"variables":[\{"name":"service","kind":"query","from":"spans","field":"service"}],"panels":[\{"id":"latency_heat","title":"Latency over time","viz":"heatmap","query":\{"from":"spans","where":["service = $service"],"measures":["count()"],"bucket":"auto","histogram":\{"field":"duration_ms","buckets":"log2"}},"drill":"traces"},\{"id":"latency_hist","title":"Latency by operation","viz":"histogram","query":\{"from":"spans","where":["service = $service"],"measures":["count()"],"by":["operation"],"histogram":\{"field":"duration_ms","buckets":"log2"}},"drill":"traces"},\{"id":"items","title":"Calls versus tail latency","viz":"scatter","query":\{"from":"spans","where":["service = $service"],"measures":["count() as rows","p95(duration_ms) as tail_latency"],"by":["operation"]},"drill":"traces"},\{"id":"states","title":"Error states","viz":"state_timeline","query":\{"from":"spans","where":["service = $service"],"measures":["error_rate()"],"by":["operation"],"bucket":"auto"},"thresholds":[\{"value":5,"status":"bad"}],"drill":"traces"},\{"id":"log_rows","title":"Recent logs","viz":"logs","query":\{"from":"logs","where":["service = $service"],"sort":"time","limit":100},"drill":"logs"},\{"id":"patterns","title":"Repeated log patterns","viz":"log_patterns","query":\{"from":"logs","where":["service = $service"],"measures":["count()"],"by":["body_template"],"bucket":"auto","limit":50},"drill":"logs"},\{"id":"trace_rows","title":"Slow traces","viz":"traces","query":\{"from":"spans","where":["service = $service"],"sort":"duration_ms","limit":20},"drill":"traces"},\{"id":"map","title":"Service calls","viz":"service_map","query":\{"from":"spans"}},\{"id":"health","title":"Service health","viz":"health","query":\{"from":"spans"}}]} Example 2: Reference shapes; use only the types the question needs. \{"name":"Interaction shapes","variables":[\{"name":"service","kind":"query","from":"spans","field":"service"}],"panels":[\{"id":"hist","title":"Metric distribution","viz":"histogram","query":\{"from":"metrics","where":["service = $service"],"measures":["count()"],"histogram":\{"field":"value","buckets":"explicit"}}},\{"id":"table","title":"Service calls and trends","viz":"table","query":\{"from":"spans","measures":["count() as calls"],"by":["service"]},"options":\{"columns":[\{"field":"service","format":"service_link","variable":"service"},\{"field":"calls","format":"sparkline"}]}},\{"id":"prior","title":"Service calls one day earlier","viz":"bar","query":\{"from":"spans","measures":["count()"],"by":["service"]},"click":\{"set_variable":"service"},"time":\{"shift":"1d"}}]} Metric examples; use when the discovered type fits. \{"name":"Metric measures","panels":[\{"id":"network","title":"Network bytes per second","viz":"timeseries","query":\{"from":"metrics","where":["name = 'system.network.io'"],"measures":["rate()"],"by":["attributes['direction']"],"bucket":"auto"}},\{"id":"duration","title":"Request duration p95","viz":"timeseries","query":\{"from":"metrics","where":["name = 'http.server.request.duration'"],"measures":["p95(value)"],"bucket":"auto"},"unit":"s"}]} Observed-resource SQL example. \{"name":"Observed resources","panels":[\{"id":"resources","title":"Span resources observed in this window","viz":"table","sql":"SELECT namespace,service,resource['service.version']::VARCHAR AS version,first_seen,last_seen FROM resource_inventory WHERE signal='spans' AND $__window(first_seen) ORDER BY namespace,service,version,first_seen,last_seen"}]} +**Create dashboard** — Create a dashboard for the authenticated user from a complete spec. Read get_telemetry_schema first and preview_panels until every non-text panel returns data. Empty or failing panels return error.code invalid_spec with their path, title and diagnosis, plus structured problems. Successful saves return a receipt for that committed version: base_version, version, changes, layout_changed, dashboard_fields and save_check. Changes describe authored panel fields and position_changed; physical packing is only layout_changed. Order chips mark the minimal moved set. save_check reports checked, reason when unchecked, total elapsed_ms and one status/rows result per saved panel with elapsed_ms when executed. checked means every panel was executed, not that error or empty panels are healthy. Text rows identify nonempty content, not telemetry. Use the structured receipt rather than prose warnings as check evidence. Create receipts list added panels without layout or dashboard edit chips. No-op saves still create a new version with no change chips. Per-panel elapsed_ms is omitted when the panel did not run. A dashboard is \{name, description, time:\{range}, variables, panels}. Each panel: \{id (lowercase), title, viz, width 1-12, query or sql}. viz: stat or gauge (one measure; gauge needs min and max), timeseries (bucket auto, at most one by), bar (one or two by), table (up to three by), text (content in Markdown), heatmap (bucket auto, no by), histogram (at most one by), scatter (exactly two measures in x then y order, one item dimension and an optional colour dimension), state_timeline (one measure, one item dimension, bucket auto and thresholds required). Rollup panel types are service_map (dependency graph) and health (overview metrics, error trend and health distribution). They require query.from=spans, no measures, by, bucket or sort, and only namespace/service equality filters with a literal or single-value variable; explicit casts and values with surrounding whitespace are rejected. Reads default to 400 services/edges and are capped at 400 even when query.limit is larger. Service scope applies before the limit; map nodes carry p95_ms and edges carry average_ms; error_rate is percent. Row panel types are logs, log_patterns and traces. Logs and traces take no measures, by or bucket. Patterns use count() by body_template. Patterns group only by body_template. Severity is the most frequent severity, ties choose the higher severity. Service is the most frequent service, ties choose ascending service name. Both describe the scoped, redacted events, not extra grouping dimensions. Each pattern returns dominant severity, top service, count and a dense trend, computed in the same bounded query after redaction and scoped filters. Redaction applies before filtering, grouping, display and drill-down. Row panels cannot use SQL. "erroring" means slowest erroring traces: filter to erroring traces, then rank by root duration. Trace candidate ranking is approximate when filters exclude the root span, as with exemplars. Categorical charts use fixed-order colour slots. Categorical options.top defaults to 6, maximum 6. Structured panels: the server computes Other; series are chosen worst-first by confidence (Wilson lower bound for error rates; at least 20 samples for latency); the rest fold into Other (N). Count, rate, sum and share rank largest first. Error rates require at least 20 requests for eligible Wilson ranking (z=1.96). Percentiles, quantile, avg, max and min require at least 20 non-null numeric samples for eligible value ranking and use better (explicit or inferred). Ineligible series follow eligible ones by sample count descending. Ties use sample count descending, then name ascending. SQL panels: series past six are left out, with a note. state_timeline defaults to 8 rows, maximum 20; other noncategorical panels use the same limits. Scatter supports options.x_scale and options.y_scale (log or linear), with distinct x_unit for x and unit for y, each inferred independently when omitted. Rows returned use the count unit. State timelines preserve null and absent buckets as unknown and keep the top items without merging them into Other. Scatter requires a structured query. SQL state_timeline requires timestamp time, a string item, one numeric measure and optional string state; its interval is one minute and thresholds are required. Items rank by volume (row count) for top N. Previous-period comparison is not drawn for scatter or state_timeline. Distribution panels use a structured query with one count() measure and histogram: \{field: duration_ms, buckets: log2} for spans or \{field: value, buckets: explicit} for metrics. Negative span durations clamp to [0,1); +Inf goes to overflow; NaN is excluded because it is not a measurement. Metric histograms use stored temporality and shared per-series increases; resets contribute current counts. Delta histograms sum observation weights. query: \{from: spans|logs|metrics, where: [filter expressions], measures: [fn(field) as alias], by: [fields], bucket, sort, limit}. Measures: count(), rate() per second, error_rate() percent, share() percent, avg/min/max/sum(field), last(value) for metrics, p50/p75/p90/p95/p99(field), quantile(field, 0.999), count_distinct(field). Fields are columns from get_telemetry_schema or attributes['key'] / resource['key'] with the key written literally. Filters are SQL boolean expressions: service = $service, kind = 'SPAN_KIND_SERVER', status = 'STATUS_CODE_ERROR', http_route IN $routes, attributes['http.response.status_code']::INTEGER >= 500. Variables: \{name, kind: query|custom|constant|text, from, field, default, include_all}; $__all selects All and removes filters that use the variable. sql: one SELECT over spans, logs, metrics, service_rollup, edge_rollup or resource_inventory that filters time with $__window(column). Compare time columns with TIMESTAMP 'YYYY-MM-DD HH:MM:SS' (UTC), or cast TIMESTAMPTZ literals with ::TIMESTAMPTZ_NS. Use values exactly as get_telemetry_schema lists them. Example panel: \{"id":"latency","title":"Checkout latency","viz":"timeseries","width":8,"query":\{"from":"spans","where":["service = 'checkout'","kind = 'SPAN_KIND_SERVER'"],"measures":["p50(duration_ms)","p95(duration_ms)","p99(duration_ms)"],"bucket":"auto"},"unit":"ms","thresholds":[\{"value":1500,"status":"bad"}]}. Metrics: gauge/updown | counter | histogram. count(): points; count_distinct(): distinct. rate(): undefined | series increase/elapsed summed | count rate summed. sum(value): series means summed | increases | sum increases. avg(value): mean series means | undefined | sum/count increases. Gauge/updown sum/avg carry earlier points within 5m. Deltas spread across covered buckets; rate alone carries within 5m. rate() is per-second increase over covered elapsed time; auto >=1m. min/max: points | points | undefined. last(value): latest strictly before bucket/window end, reported within 5m, summed | same | undefined. pNN/quantile: points | undefined | interpolated bucket increases. summary: no value aggregates. Undefined: NULL. Fix name in its own where entry: one literal or single-value variable without All or multi-value; or name in by. Read type/temporality from get_telemetry_schema. Panel SQL also supports resource_inventory: signal, namespace, service, full typed resource, first_seen and last_seen. One row per distinct full resource per signal/namespace/service observed inside the logical panel window; first_seen/last_seen are actual in-window event times. Select signal='spans', 'logs' or 'metrics' and use resource['key'] with literal dotted keys. count(*) counts resource identities per signal, not events or instances. Declare $__window(first_seen). This relation is SQL-only: query.from and variables remain spans|logs|metrics; unscoped SQL cannot read it. state_timeline needs at least one threshold marked warn or bad, rather than only ok. Annotations attach only to timeseries, heatmap and state_timeline panels, scoped by service and namespace filters. drill: logs needs query.from=logs. Drill is not available on metrics. Every table format's field must exist in the returned frame; formatting cannot reference an absent column. A structured-table sparkline field must be a measure alias from that table's query. service_link needs variable to name the query or custom variable selected by the link. highlight applies to logs and log_patterns, at most 200 characters. split: deploy needs a service equality filter, as a literal or a single-value variable; other filters are allowed. When the service is All, a split: deploy panel shows the plain bar with a note explaining the missing service scope. Use service_map for dependency context, health for service health; neither proves causality. Example 1: Reference shapes; use only the types the question needs. \{"name":"Investigation shapes","annotations":\{"deploys":true,"anomalies":true},"variables":[\{"name":"service","kind":"query","from":"spans","field":"service"}],"panels":[\{"id":"latency_heat","title":"Latency over time","viz":"heatmap","query":\{"from":"spans","where":["service = $service"],"measures":["count()"],"bucket":"auto","histogram":\{"field":"duration_ms","buckets":"log2"}},"drill":"traces"},\{"id":"latency_hist","title":"Latency by operation","viz":"histogram","query":\{"from":"spans","where":["service = $service"],"measures":["count()"],"by":["operation"],"histogram":\{"field":"duration_ms","buckets":"log2"}},"drill":"traces"},\{"id":"items","title":"Calls versus tail latency","viz":"scatter","query":\{"from":"spans","where":["service = $service"],"measures":["count() as rows","p95(duration_ms) as tail_latency"],"by":["operation"]},"drill":"traces"},\{"id":"states","title":"Error states","viz":"state_timeline","query":\{"from":"spans","where":["service = $service"],"measures":["error_rate()"],"by":["operation"],"bucket":"auto"},"thresholds":[\{"value":5,"status":"bad"}],"drill":"traces"},\{"id":"log_rows","title":"Recent logs","viz":"logs","query":\{"from":"logs","where":["service = $service"],"sort":"time","limit":100},"drill":"logs"},\{"id":"patterns","title":"Repeated log patterns","viz":"log_patterns","query":\{"from":"logs","where":["service = $service"],"measures":["count()"],"by":["body_template"],"bucket":"auto","limit":50},"drill":"logs"},\{"id":"trace_rows","title":"Slow traces","viz":"traces","query":\{"from":"spans","where":["service = $service"],"sort":"duration_ms","limit":20},"drill":"traces"},\{"id":"map","title":"Service calls","viz":"service_map","query":\{"from":"spans"}},\{"id":"health","title":"Service health","viz":"health","query":\{"from":"spans"}}]} Example 2: Reference shapes; use only the types the question needs. \{"name":"Interaction shapes","variables":[\{"name":"service","kind":"query","from":"spans","field":"service"}],"panels":[\{"id":"hist","title":"Metric distribution","viz":"histogram","query":\{"from":"metrics","where":["service = $service"],"measures":["count()"],"histogram":\{"field":"value","buckets":"explicit"}}},\{"id":"table","title":"Service calls and trends","viz":"table","query":\{"from":"spans","measures":["count() as calls"],"by":["service"]},"options":\{"columns":[\{"field":"service","format":"service_link","variable":"service"},\{"field":"calls","format":"sparkline"}]}},\{"id":"prior","title":"Service calls one day earlier","viz":"bar","query":\{"from":"spans","measures":["count()"],"by":["service"]},"click":\{"set_variable":"service"},"time":\{"shift":"1d"}}]} Metric examples; use when the discovered type fits. \{"name":"Metric measures","panels":[\{"id":"network","title":"Network bytes per second","viz":"timeseries","query":\{"from":"metrics","where":["name = 'system.network.io'"],"measures":["rate()"],"by":["attributes['direction']"],"bucket":"auto"}},\{"id":"duration","title":"Request duration p95","viz":"timeseries","query":\{"from":"metrics","where":["name = 'http.server.request.duration'"],"measures":["p95(value)"],"bucket":"auto"},"unit":"s"}]} Observed-resource SQL example. \{"name":"Observed resources","panels":[\{"id":"resources","title":"Span resources observed in this window","viz":"table","sql":"SELECT namespace,service,resource['service.version']::VARCHAR AS version,first_seen,last_seen FROM resource_inventory WHERE signal='spans' AND $__window(first_seen) ORDER BY namespace,service,version,first_seen,last_seen"}]} **Additive.** It creates something new and alters nothing that exists. @@ -170,7 +170,7 @@ Read-only. ## get_telemetry_schema -**Telemetry schema** — List the signals, columns with their common values, attribute keys seen per service, metric names, services, measure functions, units and SQL-only relations in sql_relations. Read this before drafting panels so filters use values that exist. See create_dashboard for the spec guide. +**Telemetry schema** — List the signals, columns with their common values, attribute keys seen per service, metric names, services, measure functions, units and SQL-only relations in sql_relations. Read this before drafting panels so filters use values that exist. Compare time columns with TIMESTAMP 'YYYY-MM-DD HH:MM:SS' (UTC), or cast TIMESTAMPTZ literals with ::TIMESTAMPTZ_NS. See create_dashboard for the spec guide. Read-only.