Files
gamertan 92a66db3df docs: publish Preview 19 dogfood evidence
Export the reviewed allowlisted snapshot from private source commit 05928cebd01b586cf9e9d4b8c8537a7605a6068c. This records the exact candidate, bounded capacity result, stateful migration scratch requirement, authenticated batch identity proof, and immediate live acceptance evidence.

AI-Assisted: OpenAI Codex
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-18 21:47:08 -04:00

301 lines
9.7 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only
package query
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"gamertan.com/observatory/internal/model"
)
const (
ASTVersion = 1
maxQueryWindow = 3650 * 24 * time.Hour
)
type AST struct {
Version int `json:"version"`
Signal model.Signal `json:"signal"`
Filters []Filter `json:"filters,omitempty"`
Sort *Sort `json:"sort,omitempty"`
Summary *Summary `json:"summary,omitempty"`
Limit int `json:"limit"`
Window time.Duration `json:"-"`
WindowText string `json:"window,omitempty"`
Bucket time.Duration `json:"-"`
BucketText string `json:"bucket,omitempty"`
}
type Filter struct {
Field string `json:"field"`
Op string `json:"op"`
Value string `json:"value"`
}
type Sort struct {
Field string `json:"field"`
Descending bool `json:"descending"`
}
type Summary struct {
Aggregates []Aggregate `json:"aggregates"`
GroupBy []string `json:"group_by,omitempty"`
}
type Aggregate struct {
Function string `json:"function"`
Field string `json:"field,omitempty"`
Alias string `json:"alias"`
}
func Parse(text string, maxLimit int) (AST, error) {
if len(text) == 0 || len(text) > 16_384 {
return AST{}, errors.New("query length outside accepted bounds")
}
parts := strings.Split(text, "|")
if len(parts) > 16 {
return AST{}, errors.New("too many query stages")
}
ast := AST{Version: ASTVersion, Signal: model.Signal(strings.TrimSpace(parts[0])), Limit: min(100, maxLimit)}
if ast.Signal != model.SignalLogs && ast.Signal != model.SignalMetrics && ast.Signal != model.SignalTraces && ast.Signal != model.SignalDeployments {
return AST{}, errors.New("query must begin with logs, metrics, traces, or deployments")
}
for _, raw := range parts[1:] {
stage := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(stage, "where "):
filter, err := parseFilter(strings.TrimSpace(strings.TrimPrefix(stage, "where ")))
if err != nil {
return AST{}, err
}
ast.Filters = append(ast.Filters, filter)
case strings.HasPrefix(stage, "sort "):
fields := strings.Fields(strings.TrimSpace(strings.TrimPrefix(stage, "sort ")))
if len(fields) < 1 || len(fields) > 2 || !validField(fields[0]) {
return AST{}, errors.New("invalid sort stage")
}
desc := len(fields) == 2 && fields[1] == "desc"
if len(fields) == 2 && fields[1] != "asc" && fields[1] != "desc" {
return AST{}, errors.New("sort direction must be asc or desc")
}
ast.Sort = &Sort{Field: fields[0], Descending: desc}
case strings.HasPrefix(stage, "summarize "):
if ast.Summary != nil {
return AST{}, errors.New("query may contain only one summarize stage")
}
summary, bucket, bucketText, err := parseSummary(strings.TrimSpace(strings.TrimPrefix(stage, "summarize ")))
if err != nil {
return AST{}, err
}
ast.Summary = &summary
if bucket > 0 {
ast.Bucket, ast.BucketText = bucket, bucketText
}
case strings.HasPrefix(stage, "limit "):
n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(stage, "limit ")))
if err != nil || n < 1 || n > maxLimit {
return AST{}, fmt.Errorf("limit must be between 1 and %d", maxLimit)
}
ast.Limit = n
case strings.HasPrefix(stage, "window "):
windowText := strings.TrimSpace(strings.TrimPrefix(stage, "window "))
d, err := time.ParseDuration(windowText)
if err != nil || d < time.Second || d > maxQueryWindow {
return AST{}, errors.New("window must be between 1s and 87600h")
}
ast.Window, ast.WindowText = d, windowText
default:
return AST{}, fmt.Errorf("unsupported query stage %q", stage)
}
}
if err := Validate(ast, maxLimit); err != nil {
return AST{}, err
}
return ast, nil
}
func Validate(ast AST, maxLimit int) error {
if ast.Version != ASTVersion || (ast.Signal != model.SignalLogs && ast.Signal != model.SignalMetrics && ast.Signal != model.SignalTraces && ast.Signal != model.SignalDeployments) {
return errors.New("query AST identity is invalid")
}
if ast.Limit < 1 || ast.Limit > maxLimit {
return fmt.Errorf("limit must be between 1 and %d", maxLimit)
}
if ast.Window < 0 || ast.Window > maxQueryWindow || ast.Window > 0 && ast.Window < time.Second {
return errors.New("query window is invalid")
}
if ast.Bucket < 0 || ast.Bucket > maxQueryWindow || ast.Bucket > 0 && (ast.Bucket < time.Second || ast.Summary == nil) {
return errors.New("query summary bucket is invalid")
}
if len(ast.Filters) > 16 {
return errors.New("too many query filters")
}
for _, filter := range ast.Filters {
if !validField(filter.Field) || len(filter.Value) > 4096 {
return errors.New("query filter is invalid")
}
switch filter.Op {
case "!=", ">=", "<=", "==", ">", "<":
case "=~":
if len(filter.Value) > 512 {
return errors.New("regular expression exceeds 512 bytes")
}
if _, err := regexp.Compile(filter.Value); err != nil {
return errors.New("invalid regular expression")
}
default:
return errors.New("query filter operator is invalid")
}
}
if ast.Sort != nil && !validField(ast.Sort.Field) {
return errors.New("query sort is invalid")
}
if ast.Summary != nil {
if len(ast.Summary.Aggregates) < 1 || len(ast.Summary.Aggregates) > 16 || len(ast.Summary.GroupBy) > 16 {
return errors.New("query summary is invalid")
}
aliases := map[string]bool{}
for _, aggregate := range ast.Summary.Aggregates {
switch aggregate.Function {
case "count":
if aggregate.Field != "" {
return errors.New("count accepts no field")
}
case "min", "max", "sum", "avg", "p50", "p95", "p99":
if !validField(aggregate.Field) {
return errors.New("aggregate field is invalid")
}
default:
return errors.New("aggregate function is unsupported")
}
if !validField(aggregate.Alias) || aliases[aggregate.Alias] {
return errors.New("aggregate alias is invalid or duplicated")
}
aliases[aggregate.Alias] = true
}
for _, group := range ast.Summary.GroupBy {
if !validField(group) {
return errors.New("grouping field is invalid")
}
if aliases[group] {
return errors.New("grouping field conflicts with aggregate alias")
}
}
}
return nil
}
func parseFilter(expr string) (Filter, error) {
for _, op := range []string{"!=", ">=", "<=", "==", "=~", ">", "<"} {
if i := strings.Index(expr, op); i > 0 {
field := strings.TrimSpace(expr[:i])
value := strings.TrimSpace(expr[i+len(op):])
if !validField(field) || value == "" || len(value) > 4096 {
return Filter{}, errors.New("invalid where stage")
}
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
unquoted, err := strconv.Unquote(value)
if err != nil {
return Filter{}, errors.New("invalid quoted filter value")
}
value = unquoted
}
if op == "=~" {
if len(value) > 512 {
return Filter{}, errors.New("regular expression exceeds 512 bytes")
}
if _, err := regexp.Compile(value); err != nil {
return Filter{}, errors.New("invalid regular expression")
}
}
return Filter{Field: field, Op: op, Value: value}, nil
}
}
return Filter{}, errors.New("where stage requires a comparison")
}
func parseSummary(stage string) (Summary, time.Duration, string, error) {
aggregateText, groupText, found := strings.Cut(stage, " by ")
aggregateParts := strings.Split(aggregateText, ",")
if len(aggregateParts) < 1 || len(aggregateParts) > 16 {
return Summary{}, 0, "", errors.New("summarize requires between 1 and 16 aggregates")
}
summary := Summary{}
aliases := map[string]bool{}
for _, raw := range aggregateParts {
expression := strings.TrimSpace(raw)
open := strings.IndexByte(expression, '(')
if open < 1 || !strings.HasSuffix(expression, ")") {
return Summary{}, 0, "", errors.New("invalid aggregate")
}
function := expression[:open]
field := strings.TrimSpace(expression[open+1 : len(expression)-1])
switch function {
case "count":
if field != "" {
return Summary{}, 0, "", errors.New("count accepts no field")
}
case "min", "max", "sum", "avg", "p50", "p95", "p99":
if !validField(field) {
return Summary{}, 0, "", errors.New("aggregate field is invalid")
}
default:
return Summary{}, 0, "", errors.New("aggregate function is unsupported")
}
alias := function
if field != "" {
alias += "_" + strings.ReplaceAll(field, ".", "_")
}
if aliases[alias] {
return Summary{}, 0, "", errors.New("aggregate alias is duplicated")
}
aliases[alias] = true
summary.Aggregates = append(summary.Aggregates, Aggregate{Function: function, Field: field, Alias: alias})
}
var window time.Duration
var windowText string
if found {
groups := strings.Split(groupText, ",")
if len(groups) > 16 {
return Summary{}, 0, "", errors.New("too many grouping fields")
}
for _, raw := range groups {
group := strings.TrimSpace(raw)
if strings.HasPrefix(group, "window(") && strings.HasSuffix(group, ")") {
if window > 0 {
return Summary{}, 0, "", errors.New("summary window is duplicated")
}
windowText = strings.TrimSpace(group[len("window(") : len(group)-1])
parsed, err := time.ParseDuration(windowText)
if err != nil || parsed < time.Second || parsed > maxQueryWindow {
return Summary{}, 0, "", errors.New("summary window must be between 1s and 87600h")
}
window = parsed
continue
}
if !validField(group) {
return Summary{}, 0, "", errors.New("grouping field is invalid")
}
summary.GroupBy = append(summary.GroupBy, group)
}
}
return summary, window, windowText, nil
}
func validField(field string) bool {
if field == "" || len(field) > 128 {
return false
}
for _, r := range field {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '.') {
return false
}
}
return true
}