Skip to content

feat: add fuzzy searching for log group names #72

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
- `saw watch production` Stream logs from production log group
- `saw watch production --prefix api` Stream logs from production log group with prefix "api"

- Fuzzy search the log group name
- `saw get --fuzzy LambdaFunctionName`

## Usage

- Basic
Expand Down
210 changes: 124 additions & 86 deletions blade/blade.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
package blade

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"

"github.com/TylerBrock/colorjson"
"github.com/TylerBrock/saw/config"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs"
"github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types"
"github.com/fatih/color"
)

Expand All @@ -21,98 +21,138 @@ type Blade struct {
config *config.Configuration
aws *config.AWSConfiguration
output *config.OutputConfiguration
cwl *cloudwatchlogs.CloudWatchLogs
cwl *cloudwatchlogs.Client
}

// NewBlade creates a new Blade with CloudWatchLogs instance from provided config
func NewBlade(
ctx context.Context,
config *config.Configuration,
awsConfig *config.AWSConfiguration,
outputConfig *config.OutputConfiguration,
) *Blade {
) (*Blade, error) {
blade := Blade{}
awsCfg := aws.Config{}

if awsConfig.Region != "" {
awsCfg.Region = &awsConfig.Region
}

awsSessionOpts := session.Options{
Config: awsCfg,
AssumeRoleTokenProvider: stscreds.StdinTokenProvider,
SharedConfigState: session.SharedConfigEnable,
}

var opts []func(*awsconfig.LoadOptions) error
if awsConfig.Profile != "" {
awsSessionOpts.Profile = awsConfig.Profile
opts = append(opts, awsconfig.WithSharedConfigProfile(awsConfig.Profile))
}
if awsConfig.Region != "" {
opts = append(opts, awsconfig.WithRegion(awsConfig.Region))
}
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)

sess := session.Must(session.NewSessionWithOptions(awsSessionOpts))

blade.cwl = cloudwatchlogs.New(sess)
blade.cwl = cloudwatchlogs.NewFromConfig(awsCfg)
blade.config = config
blade.output = outputConfig

return &blade
return &blade, err
}

