diff --git a/README.md b/README.md index 54b1d61..957a626 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ nginx-logtail/ ├── frontend/ │ ├── main.go │ ├── handler.go # URL param parsing, concurrent TopN+Trend, template exec + │ ├── filter.go # ParseFilterExpr / FilterExprString mini filter language │ ├── client.go # gRPC dial helper │ ├── sparkline.go # TrendPoints → inline SVG polyline │ ├── format.go # fmtCount (space thousands separator) @@ -145,11 +146,16 @@ and does not change any existing interface. ## Protobuf API (`proto/logtail.proto`) ```protobuf +enum StatusOp { EQ = 0; NE = 1; GT = 2; GE = 3; LT = 4; LE = 5; } + message Filter { optional string website = 1; optional string client_prefix = 2; optional string http_request_uri = 3; optional int32 http_response = 4; + StatusOp status_op = 5; // comparison operator for http_response + optional string website_regex = 6; // RE2 regex against website + optional string uri_regex = 7; // RE2 regex against http_request_uri } enum GroupBy { WEBSITE = 0; CLIENT_PREFIX = 1; REQUEST_URI = 2; HTTP_RESPONSE = 3; } @@ -262,8 +268,16 @@ service LogtailService { ### handler.go - All filter state in the **URL query string**: `w` (window), `by` (group_by), `f_website`, - `f_prefix`, `f_uri`, `f_status`, `n`, `target`. No server-side session — URLs are shareable - and bookmarkable; multiple operators see independent views. + `f_prefix`, `f_uri`, `f_status`, `f_website_re`, `f_uri_re`, `n`, `target`. No server-side + session — URLs are shareable and bookmarkable; multiple operators see independent views. +- **Filter expression box**: a `q=` parameter carries a mini filter language + (`status>=400 AND website~=gouda.* AND uri~=^/api/`). On submission the handler parses it + via `ParseFilterExpr` and redirects to the canonical URL with individual `f_*` params; `q=` + never appears in the final URL. Parse errors re-render the current page with an inline message. +- **Status expressions**: `f_status` accepts `200`, `!=200`, `>=400`, `<500`, etc. — parsed by + `store.ParseStatusExpr` into `(value, StatusOp)` for the filter protobuf. +- **Regex filters**: `f_website_re` and `f_uri_re` hold RE2 patterns; compiled once per request + into `store.CompiledFilter` before the query-loop iteration. Invalid regexes match nothing. - `TopN` and `Trend` RPCs issued **concurrently** (both with a 5 s deadline); page renders with whatever completes. Trend failure suppresses the sparkline without erroring the page. - **Drilldown**: clicking a table row adds the current dimension's filter and advances `by` through @@ -304,7 +318,9 @@ logtail-cli stream [flags] live snapshot feed (runs until Ctrl-C, auto-reconn | `--website` | — | Filter: website | | `--prefix` | — | Filter: client prefix | | `--uri` | — | Filter: request URI | -| `--status` | — | Filter: HTTP status code | +| `--status` | — | Filter: HTTP status expression (`200`, `!=200`, `>=400`, `<500`, …) | +| `--website-re`| — | Filter: RE2 regex against website | +| `--uri-re` | — | Filter: RE2 regex against request URI | **`topn` only**: `--n 10`, `--window 5m`, `--group-by website` @@ -345,3 +361,6 @@ with a non-zero code on gRPC error. | CLI default: human-readable table | Operator-friendly by default; `--json` opt-in for scripting | | CLI multi-target fan-out | Compare a collector vs. aggregator, or two collectors, in one command | | CLI uses stdlib `flag`, no framework | Four subcommands don't justify a dependency | +| Status filter as expression string (`!=200`, `>=400`) | Operator-friendly; parsed once at query boundary, encoded as `(int32, StatusOp)` in proto | +| Regex filters compiled once per query (`CompiledFilter`) | Up to 288 × 5 000 per-entry calls — compiling per-entry would dominate query latency | +| Filter expression box (`q=`) redirects to canonical URL | Filter state stays in individual `f_*` params; URLs remain shareable and bookmarkable | diff --git a/cmd/aggregator/cache.go b/cmd/aggregator/cache.go index 8d5816d..dcc4810 100644 --- a/cmd/aggregator/cache.go +++ b/cmd/aggregator/cache.go @@ -92,6 +92,7 @@ func (c *Cache) mergeFineBuckets(now time.Time) st.Snapshot { // QueryTopN answers a TopN request from the ring buffers. func (c *Cache) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window pb.Window) []st.Entry { + cf := st.CompileFilter(filter) c.mu.RLock() defer c.mu.RUnlock() @@ -101,7 +102,7 @@ func (c *Cache) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window p idx := (buckets.Head - 1 - i + buckets.Size) % buckets.Size for _, e := range buckets.Ring[idx].Entries { t := st.LabelTuple(e.Label) - if !st.MatchesFilter(t, filter) { + if !st.MatchesFilter(t, cf) { continue } grouped[st.DimensionLabel(t, groupBy)] += e.Count @@ -112,6 +113,7 @@ func (c *Cache) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window p // QueryTrend answers a Trend request from the ring buffers. func (c *Cache) QueryTrend(filter *pb.Filter, window pb.Window) []st.TrendPoint { + cf := st.CompileFilter(filter) c.mu.RLock() defer c.mu.RUnlock() @@ -122,7 +124,7 @@ func (c *Cache) QueryTrend(filter *pb.Filter, window pb.Window) []st.TrendPoint snap := buckets.Ring[idx] var total int64 for _, e := range snap.Entries { - if st.MatchesFilter(st.LabelTuple(e.Label), filter) { + if st.MatchesFilter(st.LabelTuple(e.Label), cf) { total += e.Count } } diff --git a/cmd/cli/flags.go b/cmd/cli/flags.go index 00b714f..32d1f55 100644 --- a/cmd/cli/flags.go +++ b/cmd/cli/flags.go @@ -4,20 +4,22 @@ import ( "flag" "fmt" "os" - "strconv" "strings" + st "git.ipng.ch/ipng/nginx-logtail/internal/store" pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb" ) // sharedFlags holds the flags common to every subcommand. type sharedFlags struct { - targets []string - jsonOut bool - website string - prefix string - uri string - status string // kept as string so we can tell "unset" from "0" + targets []string + jsonOut bool + website string + prefix string + uri string + status string // expression: "200", "!=200", ">=400", etc. + websiteRe string // RE2 regex against website + uriRe string // RE2 regex against request URI } // bindShared registers the shared flags on fs and returns a pointer to the @@ -26,10 +28,12 @@ func bindShared(fs *flag.FlagSet) (*sharedFlags, *string) { sf := &sharedFlags{} target := fs.String("target", "localhost:9090", "comma-separated host:port list") fs.BoolVar(&sf.jsonOut, "json", false, "emit newline-delimited JSON") - fs.StringVar(&sf.website, "website", "", "filter: website") - fs.StringVar(&sf.prefix, "prefix", "", "filter: client prefix") - fs.StringVar(&sf.uri, "uri", "", "filter: request URI") - fs.StringVar(&sf.status, "status", "", "filter: HTTP status code (integer)") + fs.StringVar(&sf.website, "website", "", "filter: exact website match") + fs.StringVar(&sf.prefix, "prefix", "", "filter: exact client prefix match") + fs.StringVar(&sf.uri, "uri", "", "filter: exact request URI match") + fs.StringVar(&sf.status, "status", "", "filter: HTTP status expression (200, !=200, >=400, <500, …)") + fs.StringVar(&sf.websiteRe, "website-re", "", "filter: RE2 regex against website") + fs.StringVar(&sf.uriRe, "uri-re", "", "filter: RE2 regex against request URI") return sf, target } @@ -52,7 +56,7 @@ func parseTargets(s string) []string { } func buildFilter(sf *sharedFlags) *pb.Filter { - if sf.website == "" && sf.prefix == "" && sf.uri == "" && sf.status == "" { + if sf.website == "" && sf.prefix == "" && sf.uri == "" && sf.status == "" && sf.websiteRe == "" && sf.uriRe == "" { return nil } f := &pb.Filter{} @@ -66,13 +70,19 @@ func buildFilter(sf *sharedFlags) *pb.Filter { f.HttpRequestUri = &sf.uri } if sf.status != "" { - n, err := strconv.Atoi(sf.status) - if err != nil { - fmt.Fprintf(os.Stderr, "--status: %v\n", err) + n, op, ok := st.ParseStatusExpr(sf.status) + if !ok { + fmt.Fprintf(os.Stderr, "--status: invalid expression %q; use e.g. 200, !=200, >=400, <500\n", sf.status) os.Exit(1) } - n32 := int32(n) - f.HttpResponse = &n32 + f.HttpResponse = &n + f.StatusOp = op + } + if sf.websiteRe != "" { + f.WebsiteRegex = &sf.websiteRe + } + if sf.uriRe != "" { + f.UriRegex = &sf.uriRe } return f } diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 67b946d..978c84c 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -18,7 +18,9 @@ Subcommand flags (all subcommands): --website STRING filter: exact website match --prefix STRING filter: exact client-prefix match --uri STRING filter: exact request URI match - --status INT filter: exact HTTP status code + --status EXPR filter: HTTP status expression (200, !=200, >=400, <500, …) + --website-re REGEX filter: RE2 regex against website + --uri-re REGEX filter: RE2 regex against request URI topn flags: --n INT number of entries (default 10) diff --git a/cmd/collector/store.go b/cmd/collector/store.go index bd0d2ea..ec0829f 100644 --- a/cmd/collector/store.go +++ b/cmd/collector/store.go @@ -97,6 +97,7 @@ func (s *Store) mergeFineBuckets(now time.Time) st.Snapshot { // QueryTopN answers a TopN request from the ring buffers. func (s *Store) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window pb.Window) []st.Entry { + cf := st.CompileFilter(filter) s.mu.RLock() defer s.mu.RUnlock() @@ -106,7 +107,7 @@ func (s *Store) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window p idx := (buckets.Head - 1 - i + buckets.Size) % buckets.Size for _, e := range buckets.Ring[idx].Entries { t := st.LabelTuple(e.Label) - if !st.MatchesFilter(t, filter) { + if !st.MatchesFilter(t, cf) { continue } grouped[st.DimensionLabel(t, groupBy)] += e.Count @@ -117,6 +118,7 @@ func (s *Store) QueryTopN(filter *pb.Filter, groupBy pb.GroupBy, n int, window p // QueryTrend answers a Trend request from the ring buffers. func (s *Store) QueryTrend(filter *pb.Filter, window pb.Window) []st.TrendPoint { + cf := st.CompileFilter(filter) s.mu.RLock() defer s.mu.RUnlock() @@ -127,7 +129,7 @@ func (s *Store) QueryTrend(filter *pb.Filter, window pb.Window) []st.TrendPoint snap := buckets.Ring[idx] var total int64 for _, e := range snap.Entries { - if st.MatchesFilter(st.LabelTuple(e.Label), filter) { + if st.MatchesFilter(st.LabelTuple(e.Label), cf) { total += e.Count } } diff --git a/cmd/frontend/filter.go b/cmd/frontend/filter.go new file mode 100644 index 0000000..777af64 --- /dev/null +++ b/cmd/frontend/filter.go @@ -0,0 +1,175 @@ +package main + +import ( + "fmt" + "regexp" + "strings" + + st "git.ipng.ch/ipng/nginx-logtail/internal/store" +) + +// andRe splits a filter expression on AND (case-insensitive, surrounded by whitespace). +var andRe = regexp.MustCompile(`(?i)\s+and\s+`) + +// ParseFilterExpr parses a mini filter expression into a filterState. +// +// Syntax: TERM [AND TERM ...] +// +// Terms: +// +// status=200 status!=200 status>=400 status>400 status<=500 status<500 +// website=example.com — exact match +// website~=gouda.* — RE2 regex +// uri=/api/v1/ — exact match +// uri~=^/api/.* — RE2 regex +// prefix=1.2.3.0/24 — exact match +// +// Values may be enclosed in double or single quotes. +func ParseFilterExpr(s string) (filterState, error) { + s = strings.TrimSpace(s) + if s == "" { + return filterState{}, nil + } + terms := andRe.Split(s, -1) + var fs filterState + for _, term := range terms { + term = strings.TrimSpace(term) + if term == "" { + continue + } + if err := applyTerm(term, &fs); err != nil { + return filterState{}, err + } + } + return fs, nil +} + +// applyTerm parses a single "field op value" term into fs. +func applyTerm(term string, fs *filterState) error { + // Find the first operator character: ~, !, >, <, = + opIdx := strings.IndexAny(term, "~!><=") + if opIdx <= 0 { + return fmt.Errorf("invalid term %q: expected field=value, field>=value, field~=regex, etc.", term) + } + + field := strings.ToLower(strings.TrimSpace(term[:opIdx])) + rest := term[opIdx:] + + var op, value string + switch { + case strings.HasPrefix(rest, "~="): + op, value = "~=", rest[2:] + case strings.HasPrefix(rest, "!="): + op, value = "!=", rest[2:] + case strings.HasPrefix(rest, ">="): + op, value = ">=", rest[2:] + case strings.HasPrefix(rest, "<="): + op, value = "<=", rest[2:] + case strings.HasPrefix(rest, ">"): + op, value = ">", rest[1:] + case strings.HasPrefix(rest, "<"): + op, value = "<", rest[1:] + case strings.HasPrefix(rest, "="): + op, value = "=", rest[1:] + default: + return fmt.Errorf("unrecognised operator in %q", term) + } + + value = unquote(strings.TrimSpace(value)) + + switch field { + case "status": + if op == "~=" { + return fmt.Errorf("status does not support ~=; use =, !=, >=, >, <=, <") + } + expr := op + value + if op == "=" { + expr = value // ParseStatusExpr accepts bare "200" + } + if _, _, ok := st.ParseStatusExpr(expr); !ok { + return fmt.Errorf("invalid status expression %q", expr) + } + fs.Status = expr + case "website": + switch op { + case "=": + fs.Website = value + case "~=": + fs.WebsiteRe = value + default: + return fmt.Errorf("website only supports = and ~=, not %q", op) + } + case "uri": + switch op { + case "=": + fs.URI = value + case "~=": + fs.URIRe = value + default: + return fmt.Errorf("uri only supports = and ~=, not %q", op) + } + case "prefix": + if op != "=" { + return fmt.Errorf("prefix only supports =, not %q", op) + } + fs.Prefix = value + default: + return fmt.Errorf("unknown field %q; valid: status, website, uri, prefix", field) + } + return nil +} + +// unquote strips surrounding double or single quotes. +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +// FilterExprString serialises a filterState back into the mini filter expression language. +// Returns "" when no filters are active. +func FilterExprString(f filterState) string { + var parts []string + if f.Website != "" { + parts = append(parts, "website="+quoteMaybe(f.Website)) + } + if f.WebsiteRe != "" { + parts = append(parts, "website~="+quoteMaybe(f.WebsiteRe)) + } + if f.Prefix != "" { + parts = append(parts, "prefix="+quoteMaybe(f.Prefix)) + } + if f.URI != "" { + parts = append(parts, "uri="+quoteMaybe(f.URI)) + } + if f.URIRe != "" { + parts = append(parts, "uri~="+quoteMaybe(f.URIRe)) + } + if f.Status != "" { + parts = append(parts, statusTermStr(f.Status)) + } + return strings.Join(parts, " AND ") +} + +// statusTermStr converts a stored status expression (">=400", "200") to a +// full filter term ("status>=400", "status=200"). +func statusTermStr(expr string) string { + if expr == "" { + return "" + } + if len(expr) > 0 && (expr[0] == '!' || expr[0] == '>' || expr[0] == '<') { + return "status" + expr + } + return "status=" + expr +} + +// quoteMaybe wraps s in double quotes when it contains spaces or quote characters. +func quoteMaybe(s string) string { + if strings.ContainsAny(s, " \t\"'") { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` + } + return s +} diff --git a/cmd/frontend/filter_test.go b/cmd/frontend/filter_test.go new file mode 100644 index 0000000..1a2f401 --- /dev/null +++ b/cmd/frontend/filter_test.go @@ -0,0 +1,260 @@ +package main + +import "testing" + +// --- ParseFilterExpr --- + +func TestParseEmpty(t *testing.T) { + fs, err := ParseFilterExpr("") + if err != nil { + t.Fatal(err) + } + if fs != (filterState{}) { + t.Fatalf("expected empty filterState, got %+v", fs) + } +} + +func TestParseStatusEQ(t *testing.T) { + fs, err := ParseFilterExpr("status=200") + if err != nil { + t.Fatal(err) + } + if fs.Status != "200" { + t.Fatalf("Status=%q", fs.Status) + } +} + +func TestParseStatusGE(t *testing.T) { + fs, err := ParseFilterExpr("status>=400") + if err != nil { + t.Fatal(err) + } + if fs.Status != ">=400" { + t.Fatalf("Status=%q", fs.Status) + } +} + +func TestParseStatusNE(t *testing.T) { + fs, err := ParseFilterExpr("status!=200") + if err != nil { + t.Fatal(err) + } + if fs.Status != "!=200" { + t.Fatalf("Status=%q", fs.Status) + } +} + +func TestParseStatusLT(t *testing.T) { + fs, err := ParseFilterExpr("status<500") + if err != nil { + t.Fatal(err) + } + if fs.Status != "<500" { + t.Fatalf("Status=%q", fs.Status) + } +} + +func TestParseWebsiteExact(t *testing.T) { + fs, err := ParseFilterExpr("website=example.com") + if err != nil { + t.Fatal(err) + } + if fs.Website != "example.com" { + t.Fatalf("Website=%q", fs.Website) + } +} + +func TestParseWebsiteRegex(t *testing.T) { + fs, err := ParseFilterExpr("website~=gouda.*") + if err != nil { + t.Fatal(err) + } + if fs.WebsiteRe != "gouda.*" { + t.Fatalf("WebsiteRe=%q", fs.WebsiteRe) + } +} + +func TestParseURIExact(t *testing.T) { + fs, err := ParseFilterExpr("uri=/api/v1/") + if err != nil { + t.Fatal(err) + } + if fs.URI != "/api/v1/" { + t.Fatalf("URI=%q", fs.URI) + } +} + +func TestParseURIRegex(t *testing.T) { + fs, err := ParseFilterExpr(`uri~=^/api/.*`) + if err != nil { + t.Fatal(err) + } + if fs.URIRe != `^/api/.*` { + t.Fatalf("URIRe=%q", fs.URIRe) + } +} + +func TestParsePrefix(t *testing.T) { + fs, err := ParseFilterExpr("prefix=1.2.3.0/24") + if err != nil { + t.Fatal(err) + } + if fs.Prefix != "1.2.3.0/24" { + t.Fatalf("Prefix=%q", fs.Prefix) + } +} + +func TestParseCombinedAND(t *testing.T) { + fs, err := ParseFilterExpr(`status>=400 AND website~=gouda.* AND uri~="^/.*"`) + if err != nil { + t.Fatal(err) + } + if fs.Status != ">=400" { + t.Fatalf("Status=%q", fs.Status) + } + if fs.WebsiteRe != "gouda.*" { + t.Fatalf("WebsiteRe=%q", fs.WebsiteRe) + } + if fs.URIRe != `^/.*` { // quotes stripped + t.Fatalf("URIRe=%q", fs.URIRe) + } +} + +func TestParseANDCaseInsensitive(t *testing.T) { + fs, err := ParseFilterExpr("status>=400 and website=example.com") + if err != nil { + t.Fatal(err) + } + if fs.Status != ">=400" || fs.Website != "example.com" { + t.Fatalf("%+v", fs) + } +} + +func TestParseQuotedValue(t *testing.T) { + fs, err := ParseFilterExpr(`website="example.com"`) + if err != nil { + t.Fatal(err) + } + if fs.Website != "example.com" { + t.Fatalf("Website=%q", fs.Website) + } +} + +func TestParseUnknownField(t *testing.T) { + _, err := ParseFilterExpr("host=foo") + if err == nil { + t.Fatal("expected error for unknown field") + } +} + +func TestParseStatusRegexRejected(t *testing.T) { + _, err := ParseFilterExpr("status~=4..") + if err == nil { + t.Fatal("expected error: status does not support ~=") + } +} + +func TestParseInvalidStatusExpr(t *testing.T) { + _, err := ParseFilterExpr("status>=abc") + if err == nil { + t.Fatal("expected error for non-numeric status") + } +} + +func TestParseMissingOperator(t *testing.T) { + _, err := ParseFilterExpr("status400") + if err == nil { + t.Fatal("expected error for missing operator") + } +} + +func TestParseWebsiteUnsupportedOp(t *testing.T) { + _, err := ParseFilterExpr("website>=example.com") + if err == nil { + t.Fatal("expected error: website does not support >=") + } +} + +// --- FilterExprString --- + +func TestFilterExprStringEmpty(t *testing.T) { + if s := FilterExprString(filterState{}); s != "" { + t.Fatalf("expected empty, got %q", s) + } +} + +func TestFilterExprStringStatus(t *testing.T) { + s := FilterExprString(filterState{Status: ">=400"}) + if s != "status>=400" { + t.Fatalf("got %q", s) + } +} + +func TestFilterExprStringStatusPlain(t *testing.T) { + s := FilterExprString(filterState{Status: "200"}) + if s != "status=200" { + t.Fatalf("got %q", s) + } +} + +func TestFilterExprStringWebsite(t *testing.T) { + s := FilterExprString(filterState{Website: "example.com"}) + if s != "website=example.com" { + t.Fatalf("got %q", s) + } +} + +func TestFilterExprStringWebsiteRegex(t *testing.T) { + s := FilterExprString(filterState{WebsiteRe: "gouda.*"}) + if s != "website~=gouda.*" { + t.Fatalf("got %q", s) + } +} + +func TestFilterExprStringCombined(t *testing.T) { + fs := filterState{Status: ">=400", WebsiteRe: "gouda.*", URIRe: `^/api/`} + s := FilterExprString(fs) + // Should contain all three parts joined by AND + if s == "" { + t.Fatal("expected non-empty") + } + // Round-trip: parse back + fs2, err := ParseFilterExpr(s) + if err != nil { + t.Fatalf("round-trip parse error: %v", err) + } + if fs2.Status != fs.Status || fs2.WebsiteRe != fs.WebsiteRe || fs2.URIRe != fs.URIRe { + t.Fatalf("round-trip mismatch: %+v vs %+v", fs, fs2) + } +} + +func TestFilterExprStringQuotesValue(t *testing.T) { + s := FilterExprString(filterState{Website: "has space"}) + if s != `website="has space"` { + t.Fatalf("got %q", s) + } +} + +func TestFilterExprRoundTrip(t *testing.T) { + cases := []filterState{ + {Status: "!=200"}, + {Status: "<500"}, + {Website: "example.com"}, + {WebsiteRe: "gouda.*"}, + {URI: "/api/v1/"}, + {URIRe: `^/api/`}, + {Prefix: "1.2.3.0/24"}, + {Status: ">=400", WebsiteRe: "gouda.*"}, + } + for _, fs := range cases { + expr := FilterExprString(fs) + fs2, err := ParseFilterExpr(expr) + if err != nil { + t.Errorf("round-trip parse error for %+v → %q: %v", fs, expr, err) + continue + } + if fs2 != fs { + t.Errorf("round-trip mismatch: %+v → %q → %+v", fs, expr, fs2) + } + } +} diff --git a/cmd/frontend/handler.go b/cmd/frontend/handler.go index 7dc40fc..bb8e6fe 100644 --- a/cmd/frontend/handler.go +++ b/cmd/frontend/handler.go @@ -11,6 +11,7 @@ import ( "strconv" "time" + st "git.ipng.ch/ipng/nginx-logtail/internal/store" pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb" ) @@ -44,12 +45,14 @@ type TableRow struct { DrillURL string } -// filterState holds the four optional filter fields parsed from URL params. +// filterState holds the filter fields parsed from URL params. type filterState struct { - Website string - Prefix string - URI string - Status string // kept as string so empty means "unset" + Website string + Prefix string + URI string + Status string // expression: "200", "!=200", ">=400", etc. + WebsiteRe string // RE2 regex against website + URIRe string // RE2 regex against request URI } // QueryParams holds all parsed URL parameters for one page request. @@ -65,16 +68,19 @@ type QueryParams struct { // PageData is passed to the HTML template. type PageData struct { - Params QueryParams - Source string - Entries []TableRow - TotalCount int64 - Sparkline template.HTML - Breadcrumbs []Crumb - Windows []Tab - GroupBys []Tab - RefreshSecs int - Error string + Params QueryParams + Source string + Entries []TableRow + TotalCount int64 + Sparkline template.HTML + Breadcrumbs []Crumb + Windows []Tab + GroupBys []Tab + RefreshSecs int + Error string + FilterExpr string // current filter serialised to mini-language for the input box + FilterErr string // parse error from a submitted q= expression + ClearFilterURL string // URL that removes all filter params } var windowSpecs = []struct{ s, label string }{ @@ -143,16 +149,18 @@ func (h *Handler) parseParams(r *http.Request) QueryParams { GroupByS: grpS, N: n, Filter: filterState{ - Website: q.Get("f_website"), - Prefix: q.Get("f_prefix"), - URI: q.Get("f_uri"), - Status: q.Get("f_status"), + Website: q.Get("f_website"), + Prefix: q.Get("f_prefix"), + URI: q.Get("f_uri"), + Status: q.Get("f_status"), + WebsiteRe: q.Get("f_website_re"), + URIRe: q.Get("f_uri_re"), }, } } func buildFilter(f filterState) *pb.Filter { - if f.Website == "" && f.Prefix == "" && f.URI == "" && f.Status == "" { + if f.Website == "" && f.Prefix == "" && f.URI == "" && f.Status == "" && f.WebsiteRe == "" && f.URIRe == "" { return nil } out := &pb.Filter{} @@ -166,11 +174,17 @@ func buildFilter(f filterState) *pb.Filter { out.HttpRequestUri = &f.URI } if f.Status != "" { - if n, err := strconv.Atoi(f.Status); err == nil { - n32 := int32(n) - out.HttpResponse = &n32 + if n, op, ok := st.ParseStatusExpr(f.Status); ok { + out.HttpResponse = &n + out.StatusOp = op } } + if f.WebsiteRe != "" { + out.WebsiteRegex = &f.WebsiteRe + } + if f.URIRe != "" { + out.UriRegex = &f.URIRe + } return out } @@ -193,6 +207,12 @@ func (p QueryParams) toValues() url.Values { if p.Filter.Status != "" { v.Set("f_status", p.Filter.Status) } + if p.Filter.WebsiteRe != "" { + v.Set("f_website_re", p.Filter.WebsiteRe) + } + if p.Filter.URIRe != "" { + v.Set("f_uri_re", p.Filter.URIRe) + } return v } @@ -210,6 +230,14 @@ func (p QueryParams) buildURL(overrides map[string]string) string { return "/?" + v.Encode() } +// clearFilterURL returns a URL with all filter params removed. +func (p QueryParams) clearFilterURL() string { + return p.buildURL(map[string]string{ + "f_website": "", "f_prefix": "", "f_uri": "", "f_status": "", + "f_website_re": "", "f_uri_re": "", + }) +} + // nextGroupBy advances the drill-down dimension hierarchy (cycles at the end). func nextGroupBy(s string) string { switch s { @@ -273,6 +301,18 @@ func buildCrumbs(p QueryParams) []Crumb { RemoveURL: p.buildURL(map[string]string{"f_status": ""}), }) } + if p.Filter.WebsiteRe != "" { + crumbs = append(crumbs, Crumb{ + Text: "website~=" + p.Filter.WebsiteRe, + RemoveURL: p.buildURL(map[string]string{"f_website_re": ""}), + }) + } + if p.Filter.URIRe != "" { + crumbs = append(crumbs, Crumb{ + Text: "uri~=" + p.Filter.URIRe, + RemoveURL: p.buildURL(map[string]string{"f_uri_re": ""}), + }) + } return crumbs } @@ -326,6 +366,27 @@ func buildTableRows(entries []*pb.TopNEntry, p QueryParams) ([]TableRow, int64) func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { params := h.parseParams(r) + + // Handle filter expression box submission (q= param). + var filterErr string + filterExprInput := FilterExprString(params.Filter) + if qVals, ok := r.URL.Query()["q"]; ok { + q := "" + if len(qVals) > 0 { + q = qVals[0] + } + fs, err := ParseFilterExpr(q) + if err != nil { + filterErr = err.Error() + filterExprInput = q // show what the user typed so they can fix it + // fall through: render page using existing filter params + } else { + params.Filter = fs + http.Redirect(w, r, params.buildURL(nil), http.StatusSeeOther) + return + } + } + filter := buildFilter(params.Filter) conn, client, err := dial(params.Target) @@ -390,15 +451,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } data := PageData{ - Params: params, - Source: tn.resp.Source, - Entries: rows, - TotalCount: total, - Sparkline: sparkline, - Breadcrumbs: buildCrumbs(params), - Windows: buildWindowTabs(params), - GroupBys: buildGroupByTabs(params), - RefreshSecs: h.refreshSecs, + Params: params, + Source: tn.resp.Source, + Entries: rows, + TotalCount: total, + Sparkline: sparkline, + Breadcrumbs: buildCrumbs(params), + Windows: buildWindowTabs(params), + GroupBys: buildGroupByTabs(params), + RefreshSecs: h.refreshSecs, + FilterExpr: filterExprInput, + FilterErr: filterErr, + ClearFilterURL: params.clearFilterURL(), } h.render(w, http.StatusOK, data) } @@ -413,12 +477,14 @@ func (h *Handler) render(w http.ResponseWriter, status int, data PageData) { func (h *Handler) errorPage(params QueryParams, msg string) PageData { return PageData{ - Params: params, - Windows: buildWindowTabs(params), - GroupBys: buildGroupByTabs(params), - Breadcrumbs: buildCrumbs(params), - RefreshSecs: h.refreshSecs, - Error: msg, + Params: params, + Windows: buildWindowTabs(params), + GroupBys: buildGroupByTabs(params), + Breadcrumbs: buildCrumbs(params), + RefreshSecs: h.refreshSecs, + Error: msg, + FilterExpr: FilterExprString(params.Filter), + ClearFilterURL: params.clearFilterURL(), } } diff --git a/cmd/frontend/templates/base.html b/cmd/frontend/templates/base.html index 0b17a28..614bb24 100644 --- a/cmd/frontend/templates/base.html +++ b/cmd/frontend/templates/base.html @@ -34,6 +34,12 @@ a:hover { text-decoration: underline; } .error { color: #c00; border: 1px solid #fbb; background: #fff5f5; padding: 0.7em 1em; margin: 1em 0; border-radius: 3px; } .nodata { color: #999; margin: 2em 0; font-style: italic; } footer { margin-top: 2em; padding-top: 0.6em; border-top: 1px solid #e0e0e0; font-size: 0.8em; color: #999; } +.filter-form { display: flex; gap: 0.4em; align-items: center; margin-bottom: 0.7em; } +.filter-input { flex: 1; font-family: monospace; font-size: 13px; padding: 0.25em 0.5em; border: 1px solid #aaa; } +.filter-form button { padding: 0.25em 0.8em; border: 1px solid #aaa; background: #f4f4f4; cursor: pointer; font-family: monospace; } +.filter-form button:hover { background: #e8e8e8; } +.filter-form .clear { color: #c00; font-size: 0.9em; white-space: nowrap; } +.filter-err { color: #c00; font-size: 0.85em; margin: -0.3em 0 0.6em; } diff --git a/cmd/frontend/templates/index.html b/cmd/frontend/templates/index.html index 2d29cbb..ec223df 100644 --- a/cmd/frontend/templates/index.html +++ b/cmd/frontend/templates/index.html @@ -13,6 +13,17 @@ {{- end}} +
+ + + + + + + {{- if .FilterExpr}} × clear{{end}} +
+{{- if .FilterErr}}
{{.FilterErr}}
{{end}} + {{if .Breadcrumbs}}
Filters: diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 78118ef..a94b053 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -266,15 +266,55 @@ to remove just that filter, keeping the others. **Sparkline** — inline SVG trend chart showing total request count per time bucket for the current filter state. Useful for spotting sudden spikes or sustained DDoS ramps. +**Filter expression box** — a text input above the table accepts a mini filter language that +lets you type expressions directly without editing the URL: + +``` +status>=400 +status>=400 AND website~=gouda.* +status>=400 AND website~=gouda.* AND uri~="^/api/" +website=example.com AND prefix=1.2.3.0/24 +``` + +Supported fields and operators: + +| Field | Operators | Example | +|-----------|---------------------|----------------------------| +| `status` | `=` `!=` `>` `>=` `<` `<=` | `status>=400` | +| `website` | `=` `~=` | `website~=gouda.*` | +| `uri` | `=` `~=` | `uri~=^/api/` | +| `prefix` | `=` | `prefix=1.2.3.0/24` | + +`~=` means RE2 regex match. Values with spaces or quotes may be wrapped in double or single +quotes: `uri~="^/search\?q="`. + +The box pre-fills with the current active filter (including filters set by drilldown clicks), +so you can see and extend what is applied. Submitting redirects to a clean URL with the +individual filter params; `× clear` removes all filters at once. + +On a parse error the page re-renders with the error shown below the input and the current +data and filters unchanged. + +**Status expressions** — the `f_status` URL param (and `status` in the expression box) accepts +comparison expressions: `200`, `!=200`, `>=400`, `<500`, etc. + +**Regex filters** — `f_website_re` and `f_uri_re` URL params (and `~=` in the expression box) +accept RE2 regular expressions. The breadcrumb strip shows them as `website~=gouda.*` and +`uri~=^/api/` with the usual `×` remove link. + **URL sharing** — all filter state is in the URL query string (`w`, `by`, `f_website`, -`f_prefix`, `f_uri`, `f_status`, `n`). Copy the URL to share an exact view with another -operator, or bookmark a recurring query. +`f_prefix`, `f_uri`, `f_status`, `f_website_re`, `f_uri_re`, `n`). Copy the URL to share an +exact view with another operator, or bookmark a recurring query. **JSON output** — append `&raw=1` to any URL to receive the TopN result as JSON instead of HTML. Useful for scripting without the CLI binary: ```bash +# All 429s by prefix curl -s 'http://frontend:8080/?f_status=429&by=prefix&w=1m&raw=1' | jq '.entries[0]' + +# All errors (>=400) on gouda hosts +curl -s 'http://frontend:8080/?f_status=%3E%3D400&f_website_re=gouda.*&by=uri&w=5m&raw=1' ``` **Target override** — append `?target=host:port` to point the frontend at a different gRPC @@ -309,7 +349,9 @@ logtail-cli stream [flags] live snapshot feed (runs until Ctrl-C) | `--website` | — | Filter to this website | | `--prefix` | — | Filter to this client prefix | | `--uri` | — | Filter to this request URI | -| `--status` | — | Filter to this HTTP status code (integer) | +| `--status` | — | Filter: HTTP status expression (`200`, `!=200`, `>=400`, `<500`, …) | +| `--website-re`| — | Filter: RE2 regex against website | +| `--uri-re` | — | Filter: RE2 regex against request URI | ### `topn` flags @@ -365,12 +407,21 @@ logtail-cli topn --target agg:9091 --window 1m --group-by prefix --status 429 -- logtail-cli topn --target agg:9091 --window 1m --group-by prefix --status 429 --n 20 \ --json | jq '.entries[0]' -# Which website has the most 503s over the last 24h? -logtail-cli topn --target agg:9091 --window 24h --group-by website --status 503 +# Which website has the most errors (4xx or 5xx) over the last 24h? +logtail-cli topn --target agg:9091 --window 24h --group-by website --status '>=400' + +# Which client prefixes are NOT getting 200s? (anything non-success) +logtail-cli topn --target agg:9091 --window 5m --group-by prefix --status '!=200' # Drill: top URIs on one website over the last 60 minutes logtail-cli topn --target agg:9091 --window 60m --group-by uri --website api.example.com +# Filter by website regex: all gouda hosts +logtail-cli topn --target agg:9091 --window 5m --website-re 'gouda.*' + +# Filter by URI regex: all /api/ paths +logtail-cli topn --target agg:9091 --window 5m --group-by uri --uri-re '^/api/' + # Compare two collectors side by side in one command logtail-cli topn --target nginx1:9090,nginx2:9090 --window 5m diff --git a/internal/store/store.go b/internal/store/store.go index a999399..2f2e85c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -4,7 +4,8 @@ package store import ( "container/heap" - "fmt" + "log" + "regexp" "time" pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb" @@ -113,27 +114,101 @@ func indexOf(s string, b byte) int { // --- filtering and grouping --- +// CompiledFilter wraps a pb.Filter with pre-compiled regular expressions. +// Use CompileFilter to construct one before a query loop. +type CompiledFilter struct { + Proto *pb.Filter + WebsiteRe *regexp.Regexp // nil if no website_regex or compilation failed + URIRe *regexp.Regexp // nil if no uri_regex or compilation failed +} + +// CompileFilter compiles the regex fields in f once. Invalid regexes are +// logged and treated as "match nothing" for that field. +func CompileFilter(f *pb.Filter) *CompiledFilter { + cf := &CompiledFilter{Proto: f} + if f == nil { + return cf + } + if f.WebsiteRegex != nil { + re, err := regexp.Compile(f.GetWebsiteRegex()) + if err != nil { + log.Printf("store: invalid website_regex %q: %v", f.GetWebsiteRegex(), err) + } else { + cf.WebsiteRe = re + } + } + if f.UriRegex != nil { + re, err := regexp.Compile(f.GetUriRegex()) + if err != nil { + log.Printf("store: invalid uri_regex %q: %v", f.GetUriRegex(), err) + } else { + cf.URIRe = re + } + } + return cf +} + // MatchesFilter returns true if t satisfies all constraints in f. // A nil filter matches everything. -func MatchesFilter(t Tuple4, f *pb.Filter) bool { - if f == nil { +func MatchesFilter(t Tuple4, f *CompiledFilter) bool { + if f == nil || f.Proto == nil { return true } - if f.Website != nil && t.Website != f.GetWebsite() { + p := f.Proto + if p.Website != nil && t.Website != p.GetWebsite() { return false } - if f.ClientPrefix != nil && t.Prefix != f.GetClientPrefix() { + if f.WebsiteRe != nil && !f.WebsiteRe.MatchString(t.Website) { return false } - if f.HttpRequestUri != nil && t.URI != f.GetHttpRequestUri() { + // website_regex set but failed to compile → match nothing + if p.WebsiteRegex != nil && f.WebsiteRe == nil { return false } - if f.HttpResponse != nil && t.Status != fmt.Sprint(f.GetHttpResponse()) { + if p.ClientPrefix != nil && t.Prefix != p.GetClientPrefix() { + return false + } + if p.HttpRequestUri != nil && t.URI != p.GetHttpRequestUri() { + return false + } + if f.URIRe != nil && !f.URIRe.MatchString(t.URI) { + return false + } + if p.UriRegex != nil && f.URIRe == nil { + return false + } + if p.HttpResponse != nil && !matchesStatusOp(t.Status, p.GetHttpResponse(), p.StatusOp) { return false } return true } +// matchesStatusOp applies op(statusStr, want), parsing statusStr as an integer. +// Returns false if statusStr is not a valid integer. +func matchesStatusOp(statusStr string, want int32, op pb.StatusOp) bool { + var got int32 + for _, c := range []byte(statusStr) { + if c < '0' || c > '9' { + return false + } + got = got*10 + int32(c-'0') + } + switch op { + case pb.StatusOp_NE: + return got != want + case pb.StatusOp_GT: + return got > want + case pb.StatusOp_GE: + return got >= want + case pb.StatusOp_LT: + return got < want + case pb.StatusOp_LE: + return got <= want + default: // EQ + return got == want + } +} + // DimensionLabel returns the string value of t for the given group-by dimension. func DimensionLabel(t Tuple4, g pb.GroupBy) string { switch g { @@ -150,6 +225,45 @@ func DimensionLabel(t Tuple4, g pb.GroupBy) string { } } +// ParseStatusExpr parses a status filter expression into a value and operator. +// Accepted syntax: 200, =200, ==200, !=200, >400, >=400, <500, <=500. +// Returns ok=false if the expression is empty or unparseable. +func ParseStatusExpr(s string) (value int32, op pb.StatusOp, ok bool) { + if s == "" { + return 0, pb.StatusOp_EQ, false + } + var digits string + switch { + case len(s) >= 2 && s[:2] == "!=": + op, digits = pb.StatusOp_NE, s[2:] + case len(s) >= 2 && s[:2] == ">=": + op, digits = pb.StatusOp_GE, s[2:] + case len(s) >= 2 && s[:2] == "<=": + op, digits = pb.StatusOp_LE, s[2:] + case len(s) >= 2 && s[:2] == "==": + op, digits = pb.StatusOp_EQ, s[2:] + case s[0] == '>': + op, digits = pb.StatusOp_GT, s[1:] + case s[0] == '<': + op, digits = pb.StatusOp_LT, s[1:] + case s[0] == '=': + op, digits = pb.StatusOp_EQ, s[1:] + default: + op, digits = pb.StatusOp_EQ, s + } + var n int32 + if digits == "" { + return 0, pb.StatusOp_EQ, false + } + for _, c := range []byte(digits) { + if c < '0' || c > '9' { + return 0, pb.StatusOp_EQ, false + } + n = n*10 + int32(c-'0') + } + return n, op, true +} + // --- heap-based top-K selection --- type entryHeap []Entry diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..d33a00d --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,205 @@ +package store + +import ( + "testing" + + pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb" +) + +// --- ParseStatusExpr --- + +func TestParseStatusExprEQ(t *testing.T) { + n, op, ok := ParseStatusExpr("200") + if !ok || n != 200 || op != pb.StatusOp_EQ { + t.Fatalf("got (%d,%v,%v)", n, op, ok) + } +} + +func TestParseStatusExprExplicitEQ(t *testing.T) { + for _, expr := range []string{"=200", "==200"} { + n, op, ok := ParseStatusExpr(expr) + if !ok || n != 200 || op != pb.StatusOp_EQ { + t.Fatalf("expr %q: got (%d,%v,%v)", expr, n, op, ok) + } + } +} + +func TestParseStatusExprNE(t *testing.T) { + n, op, ok := ParseStatusExpr("!=200") + if !ok || n != 200 || op != pb.StatusOp_NE { + t.Fatalf("got (%d,%v,%v)", n, op, ok) + } +} + +func TestParseStatusExprGE(t *testing.T) { + n, op, ok := ParseStatusExpr(">=400") + if !ok || n != 400 || op != pb.StatusOp_GE { + t.Fatalf("got (%d,%v,%v)", n, op, ok) + } +} + +func TestParseStatusExprGT(t *testing.T) { + n, op, ok := ParseStatusExpr(">400") + if !ok || n != 400 || op != pb.StatusOp_GT { + t.Fatalf("got (%d,%v,%v)", n, op, ok) + } +} + +func TestParseStatusExprLE(t *testing.T) { + n, op, ok := ParseStatusExpr("<=500") + if !ok || n != 500 || op != pb.StatusOp_LE { + t.Fatalf("got (%d,%v,%v)", n, op, ok) + } +} + +func TestParseStatusExprLT(t *testing.T) { + n, op, ok := ParseStatusExpr("<500") + if !ok || n != 500 || op != pb.StatusOp_LT { + t.Fatalf("got (%d,%v,%v)", n, op, ok) + } +} + +func TestParseStatusExprEmpty(t *testing.T) { + _, _, ok := ParseStatusExpr("") + if ok { + t.Fatal("expected ok=false for empty string") + } +} + +func TestParseStatusExprInvalid(t *testing.T) { + for _, expr := range []string{"abc", "!=", ">=", "2xx"} { + _, _, ok := ParseStatusExpr(expr) + if ok { + t.Fatalf("expr %q: expected ok=false", expr) + } + } +} + +// --- MatchesFilter --- + +func compiledEQ(status int32) *CompiledFilter { + v := status + return CompileFilter(&pb.Filter{HttpResponse: &v, StatusOp: pb.StatusOp_EQ}) +} + +func TestMatchesFilterNil(t *testing.T) { + if !MatchesFilter(Tuple4{Website: "x"}, nil) { + t.Fatal("nil filter should match everything") + } + if !MatchesFilter(Tuple4{Website: "x"}, &CompiledFilter{}) { + t.Fatal("empty compiled filter should match everything") + } +} + +func TestMatchesFilterExactWebsite(t *testing.T) { + w := "example.com" + cf := CompileFilter(&pb.Filter{Website: &w}) + if !MatchesFilter(Tuple4{Website: "example.com"}, cf) { + t.Fatal("expected match") + } + if MatchesFilter(Tuple4{Website: "other.com"}, cf) { + t.Fatal("expected no match") + } +} + +func TestMatchesFilterWebsiteRegex(t *testing.T) { + re := "gouda.*" + cf := CompileFilter(&pb.Filter{WebsiteRegex: &re}) + if !MatchesFilter(Tuple4{Website: "gouda.example.com"}, cf) { + t.Fatal("expected match") + } + if MatchesFilter(Tuple4{Website: "edam.example.com"}, cf) { + t.Fatal("expected no match") + } +} + +func TestMatchesFilterURIRegex(t *testing.T) { + re := "^/api/.*" + cf := CompileFilter(&pb.Filter{UriRegex: &re}) + if !MatchesFilter(Tuple4{URI: "/api/users"}, cf) { + t.Fatal("expected match") + } + if MatchesFilter(Tuple4{URI: "/health"}, cf) { + t.Fatal("expected no match") + } +} + +func TestMatchesFilterInvalidRegexMatchesNothing(t *testing.T) { + re := "[invalid" + cf := CompileFilter(&pb.Filter{WebsiteRegex: &re}) + if MatchesFilter(Tuple4{Website: "anything"}, cf) { + t.Fatal("invalid regex should match nothing") + } +} + +func TestMatchesFilterStatusEQ(t *testing.T) { + cf := compiledEQ(200) + if !MatchesFilter(Tuple4{Status: "200"}, cf) { + t.Fatal("expected match") + } + if MatchesFilter(Tuple4{Status: "404"}, cf) { + t.Fatal("expected no match") + } +} + +func TestMatchesFilterStatusNE(t *testing.T) { + v := int32(200) + cf := CompileFilter(&pb.Filter{HttpResponse: &v, StatusOp: pb.StatusOp_NE}) + if MatchesFilter(Tuple4{Status: "200"}, cf) { + t.Fatal("expected no match for 200 != 200") + } + if !MatchesFilter(Tuple4{Status: "404"}, cf) { + t.Fatal("expected match for 404 != 200") + } +} + +func TestMatchesFilterStatusGE(t *testing.T) { + v := int32(400) + cf := CompileFilter(&pb.Filter{HttpResponse: &v, StatusOp: pb.StatusOp_GE}) + if !MatchesFilter(Tuple4{Status: "400"}, cf) { + t.Fatal("expected match: 400 >= 400") + } + if !MatchesFilter(Tuple4{Status: "500"}, cf) { + t.Fatal("expected match: 500 >= 400") + } + if MatchesFilter(Tuple4{Status: "200"}, cf) { + t.Fatal("expected no match: 200 >= 400") + } +} + +func TestMatchesFilterStatusLT(t *testing.T) { + v := int32(400) + cf := CompileFilter(&pb.Filter{HttpResponse: &v, StatusOp: pb.StatusOp_LT}) + if !MatchesFilter(Tuple4{Status: "200"}, cf) { + t.Fatal("expected match: 200 < 400") + } + if MatchesFilter(Tuple4{Status: "400"}, cf) { + t.Fatal("expected no match: 400 < 400") + } +} + +func TestMatchesFilterStatusNonNumeric(t *testing.T) { + cf := compiledEQ(200) + if MatchesFilter(Tuple4{Status: "ok"}, cf) { + t.Fatal("non-numeric status should not match") + } +} + +func TestMatchesFilterCombined(t *testing.T) { + w := "example.com" + v := int32(200) + cf := CompileFilter(&pb.Filter{ + Website: &w, + HttpResponse: &v, + StatusOp: pb.StatusOp_EQ, + }) + if !MatchesFilter(Tuple4{Website: "example.com", Status: "200"}, cf) { + t.Fatal("expected match") + } + if MatchesFilter(Tuple4{Website: "other.com", Status: "200"}, cf) { + t.Fatal("expected no match: wrong website") + } + if MatchesFilter(Tuple4{Website: "example.com", Status: "404"}, cf) { + t.Fatal("expected no match: wrong status") + } +} diff --git a/proto/logtail.proto b/proto/logtail.proto index ad0053a..93519f7 100644 --- a/proto/logtail.proto +++ b/proto/logtail.proto @@ -4,13 +4,27 @@ package logtail; option go_package = "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"; +// StatusOp is the comparison operator applied to http_response in a Filter. +// Defaults to EQ (exact match) for backward compatibility. +enum StatusOp { + EQ = 0; // == + NE = 1; // != + GT = 2; // > + GE = 3; // >= + LT = 4; // < + LE = 5; // <= +} + // Filter restricts results to entries matching all specified fields. -// Unset fields match everything. +// Unset fields match everything. Exact-match and regex fields are ANDed. message Filter { - optional string website = 1; - optional string client_prefix = 2; - optional string http_request_uri = 3; - optional int32 http_response = 4; + optional string website = 1; + optional string client_prefix = 2; + optional string http_request_uri = 3; + optional int32 http_response = 4; + StatusOp status_op = 5; // operator for http_response; ignored when unset + optional string website_regex = 6; // RE2 regex matched against website + optional string uri_regex = 7; // RE2 regex matched against http_request_uri } enum GroupBy { diff --git a/proto/logtailpb/logtail.pb.go b/proto/logtailpb/logtail.pb.go index 9d61000..ae41747 100644 --- a/proto/logtailpb/logtail.pb.go +++ b/proto/logtailpb/logtail.pb.go @@ -21,6 +21,66 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// StatusOp is the comparison operator applied to http_response in a Filter. +// Defaults to EQ (exact match) for backward compatibility. +type StatusOp int32 + +const ( + StatusOp_EQ StatusOp = 0 // == + StatusOp_NE StatusOp = 1 // != + StatusOp_GT StatusOp = 2 // > + StatusOp_GE StatusOp = 3 // >= + StatusOp_LT StatusOp = 4 // < + StatusOp_LE StatusOp = 5 // <= +) + +// Enum value maps for StatusOp. +var ( + StatusOp_name = map[int32]string{ + 0: "EQ", + 1: "NE", + 2: "GT", + 3: "GE", + 4: "LT", + 5: "LE", + } + StatusOp_value = map[string]int32{ + "EQ": 0, + "NE": 1, + "GT": 2, + "GE": 3, + "LT": 4, + "LE": 5, + } +) + +func (x StatusOp) Enum() *StatusOp { + p := new(StatusOp) + *p = x + return p +} + +func (x StatusOp) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (StatusOp) Descriptor() protoreflect.EnumDescriptor { + return file_logtail_proto_enumTypes[0].Descriptor() +} + +func (StatusOp) Type() protoreflect.EnumType { + return &file_logtail_proto_enumTypes[0] +} + +func (x StatusOp) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use StatusOp.Descriptor instead. +func (StatusOp) EnumDescriptor() ([]byte, []int) { + return file_logtail_proto_rawDescGZIP(), []int{0} +} + type GroupBy int32 const ( @@ -57,11 +117,11 @@ func (x GroupBy) String() string { } func (GroupBy) Descriptor() protoreflect.EnumDescriptor { - return file_logtail_proto_enumTypes[0].Descriptor() + return file_logtail_proto_enumTypes[1].Descriptor() } func (GroupBy) Type() protoreflect.EnumType { - return &file_logtail_proto_enumTypes[0] + return &file_logtail_proto_enumTypes[1] } func (x GroupBy) Number() protoreflect.EnumNumber { @@ -70,7 +130,7 @@ func (x GroupBy) Number() protoreflect.EnumNumber { // Deprecated: Use GroupBy.Descriptor instead. func (GroupBy) EnumDescriptor() ([]byte, []int) { - return file_logtail_proto_rawDescGZIP(), []int{0} + return file_logtail_proto_rawDescGZIP(), []int{1} } type Window int32 @@ -115,11 +175,11 @@ func (x Window) String() string { } func (Window) Descriptor() protoreflect.EnumDescriptor { - return file_logtail_proto_enumTypes[1].Descriptor() + return file_logtail_proto_enumTypes[2].Descriptor() } func (Window) Type() protoreflect.EnumType { - return &file_logtail_proto_enumTypes[1] + return &file_logtail_proto_enumTypes[2] } func (x Window) Number() protoreflect.EnumNumber { @@ -128,17 +188,20 @@ func (x Window) Number() protoreflect.EnumNumber { // Deprecated: Use Window.Descriptor instead. func (Window) EnumDescriptor() ([]byte, []int) { - return file_logtail_proto_rawDescGZIP(), []int{1} + return file_logtail_proto_rawDescGZIP(), []int{2} } // Filter restricts results to entries matching all specified fields. -// Unset fields match everything. +// Unset fields match everything. Exact-match and regex fields are ANDed. type Filter struct { state protoimpl.MessageState `protogen:"open.v1"` Website *string `protobuf:"bytes,1,opt,name=website,proto3,oneof" json:"website,omitempty"` ClientPrefix *string `protobuf:"bytes,2,opt,name=client_prefix,json=clientPrefix,proto3,oneof" json:"client_prefix,omitempty"` HttpRequestUri *string `protobuf:"bytes,3,opt,name=http_request_uri,json=httpRequestUri,proto3,oneof" json:"http_request_uri,omitempty"` HttpResponse *int32 `protobuf:"varint,4,opt,name=http_response,json=httpResponse,proto3,oneof" json:"http_response,omitempty"` + StatusOp StatusOp `protobuf:"varint,5,opt,name=status_op,json=statusOp,proto3,enum=logtail.StatusOp" json:"status_op,omitempty"` // operator for http_response; ignored when unset + WebsiteRegex *string `protobuf:"bytes,6,opt,name=website_regex,json=websiteRegex,proto3,oneof" json:"website_regex,omitempty"` // RE2 regex matched against website + UriRegex *string `protobuf:"bytes,7,opt,name=uri_regex,json=uriRegex,proto3,oneof" json:"uri_regex,omitempty"` // RE2 regex matched against http_request_uri unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -201,6 +264,27 @@ func (x *Filter) GetHttpResponse() int32 { return 0 } +func (x *Filter) GetStatusOp() StatusOp { + if x != nil { + return x.StatusOp + } + return StatusOp_EQ +} + +func (x *Filter) GetWebsiteRegex() string { + if x != nil && x.WebsiteRegex != nil { + return *x.WebsiteRegex + } + return "" +} + +func (x *Filter) GetUriRegex() string { + if x != nil && x.UriRegex != nil { + return *x.UriRegex + } + return "" +} + type TopNRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Filter *Filter `protobuf:"bytes,1,opt,name=filter,proto3" json:"filter,omitempty"` @@ -629,17 +713,23 @@ var File_logtail_proto protoreflect.FileDescriptor const file_logtail_proto_rawDesc = "" + "\n" + - "\rlogtail.proto\x12\alogtail\"\xef\x01\n" + + "\rlogtail.proto\x12\alogtail\"\x8b\x03\n" + "\x06Filter\x12\x1d\n" + "\awebsite\x18\x01 \x01(\tH\x00R\awebsite\x88\x01\x01\x12(\n" + "\rclient_prefix\x18\x02 \x01(\tH\x01R\fclientPrefix\x88\x01\x01\x12-\n" + "\x10http_request_uri\x18\x03 \x01(\tH\x02R\x0ehttpRequestUri\x88\x01\x01\x12(\n" + - "\rhttp_response\x18\x04 \x01(\x05H\x03R\fhttpResponse\x88\x01\x01B\n" + + "\rhttp_response\x18\x04 \x01(\x05H\x03R\fhttpResponse\x88\x01\x01\x12.\n" + + "\tstatus_op\x18\x05 \x01(\x0e2\x11.logtail.StatusOpR\bstatusOp\x12(\n" + + "\rwebsite_regex\x18\x06 \x01(\tH\x04R\fwebsiteRegex\x88\x01\x01\x12 \n" + + "\turi_regex\x18\a \x01(\tH\x05R\buriRegex\x88\x01\x01B\n" + "\n" + "\b_websiteB\x10\n" + "\x0e_client_prefixB\x13\n" + "\x11_http_request_uriB\x10\n" + - "\x0e_http_response\"\x9a\x01\n" + + "\x0e_http_responseB\x10\n" + + "\x0e_website_regexB\f\n" + + "\n" + + "_uri_regex\"\x9a\x01\n" + "\vTopNRequest\x12'\n" + "\x06filter\x18\x01 \x01(\v2\x0f.logtail.FilterR\x06filter\x12+\n" + "\bgroup_by\x18\x02 \x01(\x0e2\x10.logtail.GroupByR\agroupBy\x12\f\n" + @@ -665,7 +755,14 @@ const file_logtail_proto_rawDesc = "" + "\bSnapshot\x12\x16\n" + "\x06source\x18\x01 \x01(\tR\x06source\x12\x1c\n" + "\ttimestamp\x18\x02 \x01(\x03R\ttimestamp\x12,\n" + - "\aentries\x18\x03 \x03(\v2\x12.logtail.TopNEntryR\aentries*M\n" + + "\aentries\x18\x03 \x03(\v2\x12.logtail.TopNEntryR\aentries*:\n" + + "\bStatusOp\x12\x06\n" + + "\x02EQ\x10\x00\x12\x06\n" + + "\x02NE\x10\x01\x12\x06\n" + + "\x02GT\x10\x02\x12\x06\n" + + "\x02GE\x10\x03\x12\x06\n" + + "\x02LT\x10\x04\x12\x06\n" + + "\x02LE\x10\x05*M\n" + "\aGroupBy\x12\v\n" + "\aWEBSITE\x10\x00\x12\x11\n" + "\rCLIENT_PREFIX\x10\x01\x12\x0f\n" + @@ -695,41 +792,43 @@ func file_logtail_proto_rawDescGZIP() []byte { return file_logtail_proto_rawDescData } -var file_logtail_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_logtail_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_logtail_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_logtail_proto_goTypes = []any{ - (GroupBy)(0), // 0: logtail.GroupBy - (Window)(0), // 1: logtail.Window - (*Filter)(nil), // 2: logtail.Filter - (*TopNRequest)(nil), // 3: logtail.TopNRequest - (*TopNEntry)(nil), // 4: logtail.TopNEntry - (*TopNResponse)(nil), // 5: logtail.TopNResponse - (*TrendRequest)(nil), // 6: logtail.TrendRequest - (*TrendPoint)(nil), // 7: logtail.TrendPoint - (*TrendResponse)(nil), // 8: logtail.TrendResponse - (*SnapshotRequest)(nil), // 9: logtail.SnapshotRequest - (*Snapshot)(nil), // 10: logtail.Snapshot + (StatusOp)(0), // 0: logtail.StatusOp + (GroupBy)(0), // 1: logtail.GroupBy + (Window)(0), // 2: logtail.Window + (*Filter)(nil), // 3: logtail.Filter + (*TopNRequest)(nil), // 4: logtail.TopNRequest + (*TopNEntry)(nil), // 5: logtail.TopNEntry + (*TopNResponse)(nil), // 6: logtail.TopNResponse + (*TrendRequest)(nil), // 7: logtail.TrendRequest + (*TrendPoint)(nil), // 8: logtail.TrendPoint + (*TrendResponse)(nil), // 9: logtail.TrendResponse + (*SnapshotRequest)(nil), // 10: logtail.SnapshotRequest + (*Snapshot)(nil), // 11: logtail.Snapshot } var file_logtail_proto_depIdxs = []int32{ - 2, // 0: logtail.TopNRequest.filter:type_name -> logtail.Filter - 0, // 1: logtail.TopNRequest.group_by:type_name -> logtail.GroupBy - 1, // 2: logtail.TopNRequest.window:type_name -> logtail.Window - 4, // 3: logtail.TopNResponse.entries:type_name -> logtail.TopNEntry - 2, // 4: logtail.TrendRequest.filter:type_name -> logtail.Filter - 1, // 5: logtail.TrendRequest.window:type_name -> logtail.Window - 7, // 6: logtail.TrendResponse.points:type_name -> logtail.TrendPoint - 4, // 7: logtail.Snapshot.entries:type_name -> logtail.TopNEntry - 3, // 8: logtail.LogtailService.TopN:input_type -> logtail.TopNRequest - 6, // 9: logtail.LogtailService.Trend:input_type -> logtail.TrendRequest - 9, // 10: logtail.LogtailService.StreamSnapshots:input_type -> logtail.SnapshotRequest - 5, // 11: logtail.LogtailService.TopN:output_type -> logtail.TopNResponse - 8, // 12: logtail.LogtailService.Trend:output_type -> logtail.TrendResponse - 10, // 13: logtail.LogtailService.StreamSnapshots:output_type -> logtail.Snapshot - 11, // [11:14] is the sub-list for method output_type - 8, // [8:11] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 0, // 0: logtail.Filter.status_op:type_name -> logtail.StatusOp + 3, // 1: logtail.TopNRequest.filter:type_name -> logtail.Filter + 1, // 2: logtail.TopNRequest.group_by:type_name -> logtail.GroupBy + 2, // 3: logtail.TopNRequest.window:type_name -> logtail.Window + 5, // 4: logtail.TopNResponse.entries:type_name -> logtail.TopNEntry + 3, // 5: logtail.TrendRequest.filter:type_name -> logtail.Filter + 2, // 6: logtail.TrendRequest.window:type_name -> logtail.Window + 8, // 7: logtail.TrendResponse.points:type_name -> logtail.TrendPoint + 5, // 8: logtail.Snapshot.entries:type_name -> logtail.TopNEntry + 4, // 9: logtail.LogtailService.TopN:input_type -> logtail.TopNRequest + 7, // 10: logtail.LogtailService.Trend:input_type -> logtail.TrendRequest + 10, // 11: logtail.LogtailService.StreamSnapshots:input_type -> logtail.SnapshotRequest + 6, // 12: logtail.LogtailService.TopN:output_type -> logtail.TopNResponse + 9, // 13: logtail.LogtailService.Trend:output_type -> logtail.TrendResponse + 11, // 14: logtail.LogtailService.StreamSnapshots:output_type -> logtail.Snapshot + 12, // [12:15] is the sub-list for method output_type + 9, // [9:12] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_logtail_proto_init() } @@ -743,7 +842,7 @@ func file_logtail_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_logtail_proto_rawDesc), len(file_logtail_proto_rawDesc)), - NumEnums: 2, + NumEnums: 3, NumMessages: 9, NumExtensions: 0, NumServices: 1,