Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add loggly hook. #44

Open
wants to merge 2 commits into
base: v2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions ext/loggly/loggly.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package loggly

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"strconv"
"strings"
"time"

"gopkg.in/inconshreveable/log15.v2"
)

// LogglyHandler sends logs to Loggly.
// LogglyHandler should be created by NewLogglyHandler.
// Exported fields can be modified during setup, but should not be touched when the Handler is in use.
// LogglyHandler implements log15.Handler
type LogglyHandler struct {
// Client can be modified or replaced with a custom http.Client
Client *http.Client

// Defaults contains key/value items that are added to every log message.
// Extra values can be added during the log15 setup.
//
// NewLogglyHandler adds a single record: "hostname", with the return value from os.Hostname().
// When os.Hostname() returns with an error, the key "hostname" is not set and this map will be empty.
Defaults map[string]interface{}

// Tags are sent to loggly with the log.
Tags []string

// Endpoint is set to the https URI where logs are sent
Endpoint string
}

// NewLogglyHandler creates a new LogglyHandler instance
// Exported field on the LogglyHandler can modified before it is being used.
func NewLogglyHandler(token string) *LogglyHandler {
lh := &LogglyHandler{
Endpoint: `https://logs-01.loggly.com/inputs/` + token,

Client: &http.Client{},

Defaults: make(map[string]interface{}),
}

// if hostname is retrievable, set it as extra field
if hostname, err := os.Hostname(); err == nil {
lh.Defaults["hostname"] = hostname
}

return lh
}

// Log sends the given *log15.Record to loggly.
// Standard fields are:
// - message, the record's message.
// - level, the record's level as string.
// - timestamp, the record's timestamp in UTC timezone truncated to microseconds.
// - context, (optional) the context fields from the record.
// Extra fields are the configurable with the LogglyHandler.Defaults map
// By default this contains:
// - hostname, the system hostname
func (lh *LogglyHandler) Log(r *log15.Record) error {
// create message structure
msg := lh.createMessage(r)

// send message
err := lh.sendSingle(msg)
if err != nil {
return err
}

return nil
}

// createMessage takes a log15.Record and returns a loggly message structure
func (lh *LogglyHandler) createMessage(r *log15.Record) map[string]interface{} {
// set standard values
msg := map[string]interface{}{
"message": r.Msg,
"level": r.Lvl.String(),
// for loggly we need to truncate the timestamp to microsecond precision and convert it to UTC timezone
"timestamp": r.Time.Truncate(time.Microsecond).In(time.UTC),
}

// apply defaults
for key, value := range lh.Defaults {
msg[key] = value
}

// optionally add context
if len(r.Ctx) > 0 {
context := make(map[string]interface{}, len(r.Ctx)/2)
for i := 0; i < len(r.Ctx); i += 2 {
key := r.Ctx[i]
value := r.Ctx[i+1]
keyStr, ok := key.(string)
if !ok {
keyStr = fmt.Sprintf("%v", key)
}
context[keyStr] = value
}
msg["context"] = context
}

// got a nice message to deliver
return msg
}

// sendSingle sends a single loggly structure to their http endpoint
func (lh *LogglyHandler) sendSingle(msg map[string]interface{}) error {
// encode the message to json
postBuffer := &bytes.Buffer{}
err := json.NewEncoder(postBuffer).Encode(msg)
if err != nil {
return err
}

// create request
req, err := http.NewRequest("POST", lh.Endpoint, postBuffer)
req.Header.Add("User-Agent", "log15")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(postBuffer.Len()))

// apply tags
if len(lh.Tags) > 0 {
req.Header.Add("X-Loggly-Tag", strings.Join(lh.Tags, ","))
}

// do request
resp, err := lh.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

// check statuscode
if resp.StatusCode != 200 {
resp, _ := ioutil.ReadAll(resp.Body)
return fmt.Errorf("error: %s", string(resp))
}

