Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f5dd2fe
docs: design the full UI without an LLM key and plan FanQL batch 1
vishr Oct 29, 2026
9b2a539
feat(fanql): lex FanQL text in filter and measure contexts
vishr Oct 29, 2026
cc4b00a
feat(fanql): parse FanQL into a tree with recovering diagnostics
vishr Oct 29, 2026
6370426
refactor(panel): share the severity table, export catalog accessors a…
vishr Oct 29, 2026
9fdd447
feat(fanql): resolve fields, vocabularies and severity ranges against…
vishr Oct 29, 2026
4dfb404
feat(fanql): lower the resolved tree to the structured panel query
vishr Oct 29, 2026
695aeb3
feat(fanql): print canonical text and lift stored queries through the…
vishr Oct 29, 2026
e6fef6d
feat(panel): conditional aggregates and measure arithmetic with budge…
vishr Oct 29, 2026
6d18a5a
feat(fanql): position-aware completion with coalesced schema fetches
vishr Oct 29, 2026
7369e54
feat(api): FanQL parse, format, complete and reference routes
vishr Oct 29, 2026
3ecbb6f
fix(fanql): address review findings for lexing through lift
vishr Oct 29, 2026
0f67f8e
feat(mcp): accept FanQL text in query_telemetry and publish the refer…
vishr Oct 29, 2026
06946c3
feat(fanql): generate the FanQL reference from the registries
vishr Oct 29, 2026
836324c
test(fanql): corpus, round-trip, truth-table, fuzz and latency accept…
vishr Oct 30, 2026
6c4131b
fix(fanql): bound completion fetches and harden quoting, numbers and …
vishr Oct 30, 2026
2e3713a
feat(ui): hide AI controls when chat is disabled
vishr Oct 30, 2026
d810338
fix(fanql): keep negation, exact integers and SQL comments stable thr…
vishr Oct 30, 2026
9a5b4f8
test(fanql): make latency acceptance deterministic and move budgets t…
vishr Oct 30, 2026
01a11ea
chore(docs): keep FanQL design under docs/specs and drop the agent plan
vishr Oct 30, 2026
81485ce
refactor(fanql): share typed engine AST and vocabulary and hide parse…
vishr Oct 30, 2026
9c1a713
refactor(fanql): build panel measures through one shared expression g…
vishr Oct 30, 2026
da0d170
fix(fanql): unify HTTP and MCP version and diagnostic contracts
vishr Oct 30, 2026
0a27f17
fix(fanql): honor completion windows and formatting and diagnostic co…
vishr Oct 30, 2026
b7db529
fix(fanql): give field-specific repairs and one diagnostic per clause
vishr Oct 30, 2026
038f6f0
fix(fanql): preserve native validation, source spans and structured t…
vishr Oct 30, 2026
b37af02
fix(mcp): attach telemetry output documentation to the correct type
vishr Oct 30, 2026
9ab68a5
Preserve independent FanQL errors and isolate tool metadata
vishr Oct 30, 2026
d5382a7
refactor(fanql): drop the language version field
vishr Oct 30, 2026
c5567e4
refactor(panel): reuse typed SQL expressions across scope helpers
vishr Oct 30, 2026
e683c45
fix(fanql): report independent diagnostics and preserve missing values
vishr Oct 30, 2026
f1727e3
Merge origin/main into feat/fanql
vishr Oct 30, 2026
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
2 changes: 2 additions & 0 deletions cmd/fanout-docgen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import (

"github.com/labstack/fanout/internal/alert"
"github.com/labstack/fanout/internal/config"
"github.com/labstack/fanout/internal/fanql"
)

func main() {
Expand Down Expand Up @@ -116,6 +117,7 @@ func run(source, alertSource, routeDirs, outDir, envExample string, check bool)
return err
}
pages["mcp.mdx"] = toolsPage
pages["fanql.md"] = []byte("---\ntitle: \"FanQL\"\ndescription: \"The preview text syntax for structured telemetry queries.\"\nsummary: \"FanQL syntax, fields, functions, variables, null semantics and examples, generated from query registries.\"\nread_when:\n - \"You are writing a FanQL query and need syntax or field names.\"\nstatus: preview\ngenerated: true\n---\n\n" + fanql.Reference())

