├── .travis.yml ├── LICENSE ├── README.md ├── data.go ├── data_test.go └── hook.go /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: go 3 | go: 4 | - 1.10.x 5 | - 1.x 6 | - tip 7 | matrix: 8 | allow_failures: 9 | - go: tip 10 | before_install: 11 | - go get github.com/axw/gocov/gocov 12 | - go get github.com/mattn/goveralls 13 | - go get golang.org/x/tools/cmd/cover 14 | - go get -v ./... 15 | before_script: 16 | - test -z "$(gofmt -s -l . | tee /dev/stderr)" 17 | - go tool vet -all -structtags -shadow . 18 | script: 19 | - go test -coverprofile=coverage.txt -covermode=atomic 20 | after_success: 21 | - bash <(curl -s https://codecov.io/bash) 22 | - $HOME/gopath/bin/goveralls -coverprofile=coverage.txt -service=travis-ci 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 evalphobia 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 | logrus_stackdriver 2 | ==== 3 | 4 | [![Build Status](https://travis-ci.org/evalphobia/logrus_stackdriver.svg?branch=master)](https://travis-ci.org/evalphobia/logrus_stackdriver) [![Coverage Status](https://coveralls.io/repos/evalphobia/logrus_stackdriver/badge.svg?branch=master&service=github)](https://coveralls.io/github/evalphobia/logrus_stackdriver?branch=master) [![codecov](https://codecov.io/gh/evalphobia/logrus_stackdriver/branch/master/graph/badge.svg)](https://codecov.io/gh/evalphobia/logrus_stackdriver) 5 | [![GoDoc](https://godoc.org/github.com/evalphobia/logrus_stackdriver?status.svg)](https://godoc.org/github.com/evalphobia/logrus_stackdriver) 6 | 7 | 8 | # Google Stackdriver logging Hook for Logrus :walrus: 9 | 10 | ## Usage 11 | 12 | ```go 13 | import ( 14 | "github.com/evalphobia/google-api-go-wrapper/config" 15 | "github.com/evalphobia/logrus_stackdriver" 16 | "github.com/sirupsen/logrus" 17 | ) 18 | 19 | func main() { 20 | hook, err := logrus_stackdriver.NewWithConfig("project_id", "test_log", config.Config{ 21 | Email: "xxx@xxx.iam.gserviceaccount.com", 22 | PrivateKey: "-----BEGIN PRIVATE KEY-----\nXXX\n-----END PRIVATE KEY-----\n", 23 | }) 24 | 25 | // set custom fire level 26 | hook.SetLevels([]logrus.Level{ 27 | logrus.PanicLevel, 28 | logrus.ErrorLevel, 29 | logrus.WarnLevel, 30 | }) 31 | 32 | // ignore field 33 | hook.AddIgnore("context") 34 | 35 | // add custome filter 36 | hook.AddFilter("error", logrus_stackdriver.FilterError) 37 | 38 | 39 | // send log with logrus 40 | logger := logrus.New() 41 | logger.Hooks.Add(hook) 42 | logger.WithFields(f).Error("my_message") // send log data to Google Stackdriver logging API 43 | } 44 | ``` 45 | 46 | 47 | ## Special fields 48 | 49 | Some logrus fields have a special meaning in this hook. 50 | 51 | | Field Name | Description | 52 | |:--|:--| 53 | |`message`|if `message` is not set, entry.Message is added to log data in "message" field. | 54 | |`log_name`|`log_name` is a custom log name. If not set, `defaultLogName` is used as log name.| 55 | |`http_request`|`http_request` is *http.Request for detailed http logging.| 56 | |`http_response`|`http_response` is *http.Response for detailed http logging.| 57 | -------------------------------------------------------------------------------- /data.go: -------------------------------------------------------------------------------- 1 | package logrus_stackdriver 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/evalphobia/google-api-go-wrapper/stackdriver/logging" 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | const ( 11 | fieldMessage = "message" 12 | fieldLogName = "log_name" 13 | fieldHTTPRequest = "http_request" 14 | fieldHTTPResponse = "http_response" 15 | ) 16 | 17 | type dataField struct { 18 | defaultLogName string 19 | data logrus.Fields 20 | logLevel logrus.Level 21 | omitList map[string]struct{} 22 | } 23 | 24 | func newDataFieldFromEntry(logName string, entry *logrus.Entry) *dataField { 25 | if _, ok := entry.Data[fieldMessage]; ok { 26 | return newDataField(logName, entry.Data, entry.Level) 27 | } 28 | 29 | // copy logrus.Fields as we are going to modify it. 30 | var fields = make(logrus.Fields) 31 | for k, v := range entry.Data { 32 | fields[k] = v 33 | } 34 | fields[fieldMessage] = entry.Message 35 | 36 | return newDataField(logName, fields, entry.Level) 37 | } 38 | 39 | func newDataField(logName string, fields logrus.Fields, level logrus.Level) *dataField { 40 | return &dataField{ 41 | defaultLogName: logName, 42 | data: fields, 43 | logLevel: level, 44 | omitList: make(map[string]struct{}), 45 | } 46 | } 47 | 48 | func (d *dataField) len() int { 49 | return len(d.data) 50 | } 51 | 52 | func (d *dataField) isOmit(key string) bool { 53 | _, ok := d.omitList[key] 54 | return ok 55 | } 56 | 57 | func (d *dataField) getRequest() *http.Request { 58 | if req, ok := d.data[fieldHTTPRequest].(*http.Request); ok { 59 | d.omitList[fieldHTTPRequest] = struct{}{} 60 | return req 61 | } 62 | return nil 63 | } 64 | 65 | func (d *dataField) getResponse() *http.Response { 66 | if resp, ok := d.data[fieldHTTPResponse].(*http.Response); ok { 67 | d.omitList[fieldHTTPResponse] = struct{}{} 68 | return resp 69 | } 70 | return nil 71 | } 72 | 73 | func (d *dataField) getLogName() string { 74 | if name, ok := d.data[fieldLogName].(string); ok { 75 | return name 76 | } 77 | return d.defaultLogName 78 | } 79 | 80 | func (d *dataField) getSeverity() logging.Severity { 81 | switch d.logLevel { 82 | case logrus.DebugLevel: 83 | return logging.SeverityDebug 84 | case logrus.InfoLevel: 85 | return logging.SeverityInfo 86 | case logrus.WarnLevel: 87 | return logging.SeverityWarning 88 | case logrus.ErrorLevel: 89 | return logging.SeverityError 90 | case logrus.PanicLevel: 91 | return logging.SeverityCritical 92 | case logrus.FatalLevel: 93 | return logging.SeverityAlert 94 | default: 95 | return logging.SeverityDefault 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /data_test.go: -------------------------------------------------------------------------------- 1 | package logrus_stackdriver 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "testing" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestLen(t *testing.T) { 14 | a := assert.New(t) 15 | 16 | tests := []struct { 17 | fieldSize int 18 | }{ 19 | {0}, // empty fileds 20 | {1}, // "0" 21 | {2}, // "0", "1" 22 | {9}, // "0", "1", "2" ... "8" 23 | {100}, // "0", "1", "2" ... "99" 24 | } 25 | 26 | for _, tt := range tests { 27 | target := fmt.Sprintf("%+v", tt) 28 | 29 | fields := logrus.Fields{} 30 | for i, max := 0, tt.fieldSize; i < max; i++ { 31 | fields[strconv.Itoa(i)] = struct{}{} 32 | } 33 | 34 | df := dataField{ 35 | data: fields, 36 | } 37 | a.Equal(tt.fieldSize, df.len(), "dataField.Len() should equal fieldSize", target) 38 | } 39 | } 40 | 41 | func TestIsOmit(t *testing.T) { 42 | a := assert.New(t) 43 | 44 | omitList := map[string]struct{}{ 45 | "key_1": {}, 46 | "key_2": {}, 47 | "key_3": {}, 48 | "key_4": {}, 49 | } 50 | 51 | tests := []struct { 52 | key string 53 | expected bool 54 | }{ 55 | {"key_1", true}, 56 | {"key_2", true}, 57 | {"key_3", true}, 58 | {"key_4", true}, 59 | {"not_key", false}, 60 | {"foo", false}, 61 | {"bar", false}, 62 | {"_key_1", false}, 63 | {"key_1_", false}, 64 | } 65 | 66 | for _, tt := range tests { 67 | target := fmt.Sprintf("%+v", tt) 68 | 69 | df := dataField{ 70 | omitList: omitList, 71 | } 72 | a.Equal(tt.expected, df.isOmit(tt.key), target) 73 | } 74 | } 75 | 76 | func TestGetLogName(t *testing.T) { 77 | a := assert.New(t) 78 | 79 | tests := []struct { 80 | key string 81 | value interface{} 82 | expected bool 83 | description string 84 | }{ 85 | {"log_name", "test_log_name", true, "valid server name"}, 86 | {"log_name", "", true, "valid server name"}, 87 | {"not_log_name", "test_log_name", false, "invalid key"}, 88 | {"log_name", 1, false, "invalid value type"}, 89 | {"log_name", true, false, "invalid value type"}, 90 | {"log_name", struct{}{}, false, "invalid value type"}, 91 | } 92 | 93 | const defaultLogName = "default_log_name" 94 | for _, tt := range tests { 95 | target := fmt.Sprintf("%+v", tt) 96 | 97 | fields := logrus.Fields{} 98 | fields[tt.key] = tt.value 99 | entry := &logrus.Entry{ 100 | Data: fields, 101 | } 102 | 103 | df := newDataFieldFromEntry(defaultLogName, entry) 104 | logName := df.getLogName() 105 | if tt.expected { 106 | a.Equal(tt.value, logName, target) 107 | } else { 108 | a.Equal(defaultLogName, logName, target) 109 | } 110 | } 111 | } 112 | 113 | func TestGetRequest(t *testing.T) { 114 | a := assert.New(t) 115 | 116 | tests := []struct { 117 | key string 118 | value interface{} 119 | expected bool 120 | description string 121 | }{ 122 | {"http_request", &http.Request{}, true, "valid http_request"}, 123 | {"not_http_request", &http.Request{}, false, "invalid key"}, 124 | {"http_request", http.Request{}, false, "invalid value type"}, 125 | {"http_request", "test_http_request", false, "invalid value type"}, 126 | {"http_request", 1, false, "invalid value type"}, 127 | {"http_request", true, false, "invalid value type"}, 128 | {"http_request", struct{}{}, false, "invalid value type"}, 129 | } 130 | 131 | const defaultLogName = "default_log_name" 132 | for _, tt := range tests { 133 | target := fmt.Sprintf("%+v", tt) 134 | 135 | fields := logrus.Fields{} 136 | fields[tt.key] = tt.value 137 | entry := &logrus.Entry{ 138 | Data: fields, 139 | } 140 | 141 | df := newDataFieldFromEntry(defaultLogName, entry) 142 | req := df.getRequest() 143 | if tt.expected { 144 | a.Equal(tt.value, req, target) 145 | a.True(df.isOmit("http_request"), "`http_request` should be in omitList") 146 | } else { 147 | a.Nil(req, target) 148 | a.False(df.isOmit("http_request"), "`http_request` should not be in omitList") 149 | } 150 | } 151 | } 152 | 153 | func TestGetResponse(t *testing.T) { 154 | a := assert.New(t) 155 | 156 | tests := []struct { 157 | key string 158 | value interface{} 159 | expected bool 160 | description string 161 | }{ 162 | {"http_response", &http.Response{}, true, "valid http_response"}, 163 | {"not_http_response", &http.Response{}, false, "invalid key"}, 164 | {"http_response", http.Response{}, false, "invalid value type"}, 165 | {"http_response", "test_http_response", false, "invalid value type"}, 166 | {"http_response", 1, false, "invalid value type"}, 167 | {"http_response", true, false, "invalid value type"}, 168 | {"http_response", struct{}{}, false, "invalid value type"}, 169 | } 170 | 171 | const defaultLogName = "default_log_name" 172 | for _, tt := range tests { 173 | target := fmt.Sprintf("%+v", tt) 174 | 175 | fields := logrus.Fields{} 176 | fields[tt.key] = tt.value 177 | entry := &logrus.Entry{ 178 | Data: fields, 179 | } 180 | 181 | df := newDataFieldFromEntry(defaultLogName, entry) 182 | resp := df.getResponse() 183 | if tt.expected { 184 | a.Equal(tt.value, resp, target) 185 | a.True(df.isOmit("http_response"), "`http_response` should be in omitList") 186 | } else { 187 | a.Nil(resp, target) 188 | a.False(df.isOmit("http_response"), "`http_response` should not be in omitList") 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /hook.go: -------------------------------------------------------------------------------- 1 | package logrus_stackdriver 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/evalphobia/google-api-go-wrapper/config" 8 | "github.com/evalphobia/google-api-go-wrapper/stackdriver/logging" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | var defaultLevels = []logrus.Level{ 13 | logrus.PanicLevel, 14 | logrus.FatalLevel, 15 | logrus.ErrorLevel, 16 | logrus.WarnLevel, 17 | logrus.InfoLevel, 18 | } 19 | 20 | // StackdriverHook is logrus hook for Google Stackdriver. 21 | type StackdriverHook struct { 22 | client *logging.Logger 23 | 24 | defaultLogName string 25 | commonLabels map[string]string 26 | async bool 27 | levels []logrus.Level 28 | ignoreFields map[string]struct{} 29 | filters map[string]func(interface{}) interface{} 30 | errorHandlers []func(entry *logrus.Entry, err error) 31 | } 32 | 33 | // New returns initialized logrus hook for Stackdriver. 34 | func New(projectID string, logName string) (*StackdriverHook, error) { 35 | return NewWithConfig(projectID, logName, config.Config{}) 36 | } 37 | 38 | // NewWithConfig returns initialized logrus hook for Stackdriver. 39 | func NewWithConfig(projectID string, logName string, conf config.Config) (*StackdriverHook, error) { 40 | logger, err := logging.NewLogger(conf, projectID) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return &StackdriverHook{ 46 | client: logger, 47 | defaultLogName: logName, 48 | levels: defaultLevels, 49 | ignoreFields: make(map[string]struct{}), 50 | filters: make(map[string]func(interface{}) interface{}), 51 | }, nil 52 | } 53 | 54 | // Levels returns logging level to fire this hook. 55 | func (h *StackdriverHook) Levels() []logrus.Level { 56 | return h.levels 57 | } 58 | 59 | // SetLevels sets logging level to fire this hook. 60 | func (h *StackdriverHook) SetLevels(levels []logrus.Level) { 61 | h.levels = levels 62 | } 63 | 64 | // SetLabels sets logging level to fire this hook. 65 | func (h *StackdriverHook) SetLabels(labels map[string]string) { 66 | h.commonLabels = labels 67 | } 68 | 69 | // Async sets async flag and send log asynchroniously. 70 | // If use this option, Fire() does not return error. 71 | func (h *StackdriverHook) Async() { 72 | h.async = true 73 | } 74 | 75 | // AddIgnore adds field name to ignore. 76 | func (h *StackdriverHook) AddIgnore(name string) { 77 | h.ignoreFields[name] = struct{}{} 78 | } 79 | 80 | // AddFilter adds a custom filter function. 81 | func (h *StackdriverHook) AddFilter(name string, fn func(interface{}) interface{}) { 82 | h.filters[name] = fn 83 | } 84 | 85 | // AddFilter adds a error handler function used when Stackdriver returns error. 86 | func (h *StackdriverHook) AddErrorHandler(fn func(entry *logrus.Entry, err error)) { 87 | h.errorHandlers = append(h.errorHandlers, fn) 88 | } 89 | 90 | // Fire is invoked by logrus and sends log to Stackdriver. 91 | func (h *StackdriverHook) Fire(entry *logrus.Entry) error { 92 | if !h.async { 93 | return h.fire(entry) 94 | } 95 | 96 | // send log asynchroniously and return no error. 97 | go h.fire(entry) 98 | return nil 99 | } 100 | 101 | // Fire is invoked by logrus and sends log to Stackdriver. 102 | func (h *StackdriverHook) fire(entry *logrus.Entry) error { 103 | df := newDataFieldFromEntry(h.defaultLogName, entry) 104 | 105 | err := h.client.Write(logging.WriteData{ 106 | Labels: h.commonLabels, 107 | Severity: df.getSeverity(), 108 | LogName: df.getLogName(), 109 | Data: h.getData(df), 110 | Request: df.getRequest(), 111 | Response: df.getResponse(), 112 | Resource: &logging.Resource{ 113 | Type: "global", 114 | }, 115 | }) 116 | if err == nil { 117 | return nil 118 | } 119 | 120 | // handle error 121 | for _, handlerFn := range h.errorHandlers { 122 | handlerFn(entry, err) 123 | } 124 | return err 125 | } 126 | 127 | func (h *StackdriverHook) getData(df *dataField) map[string]interface{} { 128 | result := make(map[string]interface{}, df.len()) 129 | for k, v := range df.data { 130 | if df.isOmit(k) { 131 | continue // skip already used special fields 132 | } 133 | if _, ok := h.ignoreFields[k]; ok { 134 | continue 135 | } 136 | 137 | if fn, ok := h.filters[k]; ok { 138 | v = fn(v) // apply custom filter 139 | } else { 140 | v = formatData(v) // use default formatter 141 | } 142 | result[k] = v 143 | } 144 | return result 145 | } 146 | 147 | // formatData returns value as a suitable format. 148 | func formatData(value interface{}) (formatted interface{}) { 149 | switch value := value.(type) { 150 | case json.Marshaler: 151 | return value 152 | case error: 153 | return value.Error() 154 | case fmt.Stringer: 155 | return value.String() 156 | default: 157 | return value 158 | } 159 | } 160 | --------------------------------------------------------------------------------