// validate response
response := &logglyResponse{}
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return err
}
if response.Response != "ok" {
return errors.New(`loggly response was not "ok"`)
}

// all done
return nil
}
119 changes: 119 additions & 0 deletions ext/loggly/loggly_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package loggly

import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"testing"
"time"
"unsafe"

"gopkg.in/inconshreveable/log15.v2"
)

func TestLogglyHandler(t *testing.T) {

const testPort = `:8123`

type resultStructure struct {
Context map[string]interface{} `json:"context"`
Hostname string `json:"hostname"`
Level string `json:"level"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}

var testCases = []struct {
Level log15.Lvl
Message string
Context log15.Ctx
Tags []string
}{
{log15.LvlInfo, "a test message", log15.Ctx{"foo": "bar"}, []string{"foo", "bar"}},
{log15.LvlWarn, "another test message", log15.Ctx{"foo": "bar", "number": 42}, nil},
}

for i, testCase := range testCases {
// setup a listener that we can close
listener, err := net.Listen("tcp", testPort)
if err != nil {
t.Fatal(err)
}
serverDoneCh := make(chan bool, 1)
server := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
fmt.Printf("%d server got request\n", i)

var fail bool
defer func(serverDoneCh chan bool) {
serverDoneCh <- fail
fmt.Printf("%d request fail is %t and ch pointer is: %x\n", i, fail, uintptr(unsafe.Pointer(&serverDoneCh)))
}(serverDoneCh)

result := &resultStructure{}
err := json.NewDecoder(r.Body).Decode(result)
if err != nil {
t.Log(err)
fail = true
return
}
if result.Message != testCase.Message {
t.Logf("Server got wrong log message. Expected: `%s`. Got: `%s`", testCase.Message, result.Message)
fail = true
}
if result.Level != testCase.Level.String() {
t.Logf("Server got wrong log level. Expected: `%s`. Got: `%s`", testCase.Level.String(), result.Level)
fail = true
}
if testCase.Tags != nil {
expectedTags := strings.Join(testCase.Tags, ",")
gotTags := r.Header.Get("X-Loggly-Tag")
if gotTags != expectedTags {
t.Logf("Server got wrong tags. Expected `%s`. Got: `%s`", expectedTags, gotTags)
fail = true
}
}
}),
}
go func() {
server.Serve(listener)
}()

time.Sleep(1 * time.Second)

logger := log15.New()
logglyHandler := NewLogglyHandler("test-token")
if testCase.Tags != nil {
logglyHandler.Tags = testCase.Tags
}
expectedEndpoint := `https://logs-01.loggly.com/inputs/test-token`
if logglyHandler.Endpoint != expectedEndpoint {
t.Errorf("invalid loggly endpoint. Expected `%s`. Got `%s`", expectedEndpoint, logglyHandler.Endpoint)
return
}
logglyHandler.Endpoint = `http://localhost` + testPort + `/`
logger.SetHandler(logglyHandler)
switch testCase.Level {
case log15.LvlInfo:
logger.Info(testCase.Message, testCase.Context)
case log15.LvlWarn:
logger.Warn(testCase.Message, testCase.Context)
}

// Wait until request has been passed to server
fmt.Printf("%d waiting for response on channel with pointer %x\n", i, uintptr(unsafe.Pointer(&serverDoneCh)))
fail := <-serverDoneCh
fmt.Printf("%d received fail is %t from channel with pointer %x\n", i, fail, uintptr(unsafe.Pointer(&serverDoneCh)))
listener.Close()
if fail {
t.FailNow()
}
time.Sleep(1 * time.Second)
fmt.Printf("%d done\n", i)
}

fmt.Println("am here")
}
9 changes: 9 additions & 0 deletions ext/loggly/response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package loggly

//go:generate ffjson $GOFILE

// logglyResponse defines the json returned by the loggly endpoint.
// The value for Response should be "ok". Unmarshalling is optimized by ffjson.
type logglyResponse struct {
Response string `json:"response"`
}
Loading