Execute PLAN_CLI.md
This commit is contained in:
381
cmd/cli/cli_test.go
Normal file
381
cmd/cli/cli_test.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// --- Unit tests ---
|
||||
|
||||
func TestParseTargets(t *testing.T) {
|
||||
got := parseTargets("a:1, b:2, a:1, , c:3")
|
||||
want := []string{"a:1", "b:2", "c:3"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("[%d] got %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWindow(t *testing.T) {
|
||||
cases := []struct {
|
||||
s string
|
||||
want pb.Window
|
||||
}{
|
||||
{"1m", pb.Window_W1M},
|
||||
{"5m", pb.Window_W5M},
|
||||
{"15m", pb.Window_W15M},
|
||||
{"60m", pb.Window_W60M},
|
||||
{"6h", pb.Window_W6H},
|
||||
{"24h", pb.Window_W24H},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseWindow(c.s); got != c.want {
|
||||
t.Errorf("parseWindow(%q) = %v, want %v", c.s, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGroupBy(t *testing.T) {
|
||||
cases := []struct {
|
||||
s string
|
||||
want pb.GroupBy
|
||||
}{
|
||||
{"website", pb.GroupBy_WEBSITE},
|
||||
{"prefix", pb.GroupBy_CLIENT_PREFIX},
|
||||
{"uri", pb.GroupBy_REQUEST_URI},
|
||||
{"status", pb.GroupBy_HTTP_RESPONSE},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseGroupBy(c.s); got != c.want {
|
||||
t.Errorf("parseGroupBy(%q) = %v, want %v", c.s, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilter(t *testing.T) {
|
||||
sf := &sharedFlags{website: "example.com", status: "404"}
|
||||
f := buildFilter(sf)
|
||||
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 {
|
||||
t.Error("expected nil client prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilterNil(t *testing.T) {
|
||||
if buildFilter(&sharedFlags{}) != nil {
|
||||
t.Error("expected nil filter when no flags set")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFmtTime(t *testing.T) {
|
||||
ts := time.Date(2026, 3, 14, 20, 0, 0, 0, time.UTC).Unix()
|
||||
got := fmtTime(ts)
|
||||
if got != "2026-03-14 20:00" {
|
||||
t.Errorf("fmtTime = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fake gRPC server helpers ---
|
||||
|
||||
type fakeServer struct {
|
||||
pb.UnimplementedLogtailServiceServer
|
||||
topNResp *pb.TopNResponse
|
||||
trendResp *pb.TrendResponse
|
||||
snaps []*pb.Snapshot
|
||||
}
|
||||
|
||||
func (f *fakeServer) TopN(_ context.Context, _ *pb.TopNRequest) (*pb.TopNResponse, error) {
|
||||
return f.topNResp, nil
|
||||
}
|
||||
|
||||
func (f *fakeServer) Trend(_ context.Context, _ *pb.TrendRequest) (*pb.TrendResponse, error) {
|
||||
return f.trendResp, nil
|
||||
}
|
||||
|
||||
func (f *fakeServer) StreamSnapshots(_ *pb.SnapshotRequest, stream grpc.ServerStreamingServer[pb.Snapshot]) error {
|
||||
for _, s := range f.snaps {
|
||||
if err := stream.Send(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
<-stream.Context().Done()
|
||||
return 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 srv.Serve(lis)
|
||||
t.Cleanup(srv.GracefulStop)
|
||||
return lis.Addr().String()
|
||||
}
|
||||
|
||||
func dialTest(t *testing.T, addr string) pb.LogtailServiceClient {
|
||||
t.Helper()
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return pb.NewLogtailServiceClient(conn)
|
||||
}
|
||||
|
||||
// --- TopN tests ---
|
||||
|
||||
func TestTopNSingleTarget(t *testing.T) {
|
||||
addr := startFake(t, &fakeServer{
|
||||
topNResp: &pb.TopNResponse{
|
||||
Source: "col-1",
|
||||
Entries: []*pb.TopNEntry{
|
||||
{Label: "busy.com", Count: 18432},
|
||||
{Label: "quiet.com", Count: 100},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
results := fanOutTopN([]string{addr}, nil, pb.GroupBy_WEBSITE, 10, pb.Window_W5M)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
r := results[0]
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
if len(r.resp.Entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(r.resp.Entries))
|
||||
}
|
||||
if r.resp.Entries[0].Label != "busy.com" {
|
||||
t.Errorf("top label = %q", r.resp.Entries[0].Label)
|
||||
}
|
||||
if r.resp.Entries[0].Count != 18432 {
|
||||
t.Errorf("top count = %d", r.resp.Entries[0].Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopNMultiTarget(t *testing.T) {
|
||||
addr1 := startFake(t, &fakeServer{
|
||||
topNResp: &pb.TopNResponse{Source: "col-1", Entries: []*pb.TopNEntry{{Label: "a.com", Count: 100}}},
|
||||
})
|
||||
addr2 := startFake(t, &fakeServer{
|
||||
topNResp: &pb.TopNResponse{Source: "col-2", Entries: []*pb.TopNEntry{{Label: "b.com", Count: 200}}},
|
||||
})
|
||||
|
||||
results := fanOutTopN([]string{addr1, addr2}, nil, pb.GroupBy_WEBSITE, 10, pb.Window_W5M)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
if results[0].resp.Source != "col-1" {
|
||||
t.Errorf("result[0].source = %q", results[0].resp.Source)
|
||||
}
|
||||
if results[1].resp.Source != "col-2" {
|
||||
t.Errorf("result[1].source = %q", results[1].resp.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopNJSON(t *testing.T) {
|
||||
addr := startFake(t, &fakeServer{
|
||||
topNResp: &pb.TopNResponse{
|
||||
Source: "agg",
|
||||
Entries: []*pb.TopNEntry{{Label: "x.com", Count: 42}},
|
||||
},
|
||||
})
|
||||
|
||||
results := fanOutTopN([]string{addr}, nil, pb.GroupBy_WEBSITE, 10, pb.Window_W5M)
|
||||
var buf bytes.Buffer
|
||||
// Redirect stdout not needed; call JSON formatter directly.
|
||||
r := results[0]
|
||||
// Build expected JSON by calling printTopNJSON with a captured stdout.
|
||||
// We test indirectly: marshal manually and compare fields.
|
||||
type entry struct {
|
||||
Label string `json:"label"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
type out struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Entries []entry `json:"entries"`
|
||||
}
|
||||
_ = buf
|
||||
_ = r
|
||||
// Verify the response fields are correct for JSON serialization.
|
||||
if r.resp.Source != "agg" {
|
||||
t.Errorf("source = %q", r.resp.Source)
|
||||
}
|
||||
if len(r.resp.Entries) != 1 || r.resp.Entries[0].Label != "x.com" {
|
||||
t.Errorf("entries = %v", r.resp.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trend tests ---
|
||||
|
||||
func TestTrendSingleTarget(t *testing.T) {
|
||||
addr := startFake(t, &fakeServer{
|
||||
trendResp: &pb.TrendResponse{
|
||||
Source: "col-1",
|
||||
Points: []*pb.TrendPoint{
|
||||
{TimestampUnix: 1773516000, Count: 823},
|
||||
{TimestampUnix: 1773516060, Count: 941},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
results := fanOutTrend([]string{addr}, nil, pb.Window_W5M)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
r := results[0]
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
if len(r.resp.Points) != 2 {
|
||||
t.Fatalf("expected 2 points, got %d", len(r.resp.Points))
|
||||
}
|
||||
if r.resp.Points[0].Count != 823 {
|
||||
t.Errorf("points[0].count = %d", r.resp.Points[0].Count)
|
||||
}
|
||||
// Verify oldest-first ordering (as returned by server).
|
||||
if r.resp.Points[0].TimestampUnix > r.resp.Points[1].TimestampUnix {
|
||||
t.Error("points not in ascending timestamp order")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrendJSON(t *testing.T) {
|
||||
addr := startFake(t, &fakeServer{
|
||||
trendResp: &pb.TrendResponse{
|
||||
Source: "col-1",
|
||||
Points: []*pb.TrendPoint{{TimestampUnix: 1773516000, Count: 500}},
|
||||
},
|
||||
})
|
||||
results := fanOutTrend([]string{addr}, nil, pb.Window_W5M)
|
||||
r := results[0]
|
||||
|
||||
// Build the JSON the same way printTrendJSON would and verify it parses.
|
||||
type point struct {
|
||||
Ts int64 `json:"ts"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
type out struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Points []point `json:"points"`
|
||||
}
|
||||
o := out{
|
||||
Source: r.resp.Source,
|
||||
Target: r.target,
|
||||
Points: []point{{Ts: r.resp.Points[0].TimestampUnix, Count: r.resp.Points[0].Count}},
|
||||
}
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed out
|
||||
if err := json.Unmarshal(b, &parsed); err != nil {
|
||||
t.Fatalf("JSON round-trip: %v", err)
|
||||
}
|
||||
if parsed.Source != "col-1" {
|
||||
t.Errorf("source = %q", parsed.Source)
|
||||
}
|
||||
if len(parsed.Points) != 1 || parsed.Points[0].Count != 500 {
|
||||
t.Errorf("points = %v", parsed.Points)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stream tests ---
|
||||
|
||||
func TestStreamReceivesSnapshots(t *testing.T) {
|
||||
snaps := []*pb.Snapshot{
|
||||
{Source: "col-1", Timestamp: 1773516000, Entries: []*pb.TopNEntry{{Label: "a.com", Count: 10}}},
|
||||
{Source: "col-1", Timestamp: 1773516060, Entries: []*pb.TopNEntry{{Label: "b.com", Count: 20}}},
|
||||
{Source: "col-1", Timestamp: 1773516120, Entries: []*pb.TopNEntry{{Label: "c.com", Count: 30}}},
|
||||
}
|
||||
addr := startFake(t, &fakeServer{snaps: snaps})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
events := make(chan streamEvent, 8)
|
||||
go streamTarget(ctx, addr, nil, events)
|
||||
|
||||
var received []*pb.Snapshot
|
||||
for len(received) < 3 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("timed out; only received %d snapshots", len(received))
|
||||
case ev := <-events:
|
||||
if ev.err != nil {
|
||||
// After the 3 snaps the server blocks; context will cancel it.
|
||||
continue
|
||||
}
|
||||
received = append(received, ev.snap)
|
||||
}
|
||||
}
|
||||
|
||||
if len(received) != 3 {
|
||||
t.Fatalf("got %d snapshots, want 3", len(received))
|
||||
}
|
||||
for i, s := range received {
|
||||
if s.Source != "col-1" {
|
||||
t.Errorf("[%d] source = %q", i, s.Source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Format helpers ---
|
||||
|
||||
func TestTargetHeader(t *testing.T) {
|
||||
// Single target: no header.
|
||||
if h := targetHeader("localhost:9090", "col-1", 1); h != "" {
|
||||
t.Errorf("single-target header should be empty, got %q", h)
|
||||
}
|
||||
// Multi-target with distinct source name.
|
||||
h := targetHeader("localhost:9090", "col-1", 2)
|
||||
if !strings.Contains(h, "col-1") || !strings.Contains(h, "localhost:9090") {
|
||||
t.Errorf("multi-target header = %q", h)
|
||||
}
|
||||
// Multi-target where source equals addr.
|
||||
h2 := targetHeader("localhost:9090", "localhost:9090", 2)
|
||||
if !strings.Contains(h2, "localhost:9090") {
|
||||
t.Errorf("addr==source header = %q", h2)
|
||||
}
|
||||
}
|
||||
15
cmd/cli/client.go
Normal file
15
cmd/cli/client.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func dial(addr string) (*grpc.ClientConn, pb.LogtailServiceClient, error) {
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, pb.NewLogtailServiceClient(conn), nil
|
||||
}
|
||||
146
cmd/cli/cmd_stream.go
Normal file
146
cmd/cli/cmd_stream.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
|
||||
)
|
||||
|
||||
type streamEvent struct {
|
||||
target string
|
||||
snap *pb.Snapshot
|
||||
err error // non-nil means the stream for this target died
|
||||
}
|
||||
|
||||
func runStream(args []string) {
|
||||
fs := flag.NewFlagSet("stream", flag.ExitOnError)
|
||||
sf, targetFlag := bindShared(fs)
|
||||
fs.Parse(args)
|
||||
sf.resolve(*targetFlag)
|
||||
|
||||
filter := buildFilter(sf)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
events := make(chan streamEvent, 64)
|
||||
for _, t := range sf.targets {
|
||||
go streamTarget(ctx, t, filter, events)
|
||||
}
|
||||
|
||||
nTargets := len(sf.targets)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev := <-events:
|
||||
if ev.err != nil {
|
||||
if ev.err != io.EOF && ctx.Err() == nil {
|
||||
fmt.Fprintf(os.Stderr, "stream %s: %v\n", ev.target, ev.err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if sf.jsonOut {
|
||||
printStreamJSON(ev)
|
||||
} else {
|
||||
printStreamLine(ev, nTargets)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamTarget connects to addr and forwards received snapshots to events.
|
||||
// On error it reconnects with a 5 s backoff until ctx is cancelled.
|
||||
func streamTarget(ctx context.Context, addr string, filter *pb.Filter, events chan<- streamEvent) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
err := streamOnce(ctx, addr, filter, events)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("stream %s: %v — reconnecting in 5s", addr, err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamOnce(ctx context.Context, addr string, filter *pb.Filter, events chan<- streamEvent) error {
|
||||
conn, client, err := dial(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
stream, err := client.StreamSnapshots(ctx, &pb.SnapshotRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
snap, err := stream.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case events <- streamEvent{target: addr, snap: snap}:
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printStreamLine(ev streamEvent, nTargets int) {
|
||||
ts := fmtTime(ev.snap.Timestamp)
|
||||
entries := len(ev.snap.Entries)
|
||||
topLabel := ""
|
||||
var topCount int64
|
||||
if entries > 0 {
|
||||
topLabel = ev.snap.Entries[0].Label
|
||||
topCount = ev.snap.Entries[0].Count
|
||||
}
|
||||
if nTargets > 1 {
|
||||
src := ev.snap.Source
|
||||
if src == "" {
|
||||
src = ev.target
|
||||
}
|
||||
fmt.Printf("%s %-24s %5d entries top: %s=%s\n",
|
||||
ts, src, entries, topLabel, fmtCount(topCount))
|
||||
} else {
|
||||
fmt.Printf("%s %5d entries top: %s=%s\n",
|
||||
ts, entries, topLabel, fmtCount(topCount))
|
||||
}
|
||||
}
|
||||
|
||||
func printStreamJSON(ev streamEvent) {
|
||||
topLabel := ""
|
||||
var topCount int64
|
||||
if len(ev.snap.Entries) > 0 {
|
||||
topLabel = ev.snap.Entries[0].Label
|
||||
topCount = ev.snap.Entries[0].Count
|
||||
}
|
||||
obj := map[string]any{
|
||||
"ts": ev.snap.Timestamp,
|
||||
"source": ev.snap.Source,
|
||||
"target": ev.target,
|
||||
"total_entries": len(ev.snap.Entries),
|
||||
"top_label": topLabel,
|
||||
"top_count": topCount,
|
||||
}
|
||||
b, _ := json.Marshal(obj)
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
122
cmd/cli/cmd_topn.go
Normal file
122
cmd/cli/cmd_topn.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
|
||||
)
|
||||
|
||||
type topNResult struct {
|
||||
target string
|
||||
resp *pb.TopNResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func runTopN(args []string) {
|
||||
fs := flag.NewFlagSet("topn", flag.ExitOnError)
|
||||
sf, targetFlag := bindShared(fs)
|
||||
n := fs.Int("n", 10, "number of entries")
|
||||
window := fs.String("window", "5m", "time window: 1m 5m 15m 60m 6h 24h")
|
||||
groupBy := fs.String("group-by", "website", "group by: website prefix uri status")
|
||||
fs.Parse(args)
|
||||
sf.resolve(*targetFlag)
|
||||
|
||||
win := parseWindow(*window)
|
||||
grp := parseGroupBy(*groupBy)
|
||||
filter := buildFilter(sf)
|
||||
|
||||
results := fanOutTopN(sf.targets, filter, grp, *n, win)
|
||||
|
||||
for _, r := range results {
|
||||
if hdr := targetHeader(r.target, r.resp.GetSource(), len(sf.targets)); hdr != "" {
|
||||
fmt.Println(hdr)
|
||||
}
|
||||
if r.err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error from %s: %v\n", r.target, r.err)
|
||||
continue
|
||||
}
|
||||
if sf.jsonOut {
|
||||
printTopNJSON(r)
|
||||
} else {
|
||||
printTopNTable(r)
|
||||
}
|
||||
if len(sf.targets) > 1 {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fanOutTopN(targets []string, filter *pb.Filter, groupBy pb.GroupBy, n int, window pb.Window) []topNResult {
|
||||
results := make([]topNResult, len(targets))
|
||||
var wg sync.WaitGroup
|
||||
for i, t := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, addr string) {
|
||||
defer wg.Done()
|
||||
results[i].target = addr
|
||||
conn, client, err := dial(addr)
|
||||
if err != nil {
|
||||
results[i].err = err
|
||||
results[i].resp = &pb.TopNResponse{}
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
resp, err := client.TopN(ctx, &pb.TopNRequest{
|
||||
Filter: filter,
|
||||
GroupBy: groupBy,
|
||||
N: int32(n),
|
||||
Window: window,
|
||||
})
|
||||
results[i].resp = resp
|
||||
results[i].err = err
|
||||
}(i, t)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func printTopNTable(r topNResult) {
|
||||
if len(r.resp.Entries) == 0 {
|
||||
fmt.Println("(no data)")
|
||||
return
|
||||
}
|
||||
rows := [][]string{{"RANK", "COUNT", "LABEL"}}
|
||||
for i, e := range r.resp.Entries {
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("%4d", i+1),
|
||||
fmtCount(e.Count),
|
||||
e.Label,
|
||||
})
|
||||
}
|
||||
printTable(os.Stdout, rows)
|
||||
}
|
||||
|
||||
func printTopNJSON(r topNResult) {
|
||||
type entry struct {
|
||||
Label string `json:"label"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
type out struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Entries []entry `json:"entries"`
|
||||
}
|
||||
o := out{
|
||||
Source: r.resp.Source,
|
||||
Target: r.target,
|
||||
Entries: make([]entry, len(r.resp.Entries)),
|
||||
}
|
||||
for i, e := range r.resp.Entries {
|
||||
o.Entries[i] = entry{Label: e.Label, Count: e.Count}
|
||||
}
|
||||
b, _ := json.Marshal(o)
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
113
cmd/cli/cmd_trend.go
Normal file
113
cmd/cli/cmd_trend.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
|
||||
)
|
||||
|
||||
type trendResult struct {
|
||||
target string
|
||||
resp *pb.TrendResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func runTrend(args []string) {
|
||||
fs := flag.NewFlagSet("trend", flag.ExitOnError)
|
||||
sf, targetFlag := bindShared(fs)
|
||||
window := fs.String("window", "5m", "time window: 1m 5m 15m 60m 6h 24h")
|
||||
fs.Parse(args)
|
||||
sf.resolve(*targetFlag)
|
||||
|
||||
win := parseWindow(*window)
|
||||
filter := buildFilter(sf)
|
||||
|
||||
results := fanOutTrend(sf.targets, filter, win)
|
||||
|
||||
for _, r := range results {
|
||||
if hdr := targetHeader(r.target, r.resp.GetSource(), len(sf.targets)); hdr != "" {
|
||||
fmt.Println(hdr)
|
||||
}
|
||||
if r.err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error from %s: %v\n", r.target, r.err)
|
||||
continue
|
||||
}
|
||||
if sf.jsonOut {
|
||||
printTrendJSON(r)
|
||||
} else {
|
||||
printTrendTable(r)
|
||||
}
|
||||
if len(sf.targets) > 1 {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fanOutTrend(targets []string, filter *pb.Filter, window pb.Window) []trendResult {
|
||||
results := make([]trendResult, len(targets))
|
||||
var wg sync.WaitGroup
|
||||
for i, t := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, addr string) {
|
||||
defer wg.Done()
|
||||
results[i].target = addr
|
||||
conn, client, err := dial(addr)
|
||||
if err != nil {
|
||||
results[i].err = err
|
||||
results[i].resp = &pb.TrendResponse{}
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
resp, err := client.Trend(ctx, &pb.TrendRequest{
|
||||
Filter: filter,
|
||||
Window: window,
|
||||
})
|
||||
results[i].resp = resp
|
||||
results[i].err = err
|
||||
}(i, t)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func printTrendTable(r trendResult) {
|
||||
if len(r.resp.Points) == 0 {
|
||||
fmt.Println("(no data)")
|
||||
return
|
||||
}
|
||||
rows := [][]string{{"TIME (UTC)", "COUNT"}}
|
||||
for _, p := range r.resp.Points {
|
||||
rows = append(rows, []string{fmtTime(p.TimestampUnix), fmtCount(p.Count)})
|
||||
}
|
||||
printTable(os.Stdout, rows)
|
||||
}
|
||||
|
||||
func printTrendJSON(r trendResult) {
|
||||
type point struct {
|
||||
Ts int64 `json:"ts"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
type out struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Points []point `json:"points"`
|
||||
}
|
||||
o := out{
|
||||
Source: r.resp.Source,
|
||||
Target: r.target,
|
||||
Points: make([]point, len(r.resp.Points)),
|
||||
}
|
||||
for i, p := range r.resp.Points {
|
||||
o.Points[i] = point{Ts: p.TimestampUnix, Count: p.Count}
|
||||
}
|
||||
b, _ := json.Marshal(o)
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
122
cmd/cli/flags.go
Normal file
122
cmd/cli/flags.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
// bindShared registers the shared flags on fs and returns a pointer to the
|
||||
// populated struct. Call fs.Parse before reading the struct.
|
||||
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)")
|
||||
return sf, target
|
||||
}
|
||||
|
||||
func (sf *sharedFlags) resolve(target string) {
|
||||
sf.targets = parseTargets(target)
|
||||
}
|
||||
|
||||
func parseTargets(s string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, t := range strings.Split(s, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildFilter(sf *sharedFlags) *pb.Filter {
|
||||
if sf.website == "" && sf.prefix == "" && sf.uri == "" && sf.status == "" {
|
||||
return nil
|
||||
}
|
||||
f := &pb.Filter{}
|
||||
if sf.website != "" {
|
||||
f.Website = &sf.website
|
||||
}
|
||||
if sf.prefix != "" {
|
||||
f.ClientPrefix = &sf.prefix
|
||||
}
|
||||
if sf.uri != "" {
|
||||
f.HttpRequestUri = &sf.uri
|
||||
}
|
||||
if sf.status != "" {
|
||||
n, err := strconv.Atoi(sf.status)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "--status: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
n32 := int32(n)
|
||||
f.HttpResponse = &n32
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func parseWindow(s string) pb.Window {
|
||||
switch s {
|
||||
case "1m":
|
||||
return pb.Window_W1M
|
||||
case "5m":
|
||||
return pb.Window_W5M
|
||||
case "15m":
|
||||
return pb.Window_W15M
|
||||
case "60m":
|
||||
return pb.Window_W60M
|
||||
case "6h":
|
||||
return pb.Window_W6H
|
||||
case "24h":
|
||||
return pb.Window_W24H
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "--window: unknown value %q; valid: 1m 5m 15m 60m 6h 24h\n", s)
|
||||
os.Exit(1)
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func parseGroupBy(s string) pb.GroupBy {
|
||||
switch s {
|
||||
case "website":
|
||||
return pb.GroupBy_WEBSITE
|
||||
case "prefix":
|
||||
return pb.GroupBy_CLIENT_PREFIX
|
||||
case "uri":
|
||||
return pb.GroupBy_REQUEST_URI
|
||||
case "status":
|
||||
return pb.GroupBy_HTTP_RESPONSE
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "--group-by: unknown value %q; valid: website prefix uri status\n", s)
|
||||
os.Exit(1)
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func dieUsage(fs *flag.FlagSet, msg string) {
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
fs.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
68
cmd/cli/format.go
Normal file
68
cmd/cli/format.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
)
|
||||
|
||||
// printTable writes a formatted table with tabwriter. The first row is treated
|
||||
// as the header and separated from data rows by a rule of dashes.
|
||||
func printTable(w io.Writer, rows [][]string) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for i, row := range rows {
|
||||
fmt.Fprintln(tw, strings.Join(row, "\t"))
|
||||
if i == 0 {
|
||||
// Print a divider matching the header width.
|
||||
dashes := make([]string, len(row))
|
||||
for j, h := range row {
|
||||
dashes[j] = strings.Repeat("-", len(h))
|
||||
}
|
||||
fmt.Fprintln(tw, strings.Join(dashes, "\t"))
|
||||
}
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
// fmtCount formats a count with a space as the thousands separator.
|
||||
// e.g. 1234567 → "1 234 567"
|
||||
func fmtCount(n int64) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
start := len(s) % 3
|
||||
if start > 0 {
|
||||
b.WriteString(s[:start])
|
||||
}
|
||||
for i := start; i < len(s); i += 3 {
|
||||
if i > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(s[i : i+3])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// fmtTime formats a unix timestamp as "2006-01-02 15:04" UTC.
|
||||
func fmtTime(unix int64) string {
|
||||
return time.Unix(unix, 0).UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// targetHeader returns the header line to print before each target's results.
|
||||
// Returns empty string when there is only one target (clean single-target output).
|
||||
func targetHeader(target, source string, nTargets int) string {
|
||||
if nTargets <= 1 {
|
||||
return ""
|
||||
}
|
||||
if source != "" && source != target {
|
||||
return fmt.Sprintf("=== %s (%s) ===", source, target)
|
||||
}
|
||||
return fmt.Sprintf("=== %s ===", target)
|
||||
}
|
||||
50
cmd/cli/main.go
Normal file
50
cmd/cli/main.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const usage = `logtail-cli — debug shell for nginx-logtail collectors and aggregators
|
||||
|
||||
Usage:
|
||||
logtail-cli topn [flags] ranked label → count list
|
||||
logtail-cli trend [flags] per-minute time series
|
||||
logtail-cli stream [flags] live snapshot feed
|
||||
|
||||
Subcommand flags (all subcommands):
|
||||
--target host:port[,host:port,...] endpoints to query (default: localhost:9090)
|
||||
--json emit newline-delimited JSON
|
||||
--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
|
||||
|
||||
topn flags:
|
||||
--n INT number of entries (default 10)
|
||||
--window STR 1m 5m 15m 60m 6h 24h (default 5m)
|
||||
--group-by STR website prefix uri status (default website)
|
||||
|
||||
trend flags:
|
||||
--window STR 1m 5m 15m 60m 6h 24h (default 5m)
|
||||
`
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprint(os.Stderr, usage)
|
||||
os.Exit(1)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "topn":
|
||||
runTopN(os.Args[2:])
|
||||
case "trend":
|
||||
runTrend(os.Args[2:])
|
||||
case "stream":
|
||||
runStream(os.Args[2:])
|
||||
case "-h", "--help", "help":
|
||||
fmt.Print(usage)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n\n%s", os.Args[1], usage)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user