Files
nginx-logtail/cmd/frontend/frontend_test.go
T
pim 6647f95be4 RELEASE 1.0.1: v2 log format, source_tag-labeled metrics, lint cleanup
Wire-format and metric overhaul. Both file and UDP ingest now share one
versioned ParseLine that dispatches on the v<N>\t prefix; v1 stays
unchanged, v2 adds $bytes_sent (replacing $body_bytes_sent),
$request_length, $upstream_response_time, and $upstream_status. File
ingest gains the same versioning, and the legacy positional file format
is removed (no live deployments).

Prometheus exposition is rewritten:

  - nginx_http_bytes_sent and nginx_http_request_duration_seconds gain
    a source_tag label.
  - nginx_http_requests_by_source_total gains status_class.
  - New v2-only metrics: nginx_http_request_bytes,
    nginx_http_upstream_duration_seconds,
    nginx_http_upstream_requests_total{status_class}.
  - Dropped nginx_http_response_body_bytes_by_source (subsumed by the
    dual-labeled bytes_sent metric).

Adds 'make fixstyle' (gofmt -w) and clears all golangci-lint findings
across the repo (errcheck, S1001, ST1005, unused).

Docs in design.md FR-2/FR-8 and user-guide.md are rewritten to present
v2 as the recommended log format.
2026-05-01 15:40:53 +02:00

