From 0d95afa886db48719c889b6c023c60a9b0b62942 Mon Sep 17 00:00:00 2001 From: Nick Schuch Date: Wed, 29 Apr 2026 16:02:07 +1000 Subject: [PATCH] New logs subcommands: query and summary --- cmd/skpr/logs/command.go | 4 + cmd/skpr/logs/query/command.go | 52 +++++++++++ cmd/skpr/logs/summary/command.go | 52 +++++++++++ go.mod | 2 +- go.sum | 2 + internal/command/logs/filter.go | 77 ++++++++++++++++ internal/command/logs/format.go | 33 +++++++ internal/command/logs/query/command.go | 107 +++++++++++++++++++++++ internal/command/logs/summary/command.go | 86 ++++++++++++++++++ internal/command/logs/tail/command.go | 30 +------ 10 files changed, 416 insertions(+), 29 deletions(-) create mode 100644 cmd/skpr/logs/query/command.go create mode 100644 cmd/skpr/logs/summary/command.go create mode 100644 internal/command/logs/filter.go create mode 100644 internal/command/logs/format.go create mode 100644 internal/command/logs/query/command.go create mode 100644 internal/command/logs/summary/command.go diff --git a/cmd/skpr/logs/command.go b/cmd/skpr/logs/command.go index d601b03..1fbe0bb 100644 --- a/cmd/skpr/logs/command.go +++ b/cmd/skpr/logs/command.go @@ -4,6 +4,8 @@ import ( "github.com/spf13/cobra" "github.com/skpr/cli/cmd/skpr/logs/list" + "github.com/skpr/cli/cmd/skpr/logs/query" + "github.com/skpr/cli/cmd/skpr/logs/summary" "github.com/skpr/cli/cmd/skpr/logs/tail" skprcommand "github.com/skpr/cli/internal/command" ) @@ -25,6 +27,8 @@ func NewCommand() *cobra.Command { cmd.AddCommand(list.NewCommand()) cmd.AddCommand(tail.NewCommand()) + cmd.AddCommand(query.NewCommand()) + cmd.AddCommand(summary.NewCommand()) return cmd } diff --git a/cmd/skpr/logs/query/command.go b/cmd/skpr/logs/query/command.go new file mode 100644 index 0000000..7009f5c --- /dev/null +++ b/cmd/skpr/logs/query/command.go @@ -0,0 +1,52 @@ +package query + +import ( + "time" + + "github.com/spf13/cobra" + + v1query "github.com/skpr/cli/internal/command/logs/query" +) + +var ( + cmdLong = `Run a bounded query over an environment's logs.` + + cmdExample = ` + # Query the default streams of an environment over the last hour. + skpr logs query dev + + # Query specific streams over the last 30 minutes. + skpr logs query dev --stream nginx --stream fpm --since 30m + + # Query an absolute time range with substring filters. + skpr logs query dev --from "1 hour ago" --to now --contains error --exclude healthcheck` +) + +// NewCommand creates a new cobra.Command for 'query' sub command. +func NewCommand() *cobra.Command { + command := v1query.Command{} + + cmd := &cobra.Command{ + Use: "query ", + Args: cobra.ExactArgs(1), + DisableFlagsInUseLine: true, + Short: "Run a bounded query over an environment's logs", + Long: cmdLong, + Example: cmdExample, + RunE: func(cmd *cobra.Command, args []string) error { + command.Environment = args[0] + return command.Run(cmd.Context()) + }, + } + + cmd.Flags().StringSliceVar(&command.Streams, "stream", nil, "Stream to include in the query (repeatable)") + cmd.Flags().DurationVar(&command.Since, "since", time.Hour, "Relative time window from now") + cmd.Flags().StringVar(&command.From, "from", "", "Absolute start of the time range (used with --to)") + cmd.Flags().StringVar(&command.To, "to", "", "Absolute end of the time range (used with --from)") + cmd.Flags().StringSliceVar(&command.Contains, "contains", nil, "Substring an event must contain (repeatable)") + cmd.Flags().StringSliceVar(&command.Exclude, "exclude", nil, "Substring an event must NOT contain (repeatable)") + cmd.Flags().Int32Var(&command.Limit, "limit", 0, "Maximum number of events to return (0 for the server default)") + cmd.Flags().BoolVar(&command.Indent, "indent", false, "Enable indenting for pretty printed logs") + + return cmd +} diff --git a/cmd/skpr/logs/summary/command.go b/cmd/skpr/logs/summary/command.go new file mode 100644 index 0000000..45b775f --- /dev/null +++ b/cmd/skpr/logs/summary/command.go @@ -0,0 +1,52 @@ +package summary + +import ( + "strings" + "time" + + "github.com/spf13/cobra" + + v1summary "github.com/skpr/cli/internal/command/logs/summary" +) + +var ( + cmdLong = `Summarise an environment's logs using a natural-language prompt.` + + cmdExample = ` + # Summarise the last hour of logs with a question. + skpr logs summary dev what errors happened recently + + # Quote the prompt when it contains shell metacharacters. + skpr logs summary dev "why did fpm crash?" + + # Restrict to specific streams and a tighter window. + skpr logs summary prod --stream nginx --since 30m top sources of 5xx responses` +) + +// NewCommand creates a new cobra.Command for 'summary' sub command. +func NewCommand() *cobra.Command { + command := v1summary.Command{} + + cmd := &cobra.Command{ + Use: "summary ", + Args: cobra.MinimumNArgs(2), + DisableFlagsInUseLine: true, + Short: "Summarise an environment's logs using a prompt", + Long: cmdLong, + Example: cmdExample, + RunE: func(cmd *cobra.Command, args []string) error { + command.Environment = args[0] + command.Prompt = strings.Join(args[1:], " ") + return command.Run(cmd.Context()) + }, + } + + cmd.Flags().StringSliceVar(&command.Streams, "stream", nil, "Stream to include (repeatable)") + cmd.Flags().DurationVar(&command.Since, "since", time.Hour, "Relative time window from now") + cmd.Flags().StringVar(&command.From, "from", "", "Absolute start of the time range (used with --to)") + cmd.Flags().StringVar(&command.To, "to", "", "Absolute end of the time range (used with --from)") + cmd.Flags().StringSliceVar(&command.Contains, "contains", nil, "Substring an event must contain (repeatable)") + cmd.Flags().StringSliceVar(&command.Exclude, "exclude", nil, "Substring an event must NOT contain (repeatable)") + + return cmd +} diff --git a/go.mod b/go.mod index c469d7f..a83fcf5 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/moby/patternmatcher v0.6.1 github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0 github.com/pkg/errors v0.9.1 - github.com/skpr/api v1.5.2 + github.com/skpr/api v1.6.0 github.com/skpr/compass/tracing v0.0.0-20251208094547-dafe383c3926 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index be8e624..2b91947 100644 --- a/go.sum +++ b/go.sum @@ -219,6 +219,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skpr/api v1.5.2 h1:JDIha2gf9Ycy/5JEYY+qyHidjKAIQa0M2q6jaFq0Vao= github.com/skpr/api v1.5.2/go.mod h1:LMyIte2bWjGuO2e8XOA6FKNFUX10itF2qYVTckM7dwg= +github.com/skpr/api v1.6.0 h1:LzbNm97wXvnWAcm+iyV1r36YKGMFWNn65y+EHhBxgqo= +github.com/skpr/api v1.6.0/go.mod h1:LMyIte2bWjGuO2e8XOA6FKNFUX10itF2qYVTckM7dwg= github.com/skpr/compass/tracing v0.0.0-20251208094547-dafe383c3926 h1:g8m8qqehB6/GMeM81BaIXQOkD2LZbCQE0HPRGear9Jw= github.com/skpr/compass/tracing v0.0.0-20251208094547-dafe383c3926/go.mod h1:Rokt2mnHteBaBmsSgiNk4P7laV0E8/BLQsSLyGUJLtk= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= diff --git a/internal/command/logs/filter.go b/internal/command/logs/filter.go new file mode 100644 index 0000000..2185418 --- /dev/null +++ b/internal/command/logs/filter.go @@ -0,0 +1,77 @@ +// Package logs contains shared helpers for log subcommands. +package logs + +import ( + "fmt" + "time" + + "github.com/skpr/api/pb" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + skprtime "github.com/skpr/cli/internal/time" +) + +// FilterParams holds the user-supplied filter inputs for query/summarise. +type FilterParams struct { + Environment string + Streams []string + Since time.Duration + From string + To string + Contains []string + Exclude []string +} + +// BuildFilter converts user inputs into a *pb.LogFilter. +func BuildFilter(p FilterParams) (*pb.LogFilter, error) { + filter := &pb.LogFilter{ + Environment: p.Environment, + Streams: p.Streams, + } + + hasFrom := p.From != "" + hasTo := p.To != "" + + switch { + case hasFrom != hasTo: + return nil, fmt.Errorf("--from and --to must be provided together") + case hasFrom && hasTo: + from, err := skprtime.ParseString(p.From) + if err != nil { + return nil, fmt.Errorf("failed to parse --from: %w", err) + } + + to, err := skprtime.ParseString(p.To) + if err != nil { + return nil, fmt.Errorf("failed to parse --to: %w", err) + } + + filter.Window = &pb.LogFilter_TimeRange{ + TimeRange: &pb.LogTimeRange{ + From: timestamppb.New(from), + To: timestamppb.New(to), + }, + } + default: + filter.Window = &pb.LogFilter_Timeframe{ + Timeframe: durationpb.New(p.Since), + } + } + + for _, v := range p.Contains { + filter.Contains = append(filter.Contains, &pb.LogContainsFilter{ + Value: v, + Exclude: false, + }) + } + + for _, v := range p.Exclude { + filter.Contains = append(filter.Contains, &pb.LogContainsFilter{ + Value: v, + Exclude: true, + }) + } + + return filter, nil +} diff --git a/internal/command/logs/format.go b/internal/command/logs/format.go new file mode 100644 index 0000000..6f8138e --- /dev/null +++ b/internal/command/logs/format.go @@ -0,0 +1,33 @@ +package logs + +import ( + "encoding/json" + + "github.com/TylerBrock/colorjson" + faithcolor "github.com/fatih/color" +) + +// PrettyPrint returns a colourised representation of a JSON message, falling +// back to the raw string when the message is not JSON. +func PrettyPrint(message string, indent bool) string { + var obj map[string]interface{} + + err := json.Unmarshal([]byte(message), &obj) + if err != nil { + return message + } + + formatter := colorjson.NewFormatter() + formatter.KeyColor = faithcolor.New(faithcolor.FgWhite).Add(faithcolor.Bold) + + if indent { + formatter.Indent = 2 + } + + raw, err := formatter.Marshal(obj) + if err != nil { + return message + } + + return string(raw) +} diff --git a/internal/command/logs/query/command.go b/internal/command/logs/query/command.go new file mode 100644 index 0000000..abd311d --- /dev/null +++ b/internal/command/logs/query/command.go @@ -0,0 +1,107 @@ +package query + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/jwalton/gchalk" + "github.com/skpr/api/pb" + + "github.com/skpr/cli/internal/client" + "github.com/skpr/cli/internal/color" + logshared "github.com/skpr/cli/internal/command/logs" +) + +// Format used for printing event timestamps. +const timestampFormat = "15:04:05.000" + +// Separator drawn between columns. +var separator = gchalk.Dim("│") + +// Command runs a bounded log query. +type Command struct { + Environment string + Streams []string + Since time.Duration + From string + To string + Contains []string + Exclude []string + Limit int32 + Indent bool +} + +// Run the command. +func (cmd *Command) Run(ctx context.Context) error { + filter, err := logshared.BuildFilter(logshared.FilterParams{ + Environment: cmd.Environment, + Streams: cmd.Streams, + Since: cmd.Since, + From: cmd.From, + To: cmd.To, + Contains: cmd.Contains, + Exclude: cmd.Exclude, + }) + if err != nil { + return err + } + + ctx, c, err := client.New(ctx) + if err != nil { + return err + } + + stream, err := c.Logs().Query(ctx, &pb.LogQueryRequest{ + Filter: filter, + Limit: cmd.Limit, + }) + if err != nil { + return fmt.Errorf("failed to start query: %w", err) + } + + var meta *pb.LogQueryMeta + + for { + resp, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to receive query response: %w", err) + } + + switch body := resp.GetBody().(type) { + case *pb.LogQueryResponse_Batch: + for _, ev := range body.Batch.GetEvents() { + printEvent(ev, cmd.Indent) + } + case *pb.LogQueryResponse_Meta: + meta = body.Meta + } + } + + if meta != nil { + ranAt := meta.GetRanAt().AsTime().Format(time.RFC3339) + fmt.Printf("\nScanned %d events at %s\n", meta.GetScanned(), ranAt) + } + + return nil +} + +func printEvent(ev *pb.LogEvent, indent bool) { + ts := gchalk.Dim(ev.GetTimestamp().AsTime().Format(timestampFormat)) + + streamLabel := formatStream(ev.GetStream()) + + message := logshared.PrettyPrint(ev.GetMessage(), indent) + + fmt.Printf("%s %s %s %s %s\n", ts, separator, streamLabel, separator, message) +} + +// formatStream colourises the visible stream name and pads/truncates to width. +// Trailing pad spaces are kept outside the colour codes to avoid background bleed. +func formatStream(name string) string { + return gchalk.WithHex(color.HexOrange).Bold(name) +} diff --git a/internal/command/logs/summary/command.go b/internal/command/logs/summary/command.go new file mode 100644 index 0000000..06758ee --- /dev/null +++ b/internal/command/logs/summary/command.go @@ -0,0 +1,86 @@ +package summary + +import ( + "context" + "fmt" + "time" + + "github.com/jwalton/gchalk" + "github.com/skpr/api/pb" + + "github.com/skpr/cli/internal/client" + "github.com/skpr/cli/internal/color" + logshared "github.com/skpr/cli/internal/command/logs" +) + +// Command runs an AI summarisation over a log window. +type Command struct { + Environment string + Streams []string + Since time.Duration + From string + To string + Contains []string + Exclude []string + Prompt string +} + +// Run the command. +func (cmd *Command) Run(ctx context.Context) error { + if cmd.Prompt == "" { + return fmt.Errorf("a prompt must be provided") + } + + filter, err := logshared.BuildFilter(logshared.FilterParams{ + Environment: cmd.Environment, + Streams: cmd.Streams, + Since: cmd.Since, + From: cmd.From, + To: cmd.To, + Contains: cmd.Contains, + Exclude: cmd.Exclude, + }) + if err != nil { + return err + } + + ctx, c, err := client.New(ctx) + if err != nil { + return err + } + + resp, err := c.Logs().Summarise(ctx, &pb.LogSummariseRequest{ + Filter: filter, + Prompt: cmd.Prompt, + }) + if err != nil { + return fmt.Errorf("failed to summarise logs: %w", err) + } + + fmt.Println(orangeBold("▌ Overview")) + fmt.Printf(" %s\n", resp.GetOverview()) + + if bullets := resp.GetBullets(); len(bullets) > 0 { + fmt.Println() + fmt.Println(orangeBold("▌ Notable")) + for _, b := range bullets { + fmt.Printf(" • %s\n", b) + } + } + + if actions := resp.GetSuggestedActions(); len(actions) > 0 { + fmt.Println() + fmt.Println(orangeBold("▌ Suggested actions")) + for _, a := range actions { + fmt.Printf(" • %s\n", a) + } + } + + fmt.Println() + + return nil +} + +func orangeBold(s string) string { + return gchalk.WithHex(color.HexOrange).Bold(s) +} diff --git a/internal/command/logs/tail/command.go b/internal/command/logs/tail/command.go index 626cbf9..92c92b9 100644 --- a/internal/command/logs/tail/command.go +++ b/internal/command/logs/tail/command.go @@ -2,20 +2,18 @@ package tail import ( "context" - "encoding/json" "fmt" "io" "slices" "strings" - "github.com/TylerBrock/colorjson" - faithcolor "github.com/fatih/color" "github.com/jwalton/gchalk" "github.com/skpr/api/pb" "golang.org/x/sync/errgroup" "github.com/skpr/cli/internal/client" "github.com/skpr/cli/internal/color" + logshared "github.com/skpr/cli/internal/command/logs" ) // Command to stream the logs for an environment. @@ -85,7 +83,7 @@ func (cmd *Command) Run(ctx context.Context) error { return fmt.Errorf("fail to tail stream: %s: %w", stream, err) } - message := prettyPrint(resp.Message, cmd.Indent) + message := logshared.PrettyPrint(resp.Message, cmd.Indent) // Only prefix when there is more than one stream. if len(cmd.Streams) > 1 { @@ -101,27 +99,3 @@ func (cmd *Command) Run(ctx context.Context) error { return e.Wait() } - -// Returns a pretty output for JSON messages. -func prettyPrint(message string, indent bool) string { - var obj map[string]interface{} - - err := json.Unmarshal([]byte(message), &obj) - if err != nil { - return message - } - - formatter := colorjson.NewFormatter() - formatter.KeyColor = faithcolor.New(faithcolor.FgWhite).Add(faithcolor.Bold) - - if indent { - formatter.Indent = 2 - } - - raw, err := formatter.Marshal(obj) - if err != nil { - return message - } - - return string(raw) -}