├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.md ├── Dockerfile ├── FUNDING.yml ├── LICENSE ├── README.md ├── aur ├── Dockerfile └── PKGBUILD ├── blade └── blade.go ├── cmd ├── get.go ├── groups.go ├── saw.go ├── streams.go ├── version.go └── watch.go ├── config ├── aws.go ├── configuration.go ├── configuration_test.go └── output.go ├── go.mod └── saw.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # circle.yml 2 | version: 2.1 3 | jobs: 4 | deploy: 5 | docker: 6 | - image: cimg/go:1.19.1 7 | steps: 8 | - checkout 9 | - run: curl -sL https://git.io/goreleaser | bash 10 | 11 | workflows: 12 | deploy_saw: 13 | jobs: 14 | - deploy: 15 | filters: 16 | tags: 17 | only: /v[0-9]+(\.[0-9]+)*(-.*)*/ -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | saw 2 | dist 3 | vendor 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | dist 3 | saw 4 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: saw 3 | goos: 4 | - darwin 5 | - linux 6 | - freebsd 7 | goarch: 8 | - amd64 9 | - arm 10 | - arm64 11 | goarm: 12 | - 6 13 | - 7 14 | - 8 15 | env: 16 | - CGO_ENANBLED=0 17 | 18 | archive: 19 | format: tar.gz 20 | files: 21 | - LICENSE 22 | 23 | brew: 24 | name: saw 25 | 26 | github: 27 | owner: TylerBrock 28 | name: homebrew-saw 29 | 30 | commit_author: 31 | name: TylerBrock 32 | email: tyler.brock@gmail.com 33 | 34 | folder: Formula 35 | homepage: "https://github.com/TylerBrock/saw" 36 | description: "Fast, multipurpose tool for AWS CloudWatch Logs" 37 | 38 | git: 39 | short_hash: true 40 | 41 | nfpm: 42 | vendor: Tyler Brock 43 | homepage: https://github.com/TylerBrock/saw 44 | maintainer: Tyler Brock 45 | description: Fast, multipurpose tool for AWS CloudWatch Logs 46 | license: MIT 47 | formats: 48 | - deb 49 | - rpm 50 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.2.2 4 | 5 | - Added support for parsing additonal time formats (@andrewpage) 6 | 7 | ## v0.2.1 8 | 9 | - Feature - de-duplicate newlines event messages (@klichukb) 10 | - Feature - Added Dockerfile (@shnhrrsn) 11 | 12 | ## v0.2.0 13 | 14 | - Added --raw flag to watch subcommand, disables decorations (@will-ockmore) 15 | - Added --pretty flag to get subcommand, enables decorations (@will-ockmore) 16 | 17 | The defaults are for the watch output to be pretty and the get output to be raw. 18 | 19 | ## v0.1.8 20 | 21 | - Support filter option for get (@cynipe) 22 | 23 | ## v0.1.7 24 | 25 | - Fix usage output for get command 26 | - Rename get command `end` flag to `stop` 27 | - Unexport some exported vars in `cmd` package 28 | 29 | ## v0.1.6 30 | 31 | - Add MFA (assumerole) support (@perriea) 32 | - Add usage for get command (@will-ockmore) 33 | 34 | ## v0.1.5 35 | 36 | - Add region and profile support 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as build 2 | WORKDIR $GOPATH/src/github.com/TylerBrock/saw 3 | 4 | # Add ca-certificates for TLS/SSL 5 | RUN apk add --no-cache git ca-certificates 6 | 7 | # Copy the rest of the project and build 8 | COPY . . 9 | RUN CGO_ENABLED=0 GOOS=linux go build -a -o /saw . 10 | 11 | # Reset to scratch to drop all of the above layers and only copy over the final binary 12 | FROM scratch 13 | ENV HOME=/home 14 | COPY --from=build /saw /bin/saw 15 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 16 | 17 | ENTRYPOINT ["/bin/saw"] 18 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: TylerBrock 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Tyler Brock 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saw 2 | 3 | `saw` is a multi-purpose tool for AWS CloudWatch Logs 4 | 5 | ![Saw Gif](https://media.giphy.com/media/3fiohCfMJAKf7lhnPp/giphy.gif) 6 | 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/TylerBrock/saw)](https://goreportcard.com/report/github.com/TylerBrock/saw) 8 | 9 | ## Features 10 | 11 | - Colorized output that can be formatted in various ways 12 | - `--expand` Explode JSON objects using indenting 13 | - `--rawString` Print JSON strings instead of escaping ("\n", ...) 14 | - `--invert` Invert white colors to black for light color schemes 15 | - `--raw`, or `--pretty`, for `watch` and `get` commands respectively, toggles display of the timestamp and stream name prefix. 16 | 17 | - Filter logs using CloudWatch patterns 18 | - `--filter foo` Filter logs for the text "foo" 19 | 20 | - Watch aggregated interleaved streams across a log group 21 | - `saw watch production` Stream logs from production log group 22 | - `saw watch production --prefix api` Stream logs from production log group with prefix "api" 23 | 24 | ## Usage 25 | 26 | - Basic 27 | ```sh 28 | # Get list of log groups 29 | saw groups 30 | 31 | # Get list of streams for production log group 32 | saw streams production 33 | ``` 34 | 35 | - Watch 36 | ```sh 37 | # Watch production log group 38 | saw watch production 39 | 40 | # Watch production log group streams for api 41 | saw watch production --prefix api 42 | 43 | # Watch production log group streams for api and filter for "error" 44 | saw watch production --prefix api --filter error 45 | ``` 46 | 47 | - Get 48 | ```sh 49 | # Get production log group for the last 2 hours 50 | saw get production --start -2h 51 | 52 | # Get production log group for the last 2 hours and filter for "error" 53 | saw get production --start -2h --filter error 54 | 55 | # Get production log group for api between 26th June 2018 and 28th June 2018 56 | saw get production --prefix api --start 2018-06-26 --stop 2018-06-28 57 | ``` 58 | 59 | ### Profile and Region Support 60 | 61 | By default Saw uses the region and credentials in your default profile. You can override these to your liking using the command line flags: 62 | 63 | ```sh 64 | # Use personal profile 65 | saw groups --profile personal 66 | 67 | # Use us-west-1 region 68 | saw groups --region us-west-1 69 | ``` 70 | 71 | ## Installation 72 | 73 | ### Run from Docker 74 | 75 | ```sh 76 | docker run --rm -it -v ~/.aws:$HOME/.aws tbrock/saw 77 | ``` 78 | 79 | ### Mac OS X 80 | 81 | ```sh 82 | brew tap TylerBrock/saw 83 | brew install saw 84 | ``` 85 | 86 | ### Linux 87 | 88 | #### Arch Linux (source) 89 | 90 | ```sh 91 | # Using yay 92 | yay saw 93 | 94 | # Using makepkg 95 | git clone https://aur.archlinux.org/saw.git 96 | cd saw 97 | makepkg -sri 98 | ``` 99 | 100 | #### Red Hat Based Distributions (Fedora/RHEL/CentOS/Amazon Linux) 101 | ```sh 102 | rpm -i 103 | ``` 104 | 105 | #### Debian Based Distributions (Debian/Ubuntu) 106 | ```sh 107 | wget 108 | sudo dpkg -i 109 | ``` 110 | 111 | ### Manual Install/Update 112 | 113 | - [Install go](https://golang.org/doc/install) 114 | - Configure your `GOPATH` and add `$GOPATH/bin` to your path 115 | - Run `go install github.com/TylerBrock/saw@latest` 116 | 117 | #### Windows Specifics 118 | 119 | - Add %GOPATH%/bin to your path (optional) 120 | - Run from gopath/bin (If not in your path) 121 | ```DOS .bat 122 | cd %GOPATH%/bin 123 | saw ... 124 | ``` 125 | 126 | Alternatively you can hard code these in your shell's init scripts (bashrc, zshrc, etc...): 127 | 128 | ```sh 129 | # Export profile and region that override the default 130 | export AWS_PROFILE='work_profile' 131 | export AWS_REGION='us-west-1' 132 | ``` 133 | 134 | ## Run Tests 135 | From root of repository: `go test -v ./...` 136 | 137 | ## TODO 138 | 139 | - Bash + ZSH completion of log groups + (streams?) 140 | - Create log streams and groups 141 | - Delete log streams and groups 142 | - Basic tests 143 | -------------------------------------------------------------------------------- /aur/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM archlinux/base 2 | 3 | RUN pacman -S base-devel --noconfirm 4 | 5 | RUN useradd build 6 | USER build 7 | -------------------------------------------------------------------------------- /aur/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Tyler Brock 2 | pkgname=saw 3 | pkgver=0.1.3 4 | pkgrel=1 5 | pkgdesc="Fast, multipurpose tool for AWS CloudWatch Logs" 6 | arch=('i686' 'x86_64' 'armv6h' 'armv7h') 7 | provides=('saw') 8 | url="https://github.com/TylerBrock/$pkgname" 9 | license=('MIT') 10 | makedepends=('go' 'git' 'dep') 11 | source=("https://github.com/TylerBrock/$pkgname/archive/v$pkgver.tar.gz") 12 | md5sums=('a9daec2bee15e595e71424d720767d8b') 13 | 14 | prepare() { 15 | mkdir -p "${srcdir}/go/src/github.com/TylerBrock/" 16 | mv "${srcdir}/${pkgname}-${pkgver}" "${srcdir}/go/src/github.com/TylerBrock/${pkgname}" 17 | } 18 | 19 | build() { 20 | export GOPATH="${srcdir}/go" 21 | export PATH="$PATH:$srcdir/go/bin" 22 | cd "${srcdir}/go/src/github.com/TylerBrock/${pkgname}" 23 | dep ensure 24 | go build . 25 | } 26 | 27 | package() { 28 | cd "${srcdir}/go/src/github.com/TylerBrock/${pkgname}" 29 | install -Dm755 saw "${pkgdir}/usr/bin/${pkgname}" 30 | install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" 31 | } 32 | -------------------------------------------------------------------------------- /blade/blade.go: -------------------------------------------------------------------------------- 1 | package blade 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/TylerBrock/colorjson" 11 | "github.com/TylerBrock/saw/config" 12 | "github.com/aws/aws-sdk-go/aws" 13 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 14 | "github.com/aws/aws-sdk-go/aws/session" 15 | "github.com/aws/aws-sdk-go/service/cloudwatchlogs" 16 | "github.com/fatih/color" 17 | ) 18 | 19 | // A Blade is a Saw execution instance 20 | type Blade struct { 21 | config *config.Configuration 22 | aws *config.AWSConfiguration 23 | output *config.OutputConfiguration 24 | cwl *cloudwatchlogs.CloudWatchLogs 25 | } 26 | 27 | // NewBlade creates a new Blade with CloudWatchLogs instance from provided config 28 | func NewBlade( 29 | config *config.Configuration, 30 | awsConfig *config.AWSConfiguration, 31 | outputConfig *config.OutputConfiguration, 32 | ) *Blade { 33 | blade := Blade{} 34 | awsCfg := aws.Config{} 35 | 36 | if awsConfig.Endpoint != "" { 37 | awsCfg.Endpoint = &awsConfig.Endpoint 38 | } 39 | 40 | if awsConfig.Region != "" { 41 | awsCfg.Region = &awsConfig.Region 42 | } 43 | 44 | awsSessionOpts := session.Options{ 45 | Config: awsCfg, 46 | AssumeRoleTokenProvider: stscreds.StdinTokenProvider, 47 | SharedConfigState: session.SharedConfigEnable, 48 | } 49 | 50 | if awsConfig.Profile != "" { 51 | awsSessionOpts.Profile = awsConfig.Profile 52 | } 53 | 54 | sess := session.Must(session.NewSessionWithOptions(awsSessionOpts)) 55 | 56 | blade.cwl = cloudwatchlogs.New(sess) 57 | blade.config = config 58 | blade.output = outputConfig 59 | 60 | return &blade 61 | } 62 | 63 | // GetLogGroups gets the log groups from AWS given the blade configuration 64 | func (b *Blade) GetLogGroups() []*cloudwatchlogs.LogGroup { 65 | input := b.config.DescribeLogGroupsInput() 66 | groups := make([]*cloudwatchlogs.LogGroup, 0) 67 | b.cwl.DescribeLogGroupsPages(input, func( 68 | out *cloudwatchlogs.DescribeLogGroupsOutput, 69 | lastPage bool, 70 | ) bool { 71 | for _, group := range out.LogGroups { 72 | groups = append(groups, group) 73 | } 74 | return !lastPage 75 | }) 76 | return groups 77 | } 78 | 79 | // GetLogStreams gets the log streams from AWS given the blade configuration 80 | func (b *Blade) GetLogStreams() []*cloudwatchlogs.LogStream { 81 | input := b.config.DescribeLogStreamsInput() 82 | streams := make([]*cloudwatchlogs.LogStream, 0) 83 | b.cwl.DescribeLogStreamsPages(input, func( 84 | out *cloudwatchlogs.DescribeLogStreamsOutput, 85 | lastPage bool, 86 | ) bool { 87 | for _, stream := range out.LogStreams { 88 | streams = append(streams, stream) 89 | } 90 | return !lastPage 91 | }) 92 | 93 | return streams 94 | } 95 | 96 | // GetEvents gets events from AWS given the blade configuration 97 | func (b *Blade) GetEvents() { 98 | formatter := b.output.Formatter() 99 | input := b.config.FilterLogEventsInput() 100 | 101 | handlePage := func(page *cloudwatchlogs.FilterLogEventsOutput, lastPage bool) bool { 102 | for _, event := range page.Events { 103 | if b.output.Pretty { 104 | fmt.Println(formatEvent(formatter, event)) 105 | } else { 106 | fmt.Println(*event.Message) 107 | } 108 | } 109 | return !lastPage 110 | } 111 | err := b.cwl.FilterLogEventsPages(input, handlePage) 112 | if err != nil { 113 | fmt.Println("Error", err) 114 | os.Exit(2) 115 | } 116 | } 117 | 118 | // StreamEvents continuously prints log events to the console 119 | func (b *Blade) StreamEvents() { 120 | var lastSeenTime *int64 121 | var seenEventIDs map[string]bool 122 | formatter := b.output.Formatter() 123 | input := b.config.FilterLogEventsInput() 124 | 125 | clearSeenEventIds := func() { 126 | seenEventIDs = make(map[string]bool, 0) 127 | } 128 | 129 | addSeenEventIDs := func(id *string) { 130 | seenEventIDs[*id] = true 131 | } 132 | 133 | updateLastSeenTime := func(ts *int64) { 134 | if lastSeenTime == nil || *ts > *lastSeenTime { 135 | lastSeenTime = ts 136 | clearSeenEventIds() 137 | } 138 | } 139 | 140 | handlePage := func(page *cloudwatchlogs.FilterLogEventsOutput, lastPage bool) bool { 141 | for _, event := range page.Events { 142 | updateLastSeenTime(event.Timestamp) 143 | if _, seen := seenEventIDs[*event.EventId]; !seen { 144 | var message string 145 | if b.output.Raw { 146 | message = *event.Message 147 | } else { 148 | message = formatEvent(formatter, event) 149 | } 150 | message = strings.TrimRight(message, "\n") 151 | fmt.Println(message) 152 | addSeenEventIDs(event.EventId) 153 | } 154 | } 155 | return !lastPage 156 | } 157 | 158 | for { 159 | err := b.cwl.FilterLogEventsPages(input, handlePage) 160 | if err != nil { 161 | fmt.Println("Error", err) 162 | os.Exit(2) 163 | } 164 | if lastSeenTime != nil { 165 | input.SetStartTime(*lastSeenTime) 166 | } 167 | time.Sleep(1 * time.Second) 168 | } 169 | } 170 | 171 | // formatEvent returns a CloudWatch log event as a formatted string using the provided formatter 172 | func formatEvent(formatter *colorjson.Formatter, event *cloudwatchlogs.FilteredLogEvent) string { 173 | red := color.New(color.FgRed).SprintFunc() 174 | white := color.New(color.FgWhite).SprintFunc() 175 | 176 | str := aws.StringValue(event.Message) 177 | bytes := []byte(str) 178 | date := aws.MillisecondsTimeValue(event.Timestamp) 179 | dateStr := date.Format(time.RFC3339) 180 | streamStr := aws.StringValue(event.LogStreamName) 181 | jl := map[string]interface{}{} 182 | 183 | if err := json.Unmarshal(bytes, &jl); err != nil { 184 | return fmt.Sprintf("[%s] (%s) %s", red(dateStr), white(streamStr), str) 185 | } 186 | 187 | output, _ := formatter.Marshal(jl) 188 | return fmt.Sprintf("[%s] (%s) %s", red(dateStr), white(streamStr), output) 189 | } 190 | -------------------------------------------------------------------------------- /cmd/get.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/TylerBrock/saw/blade" 9 | "github.com/TylerBrock/saw/config" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var getConfig config.Configuration 14 | var getOutputConfig config.OutputConfiguration 15 | 16 | var getCommand = &cobra.Command{ 17 | Use: "get ", 18 | Short: "Get log events", 19 | Long: "", 20 | Args: func(cmd *cobra.Command, args []string) error { 21 | if len(args) < 1 { 22 | return errors.New("getting events requires log group argument") 23 | } 24 | return nil 25 | }, 26 | Run: func(cmd *cobra.Command, args []string) { 27 | getConfig.Group = args[0] 28 | b := blade.NewBlade(&getConfig, &awsConfig, &getOutputConfig) 29 | if getConfig.Prefix != "" { 30 | streams := b.GetLogStreams() 31 | if len(streams) == 0 { 32 | fmt.Printf("No streams found in %s with prefix %s\n", getConfig.Group, getConfig.Prefix) 33 | fmt.Printf("To view available streams: `saw streams %s`\n", getConfig.Group) 34 | os.Exit(3) 35 | } 36 | getConfig.Streams = streams 37 | } 38 | b.GetEvents() 39 | }, 40 | } 41 | 42 | func init() { 43 | getCommand.Flags().StringVar(&getConfig.Prefix, "prefix", "", "log group prefix filter") 44 | getCommand.Flags().StringVar( 45 | &getConfig.Start, 46 | "start", 47 | "", 48 | `start getting the logs from this point 49 | Takes an absolute timestamp in RFC3339 format, or a relative time (eg. -2h). 50 | Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`, 51 | ) 52 | getCommand.Flags().StringVar( 53 | &getConfig.End, 54 | "stop", 55 | "now", 56 | `stop getting the logs at this point 57 | Takes an absolute timestamp in RFC3339 format, or a relative time (eg. -2h). 58 | Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`, 59 | ) 60 | getCommand.Flags().StringVar(&getConfig.Filter, "filter", "", "event filter pattern") 61 | getCommand.Flags().BoolVar(&getOutputConfig.Pretty, "pretty", false, "print timestamp and stream name prefix") 62 | getCommand.Flags().BoolVar(&getOutputConfig.Expand, "expand", false, "indent JSON log messages") 63 | getCommand.Flags().BoolVar(&getOutputConfig.Invert, "invert", false, "invert colors for light terminal themes") 64 | getCommand.Flags().BoolVar(&getOutputConfig.RawString, "rawString", false, "print JSON strings without escaping") 65 | } 66 | -------------------------------------------------------------------------------- /cmd/groups.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/TylerBrock/saw/blade" 7 | "github.com/TylerBrock/saw/config" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // TODO: colorize based on logGroup prefix (/aws/lambda, /aws/kinesisfirehose, etc...) 12 | var groupsConfig config.Configuration 13 | 14 | var groupsCommand = &cobra.Command{ 15 | Use: "groups", 16 | Short: "List log groups", 17 | Long: "", 18 | Run: func(cmd *cobra.Command, args []string) { 19 | b := blade.NewBlade(&groupsConfig, &awsConfig, nil) 20 | logGroups := b.GetLogGroups() 21 | for _, group := range logGroups { 22 | fmt.Println(*group.LogGroupName) 23 | } 24 | }, 25 | } 26 | 27 | func init() { 28 | groupsCommand.Flags().StringVar(&groupsConfig.Prefix, "prefix", "", "log group prefix filter") 29 | } 30 | -------------------------------------------------------------------------------- /cmd/saw.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/TylerBrock/saw/config" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | // SawCommand is the main top-level command 9 | var SawCommand = &cobra.Command{ 10 | Use: "saw ", 11 | Short: "A fast, multipurpose tool for AWS CloudWatch Logs", 12 | Long: "Saw is a fast, multipurpose tool for AWS CloudWatch Logs.", 13 | Example: ` saw groups 14 | saw streams production 15 | saw watch production`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | cmd.HelpFunc()(cmd, args) 18 | }, 19 | } 20 | 21 | var awsConfig config.AWSConfiguration 22 | 23 | func init() { 24 | SawCommand.AddCommand(groupsCommand) 25 | SawCommand.AddCommand(streamsCommand) 26 | SawCommand.AddCommand(versionCommand) 27 | SawCommand.AddCommand(watchCommand) 28 | SawCommand.AddCommand(getCommand) 29 | SawCommand.PersistentFlags().StringVar(&awsConfig.Endpoint, "endpoint-url", "", "override default endpoint URL") 30 | SawCommand.PersistentFlags().StringVar(&awsConfig.Region, "region", "", "override profile AWS region") 31 | SawCommand.PersistentFlags().StringVar(&awsConfig.Profile, "profile", "", "override default AWS profile") 32 | } 33 | -------------------------------------------------------------------------------- /cmd/streams.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/TylerBrock/saw/blade" 8 | "github.com/TylerBrock/saw/config" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var streamsConfig config.Configuration 13 | 14 | var streamsCommand = &cobra.Command{ 15 | Use: "streams ", 16 | Short: "List streams in log group", 17 | Long: "", 18 | Args: func(cmd *cobra.Command, args []string) error { 19 | if len(args) < 1 { 20 | return errors.New("listing streams requires log group argument") 21 | } 22 | return nil 23 | }, 24 | Run: func(cmd *cobra.Command, args []string) { 25 | streamsConfig.Group = args[0] 26 | b := blade.NewBlade(&streamsConfig, &awsConfig, nil) 27 | 28 | logStreams := b.GetLogStreams() 29 | for _, stream := range logStreams { 30 | fmt.Println(*stream.LogStreamName) 31 | } 32 | }, 33 | } 34 | 35 | func init() { 36 | streamsCommand.Flags().StringVar(&streamsConfig.Prefix, "prefix", "", "stream prefix filter") 37 | streamsCommand.Flags().StringVar(&streamsConfig.OrderBy, "orderBy", "LogStreamName", "order streams by LogStreamName or LastEventTime") 38 | streamsCommand.Flags().BoolVar(&streamsConfig.Descending, "descending", false, "order streams descending") 39 | } 40 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | const version = "0.2.2" 10 | 11 | var versionCommand = &cobra.Command{ 12 | Use: "version", 13 | Short: "Prints the version string", 14 | Long: "", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | fmt.Printf("v%s\n", version) 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /cmd/watch.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/TylerBrock/saw/blade" 9 | "github.com/TylerBrock/saw/config" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var watchConfig config.Configuration 14 | 15 | var watchOutputConfig config.OutputConfiguration 16 | 17 | var watchCommand = &cobra.Command{ 18 | Use: "watch ", 19 | Short: "Continuously stream log events", 20 | Long: "", 21 | Args: func(cmd *cobra.Command, args []string) error { 22 | if len(args) < 1 { 23 | return errors.New("watching streams requires log group argument") 24 | } 25 | return nil 26 | }, 27 | Run: func(cmd *cobra.Command, args []string) { 28 | watchConfig.Group = args[0] 29 | b := blade.NewBlade(&watchConfig, &awsConfig, &watchOutputConfig) 30 | if watchConfig.Prefix != "" { 31 | streams := b.GetLogStreams() 32 | if len(streams) == 0 { 33 | fmt.Printf("No streams found in %s with prefix %s\n", watchConfig.Group, watchConfig.Prefix) 34 | fmt.Printf("To view available streams: `saw streams %s`\n", watchConfig.Group) 35 | os.Exit(3) 36 | } 37 | watchConfig.Streams = streams 38 | } 39 | b.StreamEvents() 40 | }, 41 | } 42 | 43 | func init() { 44 | watchCommand.Flags().StringVar(&watchConfig.Prefix, "prefix", "", "log stream prefix filter") 45 | watchCommand.Flags().StringVar(&watchConfig.Filter, "filter", "", "event filter pattern") 46 | watchCommand.Flags().BoolVar(&watchOutputConfig.Raw, "raw", false, "print raw log event without timestamp or stream prefix") 47 | watchCommand.Flags().BoolVar(&watchOutputConfig.Expand, "expand", false, "indent JSON log messages") 48 | watchCommand.Flags().BoolVar(&watchOutputConfig.Invert, "invert", false, "invert colors for light terminal themes") 49 | watchCommand.Flags().BoolVar(&watchOutputConfig.RawString, "rawString", false, "print JSON strings without escaping") 50 | } 51 | -------------------------------------------------------------------------------- /config/aws.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type AWSConfiguration struct { 4 | Endpoint string 5 | Region string 6 | Profile string 7 | } 8 | -------------------------------------------------------------------------------- /config/configuration.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "sort" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/service/cloudwatchlogs" 10 | ) 11 | 12 | type Configuration struct { 13 | Group string 14 | Prefix string 15 | Start string 16 | End string 17 | Filter string 18 | Streams []*cloudwatchlogs.LogStream 19 | Descending bool 20 | OrderBy string 21 | } 22 | 23 | // Define the order of time formats to attempt to use to parse our input absolute time 24 | var absoluteTimeFormats = []string{ 25 | time.RFC3339, 26 | 27 | "2006-01-02", // Simple date 28 | "2006-01-02 15:04:05", // Simple date & time 29 | } 30 | 31 | // Parse the input string into a time.Time object. 32 | // Provide the currentTime as a parameter to support relative time. 33 | func getTime(timeStr string, currentTime time.Time) (time.Time, error) { 34 | relative, err := time.ParseDuration(timeStr) 35 | if err == nil { 36 | return currentTime.Add(relative), nil 37 | } 38 | 39 | // Iterate over available absolute time formats until we find one that works 40 | for _, timeFormat := range absoluteTimeFormats { 41 | absolute, err := time.Parse(timeFormat, timeStr) 42 | 43 | if err == nil { 44 | return absolute, err 45 | } 46 | } 47 | 48 | return time.Time{}, errors.New("Could not parse relative or absolute time") 49 | } 50 | 51 | func (c *Configuration) DescribeLogGroupsInput() *cloudwatchlogs.DescribeLogGroupsInput { 52 | input := cloudwatchlogs.DescribeLogGroupsInput{} 53 | if c.Prefix != "" { 54 | input.SetLogGroupNamePrefix(c.Prefix) 55 | } 56 | return &input 57 | } 58 | 59 | func (c *Configuration) DescribeLogStreamsInput() *cloudwatchlogs.DescribeLogStreamsInput { 60 | input := cloudwatchlogs.DescribeLogStreamsInput{} 61 | input.SetLogGroupName(c.Group) 62 | input.SetDescending(c.Descending) 63 | 64 | if c.OrderBy != "" { 65 | input.SetOrderBy(c.OrderBy) 66 | } 67 | 68 | if c.Prefix != "" { 69 | input.SetLogStreamNamePrefix(c.Prefix) 70 | } 71 | 72 | return &input 73 | } 74 | 75 | func (c *Configuration) FilterLogEventsInput() *cloudwatchlogs.FilterLogEventsInput { 76 | input := cloudwatchlogs.FilterLogEventsInput{} 77 | input.SetInterleaved(true) 78 | input.SetLogGroupName(c.Group) 79 | 80 | if len(c.Streams) != 0 { 81 | input.SetLogStreamNames(c.TopStreamNames()) 82 | } 83 | 84 | currentTime := time.Now() 85 | absoluteStartTime := currentTime 86 | if c.Start != "" { 87 | st, err := getTime(c.Start, currentTime) 88 | if err == nil { 89 | absoluteStartTime = st 90 | } 91 | } 92 | input.SetStartTime(aws.TimeUnixMilli(absoluteStartTime)) 93 | 94 | if c.End != "" { 95 | et, err := getTime(c.End, currentTime) 96 | if err == nil { 97 | input.SetEndTime(aws.TimeUnixMilli(et)) 98 | } 99 | } 100 | 101 | if c.Filter != "" { 102 | input.SetFilterPattern(c.Filter) 103 | } 104 | 105 | return &input 106 | } 107 | 108 | func (c *Configuration) TopStreamNames() []*string { 109 | // FilerLogEvents can only take 100 streams so lets sort by LastEventTimestamp 110 | // (descending) and take only the names of the most recent 100. 111 | sort.Slice(c.Streams, func(i int, j int) bool { 112 | return *c.Streams[i].LastEventTimestamp > *c.Streams[j].LastEventTimestamp 113 | }) 114 | 115 | numStreams := 100 116 | 117 | if len(c.Streams) < 100 { 118 | numStreams = len(c.Streams) 119 | } 120 | 121 | streamNames := make([]*string, 0) 122 | 123 | for _, stream := range c.Streams[:numStreams] { 124 | streamNames = append(streamNames, stream.LogStreamName) 125 | } 126 | 127 | return streamNames 128 | } 129 | -------------------------------------------------------------------------------- /config/configuration_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | var testTimeNow = time.Date(2018, 12, 01, 15, 30, 0, 0, time.UTC) 9 | 10 | /* 11 | * Helper method to assert equality of time objects in our tests 12 | */ 13 | func assertTimeEquality(t *testing.T, expected *time.Time, result *time.Time, err error) bool { 14 | if err != nil || !result.Equal(*expected) { 15 | t.Errorf("Expected to parse absolute time from simple string. Error: %s. Expected Time: %s, Absolute Time: %s", err, expected.String(), result.String()) 16 | 17 | return false 18 | } 19 | 20 | return true 21 | } 22 | 23 | // test the relative parsing functionality of getTime 24 | func TestGetTimeRelative(t *testing.T) { 25 | expectedTime1 := time.Date(2018, 12, 01, 13, 30, 0, 0, time.UTC) 26 | relativeTime1, err := getTime("-2h", testTimeNow) 27 | assertTimeEquality(t, &expectedTime1, &relativeTime1, err) 28 | 29 | expectedTime2 := time.Date(2018, 12, 01, 14, 15, 0, 0, time.UTC) 30 | relativeTime2, err := getTime("-1h15m", testTimeNow) 31 | assertTimeEquality(t, &expectedTime2, &relativeTime2, err) 32 | } 33 | 34 | // test the absolute RFC3339 parsing functionality of getTime 35 | func TestGetTimeAbsoluteRFC3339(t *testing.T) { 36 | expectedTime := time.Date(2006, 1, 2, 23, 4, 5, 0, time.UTC) 37 | absoluteTime1, err := getTime("2006-01-02T15:04:05-08:00", testTimeNow) 38 | 39 | assertTimeEquality(t, &expectedTime, &absoluteTime1, err) 40 | } 41 | 42 | func TestGetTimeAbsoluteSimpleDate(t *testing.T) { 43 | expectedTime := time.Date(2018, 6, 26, 0, 0, 0, 0, time.UTC) 44 | absoluteTime1, err := getTime("2018-06-26", testTimeNow) 45 | 46 | assertTimeEquality(t, &expectedTime, &absoluteTime1, err) 47 | } 48 | 49 | func TestGetTimeAbsoluteSimpleDateAndTime(t *testing.T) { 50 | expectedTime := time.Date(2018, 6, 26, 12, 43, 30, 0, time.UTC) 51 | absoluteTime1, err := getTime("2018-06-26 12:43:30", testTimeNow) 52 | 53 | assertTimeEquality(t, &expectedTime, &absoluteTime1, err) 54 | } 55 | -------------------------------------------------------------------------------- /config/output.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/TylerBrock/colorjson" 5 | "github.com/fatih/color" 6 | ) 7 | 8 | type OutputConfiguration struct { 9 | Raw bool 10 | Pretty bool 11 | Expand bool 12 | Invert bool 13 | RawString bool 14 | NoColor bool 15 | } 16 | 17 | func (c *OutputConfiguration) Formatter() *colorjson.Formatter { 18 | formatter := colorjson.NewFormatter() 19 | 20 | if c.Expand { 21 | formatter.Indent = 4 22 | } 23 | 24 | if c.RawString { 25 | formatter.RawStrings = true 26 | } 27 | 28 | if c.Invert { 29 | formatter.KeyColor = color.New(color.FgBlack) 30 | } 31 | 32 | if c.NoColor { 33 | color.NoColor = true 34 | } 35 | 36 | return formatter 37 | } 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/TylerBrock/saw 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/TylerBrock/colorjson v0.0.0-20180527164720-95ec53f28296 7 | github.com/aws/aws-sdk-go v1.13.56 8 | github.com/fatih/color v1.7.0 9 | github.com/go-ini/ini v1.37.0 // indirect 10 | github.com/hokaccha/go-prettyjson v0.0.0-20180920040306-f579f869bbfe // indirect 11 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 12 | github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 // indirect 13 | github.com/mattn/go-colorable v0.0.9 // indirect 14 | github.com/mattn/go-isatty v0.0.3 // indirect 15 | github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a // indirect 16 | github.com/spf13/cobra v0.0.3 17 | github.com/spf13/pflag v1.0.1 // indirect 18 | github.com/stretchr/testify v1.3.0 // indirect 19 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980 // indirect 20 | gopkg.in/ini.v1 v1.42.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /saw.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/TylerBrock/saw/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.SawCommand.Execute(); err != nil { 11 | os.Exit(0) 12 | } 13 | } 14 | --------------------------------------------------------------------------------