├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── error.go ├── gelf_reader.go ├── gelf_writer.go ├── go.mod ├── go.sum ├── graylog_hook.go └── graylog_hook_test.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - "stable" 4 | - "master" 5 | 6 | env: 7 | - GO111MODULE=on 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Logrus Graylog hook 2 | 3 | ## 3.0.3 - 2019-12-28 4 | 5 | * Fix concurrent logging when hook is reused (#49) 6 | 7 | ## 3.0.2 - 2019-01-10 8 | 9 | * TRACE level logs as syslog `LOG_DEBUG` level (7) 10 | 11 | ## 3.0.1 - 2019-01-09 12 | 13 | * Make pipeline green again. Credits: @psampaz 14 | 15 | ## 3.0.0 - 2019-01-08 16 | 17 | * [Use logrus ReportCaller to get file, line and function](https://github.com/gemnasium/logrus-graylog-hook/pull/39). Breaking change: This change removes the `File` and `Line` fields of entries, and replace them with `_file`, `_line`, and `method` when `ReportCaller` is true (see logrus.SetReportCaller). Credits: @psampaz 18 | * Make this package a go module 19 | 20 | ## 2.0.7 - 2018-02-09 21 | 22 | * Fix reported levels to match syslog levels (@maxatome / #27) 23 | * Removed go 1.3 support 24 | 25 | ## 2.0.6 - 2017-06-01 26 | 27 | * Update import logrus path. See https://github.com/sirupsen/logrus/pull/384 28 | 29 | ## 2.0.5 - 2017-04-14 30 | 31 | * Support uncompressed messages (@yuancheng-p / #24) 32 | 33 | ## 2.0.4 - 2017-02-19 34 | 35 | * Avoid panic if the hook can't dial Graylog (@chiffa-org / #21) 36 | 37 | ## 2.0.3 - 2016-11-30 38 | 39 | * Add support for extracting stacktraces from errors (@flimzy / #19) 40 | * Allow specifying the host instead of taking `os.Hostname` by default (@mweibel / #18) 41 | 42 | ## 2.0.2 - 2016-09-28 43 | 44 | * Get rid of github.com/SocialCodeInc/go-gelf/gelf (#14) 45 | 46 | ## 2.0.1 - 2016-08-16 47 | 48 | * Fix an issue with entry constructor (#12) 49 | 50 | ## 2.0.0 - 2016-07-02 51 | 52 | * Remove facility param in constructor, as it's an optional param in Graylog 2.0 (credits: @saward / #9) 53 | * Improve precision of TimeUnix (credits: @RaphYot / #2) 54 | * Expose Gelf Writer (we will make this an interface in later versions) (credits: @cha-won / #10) 55 | 56 | ## 1.1.2 - 2016-06-03 57 | 58 | * Fix another race condition (credits: @dreyinger / #8) 59 | 60 | ## 1.1.1 - 2016-05-10 61 | 62 | * Fix race condition (credits: @rschmukler / #6) 63 | 64 | ## 1.1.0 - 2015-12-04 65 | 66 | * The default behavior is now to send the logs synchronously. 67 | * A new asynchronous hook is available through `NewAsyncGraylogHook` 68 | 69 | 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Gemnasium 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graylog Hook for [Logrus](https://github.com/sirupsen/logrus) :walrus: [![Build Status](https://travis-ci.org/gemnasium/logrus-graylog-hook.svg?branch=master)](https://travis-ci.org/gemnasium/logrus-graylog-hook) [![godoc reference](https://godoc.org/github.com/gemnasium/logrus-graylog-hook?status.svg)](https://godoc.org/github.com/gemnasium/logrus-graylog-hook) 2 | 3 | Use this hook to send your logs to [Graylog](http://graylog2.org) server over UDP. 4 | The hook is non-blocking: even if UDP is used to send messages, the extra work 5 | should not block the logging function. 6 | 7 | All logrus fields will be sent as additional fields on Graylog. 8 | 9 | ## Usage 10 | 11 | The hook must be configured with: 12 | 13 | * A Graylog GELF UDP address (a "ip:port" string). 14 | * an optional hash with extra global fields. These fields will be included in all messages sent to Graylog 15 | 16 | ```go 17 | package main 18 | 19 | import ( 20 | log "github.com/sirupsen/logrus" 21 | "github.com/gemnasium/logrus-graylog-hook/v3" 22 | ) 23 | 24 | func main() { 25 | hook := graylog.NewGraylogHook(":", map[string]interface{}{"this": "is logged every time"}) 26 | log.AddHook(hook) 27 | log.Info("some logging message") 28 | } 29 | ``` 30 | 31 | ### Asynchronous logger 32 | 33 | ```go 34 | package main 35 | 36 | import ( 37 | log "github.com/sirupsen/logrus" 38 | "github.com/gemnasium/logrus-graylog-hook/v3" 39 | ) 40 | 41 | func main() { 42 | hook := graylog.NewAsyncGraylogHook(":", map[string]interface{}{"this": "is logged every time"}) 43 | // NOTE: you must call Flush() before your program exits to ensure ALL of your logs are sent. 44 | // This defer statement will not have that effect if you write it in a non-main() method. 45 | defer hook.Flush() 46 | log.AddHook(hook) 47 | log.Info("some logging message") 48 | } 49 | ``` 50 | 51 | ### Disable standard logging 52 | 53 | For some reason, you may want to disable logging on stdout, and keep only the messages in Graylog (ie: a webserver inside a docker container). 54 | You can redirect `stdout` to `/dev/null`, or just not log anything by creating a `NullFormatter` implementing `logrus.Formatter` interface: 55 | 56 | ```go 57 | type NullFormatter struct { 58 | } 59 | 60 | // Don't spend time formatting logs 61 | func (NullFormatter) Format(e *log.Entry) ([]byte, error) { 62 | return []byte{}, nil 63 | } 64 | ``` 65 | 66 | And set this formatter as the new logging formatter: 67 | 68 | ```go 69 | log.Infof("Log messages are now sent to Graylog (udp://%s)", graylogAddr) // Give a hint why logs are empty 70 | log.AddHook(graylog.NewGraylogHook(graylogAddr, "api", map[string]interface{}{})) // set graylogAddr accordingly 71 | log.SetFormatter(new(NullFormatter)) // Don't send logs to stdout 72 | ``` 73 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package graylog 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | // newMarshalableError builds an error which encodes its error message into JSON 9 | func newMarshalableError(err error) *marshalableError { 10 | return &marshalableError{err} 11 | } 12 | 13 | // a marshalableError is an error that can be encoded into JSON 14 | type marshalableError struct { 15 | err error 16 | } 17 | 18 | // MarshalJSON implements json.Marshaler for marshalableError 19 | func (m *marshalableError) MarshalJSON() ([]byte, error) { 20 | return json.Marshal(m.err.Error()) 21 | } 22 | 23 | type causer interface { 24 | Cause() error 25 | } 26 | 27 | type stackTracer interface { 28 | StackTrace() errors.StackTrace 29 | } 30 | 31 | func extractStackTrace(err error) errors.StackTrace { 32 | var tracer stackTracer 33 | for { 34 | if st, ok := err.(stackTracer); ok { 35 | tracer = st 36 | } 37 | if cause, ok := err.(causer); ok { 38 | err = cause.Cause() 39 | continue 40 | } 41 | break 42 | } 43 | if tracer == nil { 44 | return nil 45 | } 46 | return tracer.StackTrace() 47 | } 48 | -------------------------------------------------------------------------------- /gelf_reader.go: -------------------------------------------------------------------------------- 1 | package graylog 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "compress/zlib" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net" 11 | "strings" 12 | "sync" 13 | ) 14 | 15 | type Reader struct { 16 | mu sync.Mutex 17 | conn net.Conn 18 | } 19 | 20 | func NewUDPReader(addr string) (*Reader, error) { 21 | var err error 22 | udpAddr, err := net.ResolveUDPAddr("udp", addr) 23 | if err != nil { 24 | return nil, fmt.Errorf("ResolveUDPAddr('%s'): %s", addr, err) 25 | } 26 | 27 | conn, err := net.ListenUDP("udp", udpAddr) 28 | if err != nil { 29 | return nil, fmt.Errorf("ListenUDP: %s", err) 30 | } 31 | 32 | r := new(Reader) 33 | r.conn = conn 34 | return r, nil 35 | } 36 | 37 | func NewTCPReader(addr string) (*net.TCPListener, error) { 38 | var err error 39 | tcpAddr, err := net.ResolveTCPAddr("tcp", addr) 40 | if err != nil { 41 | return nil, fmt.Errorf("ResolveTCPAddr('%s'): %s", addr, err) 42 | } 43 | 44 | listener, err := net.ListenTCP("tcp", tcpAddr) 45 | if err != nil { 46 | return nil, fmt.Errorf("ListenTCP: %s", err) 47 | } 48 | 49 | return listener, nil 50 | } 51 | 52 | func (r *Reader) Addr() string { 53 | return r.conn.LocalAddr().String() 54 | } 55 | 56 | // FIXME: this will discard data if p isn't big enough to hold the 57 | // full message. 58 | func (r *Reader) Read(p []byte) (int, error) { 59 | msg, err := r.ReadMessage() 60 | if err != nil { 61 | return -1, err 62 | } 63 | 64 | var data string 65 | 66 | if msg.Full == "" { 67 | data = msg.Short 68 | } else { 69 | data = msg.Full 70 | } 71 | 72 | return strings.NewReader(data).Read(p) 73 | } 74 | 75 | func (r *Reader) ReadMessage() (*Message, error) { 76 | cBuf := make([]byte, ChunkSize) 77 | var ( 78 | err error 79 | n, length int 80 | buf bytes.Buffer 81 | cid, ocid []byte 82 | seq, total uint8 83 | cHead []byte 84 | cReader io.Reader 85 | chunks [][]byte 86 | ) 87 | 88 | for got := 0; got < 128 && (total == 0 || got < int(total)); got++ { 89 | if n, err = r.conn.Read(cBuf); err != nil { 90 | return nil, fmt.Errorf("Read: %s", err) 91 | } 92 | cHead, cBuf = cBuf[:2], cBuf[:n] 93 | 94 | if bytes.Equal(cHead, magicChunked) { 95 | //fmt.Printf("chunked %v\n", cBuf[:14]) 96 | cid, seq, total = cBuf[2:2+8], cBuf[2+8], cBuf[2+8+1] 97 | if ocid != nil && !bytes.Equal(cid, ocid) { 98 | return nil, fmt.Errorf("out-of-band message %v (awaited %v)", cid, ocid) 99 | } else if ocid == nil { 100 | ocid = cid 101 | chunks = make([][]byte, total) 102 | } 103 | n = len(cBuf) - chunkedHeaderLen 104 | //fmt.Printf("setting chunks[%d]: %d\n", seq, n) 105 | chunks[seq] = append(make([]byte, 0, n), cBuf[chunkedHeaderLen:]...) 106 | length += n 107 | } else { //not chunked 108 | if total > 0 { 109 | return nil, fmt.Errorf("out-of-band message (not chunked)") 110 | } 111 | break 112 | } 113 | } 114 | //fmt.Printf("\nchunks: %v\n", chunks) 115 | 116 | if length > 0 { 117 | if cap(cBuf) < length { 118 | cBuf = append(cBuf, make([]byte, 0, length-cap(cBuf))...) 119 | } 120 | cBuf = cBuf[:0] 121 | for i := range chunks { 122 | //fmt.Printf("appending %d %v\n", i, chunks[i]) 123 | cBuf = append(cBuf, chunks[i]...) 124 | } 125 | cHead = cBuf[:2] 126 | } 127 | 128 | // the data we get from the wire is compressed 129 | if bytes.Equal(cHead, magicGzip) { 130 | cReader, err = gzip.NewReader(bytes.NewReader(cBuf)) 131 | } else if cHead[0] == magicZlib[0] && 132 | (int(cHead[0])*256+int(cHead[1]))%31 == 0 { 133 | // zlib is slightly more complicated, but correct 134 | cReader, err = zlib.NewReader(bytes.NewReader(cBuf)) 135 | } else { 136 | return nil, fmt.Errorf("unknown magic: %x %v", cHead, cHead) 137 | } 138 | 139 | if err != nil { 140 | return nil, fmt.Errorf("NewUDPReader: %s", err) 141 | } 142 | 143 | if _, err = io.Copy(&buf, cReader); err != nil { 144 | return nil, fmt.Errorf("io.Copy: %s", err) 145 | } 146 | 147 | msg := new(Message) 148 | if err := json.Unmarshal(buf.Bytes(), &msg); err != nil { 149 | return nil, fmt.Errorf("json.Unmarshal: %s", err) 150 | } 151 | 152 | return msg, nil 153 | } 154 | -------------------------------------------------------------------------------- /gelf_writer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2012 SocialCode. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package graylog 6 | 7 | import ( 8 | "bytes" 9 | "compress/flate" 10 | "compress/gzip" 11 | "compress/zlib" 12 | "crypto/rand" 13 | "encoding/json" 14 | "fmt" 15 | "io" 16 | "net" 17 | "net/http" 18 | "os" 19 | "path" 20 | "strings" 21 | "sync" 22 | "time" 23 | ) 24 | 25 | type GELFWriter interface { 26 | WriteMessage(m *Message) (err error) 27 | } 28 | 29 | // LowLevelProtocolWriter implements io.Writer and is used to send both discrete 30 | // messages to a graylog2 server, or data from a stream-oriented 31 | // interface (like the functions in log). 32 | type LowLevelProtocolWriter struct { 33 | mu sync.Mutex 34 | conn net.Conn 35 | hostname string 36 | Facility string // defaults to current process name 37 | CompressionLevel int // one of the consts from compress/flate 38 | CompressionType CompressType 39 | 40 | zw writerCloserResetter 41 | zwCompressionLevel int 42 | zwCompressionType CompressType 43 | } 44 | 45 | // What compression type the writer should use when sending messages 46 | // to the graylog2 server 47 | type CompressType int 48 | 49 | const ( 50 | CompressGzip CompressType = iota 51 | CompressZlib 52 | NoCompress 53 | ) 54 | 55 | // Message represents the contents of the GELF message. It is gzipped 56 | // before sending. 57 | type Message struct { 58 | Version string `json:"version"` 59 | Host string `json:"host"` 60 | Short string `json:"short_message"` 61 | Full string `json:"full_message"` 62 | TimeUnix float64 `json:"timestamp"` 63 | Level int32 `json:"level"` 64 | Facility string `json:"facility"` 65 | File string `json:"file"` 66 | Line int `json:"line"` 67 | Extra map[string]interface{} `json:"-"` 68 | } 69 | 70 | type innerMessage Message //against circular (Un)MarshalJSON 71 | 72 | // Used to control GELF chunking. Should be less than (MTU - len(UDP 73 | // header)). 74 | // 75 | // TODO: generate dynamically using Path MTU Discovery? 76 | const ( 77 | ChunkSize = 1420 78 | chunkedHeaderLen = 12 79 | chunkedDataLen = ChunkSize - chunkedHeaderLen 80 | ) 81 | 82 | var ( 83 | magicChunked = []byte{0x1e, 0x0f} 84 | magicZlib = []byte{0x78} 85 | magicGzip = []byte{0x1f, 0x8b} 86 | ) 87 | 88 | // numChunks returns the number of GELF chunks necessary to transmit 89 | // the given compressed buffer. 90 | func numChunks(b []byte) int { 91 | lenB := len(b) 92 | if lenB <= ChunkSize { 93 | return 1 94 | } 95 | return len(b)/chunkedDataLen + 1 96 | } 97 | 98 | // NewWriter returns a new GELFWriter. This writer can be used to send the 99 | // output of the standard Go log functions to a central GELF server by 100 | // passing it to log.SetOutput() 101 | func NewWriter(addr string) (GELFWriter, error) { 102 | if strings.HasPrefix(addr, "http") { 103 | return newHTTPWriter(addr) 104 | } 105 | if strings.HasPrefix(addr, "tcp://") { 106 | return newLowLevelProtocolWriter("tcp", strings.TrimPrefix(addr, "tcp://")) 107 | } 108 | 109 | return newLowLevelProtocolWriter("udp", addr) 110 | } 111 | 112 | func newHTTPWriter(addr string) (GELFWriter, error) { 113 | httpClient := &http.Client{ 114 | Transport: &http.Transport{}, 115 | Timeout: 10 * time.Second, 116 | } 117 | 118 | return HTTPWriter{ 119 | httpClient: httpClient, 120 | addr: addr, 121 | }, nil 122 | } 123 | 124 | func newLowLevelProtocolWriter(protocol, addr string) (GELFWriter, error) { 125 | var err error 126 | w := new(LowLevelProtocolWriter) 127 | w.CompressionLevel = flate.BestSpeed 128 | 129 | if w.conn, err = net.Dial(protocol, strings.TrimPrefix(addr, "tcp://")); err != nil { 130 | return nil, err 131 | } 132 | 133 | if w.hostname, err = os.Hostname(); err != nil { 134 | return nil, err 135 | } 136 | 137 | w.Facility = path.Base(os.Args[0]) 138 | 139 | return w, nil 140 | } 141 | 142 | // writes the gzip compressed byte array to the connection as a series 143 | // of GELF chunked messages. The header format is documented at 144 | // https://github.com/Graylog2/graylog2-docs/wiki/GELF as: 145 | // 146 | // 2-byte magic (0x1e 0x0f), 8 byte id, 1 byte sequence id, 1 byte 147 | // total, chunk-data 148 | func (w *LowLevelProtocolWriter) writeChunked(zBytes []byte) (err error) { 149 | b := make([]byte, 0, ChunkSize) 150 | buf := bytes.NewBuffer(b) 151 | nChunksI := numChunks(zBytes) 152 | if nChunksI > 255 { 153 | return fmt.Errorf("msg too large, would need %d chunks", nChunksI) 154 | } 155 | nChunks := uint8(nChunksI) 156 | // use urandom to get a unique message id 157 | msgId := make([]byte, 8) 158 | n, err := io.ReadFull(rand.Reader, msgId) 159 | if err != nil || n != 8 { 160 | return fmt.Errorf("rand.Reader: %d/%s", n, err) 161 | } 162 | 163 | bytesLeft := len(zBytes) 164 | for i := uint8(0); i < nChunks; i++ { 165 | buf.Reset() 166 | // manually write header. Don't care about 167 | // host/network byte order, because the spec only 168 | // deals in individual bytes. 169 | buf.Write(magicChunked) //magic 170 | buf.Write(msgId) 171 | buf.WriteByte(i) 172 | buf.WriteByte(nChunks) 173 | // slice out our chunk from zBytes 174 | chunkLen := chunkedDataLen 175 | if chunkLen > bytesLeft { 176 | chunkLen = bytesLeft 177 | } 178 | off := int(i) * chunkedDataLen 179 | chunk := zBytes[off : off+chunkLen] 180 | buf.Write(chunk) 181 | 182 | // write this chunk, and make sure the write was good 183 | n, err := w.conn.Write(buf.Bytes()) 184 | if err != nil { 185 | return fmt.Errorf("Write (chunk %d/%d): %s", i, 186 | nChunks, err) 187 | } 188 | if n != len(buf.Bytes()) { 189 | return fmt.Errorf("Write len: (chunk %d/%d) (%d/%d)", 190 | i, nChunks, n, len(buf.Bytes())) 191 | } 192 | 193 | bytesLeft -= chunkLen 194 | } 195 | 196 | if bytesLeft != 0 { 197 | return fmt.Errorf("error: %d bytes left after sending", bytesLeft) 198 | } 199 | return nil 200 | } 201 | 202 | type bufferedWriter struct { 203 | buffer io.Writer 204 | } 205 | 206 | func (bw bufferedWriter) Write(p []byte) (n int, err error) { 207 | return bw.buffer.Write(p) 208 | } 209 | 210 | func (bw bufferedWriter) Close() error { 211 | return nil 212 | } 213 | 214 | func (bw *bufferedWriter) Reset(w io.Writer) { 215 | bw.buffer = w 216 | } 217 | 218 | type writerCloserResetter interface { 219 | io.WriteCloser 220 | Reset(w io.Writer) 221 | } 222 | 223 | // WriteMessage sends the specified message to the GELF server 224 | // specified in the call to NewWriter(). It assumes all the fields are 225 | // filled out appropriately. In general, clients will want to use 226 | // Write, rather than WriteMessage. 227 | func (w *LowLevelProtocolWriter) WriteMessage(m *Message) (err error) { 228 | w.mu.Lock() 229 | defer w.mu.Unlock() 230 | 231 | mBytes, err := json.Marshal(m) 232 | if err != nil { 233 | return 234 | } 235 | 236 | var zBuf bytes.Buffer 237 | 238 | // . If compression settings have changed, a new writer is required. 239 | if w.zwCompressionType != w.CompressionType || w.zwCompressionLevel != w.CompressionLevel { 240 | w.zw = nil 241 | } 242 | 243 | switch w.CompressionType { 244 | case CompressGzip: 245 | if w.zw == nil { 246 | w.zw, err = gzip.NewWriterLevel(&zBuf, w.CompressionLevel) 247 | } 248 | case CompressZlib: 249 | if w.zw == nil { 250 | w.zw, err = zlib.NewWriterLevel(&zBuf, w.CompressionLevel) 251 | } 252 | case NoCompress: 253 | w.zw = &bufferedWriter{} 254 | default: 255 | panic(fmt.Sprintf("unknown compression type %d", 256 | w.CompressionType)) 257 | } 258 | 259 | if err != nil { 260 | return 261 | } 262 | 263 | w.zw.Reset(&zBuf) 264 | 265 | if _, err = w.zw.Write(mBytes); err != nil { 266 | return 267 | } 268 | w.zw.Close() 269 | 270 | zBytes := zBuf.Bytes() 271 | if numChunks(zBytes) > 1 { 272 | return w.writeChunked(zBytes) 273 | } 274 | 275 | n, err := w.conn.Write(zBytes) 276 | if err != nil { 277 | return 278 | } 279 | if n != len(zBytes) { 280 | return fmt.Errorf("bad write (%d/%d)", n, len(zBytes)) 281 | } 282 | 283 | return nil 284 | } 285 | 286 | /* 287 | func (w *Writer) Alert(m string) (err error) 288 | func (w *Writer) Close() error 289 | func (w *Writer) Crit(m string) (err error) 290 | func (w *Writer) Debug(m string) (err error) 291 | func (w *Writer) Emerg(m string) (err error) 292 | func (w *Writer) Err(m string) (err error) 293 | func (w *Writer) Info(m string) (err error) 294 | func (w *Writer) Notice(m string) (err error) 295 | func (w *Writer) Warning(m string) (err error) 296 | */ 297 | 298 | // Write encodes the given string in a GELF message and sends it to 299 | // the server specified in NewWriter(). 300 | func (w *LowLevelProtocolWriter) Write(p []byte) (n int, err error) { 301 | 302 | // remove trailing and leading whitespace 303 | p = bytes.TrimSpace(p) 304 | 305 | // If there are newlines in the message, use the first line 306 | // for the short message and set the full message to the 307 | // original input. If the input has no newlines, stick the 308 | // whole thing in Short. 309 | short := p 310 | full := []byte("") 311 | if i := bytes.IndexRune(p, '\n'); i > 0 { 312 | short = p[:i] 313 | full = p 314 | } 315 | 316 | m := Message{ 317 | Version: "1.0", 318 | Host: w.hostname, 319 | Short: string(short), 320 | Full: string(full), 321 | TimeUnix: float64(time.Now().UnixNano()/1000000) / 1000., 322 | Level: 6, // info 323 | Facility: w.Facility, 324 | Extra: map[string]interface{}{}, 325 | } 326 | 327 | if err = w.WriteMessage(&m); err != nil { 328 | return 0, err 329 | } 330 | 331 | return len(p), nil 332 | } 333 | 334 | func (m *Message) MarshalJSON() ([]byte, error) { 335 | var err error 336 | var b, eb []byte 337 | 338 | extra := m.Extra 339 | b, err = json.Marshal((*innerMessage)(m)) 340 | m.Extra = extra 341 | if err != nil { 342 | return nil, err 343 | } 344 | 345 | if len(extra) == 0 { 346 | return b, nil 347 | } 348 | 349 | if eb, err = json.Marshal(extra); err != nil { 350 | return nil, err 351 | } 352 | 353 | // merge serialized message + serialized extra map 354 | b[len(b)-1] = ',' 355 | return append(b, eb[1:len(eb)]...), nil 356 | } 357 | 358 | func (m *Message) UnmarshalJSON(data []byte) error { 359 | i := make(map[string]interface{}, 16) 360 | if err := json.Unmarshal(data, &i); err != nil { 361 | return err 362 | } 363 | for k, v := range i { 364 | if k[0] == '_' { 365 | if m.Extra == nil { 366 | m.Extra = make(map[string]interface{}, 1) 367 | } 368 | m.Extra[k] = v 369 | continue 370 | } 371 | switch k { 372 | case "version": 373 | m.Version = v.(string) 374 | case "host": 375 | m.Host = v.(string) 376 | case "short_message": 377 | m.Short = v.(string) 378 | case "full_message": 379 | m.Full = v.(string) 380 | case "timestamp": 381 | m.TimeUnix = v.(float64) 382 | case "level": 383 | m.Level = int32(v.(float64)) 384 | case "facility": 385 | m.Facility = v.(string) 386 | case "file": 387 | m.File = v.(string) 388 | case "line": 389 | m.Line = int(v.(float64)) 390 | } 391 | } 392 | return nil 393 | } 394 | 395 | // HTTPWriter implements the GELFWriter interface, and cannot be used 396 | // as an io.Writer 397 | type HTTPWriter struct { 398 | httpClient *http.Client 399 | addr string 400 | } 401 | 402 | func (h HTTPWriter) WriteMessage(m *Message) (err error) { 403 | mBytes, err := json.Marshal(m) 404 | if err != nil { 405 | return 406 | } 407 | 408 | resp, err := h.httpClient.Post(h.addr, "application/json", bytes.NewBuffer(mBytes)) 409 | if err != nil { 410 | return err 411 | } 412 | defer resp.Body.Close() 413 | 414 | if resp.StatusCode != 202 { 415 | return fmt.Errorf("got code %s, expected 202", resp.Status) 416 | } 417 | 418 | return nil 419 | } 420 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gemnasium/logrus-graylog-hook/v3 2 | 3 | require ( 4 | github.com/pkg/errors v0.9.1 5 | github.com/sirupsen/logrus v1.9.3 6 | ) 7 | 8 | require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect 9 | 10 | go 1.20 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 5 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 11 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 12 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 13 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= 14 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 16 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 17 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 18 | -------------------------------------------------------------------------------- /graylog_hook.go: -------------------------------------------------------------------------------- 1 | package graylog 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "sync" 10 | "time" 11 | 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | const StackTraceKey = "_stacktrace" 16 | 17 | // Set graylog.BufSize = _before_ calling NewGraylogHook 18 | // Once the buffer is full, logging will start blocking, waiting for slots to 19 | // be available in the queue. 20 | var BufSize uint = 8192 21 | 22 | // GraylogHook to send logs to a logging service compatible with the Graylog API and the GELF format. 23 | type GraylogHook struct { 24 | Extra map[string]interface{} 25 | Host string 26 | Level logrus.Level 27 | gelfLogger GELFWriter 28 | buf chan graylogEntry 29 | wg sync.WaitGroup 30 | mu sync.RWMutex 31 | synchronous bool 32 | blacklist map[string]bool 33 | } 34 | 35 | // Graylog needs file and line params 36 | type graylogEntry struct { 37 | *logrus.Entry 38 | file string 39 | line int 40 | } 41 | 42 | // NewGraylogHook creates a hook to be added to an instance of logger. 43 | func NewGraylogHook(addr string, extra map[string]interface{}) *GraylogHook { 44 | g, err := NewWriter(addr) 45 | if err != nil { 46 | logrus.WithError(err).Error("Can't create Gelf logger") 47 | } 48 | 49 | host, err := os.Hostname() 50 | if err != nil { 51 | host = "localhost" 52 | } 53 | 54 | hook := &GraylogHook{ 55 | Host: host, 56 | Extra: extra, 57 | Level: logrus.DebugLevel, 58 | gelfLogger: g, 59 | synchronous: true, 60 | } 61 | 62 | return hook 63 | } 64 | 65 | // NewAsyncGraylogHook creates a hook to be added to an instance of logger. 66 | // The hook created will be asynchronous, and it's the responsibility of the user to call the Flush method 67 | // before exiting to empty the log queue. 68 | func NewAsyncGraylogHook(addr string, extra map[string]interface{}) *GraylogHook { 69 | g, err := NewWriter(addr) 70 | if err != nil { 71 | logrus.WithError(err).Error("Can't create Gelf logger") 72 | } 73 | 74 | host, err := os.Hostname() 75 | if err != nil { 76 | host = "localhost" 77 | } 78 | 79 | hook := &GraylogHook{ 80 | Host: host, 81 | Extra: extra, 82 | Level: logrus.DebugLevel, 83 | gelfLogger: g, 84 | buf: make(chan graylogEntry, BufSize), 85 | } 86 | go hook.fire() // Log in background 87 | 88 | return hook 89 | } 90 | 91 | // Fire is called when a log event is fired. 92 | // We assume the entry will be altered by another hook, 93 | // otherwise we might be logging something wrong to Graylog 94 | func (hook *GraylogHook) Fire(entry *logrus.Entry) error { 95 | hook.mu.RLock() // Claim the mutex as a RLock - allowing multiple go routines to log simultaneously 96 | defer hook.mu.RUnlock() 97 | 98 | var file string 99 | var line int 100 | 101 | if entry.Caller != nil { 102 | file = entry.Caller.File 103 | line = entry.Caller.Line 104 | } 105 | 106 | newData := make(map[string]interface{}) 107 | for k, v := range entry.Data { 108 | switch v := v.(type) { 109 | case error: 110 | // Otherwise errors are ignored by `encoding/json` 111 | // https://github.com/Sirupsen/logrus/issues/137 112 | newData[k] = v.Error() 113 | default: 114 | newData[k] = v 115 | } 116 | } 117 | 118 | newEntry := &logrus.Entry{ 119 | Logger: entry.Logger, 120 | Data: newData, 121 | Time: entry.Time, 122 | Level: entry.Level, 123 | Caller: entry.Caller, 124 | Message: entry.Message, 125 | } 126 | gEntry := graylogEntry{newEntry, file, line} 127 | 128 | if hook.synchronous { 129 | hook.sendEntry(gEntry) 130 | } else { 131 | hook.wg.Add(1) 132 | hook.buf <- gEntry 133 | } 134 | 135 | return nil 136 | } 137 | 138 | // Flush waits for the log queue to be empty. 139 | // This func is meant to be used when the hook was created with NewAsyncGraylogHook. 140 | func (hook *GraylogHook) Flush() { 141 | hook.mu.Lock() // claim the mutex as a Lock - we want exclusive access to it 142 | defer hook.mu.Unlock() 143 | 144 | hook.wg.Wait() 145 | } 146 | 147 | // fire will loop on the 'buf' channel, and write entries to graylog 148 | func (hook *GraylogHook) fire() { 149 | for { 150 | entry := <-hook.buf // receive new entry on channel 151 | hook.sendEntry(entry) 152 | hook.wg.Done() 153 | } 154 | } 155 | 156 | func logrusLevelToSyslog(level logrus.Level) int32 { 157 | const ( 158 | LOG_EMERG = 0 /* system is unusable */ 159 | LOG_ALERT = 1 /* action must be taken immediately */ 160 | LOG_CRIT = 2 /* critical conditions */ 161 | LOG_ERR = 3 /* error conditions */ 162 | LOG_WARNING = 4 /* warning conditions */ 163 | LOG_NOTICE = 5 /* normal but significant condition */ 164 | LOG_INFO = 6 /* informational */ 165 | LOG_DEBUG = 7 /* debug-level messages */ 166 | ) 167 | // logrus has no equivalent of syslog LOG_NOTICE 168 | switch level { 169 | case logrus.PanicLevel: 170 | return LOG_ALERT 171 | case logrus.FatalLevel: 172 | return LOG_CRIT 173 | case logrus.ErrorLevel: 174 | return LOG_ERR 175 | case logrus.WarnLevel: 176 | return LOG_WARNING 177 | case logrus.InfoLevel: 178 | return LOG_INFO 179 | case logrus.DebugLevel, logrus.TraceLevel: 180 | return LOG_DEBUG 181 | default: 182 | return LOG_DEBUG 183 | } 184 | } 185 | 186 | // sendEntry sends an entry to graylog synchronously 187 | func (hook *GraylogHook) sendEntry(entry graylogEntry) { 188 | if hook.gelfLogger == nil { 189 | fmt.Println("Can't connect to Graylog") 190 | return 191 | } 192 | w := hook.gelfLogger 193 | 194 | // remove trailing and leading whitespace 195 | p := bytes.TrimSpace([]byte(entry.Message)) 196 | 197 | // If there are newlines in the message, use the first line 198 | // for the short message and set the full message to the 199 | // original input. If the input has no newlines, stick the 200 | // whole thing in Short. 201 | short := p 202 | full := []byte("") 203 | if i := bytes.IndexRune(p, '\n'); i > 0 { 204 | short = p[:i] 205 | full = p 206 | } 207 | 208 | level := logrusLevelToSyslog(entry.Level) 209 | 210 | // Don't modify entry.Data directly, as the entry will used after this hook was fired 211 | extra := map[string]interface{}{} 212 | // Merge extra fields 213 | for k, v := range hook.Extra { 214 | k = fmt.Sprintf("_%s", k) // "[...] every field you send and prefix with a _ (underscore) will be treated as an additional field." 215 | extra[k] = v 216 | } 217 | 218 | if entry.Caller != nil { 219 | extra["_file"] = entry.Caller.File 220 | extra["_line"] = entry.Caller.Line 221 | extra["_function"] = entry.Caller.Function 222 | } 223 | 224 | for k, v := range entry.Data { 225 | if !hook.blacklist[k] { 226 | extraK := fmt.Sprintf("_%s", k) // "[...] every field you send and prefix with a _ (underscore) will be treated as an additional field." 227 | if k == logrus.ErrorKey { 228 | asError, isError := v.(error) 229 | _, isMarshaler := v.(json.Marshaler) 230 | if isError && !isMarshaler { 231 | extra[extraK] = newMarshalableError(asError) 232 | } else { 233 | extra[extraK] = v 234 | } 235 | if stackTrace := extractStackTrace(asError); stackTrace != nil { 236 | extra[StackTraceKey] = fmt.Sprintf("%+v", stackTrace) 237 | } 238 | } else { 239 | extra[extraK] = v 240 | } 241 | } 242 | } 243 | 244 | m := Message{ 245 | Version: "1.1", 246 | Host: hook.Host, 247 | Short: string(short), 248 | Full: string(full), 249 | TimeUnix: float64(time.Now().UnixNano()/1000000) / 1000., 250 | Level: level, 251 | File: entry.file, 252 | Line: entry.line, 253 | Extra: extra, 254 | } 255 | 256 | if err := w.WriteMessage(&m); err != nil { 257 | fmt.Println(err) 258 | } 259 | } 260 | 261 | // Levels returns the available logging levels. 262 | func (hook *GraylogHook) Levels() []logrus.Level { 263 | levels := []logrus.Level{} 264 | for _, level := range logrus.AllLevels { 265 | if level <= hook.Level { 266 | levels = append(levels, level) 267 | } 268 | } 269 | return levels 270 | } 271 | 272 | // Blacklist create a blacklist map to filter some message keys. 273 | // This useful when you want your application to log extra fields locally 274 | // but don't want graylog to store them. 275 | func (hook *GraylogHook) Blacklist(b []string) { 276 | hook.blacklist = make(map[string]bool) 277 | for _, elem := range b { 278 | hook.blacklist[elem] = true 279 | } 280 | } 281 | 282 | // SetWriter sets the hook Gelf writer 283 | func (hook *GraylogHook) SetWriter(w *LowLevelProtocolWriter) error { 284 | if w == nil { 285 | return errors.New("writer can't be nil") 286 | } 287 | hook.gelfLogger = w 288 | return nil 289 | } 290 | 291 | // Writer returns the writer 292 | func (hook *GraylogHook) Writer() GELFWriter { 293 | return hook.gelfLogger 294 | } 295 | -------------------------------------------------------------------------------- /graylog_hook_test.go: -------------------------------------------------------------------------------- 1 | package graylog 2 | 3 | import ( 4 | "compress/flate" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "net" 11 | "net/http" 12 | "net/http/httptest" 13 | "regexp" 14 | "runtime" 15 | "strings" 16 | "sync" 17 | "testing" 18 | 19 | pkgerrors "github.com/pkg/errors" 20 | "github.com/sirupsen/logrus" 21 | ) 22 | 23 | const SyslogInfoLevel = 6 24 | const SyslogErrorLevel = 3 25 | 26 | func TestWritingToUDP(t *testing.T) { 27 | r, err := NewUDPReader("127.0.0.1:0") 28 | if err != nil { 29 | t.Fatalf("NewUDPReader: %s", err) 30 | } 31 | hook := NewGraylogHook(r.Addr(), map[string]interface{}{"foo": "bar"}) 32 | hook.Host = "testing.local" 33 | hook.Blacklist([]string{"filterMe"}) 34 | msgData := "test message\nsecond line" 35 | 36 | log := logrus.New() 37 | log.Out = io.Discard 38 | log.Hooks.Add(hook) 39 | log.WithFields(logrus.Fields{"withField": "1", "filterMe": "1"}).Info(msgData) 40 | 41 | msg, err := r.ReadMessage() 42 | 43 | if err != nil { 44 | t.Errorf("ReadMessage: %s", err) 45 | } 46 | 47 | if msg.Short != "test message" { 48 | t.Errorf("msg.Short: expected %s, got %s", msgData, msg.Full) 49 | } 50 | 51 | if msg.Full != msgData { 52 | t.Errorf("msg.Full: expected %s, got %s", msgData, msg.Full) 53 | } 54 | 55 | if msg.Level != SyslogInfoLevel { 56 | t.Errorf("msg.Level: expected: %d, got %d)", SyslogInfoLevel, msg.Level) 57 | } 58 | 59 | if msg.Host != "testing.local" { 60 | t.Errorf("Host should match (exp: testing.local, got: %s)", msg.Host) 61 | } 62 | 63 | if len(msg.Extra) != 2 { 64 | t.Errorf("wrong number of extra fields (exp: %d, got %d) in %v", 5, len(msg.Extra), msg.Extra) 65 | } 66 | 67 | fileExpected := "" 68 | if msg.File != fileExpected { 69 | t.Errorf("msg.File: expected %s, got %s", fileExpected, 70 | msg.File) 71 | } 72 | 73 | lineExpected := 0 74 | if msg.Line != lineExpected { 75 | t.Errorf("msg.Line: expected %d, got %d", lineExpected, msg.Line) 76 | } 77 | 78 | if len(msg.Extra) != 2 { 79 | t.Errorf("wrong number of extra fields (exp: %d, got %d) in %v", 2, len(msg.Extra), msg.Extra) 80 | } 81 | 82 | extra := map[string]interface{}{"foo": "bar", "withField": "1"} 83 | 84 | for k, v := range extra { 85 | // Remember extra fields are prefixed with "_" 86 | if msg.Extra["_"+k].(string) != extra[k].(string) { 87 | t.Errorf("Expected extra '%s' to be %#v, got %#v", k, v, msg.Extra["_"+k]) 88 | } 89 | } 90 | } 91 | 92 | func TestWritingToTCP(t *testing.T) { 93 | listener, err := NewTCPReader("127.0.0.1:0") 94 | if err != nil { 95 | t.Fatalf("NewTCPReader: %s", err) 96 | } 97 | msgData := "test message\nsecond line" 98 | wg := &sync.WaitGroup{} 99 | wg.Add(1) 100 | 101 | go func(msgData string, wg *sync.WaitGroup) { 102 | r := new(Reader) 103 | r.conn, err = listener.Accept() 104 | if err != nil { 105 | fmt.Println(err) 106 | } 107 | 108 | msg, err := r.ReadMessage() 109 | 110 | if err != nil { 111 | t.Errorf("ReadMessage: %s", err) 112 | } 113 | 114 | if msg.Short != "test message" { 115 | t.Errorf("msg.Short: expected %s, got %s", msgData, msg.Full) 116 | } 117 | 118 | if msg.Full != msgData { 119 | t.Errorf("msg.Full: expected %s, got %s", msgData, msg.Full) 120 | } 121 | 122 | if msg.Level != SyslogInfoLevel { 123 | t.Errorf("msg.Level: expected: %d, got %d)", SyslogInfoLevel, msg.Level) 124 | } 125 | 126 | if msg.Host != "testing.local" { 127 | t.Errorf("Host should match (exp: testing.local, got: %s)", msg.Host) 128 | } 129 | 130 | if len(msg.Extra) != 2 { 131 | t.Errorf("wrong number of extra fields (exp: %d, got %d) in %v", 5, len(msg.Extra), msg.Extra) 132 | } 133 | 134 | fileExpected := "" 135 | if msg.File != fileExpected { 136 | t.Errorf("msg.File: expected %s, got %s", fileExpected, 137 | msg.File) 138 | } 139 | 140 | lineExpected := 0 141 | if msg.Line != lineExpected { 142 | t.Errorf("msg.Line: expected %d, got %d", lineExpected, msg.Line) 143 | } 144 | 145 | if len(msg.Extra) != 2 { 146 | t.Errorf("wrong number of extra fields (exp: %d, got %d) in %v", 2, len(msg.Extra), msg.Extra) 147 | } 148 | 149 | extra := map[string]interface{}{"foo": "bar", "withField": "1"} 150 | 151 | for k, v := range extra { 152 | // Remember extra fields are prefixed with "_" 153 | if msg.Extra["_"+k].(string) != extra[k].(string) { 154 | t.Errorf("Expected extra '%s' to be %#v, got %#v", k, v, msg.Extra["_"+k]) 155 | } 156 | } 157 | wg.Done() 158 | }(msgData, wg) 159 | 160 | hook := NewGraylogHook("tcp://"+listener.Addr().String(), map[string]interface{}{"foo": "bar"}) 161 | hook.Host = "testing.local" 162 | hook.Blacklist([]string{"filterMe"}) 163 | 164 | log := logrus.New() 165 | log.Out = io.Discard 166 | log.Hooks.Add(hook) 167 | log.WithFields(logrus.Fields{"withField": "1", "filterMe": "1"}).Info(msgData) 168 | wg.Wait() 169 | fmt.Println("test done") 170 | } 171 | 172 | func TestErrorLevelReporting(t *testing.T) { 173 | r, err := NewUDPReader("127.0.0.1:0") 174 | if err != nil { 175 | t.Fatalf("NewUDPReader: %s", err) 176 | } 177 | hook := NewGraylogHook(r.Addr(), map[string]interface{}{"foo": "bar"}) 178 | msgData := "test message\nsecond line" 179 | 180 | log := logrus.New() 181 | log.Out = io.Discard 182 | log.Hooks.Add(hook) 183 | 184 | log.Error(msgData) 185 | 186 | msg, err := r.ReadMessage() 187 | 188 | if err != nil { 189 | t.Errorf("ReadMessage: %s", err) 190 | } 191 | 192 | if msg.Short != "test message" { 193 | t.Errorf("msg.Short: expected %s, got %s", msgData, msg.Full) 194 | } 195 | 196 | if msg.Full != msgData { 197 | t.Errorf("msg.Full: expected %s, got %s", msgData, msg.Full) 198 | } 199 | 200 | if msg.Level != SyslogErrorLevel { 201 | t.Errorf("msg.Level: expected: %d, got %d)", SyslogErrorLevel, msg.Level) 202 | } 203 | } 204 | 205 | func TestJSONErrorMarshalling(t *testing.T) { 206 | r, err := NewUDPReader("127.0.0.1:0") 207 | if err != nil { 208 | t.Fatalf("NewUDPReader: %s", err) 209 | } 210 | hook := NewGraylogHook(r.Addr(), map[string]interface{}{}) 211 | 212 | log := logrus.New() 213 | log.Out = io.Discard 214 | log.Hooks.Add(hook) 215 | 216 | log.WithError(errors.New("sample error")).Info("Testing sample error") 217 | 218 | msg, err := r.ReadMessage() 219 | if err != nil { 220 | t.Errorf("ReadMessage: %s", err) 221 | } 222 | 223 | encoded, err := json.Marshal(msg) 224 | if err != nil { 225 | t.Errorf("Marshaling json: %s", err) 226 | } 227 | 228 | errSection := regexp.MustCompile(`"_error":"sample error"`) 229 | if !errSection.MatchString(string(encoded)) { 230 | t.Errorf("Expected error message to be encoded into message") 231 | } 232 | } 233 | 234 | func TestParallelLogging(t *testing.T) { 235 | r, err := NewUDPReader("127.0.0.1:0") 236 | if err != nil { 237 | t.Fatalf("NewUDPReader: %s", err) 238 | } 239 | hook := NewGraylogHook(r.Addr(), nil) 240 | asyncHook := NewAsyncGraylogHook(r.Addr(), nil) 241 | 242 | log := logrus.New() 243 | log.Out = io.Discard 244 | log.Hooks.Add(hook) 245 | log.Hooks.Add(asyncHook) 246 | 247 | log2 := logrus.New() 248 | log2.Out = io.Discard 249 | log2.Hooks.Add(hook) 250 | log2.Hooks.Add(asyncHook) 251 | 252 | quit := make(chan struct{}) 253 | defer close(quit) 254 | 255 | recordPanic := func() { 256 | if r := recover(); r != nil { 257 | t.Fatalf("Logging in parallel caused a panic") 258 | } 259 | } 260 | 261 | var wg sync.WaitGroup 262 | 263 | // Start draining messages from GELF 264 | go func() { 265 | defer recordPanic() 266 | for { 267 | select { 268 | case <-quit: 269 | return 270 | default: 271 | r.ReadMessage() 272 | } 273 | } 274 | }() 275 | 276 | // Log into our hook in parallel 277 | for i := 0; i < 100; i++ { 278 | wg.Add(1) 279 | 280 | go func() { 281 | defer wg.Done() 282 | defer recordPanic() 283 | 284 | log.Info("Logging") 285 | log2.Info("Logging from another logger") 286 | }() 287 | } 288 | 289 | wg.Wait() 290 | } 291 | 292 | func TestSetWriter(t *testing.T) { 293 | r, err := NewUDPReader("127.0.0.1:0") 294 | if err != nil { 295 | t.Fatalf("NewUDPReader: %s", err) 296 | } 297 | hook := NewGraylogHook(r.Addr(), nil) 298 | 299 | w := hook.Writer().(*LowLevelProtocolWriter) 300 | w.CompressionLevel = flate.BestCompression 301 | hook.SetWriter(w) 302 | 303 | if hook.Writer().(*LowLevelProtocolWriter).CompressionLevel != flate.BestCompression { 304 | t.Error("UDPWriter was not set correctly") 305 | } 306 | 307 | if hook.SetWriter(nil) == nil { 308 | t.Error("Setting a nil writer should raise an error") 309 | } 310 | } 311 | 312 | func TestWithInvalidGraylogAddr(t *testing.T) { 313 | addr, err := net.ResolveUDPAddr("udp", "localhost:0") 314 | if err != nil { 315 | panic(err) 316 | } 317 | logrus.SetOutput(io.Discard) 318 | hook := NewGraylogHook(addr.String(), nil) 319 | 320 | log := logrus.New() 321 | log.Out = io.Discard 322 | log.Hooks.Add(hook) 323 | 324 | // Should not panic 325 | log.WithError(errors.New("sample error")).Info("Testing sample error") 326 | } 327 | 328 | func TestStackTracer(t *testing.T) { 329 | r, err := NewUDPReader("127.0.0.1:0") 330 | if err != nil { 331 | t.Fatalf("NewUDPReader: %s", err) 332 | } 333 | hook := NewGraylogHook(r.Addr(), map[string]interface{}{}) 334 | 335 | log := logrus.New() 336 | log.SetReportCaller(true) 337 | log.Out = io.Discard 338 | log.Hooks.Add(hook) 339 | 340 | stackErr := pkgerrors.New("sample error") 341 | 342 | log.WithError(stackErr).Info("Testing sample error") 343 | 344 | msg, err := r.ReadMessage() 345 | if err != nil { 346 | t.Errorf("ReadMessage: %s", err) 347 | } 348 | 349 | fileExpected := "graylog_hook_test.go" 350 | if !strings.HasSuffix(msg.File, fileExpected) { 351 | t.Errorf("msg.File: expected %s, got %s", fileExpected, 352 | msg.File) 353 | } 354 | 355 | lineExpected := 342 // Update this if code is updated above 356 | if msg.Line != lineExpected { 357 | t.Errorf("msg.Line: expected %d, got %d", lineExpected, msg.Line) 358 | } 359 | 360 | stacktraceI, ok := msg.Extra[StackTraceKey] 361 | if !ok { 362 | t.Error("Stack Trace not found in result") 363 | } 364 | stacktrace, ok := stacktraceI.(string) 365 | if !ok { 366 | t.Error("Stack Trace is not a string") 367 | } 368 | 369 | // Run the test for stack trace only in stable versions 370 | if !strings.Contains(runtime.Version(), "devel") { 371 | stacktraceRE := regexp.MustCompile(`^ 372 | (.+)?logrus-graylog-hook(/v3)?.TestStackTracer 373 | (/|[A-Z]:/).+/logrus-graylog-hook(.v3)?/graylog_hook_test.go:\d+ 374 | testing.tRunner 375 | (/|[A-Z]:/).*/testing.go:\d+ 376 | runtime.* 377 | (/|[A-Z]:/).*/runtime/.*:\d+$`) 378 | 379 | if !stacktraceRE.MatchString(stacktrace) { 380 | t.Errorf("Stack Trace not as expected. Got:\n%s\n", stacktrace) 381 | } 382 | } 383 | } 384 | 385 | func TestLogrusLevelToSyslog(t *testing.T) { 386 | // Syslog constants 387 | const ( 388 | LOG_EMERG = 0 /* system is unusable */ 389 | LOG_ALERT = 1 /* action must be taken immediately */ 390 | LOG_CRIT = 2 /* critical conditions */ 391 | LOG_ERR = 3 /* error conditions */ 392 | LOG_WARNING = 4 /* warning conditions */ 393 | LOG_NOTICE = 5 /* normal but significant condition */ 394 | LOG_INFO = 6 /* informational */ 395 | LOG_DEBUG = 7 /* debug-level messages */ 396 | ) 397 | 398 | if logrusLevelToSyslog(logrus.TraceLevel) != LOG_DEBUG { 399 | t.Error("logrusLevelToSyslog(TraceLevel) != LOG_DEBUG") 400 | } 401 | 402 | if logrusLevelToSyslog(logrus.DebugLevel) != LOG_DEBUG { 403 | t.Error("logrusLevelToSyslog(DebugLevel) != LOG_DEBUG") 404 | } 405 | 406 | if logrusLevelToSyslog(logrus.InfoLevel) != LOG_INFO { 407 | t.Error("logrusLevelToSyslog(InfoLevel) != LOG_INFO") 408 | } 409 | 410 | if logrusLevelToSyslog(logrus.WarnLevel) != LOG_WARNING { 411 | t.Error("logrusLevelToSyslog(WarnLevel) != LOG_WARNING") 412 | } 413 | 414 | if logrusLevelToSyslog(logrus.ErrorLevel) != LOG_ERR { 415 | t.Error("logrusLevelToSyslog(ErrorLevel) != LOG_ERR") 416 | } 417 | 418 | if logrusLevelToSyslog(logrus.FatalLevel) != LOG_CRIT { 419 | t.Error("logrusLevelToSyslog(FatalLevel) != LOG_CRIT") 420 | } 421 | 422 | if logrusLevelToSyslog(logrus.PanicLevel) != LOG_ALERT { 423 | t.Error("logrusLevelToSyslog(PanicLevel) != LOG_ALERT") 424 | } 425 | } 426 | 427 | func TestReportCallerEnabled(t *testing.T) { 428 | r, err := NewUDPReader("127.0.0.1:0") 429 | if err != nil { 430 | t.Fatalf("NewUDPReader: %s", err) 431 | } 432 | hook := NewGraylogHook(r.Addr(), map[string]interface{}{}) 433 | hook.Host = "testing.local" 434 | msgData := "test message\nsecond line" 435 | 436 | log := logrus.New() 437 | log.SetReportCaller(true) 438 | log.Out = io.Discard 439 | log.Hooks.Add(hook) 440 | log.Info(msgData) 441 | 442 | msg, err := r.ReadMessage() 443 | 444 | if err != nil { 445 | t.Errorf("ReadMessage: %s", err) 446 | } 447 | 448 | fileField, ok := msg.Extra["_file"] 449 | if !ok { 450 | t.Error("_file field not present in extra fields") 451 | } 452 | 453 | fileGot, ok := fileField.(string) 454 | if !ok { 455 | t.Error("_file field is not a string") 456 | } 457 | 458 | fileExpected := "graylog_hook_test.go" 459 | if !strings.HasSuffix(fileGot, fileExpected) { 460 | t.Errorf("msg.Extra[\"_file\"]: expected %s, got %s", fileExpected, fileGot) 461 | } 462 | 463 | lineField, ok := msg.Extra["_line"] 464 | if !ok { 465 | t.Error("_line field not present in extra fields") 466 | } 467 | 468 | lineGot, ok := lineField.(float64) 469 | if !ok { 470 | t.Error("_line dowes not have the correct type") 471 | } 472 | 473 | lineExpected := 440 // Update this if code is updated above 474 | if msg.Line != lineExpected { 475 | t.Errorf("msg.Extra[\"_line\"]: expected %d, got %d", lineExpected, int(lineGot)) 476 | } 477 | 478 | functionField, ok := msg.Extra["_function"] 479 | if !ok { 480 | t.Error("_function field not present in extra fields") 481 | } 482 | 483 | functionGot, ok := functionField.(string) 484 | if !ok { 485 | t.Error("_function field is not a string") 486 | } 487 | 488 | functionExpected := "TestReportCallerEnabled" 489 | if !strings.HasSuffix(functionGot, functionExpected) { 490 | t.Errorf("msg.Extra[\"_function\"]: expected %s, got %s", functionExpected, functionGot) 491 | } 492 | 493 | gelfFileExpected := "graylog_hook_test.go" 494 | if !strings.HasSuffix(msg.File, gelfFileExpected) { 495 | t.Errorf("msg.File: expected %s, got %s", gelfFileExpected, 496 | msg.File) 497 | } 498 | 499 | gelfLineExpected := 440 // Update this if code is updated above 500 | if msg.Line != lineExpected { 501 | t.Errorf("msg.Line: expected %d, got %d", gelfLineExpected, msg.Line) 502 | } 503 | } 504 | 505 | func TestReportCallerDisabled(t *testing.T) { 506 | r, err := NewUDPReader("127.0.0.1:0") 507 | if err != nil { 508 | t.Fatalf("NewUDPReader: %s", err) 509 | } 510 | hook := NewGraylogHook(r.Addr(), map[string]interface{}{}) 511 | hook.Host = "testing.local" 512 | msgData := "test message\nsecond line" 513 | 514 | log := logrus.New() 515 | log.SetReportCaller(false) 516 | log.Out = io.Discard 517 | log.Hooks.Add(hook) 518 | log.Info(msgData) 519 | 520 | msg, err := r.ReadMessage() 521 | 522 | if err != nil { 523 | t.Errorf("ReadMessage: %s", err) 524 | } 525 | 526 | if _, ok := msg.Extra["_file"]; ok { 527 | t.Error("_file field should not present in extra fields") 528 | } 529 | 530 | if _, ok := msg.Extra["_line"]; ok { 531 | t.Error("_line field should not present in extra fields") 532 | } 533 | 534 | if _, ok := msg.Extra["_function"]; ok { 535 | t.Error("_function field should not present in extra fields") 536 | } 537 | 538 | // if reportCaller is disabled (this is the default setting) the File and Line field should have the default values 539 | // corresponding to the types. "" and 0 respectively. 540 | gelfFileExpected := "" 541 | if msg.File != gelfFileExpected { 542 | t.Errorf("msg.File: expected %s, got %s", gelfFileExpected, msg.File) 543 | } 544 | 545 | gelfLineExpected := 0 546 | if msg.Line != gelfLineExpected { 547 | t.Errorf("msg.Line: expected %d, got %d", gelfLineExpected, msg.Line) 548 | } 549 | } 550 | 551 | func TestHTTPWriter(t *testing.T) { 552 | var gelf map[string]interface{} 553 | 554 | server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 555 | // Test request parameters 556 | all, err := ioutil.ReadAll(req.Body) 557 | if err != nil { 558 | t.Fatal("Unable to read response body") 559 | } 560 | 561 | err = json.Unmarshal(all, &gelf) 562 | if err != nil { 563 | t.Fatal("Unable to unmarshal json") 564 | } 565 | 566 | if gelf["host"] != "testing.local" { 567 | t.Errorf("host: expected %s, got %s", "testing.local", gelf["host"]) 568 | } 569 | 570 | rw.WriteHeader(204) 571 | })) 572 | // Close the server when test finishes 573 | defer server.Close() 574 | 575 | hook := NewGraylogHook(server.URL, map[string]interface{}{}) 576 | hook.Host = "testing.local" 577 | msgData := "test message\nsecond line" 578 | 579 | log := logrus.New() 580 | log.SetReportCaller(false) 581 | log.Out = io.Discard 582 | log.Hooks.Add(hook) 583 | log.Info(msgData) 584 | } 585 | --------------------------------------------------------------------------------