outputs := map[string][]byte{envExample: renderEnvironment(fields)}
for name, body := range pages {
Expand Down
21 changes: 21 additions & 0 deletions cmd/fanout-docgen/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,31 @@ import (
"testing"

"github.com/labstack/fanout/internal/config"
"github.com/labstack/fanout/internal/fanql"
)

const configSource = "../../internal/config/config.go"

func TestDocgenWritesFanQLReference(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "reference")
if err := run(configSource, "../../internal/alert/types.go", strings.Join(routeDirs, ","), out, filepath.Join(dir, ".env.example"), false); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(out, "fanql.md"))
if err != nil {
t.Fatal(err)
}
page := string(raw)
if !strings.HasPrefix(page, "---\n") || !strings.Contains(page, "generated: true\n") || !strings.Contains(page, "summary: ") {
t.Fatal("missing site front matter")
}
_, content, ok := strings.Cut(page, "\n---\n\n")
if !ok || content != fanql.Reference() {
t.Fatalf("page content differs from the API/MCP reference: %q", page)
}
}

// The generator's value is that a page cannot describe a setting the binary
// does not accept. That holds only while reflection and the source parse agree,
// so the cross-check is the thing worth testing.
Expand Down
1 change: 1 addition & 0 deletions cmd/fanout/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ func main() {
panels := panel.NewExecutor(q, cfg.RetentionDays)
panels.SetRollupReader(queries)
api.RegisterPanelRoutes(e, panels)
api.RegisterFanQLRoutes(e, q, panels)
api.RegisterAnnotationRoutes(e, annotations.New(q))
api.RegisterTraceRoutes(e, queries, cfg.RetentionDays)
api.RegisterIntelligenceRoutes(e, detector)
Expand Down
627 changes: 627 additions & 0 deletions docs/specs/2026-10-29-no-llm-ui-design.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,12 @@ outside the verification environment's scope. Neither command publishes the site
Refresh the README's light and dark System overview captures with `just landing-captures` (real seeded server and local Chrome). To capture an existing instance, set `FANOUT_LANDING_BASE_URL` to its URL and `FANOUT_LANDING_SESSION_COOKIE` to a `name=value` session cookie before running the same command; no server starts and no data is seeded.

To record chat and dashboard clips, set `FANOUT_LANDING_RECORDINGS=1` and the external-instance variables above, then run `bun run e2e e2e/specs/app/landing-recordings.spec.ts --project=app-1440-light --project=app-1440-dark` from `ui/host`. Follow [landing-recordings.md](../site/scripts/landing-recordings.md) to encode and upload them.


## FanQL performance checks

The FanQL tests always enforce allocation ceilings and bounded parser/renderer
work. Wall-clock acceptance is opt-in: run `just fanql-perf`, or set
`FANOUT_TEST_LATENCY=1` when running the FanQL tests through
`scripts/with-duckdb.sh`. Use an uninstrumented build for timing; race builds
still check deterministic work bounds but skip wall-clock assertions.
4 changes: 3 additions & 1 deletion internal/api/auth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,9 @@ func classifyRoute(method, path string) (routePolicy, bool) {
return routePolicy{kind: routePolicyCapability, capability: ManageAlerts}, unsafe
case path == "/api/panels/query" || path == "/api/panels/exemplars" || path == "/api/panels/variables/resolve" || path == "/api/annotations":
return routePolicy{kind: routePolicyCapability, capability: ReadTelemetry}, method == http.MethodPost
case path == "/api/telemetry/schema":
case path == "/api/fanql/parse" || path == "/api/fanql/format" || path == "/api/fanql/complete":
return routePolicy{kind: routePolicyCapability, capability: ReadTelemetry}, method == http.MethodPost
case path == "/api/fanql/reference" || path == "/api/telemetry/schema":
return routePolicy{kind: routePolicyCapability, capability: ReadTelemetry}, read
case path == "/api/dashboards" || strings.HasPrefix(path, "/api/dashboards/"):
return routePolicy{kind: routePolicyCapability, capability: ManageOwnDashboards}, read || unsafe
Expand Down
4 changes: 4 additions & 0 deletions internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ func TestRoutePolicyClassification(t *testing.T) {
{http.MethodPost, "/api/dashboards/dashboard-1/versions/1/restore", routePolicyCapability, ManageOwnDashboards},
{http.MethodPost, "/api/agent/runs", routePolicyCapability, RunAgent},
{http.MethodPost, "/api/panels/variables/resolve", routePolicyCapability, ReadTelemetry},
{http.MethodPost, "/api/fanql/parse", routePolicyCapability, ReadTelemetry},
{http.MethodPost, "/api/fanql/format", routePolicyCapability, ReadTelemetry},
{http.MethodPost, "/api/fanql/complete", routePolicyCapability, ReadTelemetry},
{http.MethodGet, "/api/fanql/reference", routePolicyCapability, ReadTelemetry},
{http.MethodGet, "/api/settings/ingest", routePolicyCapability, ReadIngestMetadata},
{http.MethodPost, "/api/settings/ingest/token/rotate", routePolicyCapability, ManageIngest},
{http.MethodPost, "/api/users/user-1/access/revoke", routePolicyCapability, ManageUsers},
Expand Down
7 changes: 6 additions & 1 deletion internal/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ func HTTPErrorHandler(c *echo.Context, err error) {
if c.Request().Method == http.MethodHead {
writeErr = c.NoContent(status)
} else {
writeErr = c.JSON(status, body)
var domain interface{ ResponseBody() any }
if errors.As(err, &domain) {
writeErr = c.JSON(status, domain.ResponseBody())
} else {
writeErr = c.JSON(status, body)
}
}
if writeErr != nil {
slog.Error("HTTP error response failed", "route", c.Path(), "error", writeErr)
Expand Down
145 changes: 145 additions & 0 deletions internal/api/fanql.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package api

import (
"context"
"net/http"
"time"

"github.com/labstack/echo/v5"
"github.com/labstack/fanout/internal/fanql"
"github.com/labstack/fanout/internal/panel"
)

type FanQLHandler struct {
parser panel.Parser
engine PanelEngine
completer *fanql.Completer
}

func RegisterFanQLRoutes(e *echo.Echo, parser panel.Parser, engine PanelEngine) {
h := &FanQLHandler{parser: parser, engine: engine, completer: fanql.NewCompleter(engine)}
read := RequireCapability(ReadTelemetry)
e.POST("/api/fanql/parse", h.parse, read)
e.POST("/api/fanql/format", h.format, read)
e.POST("/api/fanql/complete", h.complete, read)
e.GET("/api/fanql/reference", h.reference, read)
}

type fanqlPreview struct {
Text string `json:"text"`
Note string `json:"note"`
}

// FanQL adds editor-only metadata at its own error boundary.
type fanqlFailure struct {
*apiError
Preview *fanqlPreview `json:"preview,omitempty"`
}

func (e *fanqlFailure) ResponseBody() any { return e }
func fanqlNotices(notices []fanql.Notice) []fanql.Notice {
if notices == nil {
return []fanql.Notice{}
}
return notices
}

func (h *FanQLHandler) parse(c *echo.Context) error {
var req struct {
fanql.Options
Text string `json:"text"`
Preview bool `json:"preview,omitempty"`
}
if err := decodeStrict(c, &req, 512<<10); err != nil {
return err
}
compiled, problems := fanql.Compile(req.Text, req.Options)
if len(problems) != 0 {
failure := &fanqlFailure{apiError: newAPIError(http.StatusBadRequest, "invalid_fanql", "The query has problems.")}
failure.Problems = panel.PresentProblems(problems)
if req.Preview {
failure.Preview = &fanqlPreview{Text: req.Text, Note: "not executable"}
}
return failure
}
return c.JSON(http.StatusOK, struct {
Query panel.Query `json:"query"`
Canonical string `json:"canonical"`
Viz struct {
Requested string `json:"requested,omitempty"`
Compatible []panel.VizCompat `json:"compatible"`
Edits []fanql.VizEdit `json:"edits"`
} `json:"viz"`
Notices []fanql.Notice `json:"notices"`
}{
Query: compiled.Query, Canonical: compiled.Canonical,
Viz: struct {
Requested string `json:"requested,omitempty"`
Compatible []panel.VizCompat `json:"compatible"`
Edits []fanql.VizEdit `json:"edits"`
}{Requested: req.Viz, Compatible: compiled.Compatible, Edits: compiled.Edits},
Notices: fanqlNotices(compiled.Notices),
})
}

func (h *FanQLHandler) format(c *echo.Context) error {
var req struct {
fanql.Options
Query panel.Query `json:"query"`
}
if err := decodeStrict(c, &req, 512<<10); err != nil {
return err
}
ctx, cancel := context.WithTimeout(c.Request().Context(), panelDeadline)
defer cancel()
formatted, problems, err := fanql.Format(ctx, h.parser, req.Query, req.Options)
if err != nil {
return fanqlError(err)
}
if len(problems) != 0 {
failure := newAPIError(http.StatusBadRequest, "invalid_spec", "The query spec is invalid.")
failure.Problems = panel.PresentProblems(problems)
return failure
}
formatted.Notices = fanqlNotices(formatted.Notices)
return c.JSON(http.StatusOK, formatted)
}

func fanqlError(err error) error {
return newAPIError(http.StatusServiceUnavailable, "unavailable", panel.PresentError(err, panel.ReadError))
}

func (h *FanQLHandler) complete(c *echo.Context) error {
var req struct {
fanql.Options
Text string `json:"text"`
Cursor int `json:"cursor"`
Window *string `json:"window,omitempty"`
From *time.Time `json:"from,omitempty"`
To *time.Time `json:"to,omitempty"`
Namespace string `json:"namespace,omitempty"`
}
if err := decodeStrict(c, &req, 512<<10); err != nil {
return err
}
if req.From != nil || req.To != nil {
if req.From == nil || req.To == nil || !req.From.Before(*req.To) || req.Window != nil {
return newAPIError(http.StatusBadRequest, "invalid_input", "use from and to with the start before the end, or a window duration")
}
}
window := ""
if req.Window != nil {
window = *req.Window
}
ctx, cancel := context.WithTimeout(c.Request().Context(), panelDeadline)
defer cancel()
out := h.completer.Complete(ctx, fanql.CompleteRequest{
Text: req.Text, Cursor: req.Cursor, Options: req.Options,
Window: window, From: req.From, To: req.To, Namespace: req.Namespace,
})
return c.JSON(http.StatusOK, out)
}

func (h *FanQLHandler) reference(c *echo.Context) error {
return c.Blob(http.StatusOK, "text/markdown; charset=utf-8", []byte(fanql.Reference()))
}
114 changes: 114 additions & 0 deletions internal/api/fanql_corpus_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package api

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

"github.com/labstack/fanout/internal/auth"
"github.com/labstack/fanout/internal/fanql"
"github.com/labstack/fanout/internal/panel"
)

type nonExecutingFanQLPanels struct {
fakePanels
calls int
}

func (p *nonExecutingFanQLPanels) Run(context.Context, panel.RunRequest) ([]panel.Result, error) {
p.calls++
return nil, fmt.Errorf("FanQL parsing must not execute telemetry")
}

func TestNonExecution(t *testing.T) {
file, err := os.Open("../fanql/testdata/corpus.txt")
if err != nil {
t.Fatal(err)
}
defer file.Close()

s := newTestAuthServer(t)
if _, err := s.users.CreateWithAudit("admin@example.com", "", "admin", auth.AuditEvent{EventType: "user.created", Outcome: "success"}); err != nil {
t.Fatal(err)
}
viewer, err := s.users.CreateWithAudit("viewer@example.com", "", "viewer", auth.AuditEvent{EventType: "user.created", Outcome: "success"})
if err != nil {
t.Fatal(err)
}
cookie := s.login(t, viewer)
engine := &nonExecutingFanQLPanels{}
RegisterFanQLRoutes(s.e, nil, engine)

decoder := json.NewDecoder(file)
negative := 0
for fixture := 0; ; fixture++ {
var tc struct {
Text string `json:"text"`
Viz string `json:"viz"`
Query *panel.Query `json:"query"`
Problems panel.Problems `json:"problems"`
GateProblems bool `json:"gate_problems"`
}
if err := decoder.Decode(&tc); err == io.EOF {
break
} else if err != nil {
t.Fatalf("corpus fixture %d: %v", fixture, err)
}
// Raw SQL rejected only by the existing execution gate is clean FanQL.
// The parse route must retain the same Compile-only contract.
if tc.Query != nil || len(tc.Problems) == 0 || tc.GateProblems {
continue
}
negative++
t.Run(fmt.Sprintf("%d/%s", fixture, tc.Text), func(t *testing.T) {
compiled, problems := fanql.Compile(tc.Text, fanql.Options{Viz: tc.Viz})
if compiled != nil || len(problems) == 0 {
t.Fatalf("negative corpus text became executable: %+v %+v", compiled, problems)
}
for _, preview := range []bool{false, true} {
input, err := json.Marshal(map[string]any{"text": tc.Text, "viz": tc.Viz, "preview": preview})
if err != nil {
t.Fatal(err)
}
req := sessionRequest(http.MethodPost, "/api/fanql/parse", strings.NewReader(string(input)), cookie)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
s.e.ServeHTTP(rec, req)
var body map[string]json.RawMessage
fanqlResponse(t, rec, http.StatusBadRequest, &body)
if string(body["code"]) != `"invalid_fanql"` || body["problems"] == nil {
t.Fatalf("preview=%t: wrong error contract: %s", preview, rec.Body)
}
if _, exists := body["query"]; exists {
t.Fatalf("preview=%t: problem response contains executable query: %s", preview, rec.Body)
}
if preview {
var got struct {
Text string `json:"text"`
Note string `json:"note"`
}
if err := json.Unmarshal(body["preview"], &got); err != nil {
t.Fatal(err)
}
if got.Text != tc.Text || got.Note != "not executable" {
t.Fatalf("preview lost authored text or execution warning: %s", rec.Body)
}
} else if _, exists := body["preview"]; exists {
t.Fatalf("unrequested preview: %s", rec.Body)
}
}
})
}
if negative == 0 {
t.Fatal("corpus has no non-executable texts")
}
if engine.calls != 0 {
t.Fatalf("parse executed %d telemetry queries", engine.calls)
}
}
Loading
Loading