├── test └── logger.go ├── .travis.yml ├── example_test.go ├── LICENSE ├── stackskip_test.go ├── README.md ├── formatter_test.go └── formatter.go /test/logger.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | type LogWrapper struct { 6 | Logger *logrus.Logger 7 | } 8 | 9 | func (l *LogWrapper) Error(msg string) { 10 | l.Logger.Error(msg) 11 | } 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.9.2 5 | 6 | sudo: false 7 | 8 | install: 9 | - go get github.com/go-stack/stack 10 | - go get github.com/sirupsen/logrus 11 | - go get golang.org/x/lint/golint 12 | - go get github.com/kr/pretty 13 | 14 | script: 15 | - golint -set_exit_status 16 | - go vet 17 | - go test -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package stackdriver_test 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | 7 | stackdriver "github.com/TV4/logrus-stackdriver-formatter" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func ExampleLogError() { 12 | logger := logrus.New() 13 | logger.Out = os.Stdout 14 | logger.Formatter = stackdriver.NewFormatter( 15 | stackdriver.WithService("test-service"), 16 | stackdriver.WithVersion("v0.1.0"), 17 | ) 18 | 19 | logger.Info("application up and running") 20 | 21 | _, err := strconv.ParseInt("text", 10, 64) 22 | if err != nil { 23 | logger.WithError(err).Errorln("unable to parse integer") 24 | } 25 | 26 | // Output: 27 | // {"message":"application up and running","severity":"INFO","context":{}} 28 | // {"serviceContext":{"service":"test-service","version":"v0.1.0"},"message":"unable to parse integer: strconv.ParseInt: parsing \"text\": invalid syntax","severity":"ERROR","context":{"reportLocation":{"filePath":"github.com/TV4/logrus-stackdriver-formatter/example_test.go","lineNumber":23,"functionName":"ExampleLogError"}}} 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Bonnier Broadcasting 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /stackskip_test.go: -------------------------------------------------------------------------------- 1 | package stackdriver 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/TV4/logrus-stackdriver-formatter/test" 10 | "github.com/kr/pretty" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func TestStackSkip(t *testing.T) { 15 | var out bytes.Buffer 16 | 17 | logger := logrus.New() 18 | logger.Out = &out 19 | logger.Formatter = NewFormatter( 20 | WithService("test"), 21 | WithVersion("0.1"), 22 | WithStackSkip("github.com/TV4/logrus-stackdriver-formatter/test"), 23 | ) 24 | 25 | mylog := test.LogWrapper{ 26 | Logger: logger, 27 | } 28 | 29 | mylog.Error("my log entry") 30 | 31 | var got map[string]interface{} 32 | json.Unmarshal(out.Bytes(), &got) 33 | 34 | want := map[string]interface{}{ 35 | "severity": "ERROR", 36 | "message": "my log entry", 37 | "serviceContext": map[string]interface{}{ 38 | "service": "test", 39 | "version": "0.1", 40 | }, 41 | "context": map[string]interface{}{ 42 | "reportLocation": map[string]interface{}{ 43 | "filePath": "github.com/TV4/logrus-stackdriver-formatter/stackskip_test.go", 44 | "lineNumber": 29.0, 45 | "functionName": "TestStackSkip", 46 | }, 47 | }, 48 | } 49 | 50 | if !reflect.DeepEqual(got, want) { 51 | t.Errorf("unexpected output = %# v; want = %# v", pretty.Formatter(got), pretty.Formatter(want)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # logrus-stackdriver-formatter 2 | 3 | [![Build Status](https://travis-ci.org/TV4/logrus-stackdriver-formatter.svg?branch=master)](https://travis-ci.org/TV4/logrus-stackdriver-formatter) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/TV4/logrus-stackdriver-formatter)](https://goreportcard.com/report/github.com/TV4/logrus-stackdriver-formatter) 5 | [![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://godoc.org/github.com/TV4/logrus-stackdriver-formatter) 6 | [![License MIT](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat)](https://github.com/TV4/logrus-stackdriver-formatter#license) 7 | 8 | [logrus](https://github.com/sirupsen/logrus) formatter for Stackdriver. 9 | 10 | In addition to supporting level-based logging to Stackdriver, for Error, Fatal and Panic levels it will append error context for [Error Reporting](https://cloud.google.com/error-reporting/). 11 | 12 | ## Installation 13 | 14 | ```shell 15 | go get -u github.com/TV4/logrus-stackdriver-formatter 16 | ``` 17 | 18 | ## Usage 19 | 20 | ```go 21 | package main 22 | 23 | import ( 24 | "github.com/sirupsen/logrus" 25 | stackdriver "github.com/TV4/logrus-stackdriver-formatter" 26 | ) 27 | 28 | var log = logrus.New() 29 | 30 | func init() { 31 | log.Formatter = stackdriver.NewFormatter( 32 | stackdriver.WithService("your-service"), 33 | stackdriver.WithVersion("v0.1.0"), 34 | ) 35 | log.Level = logrus.DebugLevel 36 | 37 | log.Info("ready to log!") 38 | } 39 | ``` 40 | 41 | Here's a sample entry (prettified) from the example: 42 | 43 | ```json 44 | { 45 | "serviceContext": { 46 | "service": "test-service", 47 | "version": "v0.1.0" 48 | }, 49 | "message": "unable to parse integer: strconv.ParseInt: parsing \"text\": invalid syntax", 50 | "severity": "ERROR", 51 | "context": { 52 | "reportLocation": { 53 | "filePath": "github.com/TV4/logrus-stackdriver-formatter/example_test.go", 54 | "lineNumber": 21, 55 | "functionName": "ExampleLogError" 56 | } 57 | } 58 | } 59 | ``` 60 | 61 | ## HTTP request context 62 | 63 | If you'd like to add additional context like the `httpRequest`, here's a convenience function for creating a HTTP logger: 64 | 65 | ```go 66 | func httpLogger(logger *logrus.Logger, r *http.Request) *logrus.Entry { 67 | return logger.WithFields(logrus.Fields{ 68 | "httpRequest": map[string]interface{}{ 69 | "method": r.Method, 70 | "url": r.URL.String(), 71 | "userAgent": r.Header.Get("User-Agent"), 72 | "referrer": r.Header.Get("Referer"), 73 | }, 74 | }) 75 | } 76 | ``` 77 | 78 | Then, in your HTTP handler, create a new context logger and all your log entries will have the HTTP request context appended to them: 79 | 80 | ```go 81 | func handler(w http.ResponseWriter, r *http.Request) { 82 | httplog := httpLogger(log, r) 83 | // ... 84 | httplog.Infof("Logging with HTTP request context") 85 | } 86 | ``` 87 | -------------------------------------------------------------------------------- /formatter_test.go: -------------------------------------------------------------------------------- 1 | package stackdriver 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/kr/pretty" 11 | 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | func TestFormatter(t *testing.T) { 16 | skipTimestamp = true 17 | 18 | for _, tt := range formatterTests { 19 | var out bytes.Buffer 20 | 21 | logger := logrus.New() 22 | logger.Out = &out 23 | logger.Formatter = NewFormatter( 24 | WithService("test"), 25 | WithVersion("0.1"), 26 | ) 27 | 28 | tt.run(logger) 29 | 30 | var got map[string]interface{} 31 | json.Unmarshal(out.Bytes(), &got) 32 | 33 | if !reflect.DeepEqual(got, tt.out) { 34 | t.Errorf("unexpected output = %# v; want = %# v", pretty.Formatter(got), pretty.Formatter(tt.out)) 35 | } 36 | } 37 | } 38 | 39 | var formatterTests = []struct { 40 | run func(*logrus.Logger) 41 | out map[string]interface{} 42 | }{ 43 | { 44 | run: func(logger *logrus.Logger) { 45 | logger.WithField("foo", "bar").Info("my log entry") 46 | }, 47 | out: map[string]interface{}{ 48 | "severity": "INFO", 49 | "message": "my log entry", 50 | "context": map[string]interface{}{ 51 | "data": map[string]interface{}{ 52 | "foo": "bar", 53 | }, 54 | }, 55 | }, 56 | }, 57 | { 58 | run: func(logger *logrus.Logger) { 59 | logger.WithField("foo", "bar").Error("my log entry") 60 | }, 61 | out: map[string]interface{}{ 62 | "severity": "ERROR", 63 | "message": "my log entry", 64 | "serviceContext": map[string]interface{}{ 65 | "service": "test", 66 | "version": "0.1", 67 | }, 68 | "context": map[string]interface{}{ 69 | "data": map[string]interface{}{ 70 | "foo": "bar", 71 | }, 72 | "reportLocation": map[string]interface{}{ 73 | "filePath": "github.com/TV4/logrus-stackdriver-formatter/formatter_test.go", 74 | "lineNumber": 59.0, 75 | "functionName": "glob..func2", 76 | }, 77 | }, 78 | }, 79 | }, 80 | { 81 | run: func(logger *logrus.Logger) { 82 | logger. 83 | WithField("foo", "bar"). 84 | WithError(errors.New("test error")). 85 | Error("my log entry") 86 | }, 87 | out: map[string]interface{}{ 88 | "severity": "ERROR", 89 | "message": "my log entry: test error", 90 | "serviceContext": map[string]interface{}{ 91 | "service": "test", 92 | "version": "0.1", 93 | }, 94 | "context": map[string]interface{}{ 95 | "data": map[string]interface{}{ 96 | "foo": "bar", 97 | }, 98 | "reportLocation": map[string]interface{}{ 99 | "filePath": "github.com/TV4/logrus-stackdriver-formatter/formatter_test.go", 100 | "lineNumber": 85.0, 101 | "functionName": "glob..func3", 102 | }, 103 | }, 104 | }, 105 | }, 106 | { 107 | run: func(logger *logrus.Logger) { 108 | logger. 109 | WithFields(logrus.Fields{ 110 | "foo": "bar", 111 | "httpRequest": map[string]interface{}{ 112 | "method": "GET", 113 | }, 114 | }). 115 | Error("my log entry") 116 | }, 117 | out: map[string]interface{}{ 118 | "severity": "ERROR", 119 | "message": "my log entry", 120 | "serviceContext": map[string]interface{}{ 121 | "service": "test", 122 | "version": "0.1", 123 | }, 124 | "context": map[string]interface{}{ 125 | "data": map[string]interface{}{ 126 | "foo": "bar", 127 | }, 128 | "httpRequest": map[string]interface{}{ 129 | "method": "GET", 130 | }, 131 | "reportLocation": map[string]interface{}{ 132 | "filePath": "github.com/TV4/logrus-stackdriver-formatter/formatter_test.go", 133 | "lineNumber": 115.0, 134 | "functionName": "glob..func4", 135 | }, 136 | }, 137 | }, 138 | }, 139 | } 140 | -------------------------------------------------------------------------------- /formatter.go: -------------------------------------------------------------------------------- 1 | package stackdriver 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/go-stack/stack" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | var skipTimestamp bool 15 | 16 | type severity string 17 | 18 | const ( 19 | severityDebug severity = "DEBUG" 20 | severityInfo severity = "INFO" 21 | severityWarning severity = "WARNING" 22 | severityError severity = "ERROR" 23 | severityCritical severity = "CRITICAL" 24 | severityAlert severity = "ALERT" 25 | ) 26 | 27 | var levelsToSeverity = map[logrus.Level]severity{ 28 | logrus.DebugLevel: severityDebug, 29 | logrus.InfoLevel: severityInfo, 30 | logrus.WarnLevel: severityWarning, 31 | logrus.ErrorLevel: severityError, 32 | logrus.FatalLevel: severityCritical, 33 | logrus.PanicLevel: severityAlert, 34 | } 35 | 36 | type serviceContext struct { 37 | Service string `json:"service,omitempty"` 38 | Version string `json:"version,omitempty"` 39 | } 40 | 41 | type reportLocation struct { 42 | FilePath string `json:"filePath,omitempty"` 43 | LineNumber int `json:"lineNumber,omitempty"` 44 | FunctionName string `json:"functionName,omitempty"` 45 | } 46 | 47 | type context struct { 48 | Data map[string]interface{} `json:"data,omitempty"` 49 | ReportLocation *reportLocation `json:"reportLocation,omitempty"` 50 | HTTPRequest map[string]interface{} `json:"httpRequest,omitempty"` 51 | } 52 | 53 | type entry struct { 54 | Timestamp string `json:"timestamp,omitempty"` 55 | ServiceContext *serviceContext `json:"serviceContext,omitempty"` 56 | Message string `json:"message,omitempty"` 57 | Severity severity `json:"severity,omitempty"` 58 | Context *context `json:"context,omitempty"` 59 | } 60 | 61 | // Formatter implements Stackdriver formatting for logrus. 62 | type Formatter struct { 63 | Service string 64 | Version string 65 | StackSkip []string 66 | } 67 | 68 | // Option lets you configure the Formatter. 69 | type Option func(*Formatter) 70 | 71 | // WithService lets you configure the service name used for error reporting. 72 | func WithService(n string) Option { 73 | return func(f *Formatter) { 74 | f.Service = n 75 | } 76 | } 77 | 78 | // WithVersion lets you configure the service version used for error reporting. 79 | func WithVersion(v string) Option { 80 | return func(f *Formatter) { 81 | f.Version = v 82 | } 83 | } 84 | 85 | // WithStackSkip lets you configure which packages should be skipped for locating the error. 86 | func WithStackSkip(v string) Option { 87 | return func(f *Formatter) { 88 | f.StackSkip = append(f.StackSkip, v) 89 | } 90 | } 91 | 92 | // NewFormatter returns a new Formatter. 93 | func NewFormatter(options ...Option) *Formatter { 94 | fmtr := Formatter{ 95 | StackSkip: []string{ 96 | "github.com/sirupsen/logrus", 97 | }, 98 | } 99 | for _, option := range options { 100 | option(&fmtr) 101 | } 102 | return &fmtr 103 | } 104 | 105 | func (f *Formatter) errorOrigin() (stack.Call, error) { 106 | skip := func(pkg string) bool { 107 | for _, skip := range f.StackSkip { 108 | if pkg == skip { 109 | return true 110 | } 111 | } 112 | return false 113 | } 114 | 115 | // We start at 2 to skip this call and our caller's call. 116 | for i := 2; ; i++ { 117 | c := stack.Caller(i) 118 | // ErrNoFunc indicates we're over traversing the stack. 119 | if _, err := c.MarshalText(); err != nil { 120 | return stack.Call{}, nil 121 | } 122 | pkg := fmt.Sprintf("%+k", c) 123 | // Remove vendoring from package path. 124 | parts := strings.SplitN(pkg, "/vendor/", 2) 125 | pkg = parts[len(parts)-1] 126 | if !skip(pkg) { 127 | return c, nil 128 | } 129 | } 130 | } 131 | 132 | // Format formats a logrus entry according to the Stackdriver specifications. 133 | func (f *Formatter) Format(e *logrus.Entry) ([]byte, error) { 134 | severity := levelsToSeverity[e.Level] 135 | 136 | ee := entry{ 137 | 138 | Message: e.Message, 139 | Severity: severity, 140 | Context: &context{ 141 | Data: e.Data, 142 | }, 143 | } 144 | 145 | if !skipTimestamp { 146 | ee.Timestamp = time.Now().UTC().Format(time.RFC3339) 147 | } 148 | 149 | switch severity { 150 | case severityError, severityCritical, severityAlert: 151 | ee.ServiceContext = &serviceContext{ 152 | Service: f.Service, 153 | Version: f.Version, 154 | } 155 | 156 | // When using WithError(), the error is sent separately, but Error 157 | // Reporting expects it to be a part of the message so we append it 158 | // instead. 159 | if err, ok := ee.Context.Data["error"]; ok { 160 | ee.Message = fmt.Sprintf("%s: %s", e.Message, err) 161 | delete(ee.Context.Data, "error") 162 | } else { 163 | ee.Message = e.Message 164 | } 165 | 166 | // As a convenience, when using supplying the httpRequest field, it 167 | // gets special care. 168 | if reqData, ok := ee.Context.Data["httpRequest"]; ok { 169 | if req, ok := reqData.(map[string]interface{}); ok { 170 | ee.Context.HTTPRequest = req 171 | delete(ee.Context.Data, "httpRequest") 172 | } 173 | } 174 | 175 | // Extract report location from call stack. 176 | if c, err := f.errorOrigin(); err == nil { 177 | lineNumber, _ := strconv.ParseInt(fmt.Sprintf("%d", c), 10, 64) 178 | 179 | ee.Context.ReportLocation = &reportLocation{ 180 | FilePath: fmt.Sprintf("%+s", c), 181 | LineNumber: int(lineNumber), 182 | FunctionName: fmt.Sprintf("%n", c), 183 | } 184 | } 185 | } 186 | 187 | b, err := json.Marshal(ee) 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | return append(b, '\n'), nil 193 | } 194 | --------------------------------------------------------------------------------