// GetLogGroups gets the log groups from AWS given the blade configuration
func (b *Blade) GetLogGroups() []*cloudwatchlogs.LogGroup {
func (b *Blade) GetLogGroups(ctx context.Context) (groups []types.LogGroup, err error) {
input := b.config.DescribeLogGroupsInput()
groups := make([]*cloudwatchlogs.LogGroup, 0)
b.cwl.DescribeLogGroupsPages(input, func(
out *cloudwatchlogs.DescribeLogGroupsOutput,
lastPage bool,
) bool {
for _, group := range out.LogGroups {
groups = append(groups, group)
logGroupsPaginator := cloudwatchlogs.NewDescribeLogGroupsPaginator(b.cwl, input)
var page *cloudwatchlogs.DescribeLogGroupsOutput
for logGroupsPaginator.HasMorePages() {
page, err = logGroupsPaginator.NextPage(ctx)
if err != nil {
return
}
return !lastPage
})
return groups
groups = append(groups, page.LogGroups...)
}
return
}

func groupNameMatches(s, substr string) bool {
return strings.Contains(s, substr)
}

func (b *Blade) ResolveFuzzyGroupName(ctx context.Context) (err error) {
if !b.config.Fuzzy {
return
}
b.config.Fuzzy = false
groups, err := b.GetLogGroups(ctx)
if err != nil {
return
}
if len(groups) == 0 {
return errors.New("no log groups found")
}
filtered := filterGroupNames(groups, b.config.Group)
if len(filtered) > 1 {
return fmt.Errorf("too many results for log group fuzzy search\n%s", strings.Join(filtered, "\n"))
}
if len(filtered) == 0 {
return fmt.Errorf("no results for log group fuzzy search in %d groups\n%s", len(groups), strings.Join(getGroupNames(groups), "\n"))
}
b.config.Group = filtered[0]
return
}

func getGroupNames(groups []types.LogGroup) (op []string) {
op = make([]string, len(groups))
for i := 0; i < len(groups); i++ {
op[i] = *groups[i].LogGroupName
}
return
}

func filterGroupNames(groups []types.LogGroup, group string) (op []string) {
for i := 0; i < len(groups); i++ {
if groupNameMatches(*groups[i].LogGroupName, group) {
op = append(op, *groups[i].LogGroupName)
}
}
return
}

// GetLogStreams gets the log streams from AWS given the blade configuration
func (b *Blade) GetLogStreams() []*cloudwatchlogs.LogStream {
func (b *Blade) GetLogStreams(ctx context.Context) (streams []types.LogStream, err error) {
if err := b.ResolveFuzzyGroupName(ctx); err != nil {
return nil, err
}
input := b.config.DescribeLogStreamsInput()
streams := make([]*cloudwatchlogs.LogStream, 0)
b.cwl.DescribeLogStreamsPages(input, func(
out *cloudwatchlogs.DescribeLogStreamsOutput,
lastPage bool,
) bool {
for _, stream := range out.LogStreams {
streams = append(streams, stream)
logStreamsPaginator := cloudwatchlogs.NewDescribeLogStreamsPaginator(b.cwl, input)
var page *cloudwatchlogs.DescribeLogStreamsOutput
for logStreamsPaginator.HasMorePages() {
page, err = logStreamsPaginator.NextPage(ctx)
if err != nil {
return
}
return !lastPage
})

return streams
streams = append(streams, page.LogStreams...)
}
return
}

// GetEvents gets events from AWS given the blade configuration
func (b *Blade) GetEvents() {
func (b *Blade) GetEvents(ctx context.Context) (err error) {
if err := b.ResolveFuzzyGroupName(ctx); err != nil {
return err
}
formatter := b.output.Formatter()
input := b.config.FilterLogEventsInput()

handlePage := func(page *cloudwatchlogs.FilterLogEventsOutput, lastPage bool) bool {
logEventsPaginator := cloudwatchlogs.NewFilterLogEventsPaginator(b.cwl, input)
var page *cloudwatchlogs.FilterLogEventsOutput
for logEventsPaginator.HasMorePages() {
page, err = logEventsPaginator.NextPage(ctx)
if err != nil {
return
}
for _, event := range page.Events {
if b.output.Pretty {
fmt.Println(formatEvent(formatter, event))
fmt.Println(strings.TrimRight(formatEvent(formatter, event), "\n"))
} else {
fmt.Println(*event.Message)
fmt.Println(strings.TrimRight(*event.Message, "\n"))
}
}
return !lastPage
}
err := b.cwl.FilterLogEventsPages(input, handlePage)
if err != nil {
fmt.Println("Error", err)
os.Exit(2)
}
return
}

// StreamEvents continuously prints log events to the console
func (b *Blade) StreamEvents() {
func (b *Blade) StreamEvents(ctx context.Context) (err error) {
var lastSeenTime *int64
var seenEventIDs map[string]bool
formatter := b.output.Formatter()
Expand All @@ -133,47 +173,45 @@ func (b *Blade) StreamEvents() {
}
}

handlePage := func(page *cloudwatchlogs.FilterLogEventsOutput, lastPage bool) bool {
for _, event := range page.Events {
updateLastSeenTime(event.Timestamp)
if _, seen := seenEventIDs[*event.EventId]; !seen {
var message string
if b.output.Raw {
message = *event.Message
} else {
message = formatEvent(formatter, event)
for {
logEventsPaginator := cloudwatchlogs.NewFilterLogEventsPaginator(b.cwl, input)
var page *cloudwatchlogs.FilterLogEventsOutput
for logEventsPaginator.HasMorePages() {
page, err = logEventsPaginator.NextPage(ctx)
if err != nil {
return
}
for _, event := range page.Events {
updateLastSeenTime(event.Timestamp)
if _, seen := seenEventIDs[*event.EventId]; !seen {
var message string
if b.output.Raw {
message = *event.Message
} else {
message = formatEvent(formatter, event)
}
message = strings.TrimRight(message, "\n")
fmt.Println(message)
addSeenEventIDs(event.EventId)
}
message = strings.TrimRight(message, "\n")
fmt.Println(message)
addSeenEventIDs(event.EventId)
}
}
return !lastPage
}

for {
err := b.cwl.FilterLogEventsPages(input, handlePage)
if err != nil {
fmt.Println("Error", err)
os.Exit(2)
}
if lastSeenTime != nil {
input.SetStartTime(*lastSeenTime)
input.StartTime = lastSeenTime
}
time.Sleep(1 * time.Second)
}
}

// formatEvent returns a CloudWatch log event as a formatted string using the provided formatter
func formatEvent(formatter *colorjson.Formatter, event *cloudwatchlogs.FilteredLogEvent) string {
func formatEvent(formatter *colorjson.Formatter, event types.FilteredLogEvent) string {
red := color.New(color.FgRed).SprintFunc()
white := color.New(color.FgWhite).SprintFunc()

str := aws.StringValue(event.Message)
bytes := []byte(str)
date := aws.MillisecondsTimeValue(event.Timestamp)
dateStr := date.Format(time.RFC3339)
streamStr := aws.StringValue(event.LogStreamName)
str := *event.Message
bytes := []byte(*event.Message)
dateStr := time.UnixMilli(*event.Timestamp).Format(time.RFC3339)
streamStr := *event.LogStreamName
jl := map[string]interface{}{}

if err := json.Unmarshal(bytes, &jl); err != nil {
Expand Down
17 changes: 12 additions & 5 deletions cmd/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,35 @@ var getCommand = &cobra.Command{
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) (err error) {
getConfig.Group = args[0]
b := blade.NewBlade(&getConfig, &awsConfig, &getOutputConfig)
b, err := blade.NewBlade(cmd.Context(), &getConfig, &awsConfig, &getOutputConfig)
if err != nil {
return
}
if getConfig.Prefix != "" {
streams := b.GetLogStreams()
streams, err := b.GetLogStreams(cmd.Context())
if err != nil {
return fmt.Errorf("failed to get log streams: %w", err)
}
if len(streams) == 0 {
fmt.Printf("No streams found in %s with prefix %s\n", getConfig.Group, getConfig.Prefix)
fmt.Printf("To view available streams: `saw streams %s`\n", getConfig.Group)
os.Exit(3)
}
getConfig.Streams = streams
}
b.GetEvents()
return b.GetEvents(cmd.Context())
},
}

func init() {
getCommand.Flags().BoolVar(&getConfig.Fuzzy, "fuzzy", false, "log group fuzzy match")
getCommand.Flags().StringVar(&getConfig.Prefix, "prefix", "", "log group prefix filter")
getCommand.Flags().StringVar(
&getConfig.Start,
"start",
"",
"-10m",
`start getting the logs from this point
Takes an absolute timestamp in RFC3339 format, or a relative time (eg. -2h).
Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`,
Expand Down
21 changes: 18 additions & 3 deletions cmd/groups.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"errors"
"fmt"

"github.com/TylerBrock/saw/blade"
Expand All @@ -15,15 +16,29 @@ var groupsCommand = &cobra.Command{
Use: "groups",
Short: "List log groups",
Long: "",
Run: func(cmd *cobra.Command, args []string) {
b := blade.NewBlade(&groupsConfig, &awsConfig, nil)
logGroups := b.GetLogGroups()
RunE: func(cmd *cobra.Command, args []string) (err error) {
if groupsConfig.Fuzzy {
if len(args) < 1 {
return errors.New("listing groups with fuzzy search requires log group argument")
}
groupsConfig.Group = args[0]
}
b, err := blade.NewBlade(cmd.Context(), &groupsConfig, &awsConfig, nil)
if err != nil {
return
}
logGroups, err := b.GetLogGroups(cmd.Context())
if err != nil {
return fmt.Errorf("failed to get log groups: %w", err)
}
for _, group := range logGroups {
fmt.Println(*group.LogGroupName)
}
return
},
}

func init() {
groupsCommand.Flags().BoolVar(&groupsConfig.Fuzzy, "fuzzy", false, "log group fuzzy match")
groupsCommand.Flags().StringVar(&groupsConfig.Prefix, "prefix", "", "log group prefix filter")
}
Loading