feat: add Loki log query support to lab-monitoring
Add 3 opt-in Loki tools (query_logs, list_labels, list_label_values) that are registered when LOKI_URL is configured. Includes Loki HTTP client, CLI commands (logs, labels), NixOS module option, formatting, and tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ import (
|
||||
"git.t-juice.club/torjus/labmcp/internal/monitoring"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
const version = "0.2.0"
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
@@ -35,6 +35,11 @@ func main() {
|
||||
EnvVars: []string{"ALERTMANAGER_URL"},
|
||||
Value: "http://localhost:9093",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "loki-url",
|
||||
Usage: "Loki base URL (optional, enables log query tools)",
|
||||
EnvVars: []string{"LOKI_URL"},
|
||||
},
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
serveCommand(),
|
||||
@@ -42,6 +47,8 @@ func main() {
|
||||
queryCommand(),
|
||||
targetsCommand(),
|
||||
metricsCommand(),
|
||||
logsCommand(),
|
||||
labelsCommand(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -176,6 +183,11 @@ func runServe(c *cli.Context) error {
|
||||
prom := monitoring.NewPrometheusClient(c.String("prometheus-url"))
|
||||
am := monitoring.NewAlertmanagerClient(c.String("alertmanager-url"))
|
||||
|
||||
var loki *monitoring.LokiClient
|
||||
if lokiURL := c.String("loki-url"); lokiURL != "" {
|
||||
loki = monitoring.NewLokiClient(lokiURL)
|
||||
}
|
||||
|
||||
config.InstructionsFunc = func() string {
|
||||
return monitoring.AlertSummary(am)
|
||||
}
|
||||
@@ -184,7 +196,7 @@ func runServe(c *cli.Context) error {
|
||||
opts := monitoring.HandlerOptions{
|
||||
EnableSilences: c.Bool("enable-silences"),
|
||||
}
|
||||
monitoring.RegisterHandlers(server, prom, am, opts)
|
||||
monitoring.RegisterHandlers(server, prom, am, loki, opts)
|
||||
|
||||
transport := c.String("transport")
|
||||
switch transport {
|
||||
@@ -347,6 +359,199 @@ func runMetrics(c *cli.Context, query string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func logsCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "logs",
|
||||
Usage: "Query logs from Loki using LogQL",
|
||||
ArgsUsage: "<logql>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "start",
|
||||
Usage: "Start time: relative duration (e.g., '1h'), RFC3339, or Unix epoch",
|
||||
Value: "1h",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "end",
|
||||
Usage: "End time: relative duration, RFC3339, or Unix epoch",
|
||||
Value: "now",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Maximum number of entries",
|
||||
Value: 100,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "direction",
|
||||
Usage: "Sort order: 'backward' (newest first) or 'forward' (oldest first)",
|
||||
Value: "backward",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
return fmt.Errorf("LogQL expression required")
|
||||
}
|
||||
return runLogs(c, c.Args().First())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func labelsCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "labels",
|
||||
Usage: "List labels from Loki, or values for a specific label",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "values",
|
||||
Usage: "Get values for this label name instead of listing labels",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
return runLabels(c)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runLogs(c *cli.Context, logql string) error {
|
||||
lokiURL := c.String("loki-url")
|
||||
if lokiURL == "" {
|
||||
return fmt.Errorf("--loki-url or LOKI_URL is required for log queries")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
loki := monitoring.NewLokiClient(lokiURL)
|
||||
|
||||
now := time.Now()
|
||||
start, err := parseCLITime(c.String("start"), now.Add(-time.Hour))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid start time: %w", err)
|
||||
}
|
||||
end, err := parseCLITime(c.String("end"), now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid end time: %w", err)
|
||||
}
|
||||
|
||||
data, err := loki.QueryRange(ctx, logql, start, end, c.Int("limit"), c.String("direction"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("log query failed: %w", err)
|
||||
}
|
||||
|
||||
totalEntries := 0
|
||||
for _, stream := range data.Result {
|
||||
totalEntries += len(stream.Values)
|
||||
}
|
||||
|
||||
if totalEntries == 0 {
|
||||
fmt.Println("No log entries found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, stream := range data.Result {
|
||||
// Print stream labels
|
||||
labels := ""
|
||||
for k, v := range stream.Stream {
|
||||
if labels != "" {
|
||||
labels += ", "
|
||||
}
|
||||
labels += fmt.Sprintf("%s=%q", k, v)
|
||||
}
|
||||
fmt.Printf("--- {%s} ---\n", labels)
|
||||
|
||||
for _, entry := range stream.Values {
|
||||
ts := formatCLITimestamp(entry[0])
|
||||
fmt.Printf("[%s] %s\n", ts, entry[1])
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runLabels(c *cli.Context) error {
|
||||
lokiURL := c.String("loki-url")
|
||||
if lokiURL == "" {
|
||||
return fmt.Errorf("--loki-url or LOKI_URL is required for label queries")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
loki := monitoring.NewLokiClient(lokiURL)
|
||||
|
||||
if label := c.String("values"); label != "" {
|
||||
values, err := loki.LabelValues(ctx, label)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list label values: %w", err)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
fmt.Printf("No values found for label '%s'.\n", label)
|
||||
return nil
|
||||
}
|
||||
for _, v := range values {
|
||||
fmt.Println(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
labels, err := loki.Labels(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list labels: %w", err)
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
fmt.Println("No labels found.")
|
||||
return nil
|
||||
}
|
||||
for _, label := range labels {
|
||||
fmt.Println(label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseCLITime parses a time string for CLI use. Handles "now", relative durations,
|
||||
// RFC3339, and Unix epoch seconds.
|
||||
func parseCLITime(s string, defaultTime time.Time) (time.Time, error) {
|
||||
if s == "now" || s == "" {
|
||||
return time.Now(), nil
|
||||
}
|
||||
|
||||
// Try as relative duration
|
||||
if d, err := time.ParseDuration(s); err == nil {
|
||||
return time.Now().Add(-d), nil
|
||||
}
|
||||
|
||||
// Try as RFC3339
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Try as Unix epoch seconds
|
||||
var epoch int64
|
||||
validDigits := true
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
epoch = epoch*10 + int64(c-'0')
|
||||
} else {
|
||||
validDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if validDigits && len(s) > 0 {
|
||||
return time.Unix(epoch, 0), nil
|
||||
}
|
||||
|
||||
return defaultTime, fmt.Errorf("cannot parse time '%s'", s)
|
||||
}
|
||||
|
||||
// formatCLITimestamp converts a nanosecond Unix timestamp string to a readable format.
|
||||
func formatCLITimestamp(nsStr string) string {
|
||||
var ns int64
|
||||
for _, c := range nsStr {
|
||||
if c >= '0' && c <= '9' {
|
||||
ns = ns*10 + int64(c-'0')
|
||||
}
|
||||
}
|
||||
t := time.Unix(0, ns)
|
||||
return t.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
sLower := make([]byte, len(s))
|
||||
subLower := make([]byte, len(substr))
|
||||
|
||||
Reference in New Issue
Block a user