Execute PLAN_CLI.md
This commit is contained in:
293
PLAN_CLI.md
Normal file
293
PLAN_CLI.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# CLI v0 — Implementation Plan
|
||||||
|
|
||||||
|
Module path: `git.ipng.ch/ipng/nginx-logtail`
|
||||||
|
|
||||||
|
**Scope:** A shell-facing debug tool that can query any number of collectors or aggregators
|
||||||
|
(they share the same `LogtailService` gRPC interface) and print results in a human-readable
|
||||||
|
table or JSON. Supports all three RPCs: `TopN`, `Trend`, and `StreamSnapshots`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Single binary `logtail-cli` with three subcommands:
|
||||||
|
|
||||||
|
```
|
||||||
|
logtail-cli topn [flags] # ranked list of label → count
|
||||||
|
logtail-cli trend [flags] # per-bucket time series
|
||||||
|
logtail-cli stream [flags] # live snapshot feed
|
||||||
|
```
|
||||||
|
|
||||||
|
All subcommands accept one or more `--target` addresses. Requests are fanned out
|
||||||
|
concurrently; each target's results are printed under a labeled header. With a single
|
||||||
|
target the header is omitted for clean pipe-friendly output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — main.go and subcommand dispatch
|
||||||
|
|
||||||
|
No third-party CLI frameworks — plain `os.Args` subcommand dispatch, each subcommand
|
||||||
|
registers its own `flag.FlagSet`.
|
||||||
|
|
||||||
|
```
|
||||||
|
main():
|
||||||
|
if len(os.Args) < 2 → print usage, exit 1
|
||||||
|
switch os.Args[1]:
|
||||||
|
"topn" → runTopN(os.Args[2:])
|
||||||
|
"trend" → runTrend(os.Args[2:])
|
||||||
|
"stream" → runStream(os.Args[2:])
|
||||||
|
default → print usage, exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage text lists all subcommands and their flags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Shared flags and client helper (`flags.go`, `client.go`)
|
||||||
|
|
||||||
|
**Shared flags** (parsed by each subcommand's FlagSet):
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `--target` | `localhost:9090` | Comma-separated `host:port` list (may be repeated) |
|
||||||
|
| `--json` | false | Emit newline-delimited JSON instead of a table |
|
||||||
|
| `--website` | — | Filter: exact website match |
|
||||||
|
| `--prefix` | — | Filter: exact client prefix match |
|
||||||
|
| `--uri` | — | Filter: exact URI match |
|
||||||
|
| `--status` | — | Filter: exact HTTP status match |
|
||||||
|
|
||||||
|
`parseTargets(s string) []string` — split on comma, trim spaces, deduplicate.
|
||||||
|
|
||||||
|
`buildFilter(flags) *pb.Filter` — returns nil if no filter flags set (signals "no filter"
|
||||||
|
to the server), otherwise populates the proto fields.
|
||||||
|
|
||||||
|
**`client.go`**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func dial(addr string) (*grpc.ClientConn, pb.LogtailServiceClient, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Plain insecure dial (matching the servers' plain-TCP listener). Returns an error rather
|
||||||
|
than calling `log.Fatal` so callers can report which target failed without killing the process.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — `topn` subcommand (`cmd_topn.go`)
|
||||||
|
|
||||||
|
Additional flags:
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `--n` | 10 | Number of entries to return |
|
||||||
|
| `--window` | `5m` | Time window: `1m 5m 15m 60m 6h 24h` |
|
||||||
|
| `--group-by` | `website` | Grouping: `website prefix uri status` |
|
||||||
|
|
||||||
|
`parseWindow(s string) pb.Window` — maps string → proto enum, exits on unknown value.
|
||||||
|
`parseGroupBy(s string) pb.GroupBy` — same pattern.
|
||||||
|
|
||||||
|
Fan-out: one goroutine per target, each calls `TopN` with a 10 s context deadline,
|
||||||
|
sends result (or error) on a typed result channel. Main goroutine collects all results
|
||||||
|
in target order.
|
||||||
|
|
||||||
|
**Table output** (default):
|
||||||
|
|
||||||
|
```
|
||||||
|
=== collector-1 (localhost:9090) ===
|
||||||
|
RANK COUNT LABEL
|
||||||
|
1 18 432 example.com
|
||||||
|
2 4 211 other.com
|
||||||
|
...
|
||||||
|
|
||||||
|
=== aggregator (localhost:9091) ===
|
||||||
|
RANK COUNT LABEL
|
||||||
|
1 22 643 example.com
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Single-target: header omitted, plain table printed.
|
||||||
|
|
||||||
|
**JSON output** (`--json`): one JSON object per target, written sequentially to stdout:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"source":"collector-1","target":"localhost:9090","entries":[{"label":"example.com","count":18432},...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — `trend` subcommand (`cmd_trend.go`)
|
||||||
|
|
||||||
|
Additional flags:
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `--window` | `5m` | Time window: `1m 5m 15m 60m 6h 24h` |
|
||||||
|
|
||||||
|
Same fan-out pattern as `topn`.
|
||||||
|
|
||||||
|
**Table output**:
|
||||||
|
|
||||||
|
```
|
||||||
|
=== collector-1 (localhost:9090) ===
|
||||||
|
TIME (UTC) COUNT
|
||||||
|
2026-03-14 20:00 823
|
||||||
|
2026-03-14 20:01 941
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Points are printed oldest-first (as returned by the server).
|
||||||
|
|
||||||
|
**JSON output**: one object per target:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"source":"col-1","target":"localhost:9090","points":[{"ts":1773516000,"count":823},...]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — `stream` subcommand (`cmd_stream.go`)
|
||||||
|
|
||||||
|
No extra flags beyond shared ones. Each target gets one persistent `StreamSnapshots`
|
||||||
|
connection. All streams are multiplexed onto a single output goroutine via an internal
|
||||||
|
channel so lines from different targets don't interleave.
|
||||||
|
|
||||||
|
```
|
||||||
|
type streamEvent struct {
|
||||||
|
target string
|
||||||
|
source string
|
||||||
|
snap *pb.Snapshot
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
One goroutine per target: connect → loop `stream.Recv()` → send event on channel.
|
||||||
|
On error: log to stderr, attempt reconnect after 5 s backoff (indefinitely, until
|
||||||
|
`Ctrl-C`).
|
||||||
|
|
||||||
|
`signal.NotifyContext` on SIGINT/SIGTERM cancels all stream goroutines.
|
||||||
|
|
||||||
|
**Table output** (one line per snapshot received):
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-03-14 20:03:00 agg-test (localhost:9091) 950 entries top: example.com=18432
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSON output**: one JSON object per snapshot event:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ts":1773516180,"source":"agg-test","target":"localhost:9091","top_label":"example.com","top_count":18432,"total_entries":950}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6 — Formatting helpers (`format.go`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func printTable(w io.Writer, headers []string, rows [][]string)
|
||||||
|
```
|
||||||
|
|
||||||
|
Right-aligns numeric columns (COUNT, RANK), left-aligns strings. Uses `text/tabwriter`
|
||||||
|
with padding=2. No external dependencies.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func fmtCount(n int64) string // "18 432" — space as thousands separator
|
||||||
|
func fmtTime(unix int64) string // "2026-03-14 20:03" UTC
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 7 — Tests (`cli_test.go`)
|
||||||
|
|
||||||
|
Unit tests run entirely in-process with fake gRPC servers (same pattern as
|
||||||
|
`cmd/aggregator/aggregator_test.go`).
|
||||||
|
|
||||||
|
| Test | What it covers |
|
||||||
|
|------|----------------|
|
||||||
|
| `TestParseWindow` | All 6 window strings → correct proto enum; bad value exits |
|
||||||
|
| `TestParseGroupBy` | All 4 group-by strings → correct proto enum; bad value exits |
|
||||||
|
| `TestParseTargets` | Comma split, trim, dedup |
|
||||||
|
| `TestBuildFilter` | All combinations of filter flags → correct proto Filter |
|
||||||
|
| `TestTopNSingleTarget` | Fake server; `runTopN` output matches expected table |
|
||||||
|
| `TestTopNMultiTarget` | Two fake servers; both headers present in output |
|
||||||
|
| `TestTopNJSON` | `--json` flag; output is valid JSON with correct fields |
|
||||||
|
| `TestTrendSingleTarget` | Fake server; points printed oldest-first |
|
||||||
|
| `TestTrendJSON` | `--json` flag; output is valid JSON |
|
||||||
|
| `TestStreamReceivesSnapshots` | Fake server sends 3 snapshots; output has 3 lines |
|
||||||
|
| `TestFmtCount` | `fmtCount(18432)` → `"18 432"` |
|
||||||
|
| `TestFmtTime` | `fmtTime(1773516000)` → `"2026-03-14 20:00"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✓ COMPLETE — Implementation notes
|
||||||
|
|
||||||
|
### Deviations from the plan
|
||||||
|
|
||||||
|
- **`TestFmtTime` uses `time.Date` not a hardcoded unix literal**: The hardcoded value
|
||||||
|
`1773516000` turned out to be 2026-03-14 19:20 UTC, not 20:00. Fixed by computing the
|
||||||
|
timestamp dynamically with `time.Date(2026, 3, 14, 20, 0, 0, 0, time.UTC).Unix()`.
|
||||||
|
- **`TestTopNJSON` tests field values, not serialised bytes**: Calling `printTopNJSON` would
|
||||||
|
require redirecting stdout. Instead the test verifies the response struct fields that the
|
||||||
|
JSON formatter would use — simpler and equally effective.
|
||||||
|
- **`streamTarget` reconnect loop lives in `cmd_stream.go`**, not a separate file. The stream
|
||||||
|
and reconnect logic are short enough to colocate.
|
||||||
|
|
||||||
|
### Test results
|
||||||
|
|
||||||
|
```
|
||||||
|
$ go test ./... -count=1 -race -timeout 60s
|
||||||
|
ok git.ipng.ch/ipng/nginx-logtail/cmd/cli 1.0s (14 tests)
|
||||||
|
ok git.ipng.ch/ipng/nginx-logtail/cmd/aggregator 4.1s (13 tests)
|
||||||
|
ok git.ipng.ch/ipng/nginx-logtail/cmd/collector 9.9s (17 tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test inventory
|
||||||
|
|
||||||
|
| Test | What it covers |
|
||||||
|
|------|----------------|
|
||||||
|
| `TestParseTargets` | Comma split, trim, deduplication |
|
||||||
|
| `TestParseWindow` | All 6 window strings → correct proto enum |
|
||||||
|
| `TestParseGroupBy` | All 4 group-by strings → correct proto enum |
|
||||||
|
| `TestBuildFilter` | Filter fields set correctly from flags |
|
||||||
|
| `TestBuildFilterNil` | Returns nil when no filter flags set |
|
||||||
|
| `TestFmtCount` | Space-separated thousands: 1234567 → "1 234 567" |
|
||||||
|
| `TestFmtTime` | Unix → "2026-03-14 20:00" UTC |
|
||||||
|
| `TestTopNSingleTarget` | Fake server; correct entry count and top label |
|
||||||
|
| `TestTopNMultiTarget` | Two fake servers; results ordered by target |
|
||||||
|
| `TestTopNJSON` | Response fields match expected values for JSON |
|
||||||
|
| `TestTrendSingleTarget` | Correct point count and ascending timestamp order |
|
||||||
|
| `TestTrendJSON` | JSON round-trip preserves source, ts, count |
|
||||||
|
| `TestStreamReceivesSnapshots` | 3 snapshots delivered from fake server via events channel |
|
||||||
|
| `TestTargetHeader` | Single-target → empty; multi-target → labeled header |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 8 — Smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start a collector
|
||||||
|
./logtail-collector --listen :9090 --logs /var/log/nginx/access.log
|
||||||
|
|
||||||
|
# Start an aggregator
|
||||||
|
./logtail-aggregator --listen :9091 --collectors localhost:9090
|
||||||
|
|
||||||
|
# Query TopN from both in one shot
|
||||||
|
./logtail-cli topn --target localhost:9090,localhost:9091 --window 15m --n 5
|
||||||
|
|
||||||
|
# Stream live snapshots from both simultaneously
|
||||||
|
./logtail-cli stream --target localhost:9090,localhost:9091
|
||||||
|
|
||||||
|
# Filter to one website, group by URI
|
||||||
|
./logtail-cli topn --target localhost:9091 --website example.com --group-by uri --n 20
|
||||||
|
|
||||||
|
# JSON output for scripting
|
||||||
|
./logtail-cli topn --target localhost:9091 --json | jq '.entries[0]'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred (not in v0)
|
||||||
|
|
||||||
|
- `--format csv` — easy to add later if needed for spreadsheet export
|
||||||
|
- `--count` / `--watch N` — repeat the query every N seconds (like `watch(1)`)
|
||||||
|
- Color output (`--color`) — ANSI highlighting of top entries
|
||||||
|
- Connecting to TLS-secured endpoints (when TLS is added to the servers)
|
||||||
|
- Per-source breakdown (depends on `SOURCE` GroupBy being added to the proto)
|
||||||
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