Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/skpr/logs/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
}
52 changes: 52 additions & 0 deletions cmd/skpr/logs/query/command.go
Original file line number Diff line number Diff line change
@@ -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 <environment>",
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
}
52 changes: 52 additions & 0 deletions cmd/skpr/logs/summary/command.go
Original file line number Diff line number Diff line change
@@ -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 <environment> <prompt...>",
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
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
77 changes: 77 additions & 0 deletions internal/command/logs/filter.go
Original file line number Diff line number Diff line change
@@ -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
}
33 changes: 33 additions & 0 deletions internal/command/logs/format.go
Original file line number Diff line number Diff line change
@@ -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)
}
107 changes: 107 additions & 0 deletions internal/command/logs/query/command.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading