├── ping.go ├── go.mod ├── .goreleaser.yml ├── .github └── workflows │ └── release.yml ├── template_helpers_test.go ├── LICENSE ├── go.sum ├── format.go ├── CONTRIBUTING.md ├── main.go ├── conf.go ├── template_helpers.go ├── README.md └── match.go /ping.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "golang.org/x/net/websocket" 4 | 5 | var pingCodec = websocket.Codec{Marshal: ping, Unmarshal: nil} 6 | 7 | func ping(v interface{}) (msg []byte, payloadType byte, err error) { 8 | return nil, websocket.PingFrame, nil 9 | } 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ovh/ldp-tail 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.4.0 7 | github.com/sirupsen/logrus v1.9.3 8 | github.com/spf13/pflag v1.0.6 9 | golang.org/x/net v0.37.0 10 | ) 11 | 12 | require golang.org/x/sys v0.31.0 // indirect 13 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - 4 | binary: ldp-tail 5 | goos: 6 | - windows 7 | - darwin 8 | - linux 9 | goarch: 10 | - amd64 11 | - arm64 12 | ldflags: -s -w -X main.buildVersion={{.Version}} -X main.buildCommit={{.Commit}} -X main.buildDate={{.Date}} 13 | archives: 14 | - 15 | files: 16 | - LICENSE 17 | format_overrides: 18 | - goos: windows 19 | formats: ['zip'] 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: "1.23.x" 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v6 22 | with: 23 | version: '~> v2' 24 | args: release --clean 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /template_helpers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestDate(t *testing.T) { 9 | defer os.Setenv("TZ", os.Getenv("TZ")) 10 | os.Setenv("TZ", "UTC") 11 | 12 | r := date(1498508631) 13 | value := "2017-06-26 20:23:51" 14 | if r != value { 15 | t.Fatalf("Date with default format, got: %s, wanted: %s", r, value) 16 | } 17 | 18 | r = date(1498508631, "02/01/2006:15:04:05") 19 | value = "26/06/2017:20:23:51" 20 | if r != value { 21 | t.Fatalf("Date with custom format, got: %s, wanted: %s", r, value) 22 | } 23 | } 24 | 25 | func TestLevel(t *testing.T) { 26 | r, err := level(3) 27 | value := "err" 28 | if err != nil { 29 | t.Fatalf("Level with int failure: %s", err.Error()) 30 | } 31 | if r != value { 32 | t.Fatalf("Level with int, got: %s, wanted: %s", r, value) 33 | } 34 | 35 | r, err = level("4") 36 | value = "warn" 37 | if err != nil { 38 | t.Fatalf("Level with string failure: %s", err.Error()) 39 | } 40 | if r != value { 41 | t.Fatalf("Level with string, got: %s, wanted: %s", r, value) 42 | } 43 | 44 | _, err = level("foo") 45 | if err == nil { 46 | t.Fatalf("Level with bad string should have returned an error") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2017, OVH SAS. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | * Neither the name of OVH SAS nor the 13 | names of its contributors may be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY OVH SAS AND CONTRIBUTORS ``AS IS'' AND ANY 17 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL OVH SAS AND CONTRIBUTORS BE LIABLE FOR ANY 20 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 9 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 10 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 11 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 12 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 13 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 14 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 16 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 17 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 18 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 19 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 21 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 22 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 23 | -------------------------------------------------------------------------------- /format.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | "time" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var supportedFormatMap = map[string]func(map[string]interface{}) string{ 14 | "logrus": logrusFormaterWithoutColor, 15 | "logrus-color": logrusFormaterWithColor, 16 | } 17 | 18 | var supportedFormat = func() []string { 19 | a := make([]string, 0, len(supportedFormatMap)) 20 | 21 | for k := range supportedFormatMap { 22 | a = append(a, k) 23 | } 24 | 25 | return a 26 | }() 27 | 28 | var syslogLevelToLogrus = []logrus.Level{ 29 | logrus.PanicLevel, // LOG_EMERG = 0 30 | logrus.PanicLevel, // LOG_ALERT = 1 31 | logrus.FatalLevel, // LOG_CRIT = 2 32 | logrus.ErrorLevel, // LOG_ERR = 3 33 | logrus.WarnLevel, // LOG_WARNING = 4 34 | logrus.InfoLevel, // LOG_NOTICE = 5 35 | logrus.InfoLevel, // LOG_INFO = 6 36 | logrus.DebugLevel, // LOG_DEBUG = 7 37 | } 38 | 39 | func logrusLevelToColor(l logrus.Level) int { 40 | switch l { 41 | case logrus.DebugLevel: 42 | return 37 // gray 43 | case logrus.WarnLevel: 44 | return 33 // yellow 45 | case logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel: 46 | return 31 // red 47 | default: 48 | return 36 // blue 49 | } 50 | } 51 | 52 | func logrusFormaterWithColor(v map[string]interface{}) string { 53 | return logrusFormater(v, true) 54 | } 55 | 56 | func logrusFormaterWithoutColor(v map[string]interface{}) string { 57 | return logrusFormater(v, false) 58 | } 59 | 60 | func logrusFormater(v map[string]interface{}, color bool) string { 61 | syslogLevel := int(v["level"].(float64)) 62 | level := syslogLevelToLogrus[syslogLevel] 63 | timestamp := int64(v["timestamp"].(float64)) 64 | 65 | // Fields 66 | keys := make([]string, 0, len(v)) 67 | for k := range v { 68 | if k[0] == '_' && k != "_file" && k != "_line" && k != "_pid" { 69 | keys = append(keys, k) 70 | } 71 | } 72 | sort.Strings(keys) 73 | 74 | // Output 75 | b := &bytes.Buffer{} 76 | levelString := strings.ToUpper(level.String())[0:4] 77 | if color { 78 | levelString = fmt.Sprintf("\x1b[%dm%s\x1b[0m", logrusLevelToColor(level), levelString) 79 | } 80 | fmt.Fprintf(b, "%s[%s] %-44s", levelString, time.Unix(timestamp, 0).Format(time.RFC3339), v["short_message"]) 81 | 82 | for _, k := range keys { 83 | fmt.Fprintf(b, ` %s="%v"`, k[1:], v[k]) 84 | } 85 | 86 | return b.String() 87 | } 88 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ldp-tail 2 | 3 | This project accepts contributions. In order to contribute, you should pay attention to a few things: 4 | 5 | 1. your code must follow the coding style rules 6 | 2. your code must be unit-tested 7 | 3. your code must be documented 8 | 4. your work must be signed (see below) 9 | 5. you may contribute through GitHub Pull Requests 10 | 11 | # Coding and documentation Style 12 | 13 | - Code must be formated with `go fmt` 14 | - Code must pass `go vet` 15 | - Code must pass `golint` 16 | 17 | # Submitting Modifications 18 | 19 | The contributions should be submitted through Github Pull Requests and follow the DCO which is defined below. 20 | 21 | # Licensing for new files 22 | 23 | ldp-tail is licensed under a Modified 3-Clause BSD license. Anything contributed to ldp-tail must be released under this license. 24 | 25 | When introducing a new file into the project, please make sure it has a copyright header making clear under which license it's being released. 26 | 27 | # Developer Certificate of Origin (DCO) 28 | 29 | To improve tracking of contributions to this project we will use a process modeled on the modified DCO 1.1 and use a "sign-off" procedure on patches that are being emailed around or contributed in any other way. 30 | 31 | The sign-off is a simple line at the end of the explanation for the patch, which certifies that you wrote it or otherwise have the right to pass it on as an open-source patch. The rules are pretty simple: if you can certify the below: 32 | 33 | By making a contribution to this project, I certify that: 34 | 35 | (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or 36 | 37 | (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source License and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or 38 | 39 | (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. 40 | 41 | (d) The contribution is made free of any other party's intellectual property claims or rights. 42 | 43 | (e) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. 44 | 45 | then you just add a line saying 46 | 47 | ``` 48 | Signed-off-by: Random J Developer 49 | ``` 50 | 51 | using your real name (sorry, no pseudonyms or anonymous contributions.) 52 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net" 8 | "net/url" 9 | "os" 10 | "text/template" 11 | "time" 12 | 13 | "golang.org/x/net/websocket" 14 | ) 15 | 16 | func init() { 17 | log.SetOutput(os.Stderr) 18 | } 19 | 20 | func main() { 21 | c := getConf() 22 | 23 | t, err := template.New("template").Funcs(template.FuncMap{ 24 | "color": color, 25 | "bColor": bColor, 26 | "noColor": func() string { return color("reset") }, 27 | "date": date, 28 | "join": join, 29 | "concat": concat, 30 | "duration": duration, 31 | "int": toInt, 32 | "float": toFloat, 33 | "string": toString, 34 | "get": get, 35 | "column": column, 36 | "begin": begin, 37 | "contain": contain, 38 | "level": level, 39 | }).Parse(c.Pattern) 40 | if err != nil { 41 | log.Fatalf("Failed to parse pattern: %s", err.Error()) 42 | } 43 | 44 | var u *url.URL 45 | 46 | u, err = url.Parse(c.Address) 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | 51 | // Display filters 52 | if len(c.Match) > 0 { 53 | log.Printf("Filters are:") 54 | for _, f := range c.Match { 55 | if f.Not { 56 | log.Printf(" "+supportedMatchOperatorsMap[f.Operator].descriptionNot, f.Key, f.Value) 57 | } else { 58 | log.Printf(" "+supportedMatchOperatorsMap[f.Operator].description, f.Key, f.Value) 59 | } 60 | 61 | } 62 | } 63 | 64 | for { 65 | // Try to connect 66 | log.Printf("Connecting to %s...\n", u.Host) 67 | 68 | ws, err := websocket.Dial(u.String(), "", "http://mySelf") 69 | if err != nil { 70 | log.Fatal(err) 71 | } 72 | 73 | log.Println("Connected!") 74 | 75 | var msg []byte 76 | for { 77 | ws.SetReadDeadline(time.Now().Add(5 * time.Second)) 78 | err := websocket.Message.Receive(ws, &msg) 79 | 80 | if t, ok := err.(net.Error); ok && t.Timeout() { 81 | // Timeout, send a Pong && continue 82 | pingCodec.Send(ws, nil) 83 | continue 84 | } 85 | 86 | if err != nil { 87 | log.Printf("Error while reading from %q: %q. Will try to reconnect after 1s...\n", u.Host, err.Error()) 88 | time.Sleep(1 * time.Second) 89 | break 90 | } 91 | 92 | // Extract Message 93 | var logMessage struct { 94 | Message string `json:"message"` 95 | } 96 | json.Unmarshal(msg, &logMessage) 97 | 98 | // Extract infos 99 | var message map[string]interface{} 100 | json.Unmarshal([]byte(logMessage.Message), &message) 101 | 102 | if !match(message, c.Match) { 103 | continue 104 | } 105 | 106 | if c.Raw { 107 | fmt.Println(string(logMessage.Message)) 108 | } else if c.formatFunc != nil { 109 | fmt.Println(c.formatFunc(message)) 110 | } else { 111 | // Print them 112 | err = t.Execute(os.Stdout, message) 113 | os.Stdout.Write([]byte{'\n'}) 114 | if err != nil { 115 | log.Printf("Error while executing template: %s", err.Error()) 116 | } 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /conf.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/BurntSushi/toml" 11 | flag "github.com/spf13/pflag" 12 | ) 13 | 14 | type conf struct { 15 | Address string 16 | Match []matchCriterion 17 | Raw bool 18 | Format string 19 | formatFunc func(map[string]interface{}) string 20 | Pattern string 21 | } 22 | 23 | var defaultConf = conf{ 24 | "", 25 | nil, 26 | false, 27 | "", 28 | nil, 29 | "{{._appID}}> {{.short_message}}", 30 | } 31 | 32 | var operatorRegexp = regexp.MustCompile(`(.+?)\.(not\.)?(` + strings.Join(supportedMatchOperators, "|") + `)`) 33 | 34 | // Build details 35 | var buildVersion = "dev" 36 | var buildCommit = "unknown" 37 | var buildDate = "unknown" 38 | 39 | func init() { 40 | flag.Usage = func() { 41 | fmt.Fprintf(os.Stderr, "Usage of %s (Version %s):\n", os.Args[0], buildVersion) 42 | flag.PrintDefaults() 43 | } 44 | } 45 | 46 | func getConf() conf { 47 | configFile := flag.String("config", "", "Configuration file") 48 | 49 | address := flag.String("address", defaultConf.Address, "URI of the websocket") 50 | match := flag.StringArray("match", nil, "Fields to match") 51 | raw := flag.Bool("raw", defaultConf.Raw, "Display raw message instead of parsing it") 52 | format := flag.String("format", defaultConf.Format, fmt.Sprintf("Display messages using a pre-defined format. Valid values: (%s)", strings.Join(supportedFormat, ", "))) 53 | pattern := flag.String("pattern", defaultConf.Pattern, "Template to apply on each message to display it") 54 | 55 | flag.Parse() 56 | 57 | c := defaultConf 58 | 59 | // Load Override default config with file 60 | if *configFile != "" { 61 | _, err := toml.DecodeFile(*configFile, &c) 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | } 66 | 67 | // Override configuration with flags 68 | if *address != defaultConf.Address { 69 | c.Address = *address 70 | } 71 | if *raw != defaultConf.Raw { 72 | c.Raw = *raw 73 | } 74 | if *format != defaultConf.Format { 75 | c.Format = *format 76 | } 77 | if *pattern != defaultConf.Pattern { 78 | c.Pattern = *pattern 79 | } 80 | 81 | // Match Criteria 82 | for _, m := range *match { 83 | v := strings.SplitN(m, "=", 2) 84 | var key, operator, value string 85 | var not bool 86 | 87 | // Check if key match an operator 88 | if subMatch := operatorRegexp.FindStringSubmatch(v[0]); subMatch != nil { 89 | key = subMatch[1] 90 | not = subMatch[2] != "" 91 | operator = subMatch[3] 92 | } else { 93 | // Default operator 94 | key = v[0] 95 | operator = "eq" 96 | } 97 | 98 | if operator != "present" && operator != "missing" { 99 | if len(v) != 2 { 100 | log.Fatal(fmt.Errorf("Match should be in the form 'key(.operator)?=value', found %s", v)) 101 | } else { 102 | value = v[1] 103 | } 104 | } 105 | 106 | c.Match = append(c.Match, matchCriterion{Key: key, Operator: operator, Not: not, Value: value}) 107 | } 108 | if ok, err := isValidMatchCriteria(c.Match); !ok { 109 | log.Fatal(err) 110 | } 111 | 112 | if flag.NArg() > 0 { 113 | if flag.Arg(0) == "version" { 114 | fmt.Fprintf(os.Stderr, "ldp-tail version %s (%s - %s)\n", buildVersion, buildCommit, buildDate) 115 | os.Exit(0) 116 | } else if flag.Arg(0) == "help" { 117 | flag.Usage() 118 | os.Exit(0) 119 | } else { 120 | fmt.Printf("Invalid command %q\n", flag.Arg(0)) 121 | flag.Usage() 122 | os.Exit(-1) 123 | } 124 | } 125 | 126 | // Check format helper 127 | if c.Format != "" { 128 | f, ok := supportedFormatMap[c.Format] 129 | if !ok { 130 | fmt.Fprintf(os.Stderr, "Invalid `format`: %q\n", c.Format) 131 | flag.Usage() 132 | os.Exit(-1) 133 | } 134 | c.formatFunc = f 135 | } 136 | 137 | if c.Address == "" { 138 | fmt.Fprintf(os.Stderr, "No `address` specified. Please specify it with --address or thru a config file\n") 139 | flag.Usage() 140 | os.Exit(-1) 141 | } 142 | 143 | return c 144 | } 145 | -------------------------------------------------------------------------------- /template_helpers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | var bColors = map[string][]byte{ 12 | "green": {27, 91, 52, 50, 109}, 13 | "white": {27, 91, 52, 55, 109}, 14 | "yellow": {27, 91, 52, 51, 109}, 15 | "red": {27, 91, 52, 49, 109}, 16 | "blue": {27, 91, 52, 52, 109}, 17 | "magenta": {27, 91, 52, 53, 109}, 18 | "cyan": {27, 91, 52, 54, 109}, 19 | "reset": {27, 91, 48, 109}, 20 | } 21 | 22 | func bColor(c string) string { 23 | if s, ok := bColors[c]; ok { 24 | return string(s) 25 | } 26 | return "" 27 | } 28 | 29 | var colors = map[string][]byte{ 30 | "green": {27, 91, 51, 50, 109}, 31 | "white": {27, 91, 51, 55, 109}, 32 | "yellow": {27, 91, 51, 51, 109}, 33 | "red": {27, 91, 51, 49, 109}, 34 | "blue": {27, 91, 51, 52, 109}, 35 | "magenta": {27, 91, 51, 53, 109}, 36 | "cyan": {27, 91, 51, 54, 109}, 37 | "reset": {27, 91, 48, 109}, 38 | } 39 | 40 | func color(c string) string { 41 | if s, ok := colors[c]; ok { 42 | return string(s) 43 | } 44 | return "" 45 | } 46 | 47 | func date(v float64, f ...string) string { 48 | 49 | t := time.Unix(int64(v), 0) 50 | 51 | if len(f) == 0 { 52 | return t.Format("2006-01-02 15:04:05") 53 | } 54 | 55 | return t.Format(f[0]) 56 | } 57 | 58 | func join(s ...string) string { 59 | return strings.Join(s[1:], s[0]) 60 | } 61 | 62 | func concat(s ...string) string { 63 | var b bytes.Buffer 64 | for _, v := range s { 65 | b.WriteString(v) 66 | } 67 | return b.String() 68 | } 69 | 70 | func duration(v interface{}, factor float64) (string, error) { 71 | var d time.Duration 72 | switch value := v.(type) { 73 | case string: 74 | f, err := strconv.ParseFloat(value, 64) 75 | if err != nil { 76 | return "", err 77 | } 78 | d = time.Duration(f * factor) 79 | case float64: 80 | d = time.Duration(value * factor) 81 | case int64: 82 | d = time.Duration(value * int64(factor)) 83 | default: 84 | return "", fmt.Errorf("Invalid type %T for duration", v) 85 | } 86 | return d.String(), nil 87 | } 88 | 89 | func get(v map[string]interface{}, k string) interface{} { 90 | return v[k] 91 | } 92 | 93 | var columnLength []int 94 | 95 | func column(sep string, s ...string) (string, error) { 96 | if columnLength == nil { 97 | columnLength = make([]int, len(s)) 98 | } 99 | 100 | if len(s) != len(columnLength) { 101 | return "", fmt.Errorf("Invalid number of arguments to 'column'") 102 | } 103 | 104 | for k, v := range s { 105 | if len(v) > columnLength[k] { 106 | columnLength[k] = len(v) 107 | } else { 108 | s[k] = v + strings.Repeat(" ", columnLength[k]-len(v)) 109 | } 110 | } 111 | 112 | return strings.Join(s, sep), nil 113 | } 114 | 115 | func begin(v interface{}, substr string) bool { 116 | var value string 117 | 118 | switch v.(type) { 119 | case string: 120 | value = v.(string) 121 | default: 122 | value = fmt.Sprintf("%v", v) 123 | } 124 | return strings.HasPrefix(value, substr) 125 | } 126 | 127 | func contain(v interface{}, substr string) bool { 128 | var value string 129 | 130 | switch v.(type) { 131 | case string: 132 | value = v.(string) 133 | default: 134 | value = fmt.Sprintf("%v", v) 135 | } 136 | return strings.Contains(value, substr) 137 | } 138 | 139 | var syslogLevels = map[int]string{ 140 | 0: "emerg", 141 | 1: "alert", 142 | 2: "crit", 143 | 3: "err", 144 | 4: "warn", 145 | 5: "notice", 146 | 6: "info", 147 | 7: "debug", 148 | } 149 | 150 | func level(v interface{}) (string, error) { 151 | vFloat, err := toNumber(v) 152 | if err != nil { 153 | return "", err 154 | } 155 | 156 | value, ok := syslogLevels[int(vFloat)] 157 | if !ok { 158 | value = fmt.Sprintf("(invalid:%d)", int(vFloat)) 159 | } 160 | 161 | return value, nil 162 | } 163 | 164 | func toInt(v interface{}) (int64, error) { 165 | if f, ok := v.(float64); ok { 166 | return int64(f), nil 167 | } 168 | if s, ok := v.(string); ok { 169 | f, e := strconv.ParseFloat(s, 64) 170 | return int64(f), e 171 | } 172 | return 0, fmt.Errorf("Invalid type %T for conversion to `int`", v) 173 | } 174 | 175 | func toFloat(v interface{}) (float64, error) { 176 | if f, ok := v.(float64); ok { 177 | return f, nil 178 | } 179 | if s, ok := v.(string); ok { 180 | f, e := strconv.ParseFloat(s, 64) 181 | return f, e 182 | } 183 | return 0, fmt.Errorf("Invalid type %T for conversion to `float`", v) 184 | } 185 | 186 | func toString(v interface{}) string { 187 | return fmt.Sprintf("%v", v) 188 | } 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ovh/ldp-tail.svg?branch=master)](https://travis-ci.org/ovh/ldp-tail) 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/ovh/ldp-tail)](https://goreportcard.com/report/github.com/ovh/ldp-tail) 3 | 4 | Logs Data Platform - Tail 5 | ========================= 6 | 7 | This tool allows you to display the logs pushed in Logs Data Platform in real time. 8 | More infos on Logs Data Platform and how to obtain the stream uri: https://docs.ovh.com/gb/en/logs-data-platform/ldp-tail/ 9 | 10 | 11 | Installation 12 | ------------ 13 | To install cli, simply run: 14 | ``` 15 | $ go install github.com/ovh/ldp-tail@latest 16 | ``` 17 | 18 | Usage 19 | ----- 20 | ```sh 21 | ldp-tail --address 22 | ``` 23 | 24 | Demo 25 | ---- 26 | ```sh 27 | ldp-tail --address wss://gra1.logs.ovh.com/tail/?tk=demo --pattern "{{ .short_message }}" 28 | ``` 29 | 30 | Parameters 31 | ---------- 32 | * Server 33 | * `address` URI of the websocket 34 | * Filtering 35 | * `match` Display only messages matching the condition. Example: `_method=verifyPassword`. You may specify an operator like: `_method.begin=verify` or negate its meaning like: `_method.not.begin=verify`. Available operators are: 36 | * `present` The field is present 37 | * `begin` The field begins with the value 38 | * `contain` The field contains the value 39 | * `lt` The field is less than the value 40 | * `le` The field is less than or equal to the value 41 | * `eq` The field is equal to the value 42 | * `ge` The field is greater than or equal to the value 43 | * `gt` The field is greater than the value 44 | * `regex` The field match the regular expression 45 | * Formatting 46 | * `raw` Display raw JSON message instead of parsing it 47 | * `format` Display messages using a pre-defined format. Valid values: 48 | * `logrus` Display messages like [Logrus](https://github.com/sirupsen/logrus) for messages sent with [Graylog Hook for Logrus](https://github.com/gemnasium/logrus-graylog-hook/) 49 | * `logrus-color` Display colored messages like [Logrus](https://github.com/sirupsen/logrus) for messages sent with [Graylog Hook for Logrus](https://github.com/gemnasium/logrus-graylog-hook/) 50 | * `pattern` Template to apply on each message to display it. Default: `{{._appID}}> {{.short_message}}`. Custom available functions are: 51 | * `color` Set text color. Available colors are: `green` `white` `yellow` `red` `blue` `magenta` `cyan` 52 | * `bColor` Set background color. Available colors are: `green` `white` `yellow` `red` `blue` `magenta` `cyan` 53 | * `noColor` Disable text and background color 54 | * `date` Transform a timestamp in a human readable date. Default format is `2006-01-02 15:04:05` but can be customized with the second optional argument 55 | * `join` Concatenates strings passed in argument with the first argument used as separator 56 | * `concat` Concatenates strings passed in argument 57 | * `duration` Transform a value in a human readable duration. First argument must be a parsable number. The second argument is the multiplier coefficient to be applied based on nanoseconds. Ex: 1000000 if the value is in milliseconds. 58 | * `int` Converts a string or a number to an int64 59 | * `float` Converts a string to float64 60 | * `string` Converts a value to a string 61 | * `get` Return the value under the key passed in the second argument of the map passed first argument. Useful for accessing keys containing a period. Ex: `{{ get . "foo.bar" }}` 62 | * `column` Formats input into multiple columns. Columns are delimited with the characters supplied in the first argument. Ex: `"{{ column " | " (date .timestamp) (concat ._method " " ._path ) ._httpStatus_int }}` 63 | * `begin` Return true if the first argument begins with the second 64 | * `contain` Return true if the second argument is within the first 65 | * `level` Transform a Gelf/Syslog level value (0-7) to a syslog severity keyword 66 | * Config 67 | * `config` Config file loaded before parsing parameters, so parameters will override the values in the config file (except for `match` where parameters will add more criteria instead of replacing them). The config file use the [TOML](https://github.com/toml-lang/toml) file format. The structure of the configuration file is: 68 | ``` 69 | Address string 70 | Match []{ 71 | Key string 72 | Operator string 73 | Value interface{} 74 | Not bool 75 | } 76 | Pattern string 77 | Raw bool 78 | ``` 79 | Exemple: 80 | ``` 81 | Address = "wss://gra1.logs.ovh.com/tail/?tk=demo" 82 | Pattern = "{{date .timestamp}}: {{if ne ._title \"\"}}[ {{._title}} ] {{end}}{{ .short_message }}" 83 | ``` 84 | 85 | # Contributing 86 | 87 | You've developed a new cool feature? Fixed an annoying bug? We'd be happy 88 | to hear from you! Make sure to read [CONTRIBUTING.md](./CONTRIBUTING.md) before. 89 | 90 | # License 91 | 92 | This work is under the BSD license, see the [LICENSE](LICENSE) file for details. 93 | -------------------------------------------------------------------------------- /match.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | var supportedMatchOperatorsMap = map[string]struct { 12 | description string 13 | descriptionNot string 14 | }{ 15 | "present": {"Field %[1]q is present", "Field %[1]q is not present"}, 16 | "begin": {"Field %[1]q begins with %[2]v", "Field %[1]q does not begins with %[2]v"}, 17 | "contain": {"Field %[1]q contains %[2]v", "Field %[1]q does not contains %[2]v"}, 18 | "lt": {"Field %[1]q is less than %[2]v", "Field %[1]q is greater than or equal to %[2]v"}, 19 | "le": {"Field %[1]q is less than or equal to %[2]v", "Field %[1]q is greater than %[2]v"}, 20 | "eq": {"Field %[1]q is equal to %[2]v", "Field %[1]q is not equal to %[2]v"}, 21 | "ge": {"Field %[1]q is greater than or equal to %[2]v", "Field %[1]q is less than %[2]v"}, 22 | "gt": {"Field %[1]q is greater than %[2]v", "Field %[1]q is less than or equal to %[2]v"}, 23 | "regex": {"Field %[1]q match %[2]q", "Field %[1]q does not match %[2]q"}, 24 | } 25 | 26 | var supportedMatchOperators = func() []string { 27 | a := make([]string, 0, len(supportedMatchOperatorsMap)) 28 | 29 | for k := range supportedMatchOperatorsMap { 30 | a = append(a, k) 31 | } 32 | 33 | return a 34 | }() 35 | 36 | type matchCriterion struct { 37 | Key string 38 | Operator string 39 | Value interface{} 40 | Not bool 41 | } 42 | 43 | func isValidMatchCriteria(criteria []matchCriterion) (bool, error) { 44 | for k, m := range criteria { 45 | if _, ok := supportedMatchOperatorsMap[m.Operator]; !ok { 46 | return false, fmt.Errorf("invalid operator %q in %+v", m.Operator, m) 47 | } 48 | if m.Operator == "begin" { 49 | if _, ok := m.Value.(string); !ok { 50 | return false, fmt.Errorf("invalid value for operator 'begin' in %+v", m) 51 | } 52 | } 53 | if m.Operator == "lt" || m.Operator == "le" || m.Operator == "ge" || m.Operator == "gt" { 54 | f, err := toNumber(m.Value) 55 | if err != nil { 56 | return false, fmt.Errorf("invalid value for operator '%s' in %+v: %s", m.Operator, m, err.Error()) 57 | } 58 | // Overwrite value 59 | criteria[k].Value = f 60 | } 61 | if m.Operator == "regex" { 62 | // Be sure it's a string 63 | v, ok := m.Value.(string) 64 | if !ok { 65 | return false, fmt.Errorf("invalid value for operator 'regex' in %+v", m) 66 | } 67 | // Check regular expression 68 | r, err := regexp.Compile(v) 69 | if err != nil { 70 | return false, fmt.Errorf("invalid regular expression in %+v: %s", m, err.Error()) 71 | } 72 | // Overwrite value 73 | criteria[k].Value = r 74 | } 75 | } 76 | 77 | return true, nil 78 | } 79 | 80 | func match(value map[string]interface{}, criteria []matchCriterion) bool { 81 | for _, m := range criteria { 82 | v, ok := value[m.Key] 83 | 84 | // Key is not present, don't match 85 | if !ok && m.Operator != "present" { 86 | return false 87 | } 88 | 89 | switch m.Operator { 90 | case "present": 91 | if !ok != m.Not { 92 | return false 93 | } 94 | case "eq": 95 | if (v != m.Value) != m.Not { 96 | return false 97 | } 98 | case "begin": 99 | vString, ok := v.(string) 100 | if !ok { 101 | // Stringify value 102 | vString = fmt.Sprintf("%v", v) 103 | } 104 | if (!strings.HasPrefix(vString, m.Value.(string))) != m.Not { 105 | return false 106 | } 107 | case "contain": 108 | vString, ok := v.(string) 109 | if !ok { 110 | // Stringify value 111 | vString = fmt.Sprintf("%v", v) 112 | } 113 | if (!strings.Contains(vString, m.Value.(string))) != m.Not { 114 | return false 115 | } 116 | case "lt": 117 | vFloat, err := toNumber(v) 118 | if err != nil { 119 | log.Printf("lt: incompatible value for comparison: %s", err.Error()) 120 | } 121 | if (vFloat >= m.Value.(float64)) != m.Not { 122 | return false 123 | } 124 | case "le": 125 | vFloat, err := toNumber(v) 126 | if err != nil { 127 | log.Printf("lt: incompatible value for comparison: %s", err.Error()) 128 | } 129 | if (vFloat > m.Value.(float64)) != m.Not { 130 | return false 131 | } 132 | case "ge": 133 | vFloat, err := toNumber(v) 134 | if err != nil { 135 | log.Printf("lt: incompatible value for comparison: %s", err.Error()) 136 | } 137 | if (vFloat < m.Value.(float64)) != m.Not { 138 | return false 139 | } 140 | case "gt": 141 | vFloat, err := toNumber(v) 142 | if err != nil { 143 | log.Printf("lt: incompatible value for comparison: %s", err.Error()) 144 | } 145 | if (vFloat <= m.Value.(float64)) != m.Not { 146 | return false 147 | } 148 | case "regex": 149 | vString, ok := v.(string) 150 | if !ok { 151 | // Stringify value 152 | vString = fmt.Sprintf("%v", v) 153 | } 154 | if (!m.Value.(*regexp.Regexp).MatchString(vString)) != m.Not { 155 | return false 156 | } 157 | default: 158 | panic("Unhandled operator") 159 | } 160 | } 161 | return true 162 | } 163 | 164 | func toNumber(v interface{}) (float64, error) { 165 | switch value := v.(type) { 166 | case string: 167 | // Try to parse value as float64 168 | f, err := strconv.ParseFloat(value, 64) 169 | if err != nil { 170 | return 0, fmt.Errorf("'%v' can't be parsed as a number", v) 171 | } 172 | return f, nil 173 | case uint: 174 | return float64(value), nil 175 | case uint8: 176 | return float64(value), nil 177 | case uint16: 178 | return float64(value), nil 179 | case uint32: 180 | return float64(value), nil 181 | case uint64: 182 | return float64(value), nil 183 | case int: 184 | return float64(value), nil 185 | case int8: 186 | return float64(value), nil 187 | case int16: 188 | return float64(value), nil 189 | case int32: 190 | return float64(value), nil 191 | case int64: 192 | return float64(value), nil 193 | case float32: 194 | return float64(value), nil 195 | case float64: 196 | return value, nil 197 | default: 198 | return 0, fmt.Errorf("can't parse type %T as a number", v) 199 | } 200 | } 201 | --------------------------------------------------------------------------------