551 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"html/template"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// --- Fake gRPC server ---
type fakeServer struct {
pb.UnimplementedLogtailServiceServer
topNResp *pb.TopNResponse
trendResp *pb.TrendResponse
// Captured for inspection.
lastTopN *pb.TopNRequest
lastTrend *pb.TrendRequest
}
func (f *fakeServer) TopN(_ context.Context, req *pb.TopNRequest) (*pb.TopNResponse, error) {
f.lastTopN = req
if f.topNResp == nil {
return &pb.TopNResponse{}, nil
}
return f.topNResp, nil
}
func (f *fakeServer) Trend(_ context.Context, req *pb.TrendRequest) (*pb.TrendResponse, error) {
f.lastTrend = req
if f.trendResp == nil {
return &pb.TrendResponse{}, nil
}
return f.trendResp, nil
}
func startFake(t *testing.T, fs *fakeServer) string {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := grpc.NewServer()
pb.RegisterLogtailServiceServer(srv, fs)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.GracefulStop)
return lis.Addr().String()
}
func mustLoadTemplate(t *testing.T) *template.Template {
t.Helper()
funcMap := template.FuncMap{"fmtCount": fmtCount}
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html")
if err != nil {
t.Fatal(err)
}
return tmpl
}
func newHandler(t *testing.T, target string) *Handler {
t.Helper()
return &Handler{
defaultTarget: target,
defaultN: 25,
refreshSecs: 30,
tmpl: mustLoadTemplate(t),
}
}
func get(t *testing.T, h http.Handler, path string) (int, string) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
body, _ := io.ReadAll(rec.Body)
return rec.Code, string(body)
}
// --- Unit tests ---
func TestParseWindowString(t *testing.T) {
cases := []struct {
in string
wantPB pb.Window
wantStr string
}{
{"1m", pb.Window_W1M, "1m"},
{"5m", pb.Window_W5M, "5m"},
{"15m", pb.Window_W15M, "15m"},
{"60m", pb.Window_W60M, "60m"},
{"6h", pb.Window_W6H, "6h"},
{"24h", pb.Window_W24H, "24h"},
{"bad", pb.Window_W5M, "5m"}, // default
{"", pb.Window_W5M, "5m"},
}
for _, c := range cases {
w, s := parseWindowString(c.in)
if w != c.wantPB || s != c.wantStr {
t.Errorf("parseWindowString(%q) = (%v, %q), want (%v, %q)", c.in, w, s, c.wantPB, c.wantStr)
}
}
}
func TestParseGroupByString(t *testing.T) {
cases := []struct {
in string
wantPB pb.GroupBy
wantStr string
}{
{"website", pb.GroupBy_WEBSITE, "website"},
{"prefix", pb.GroupBy_CLIENT_PREFIX, "prefix"},
{"uri", pb.GroupBy_REQUEST_URI, "uri"},
{"status", pb.GroupBy_HTTP_RESPONSE, "status"},
{"bad", pb.GroupBy_WEBSITE, "website"}, // default
}
for _, c := range cases {
g, s := parseGroupByString(c.in)
if g != c.wantPB || s != c.wantStr {
t.Errorf("parseGroupByString(%q) = (%v, %q), want (%v, %q)", c.in, g, s, c.wantPB, c.wantStr)
}
}
}
func TestParseQueryParams(t *testing.T) {
h := &Handler{defaultTarget: "default:9091", defaultN: 25}
req := httptest.NewRequest("GET",
"/?target=other:9090&w=60m&by=prefix&n=10&f_website=example.com&f_status=429", nil)
p := h.parseParams(req)
if p.Target != "other:9090" {
t.Errorf("target = %q", p.Target)
}
if p.WindowS != "60m" || p.Window != pb.Window_W60M {
t.Errorf("window = %q / %v", p.WindowS, p.Window)
}
if p.GroupByS != "prefix" || p.GroupBy != pb.GroupBy_CLIENT_PREFIX {
t.Errorf("group-by = %q / %v", p.GroupByS, p.GroupBy)
}
if p.N != 10 {
t.Errorf("n = %d", p.N)
}
if p.Filter.Website != "example.com" {
t.Errorf("f_website = %q", p.Filter.Website)
}
if p.Filter.Status != "429" {
t.Errorf("f_status = %q", p.Filter.Status)
}
}
func TestParseQueryParamsDefaults(t *testing.T) {
h := &Handler{defaultTarget: "agg:9091", defaultN: 20}
req := httptest.NewRequest("GET", "/", nil)
p := h.parseParams(req)
if p.Target != "agg:9091" {
t.Errorf("default target = %q", p.Target)
}
if p.WindowS != "5m" {
t.Errorf("default window = %q", p.WindowS)
}
if p.GroupByS != "website" {
t.Errorf("default group-by = %q", p.GroupByS)
}
if p.N != 20 {
t.Errorf("default n = %d", p.N)
}
}
func TestBuildFilter(t *testing.T) {
f := buildFilter(filterState{Website: "example.com", Status: "404"})
if f == nil {
t.Fatal("expected non-nil filter")
}
if f.GetWebsite() != "example.com" {
t.Errorf("website = %q", f.GetWebsite())
}
if f.GetHttpResponse() != 404 {
t.Errorf("status = %d", f.GetHttpResponse())
}
if f.ClientPrefix != nil || f.HttpRequestUri != nil {
t.Error("unexpected non-nil fields")
}
}
func TestBuildFilterNil(t *testing.T) {
if buildFilter(filterState{}) != nil {
t.Error("expected nil when no filter set")
}
}
func TestDrillURL(t *testing.T) {
p := QueryParams{Target: "agg:9091", WindowS: "5m", GroupByS: "website", N: 25}
u := p.drillURL("example.com")
if !strings.Contains(u, "f_website=example.com") {
t.Errorf("drill from website: missing f_website in %q", u)
}
if !strings.Contains(u, "by=prefix") {
t.Errorf("drill from website: expected by=prefix in %q", u)
}
p.GroupByS = "prefix"
u = p.drillURL("1.2.3.0/24")
if !strings.Contains(u, "by=uri") {
t.Errorf("drill from prefix: expected by=uri in %q", u)
}
p.GroupByS = "status"
u = p.drillURL("429")
if !strings.Contains(u, "f_status=429") {
t.Errorf("drill from status: missing f_status in %q", u)
}
if !strings.Contains(u, "by=asn") {
t.Errorf("drill from status: expected next by=asn in %q", u)
}
p.GroupByS = "asn"
u = p.drillURL("12345")
if !strings.Contains(u, "f_asn=12345") {
t.Errorf("drill from asn: missing f_asn in %q", u)
}
if !strings.Contains(u, "by=source_tag") {
t.Errorf("drill from asn: expected next by=source_tag in %q", u)
}
p.GroupByS = "source_tag"
u = p.drillURL("direct")
if !strings.Contains(u, "f_source_tag=direct") {
t.Errorf("drill from source_tag: missing f_source_tag in %q", u)
}
if !strings.Contains(u, "by=website") {
t.Errorf("drill from source_tag: expected cycle back to by=website in %q", u)
}
}
func TestBuildCrumbs(t *testing.T) {
p := QueryParams{
Target: "agg:9091",
WindowS: "5m",
GroupByS: "website",
N: 25,
Filter: filterState{Website: "example.com", Status: "429"},
}
crumbs := buildCrumbs(p)
if len(crumbs) != 2 {
t.Fatalf("expected 2 crumbs, got %d", len(crumbs))
}
if crumbs[0].Text != "website=example.com" {
t.Errorf("crumb[0].text = %q", crumbs[0].Text)
}
// RemoveURL for website crumb should not contain f_website.
if strings.Contains(crumbs[0].RemoveURL, "f_website") {
t.Errorf("remove URL still has f_website: %q", crumbs[0].RemoveURL)
}
// RemoveURL for website crumb should still contain f_status.
if !strings.Contains(crumbs[0].RemoveURL, "f_status=429") {
t.Errorf("remove URL missing f_status: %q", crumbs[0].RemoveURL)
}
}
func TestRenderSparkline(t *testing.T) {
points := []*pb.TrendPoint{
{TimestampUnix: 1, Count: 100},
{TimestampUnix: 2, Count: 200},
{TimestampUnix: 3, Count: 150},
{TimestampUnix: 4, Count: 50},
{TimestampUnix: 5, Count: 300},
}
svg := string(renderSparkline(points))
if !strings.Contains(svg, "<svg") {
t.Error("expected <svg tag")
}
if !strings.Contains(svg, "<polyline") {
t.Error("expected <polyline tag")
}
if !strings.Contains(svg, "points=") {
t.Error("expected points= attribute")
}
}
func TestRenderSparklineTooFewPoints(t *testing.T) {
if renderSparkline(nil) != "" {
t.Error("nil → expected empty")
}
if renderSparkline([]*pb.TrendPoint{{Count: 100}}) != "" {
t.Error("1 point → expected empty")
}
}
func TestRenderSparklineAllZero(t *testing.T) {
points := []*pb.TrendPoint{{Count: 0}, {Count: 0}, {Count: 0}}
if renderSparkline(points) != "" {
t.Error("all-zero → expected empty")
}
}
func TestFmtCount(t *testing.T) {
cases := []struct {
n int64
want string
}{
{0, "0"},
{999, "999"},
{1000, "1 000"},
{18432, "18 432"},
{1234567, "1 234 567"},
}
for _, c := range cases {
if got := fmtCount(c.n); got != c.want {
t.Errorf("fmtCount(%d) = %q, want %q", c.n, got, c.want)
}
}
}
// --- Handler integration tests ---
func TestHandlerTopN(t *testing.T) {
fake := &fakeServer{
topNResp: &pb.TopNResponse{
Source: "col-1",
Entries: []*pb.TopNEntry{
{Label: "busy.com", Count: 18432},
{Label: "quiet.com", Count: 100},
},
},
}
addr := startFake(t, fake)
h := newHandler(t, addr)
code, body := get(t, h, "/?target="+addr)
if code != 200 {
t.Fatalf("status = %d", code)
}
if !strings.Contains(body, "busy.com") {
t.Error("expected busy.com in body")
}
if !strings.Contains(body, "quiet.com") {
t.Error("expected quiet.com in body")
}
if !strings.Contains(body, "18 432") {
t.Error("expected formatted count 18 432 in body")
}
}
func TestHandlerRaw(t *testing.T) {
fake := &fakeServer{
topNResp: &pb.TopNResponse{
Source: "agg",
Entries: []*pb.TopNEntry{{Label: "x.com", Count: 42}},
},
}
addr := startFake(t, fake)
h := newHandler(t, addr)
code, body := get(t, h, "/?target="+addr+"&raw=1&w=15m&by=prefix")
if code != 200 {
t.Fatalf("status = %d", code)
}
var result struct {
Collector string `json:"collector"`
Window string `json:"window"`
GroupBy string `json:"group_by"`
Entries []struct {
Label string `json:"label"`
Count int64 `json:"count"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("JSON parse: %v\nbody: %s", err, body)
}
if result.Collector != "agg" {
t.Errorf("collector = %q", result.Collector)
}
if result.Window != "15m" {
t.Errorf("window = %q", result.Window)
}
if result.GroupBy != "prefix" {
t.Errorf("group_by = %q", result.GroupBy)
}
if len(result.Entries) != 1 || result.Entries[0].Label != "x.com" {
t.Errorf("entries = %+v", result.Entries)
}
}
func TestHandlerBadTarget(t *testing.T) {
h := newHandler(t, "127.0.0.1:1") // always refused
code, body := get(t, h, "/?target=127.0.0.1:1")
if code != http.StatusBadGateway {
t.Errorf("status = %d, want 502", code)
}
if !strings.Contains(body, "error") && !strings.Contains(body, "Error") {
t.Error("expected error message in body")
}
}
func TestHandlerFilterPassedToServer(t *testing.T) {
fake := &fakeServer{}
addr := startFake(t, fake)
h := newHandler(t, addr)
get(t, h, "/?target="+addr+"&f_website=example.com&f_status=429")
if fake.lastTopN == nil {
t.Fatal("no TopN request received")
}
f := fake.lastTopN.Filter
if f == nil {
t.Fatal("filter is nil")
}
if f.GetWebsite() != "example.com" {
t.Errorf("website = %q", f.GetWebsite())
}
if f.GetHttpResponse() != 429 {
t.Errorf("status = %d", f.GetHttpResponse())
}
}
func TestHandlerWindowPassedToServer(t *testing.T) {
fake := &fakeServer{}
addr := startFake(t, fake)
h := newHandler(t, addr)
get(t, h, "/?target="+addr+"&w=60m")
if fake.lastTopN == nil {
t.Fatal("no TopN request received")
}
if fake.lastTopN.Window != pb.Window_W60M {
t.Errorf("window = %v, want W60M", fake.lastTopN.Window)
}
}
func TestHandlerBreadcrumbInHTML(t *testing.T) {
fake := &fakeServer{topNResp: &pb.TopNResponse{}}
addr := startFake(t, fake)
h := newHandler(t, addr)
_, body := get(t, h, "/?target="+addr+"&f_website=example.com")
if !strings.Contains(body, "website=example.com") {
t.Error("expected breadcrumb with website=example.com")
}
// Remove link should exist.
if !strings.Contains(body, "×") {
t.Error("expected × remove link in breadcrumb")
}
}
func TestHandlerSparklineInHTML(t *testing.T) {
fake := &fakeServer{
topNResp: &pb.TopNResponse{Entries: []*pb.TopNEntry{{Label: "x.com", Count: 1}}},
trendResp: &pb.TrendResponse{Points: []*pb.TrendPoint{
{TimestampUnix: 1, Count: 100},
{TimestampUnix: 2, Count: 200},
{TimestampUnix: 3, Count: 150},
}},
}
addr := startFake(t, fake)
h := newHandler(t, addr)
_, body := get(t, h, "/?target="+addr)
if !strings.Contains(body, "<svg") {
t.Error("expected SVG sparkline in body")
}
if !strings.Contains(body, "<polyline") {
t.Error("expected polyline in SVG sparkline")
}
}
func TestHandlerPctBar(t *testing.T) {
fake := &fakeServer{
topNResp: &pb.TopNResponse{
Entries: []*pb.TopNEntry{
{Label: "top.com", Count: 1000},
{Label: "half.com", Count: 500},
},
},
}
addr := startFake(t, fake)
h := newHandler(t, addr)
_, body := get(t, h, "/?target="+addr)
if !strings.Contains(body, "<meter") {
t.Error("expected <meter bar in body")
}
// top.com should be 100%, half.com 50%.
if !strings.Contains(body, `value="100"`) {
t.Error("expected 100% bar for top entry")
}
if !strings.Contains(body, `value="50"`) {
t.Error("expected 50% bar for half entry")
}
}
func TestHandlerWindowTabsInHTML(t *testing.T) {
fake := &fakeServer{}
addr := startFake(t, fake)
h := newHandler(t, addr)
_, body := get(t, h, "/?target="+addr+"&w=15m")
// All window labels should appear.
for _, w := range []string{"1m", "5m", "15m", "60m", "6h", "24h"} {
if !strings.Contains(body, ">"+w+"<") {
t.Errorf("expected window tab %q in body", w)
}
}
}
func TestHandlerNoData(t *testing.T) {
fake := &fakeServer{topNResp: &pb.TopNResponse{}}
addr := startFake(t, fake)
h := newHandler(t, addr)
code, body := get(t, h, "/?target="+addr)
if code != 200 {
t.Fatalf("status = %d", code)
}
if !strings.Contains(body, "no data") {
t.Error("expected 'no data' message in body")
}
}
// Verify the fake gRPC server is reachable (sanity check for test infrastructure).
func TestDialFake(t *testing.T) {
addr := startFake(t, &fakeServer{})
conn, client, err := dial(addr)
if err != nil {
t.Fatal(err)
}
defer func() { _ = conn.Close() }()
_ = client
// If we got here without error, the fake server is reachable.
_ = grpc.NewClient // suppress unused import warning in case of refactor
_ = insecure.NewCredentials
}