diff --git a/ext/loggly/loggly.go b/ext/loggly/loggly.go new file mode 100644 index 0000000..7ff5fbb --- /dev/null +++ b/ext/loggly/loggly.go @@ -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.endpointSingle, 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 +} diff --git a/ext/loggly/response.go b/ext/loggly/response.go new file mode 100644 index 0000000..b4af4d8 --- /dev/null +++ b/ext/loggly/response.go @@ -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"` +} diff --git a/ext/loggly/response_ffjson.go b/ext/loggly/response_ffjson.go new file mode 100644 index 0000000..1345708 --- /dev/null +++ b/ext/loggly/response_ffjson.go @@ -0,0 +1,190 @@ +// DO NOT EDIT! +// Code generated by ffjson +// source: response.go +// DO NOT EDIT! + +package loggly + +import ( + "bytes" + "fmt" + fflib "github.com/pquerna/ffjson/fflib/v1" +) + +func (mj *logglyResponse) MarshalJSON() ([]byte, error) { + var buf fflib.Buffer + err := mj.MarshalJSONBuf(&buf) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} +func (mj *logglyResponse) MarshalJSONBuf(buf fflib.EncodingBuffer) error { + var err error + var obj []byte + _ = obj + _ = err + buf.WriteString(`{"response":`) + fflib.WriteJsonString(buf, string(mj.Response)) + buf.WriteByte('}') + return nil +} + +const ( + ffj_t_logglyResponsebase = iota + ffj_t_logglyResponseno_such_key + + ffj_t_logglyResponse_Response +) + +var ffj_key_logglyResponse_Response = []byte("response") + +func (uj *logglyResponse) UnmarshalJSON(input []byte) error { + fs := fflib.NewFFLexer(input) + return uj.UnmarshalJSONFFLexer(fs, fflib.FFParse_map_start) +} + +func (uj *logglyResponse) UnmarshalJSONFFLexer(fs *fflib.FFLexer, state fflib.FFParseState) error { + var err error = nil + currentKey := ffj_t_logglyResponsebase + _ = currentKey + tok := fflib.FFTok_init + wantedTok := fflib.FFTok_init + +mainparse: + for { + tok = fs.Scan() + // println(fmt.Sprintf("debug: tok: %v state: %v", tok, state)) + if tok == fflib.FFTok_error { + goto tokerror + } + + switch state { + + case fflib.FFParse_map_start: + if tok != fflib.FFTok_left_bracket { + wantedTok = fflib.FFTok_left_bracket + goto wrongtokenerror + } + state = fflib.FFParse_want_key + continue + + case fflib.FFParse_after_value: + if tok == fflib.FFTok_comma { + state = fflib.FFParse_want_key + } else if tok == fflib.FFTok_right_bracket { + goto done + } else { + wantedTok = fflib.FFTok_comma + goto wrongtokenerror + } + + case fflib.FFParse_want_key: + // json {} ended. goto exit. woo. + if tok == fflib.FFTok_right_bracket { + goto done + } + if tok != fflib.FFTok_string { + wantedTok = fflib.FFTok_string + goto wrongtokenerror + } + + kn := fs.Output.Bytes() + if len(kn) <= 0 { + // "" case. hrm. + currentKey = ffj_t_logglyResponseno_such_key + state = fflib.FFParse_want_colon + goto mainparse + } else { + switch kn[0] { + + case 'r': + + if bytes.Equal(ffj_key_logglyResponse_Response, kn) { + currentKey = ffj_t_logglyResponse_Response + state = fflib.FFParse_want_colon + goto mainparse + } + + } + + if fflib.EqualFoldRight(ffj_key_logglyResponse_Response, kn) { + currentKey = ffj_t_logglyResponse_Response + state = fflib.FFParse_want_colon + goto mainparse + } + + currentKey = ffj_t_logglyResponseno_such_key + state = fflib.FFParse_want_colon + goto mainparse + } + + case fflib.FFParse_want_colon: + if tok != fflib.FFTok_colon { + wantedTok = fflib.FFTok_colon + goto wrongtokenerror + } + state = fflib.FFParse_want_value + continue + case fflib.FFParse_want_value: + + if tok == fflib.FFTok_left_brace || tok == fflib.FFTok_left_bracket || tok == fflib.FFTok_integer || tok == fflib.FFTok_double || tok == fflib.FFTok_string || tok == fflib.FFTok_bool || tok == fflib.FFTok_null { + switch currentKey { + + case ffj_t_logglyResponse_Response: + goto handle_Response + + case ffj_t_logglyResponseno_such_key: + err = fs.SkipField(tok) + if err != nil { + return fs.WrapErr(err) + } + state = fflib.FFParse_after_value + goto mainparse + } + } else { + goto wantedvalue + } + } + } + +handle_Response: + + /* handler: uj.Response type=string kind=string */ + + { + + { + if tok != fflib.FFTok_string && tok != fflib.FFTok_null { + return fs.WrapErr(fmt.Errorf("cannot unmarshal %s into Go value for string", tok)) + } + } + + if tok == fflib.FFTok_null { + + } else { + + uj.Response = string(fs.Output.String()) + + } + } + + state = fflib.FFParse_after_value + goto mainparse + +wantedvalue: + return fs.WrapErr(fmt.Errorf("wanted value token, but got token: %v", tok)) +wrongtokenerror: + return fs.WrapErr(fmt.Errorf("ffjson: wanted token: %v, but got token: %v output=%s", wantedTok, tok, fs.Output.String())) +tokerror: + if fs.BigError != nil { + return fs.WrapErr(fs.BigError) + } + err = fs.Error.ToError() + if err != nil { + return fs.WrapErr(err) + } + panic("ffjson-generated: unreachable, please report bug.") +done: + return nil +}