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
26 changes: 26 additions & 0 deletions internal/mcp/panel_examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mcp
import (
"encoding/json"
"math"
"reflect"
"strings"
"testing"
"time"
Expand All @@ -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{}
Expand Down
4 changes: 2 additions & 2 deletions internal/mcp/panels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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{
Expand Down
60 changes: 60 additions & 0 deletions internal/mcp/presentation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,73 @@ import (
"regexp"
"strings"
"testing"
"time"

"github.com/labstack/fanout/internal/dashboard"
"github.com/labstack/fanout/internal/panel"
"github.com/labstack/fanout/internal/toolerror"
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
Expand Down
2 changes: 1 addition & 1 deletion internal/panel/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}
Expand Down
54 changes: 54 additions & 0 deletions internal/panel/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading