This commit is contained in:
Jeffrey Paul 2024-05-13 21:43:47 -07:00
commit f21c916eb3
15 changed files with 437 additions and 0 deletions

14
Makefile Normal file
View File

@ -0,0 +1,14 @@
.PHONY: test
default: test
test:
@go test -v ./...
fmt:
goimports -l -w .
golangci-lint run --fix
lint:
golangci-lint run
sh -c 'test -z "$$(gofmt -l .)"'

50
README.md Normal file
View File

@ -0,0 +1,50 @@
# simplelog
simplelog is an opinionated logging package designed to facilitate easy and
structured logging in Go applications with an absolute minimum of
boilerplate.
The idea is that you can add a single import line which replaces the
stdlib `log/slog` default handler, and solve the 90% case for logging.
## Features
* if output is a tty, outputs pretty color logs
* if output is not a tty, outputs json
* supports delivering logs via tcp RELP (e.g. to remote rsyslog using imrelp)
* supports delivering each log message via a webhook
## Installation
To use simplelog, first ensure your project is set up with Go modules:
```bash
go mod init your_project_name
```
Then, add SimpleLog to your project:
```bash
go get git.eeqj.de/sneak/go-simplelog
```
## Usage
Below is an example of how to use SimpleLog in a Go application. This example is provided in the form of a `main.go` file, which demonstrates logging at various levels using structured logging syntax.
```go
package main
import (
"log/slog"
_ "git.eeqj.de/sneak/go-simplelog"
)
func main() {
// log structured data with slog as usual:
slog.Info("User login attempt", slog.String("user", "JohnDoe"), slog.Int("attempt", 3))
slog.Warn("Configuration mismatch", slog.String("expected", "config.json"), slog.String("found", "config.dev.json"))
slog.Error("Failed to save data", slog.String("reason", "permission denied"))
}
```

5
TODO Normal file
View File

@ -0,0 +1,5 @@
# TODO
* fix relp output to cache
* add regex for filtering webhook logs
* better console output format

16
console_handler.go Normal file
View File

@ -0,0 +1,16 @@
package simplelog
import (
"github.com/fatih/color"
)
type ConsoleHandler struct{}
func NewConsoleHandler() *ConsoleHandler {
return &ConsoleHandler{}
}
func (c *ConsoleHandler) Log(event Event) error {
color.New(color.FgBlue).PrintfFunc()("%s: %s\n", event.Level, event.Message)
return nil
}

41
custom_logger.go Normal file
View File

@ -0,0 +1,41 @@
package simplelog
import (
"log"
"os"
"github.com/mattn/go-isatty"
)
var (
relpServerURL = os.Getenv("LOGGER_RELP_URL")
webhookURL = os.Getenv("LOGGER_WEBHOOK_URL")
)
type CustomLogger struct {
handlers []Handler
}
func NewCustomLogger() *CustomLogger {
cl := &CustomLogger{}
if isatty.IsTerminal(os.Stdout.Fd()) {
cl.handlers = append(cl.handlers, NewConsoleHandler())
} else {
cl.handlers = append(cl.handlers, NewJSONHandler())
}
if relpServerURL != "" {
handler, err := NewRELPHandler(relpServerURL)
if err != nil {
log.Fatalf("Failed to initialize RELP handler: %v", err)
}
cl.handlers = append(cl.handlers, handler)
}
if webhookURL != "" {
handler, err := NewWebhookHandler(webhookURL)
if err != nil {
log.Fatalf("Failed to initialize Webhook handler: %v", err)
}
cl.handlers = append(cl.handlers, handler)
}
return cl
}

26
event.go Normal file
View File

@ -0,0 +1,26 @@
package simplelog
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type Event struct {
ID uuid.UUID `json:"id"`
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func NewEvent(level, message string, data json.RawMessage) Event {
return Event{
ID: uuid.New(),
Timestamp: time.Now().UTC(),
Level: level,
Message: message,
Data: data,
}
}

14
go.mod Normal file
View File

@ -0,0 +1,14 @@
module git.eeqj.de/sneak/go-simplelog
go 1.22.1
require (
github.com/fatih/color v1.17.0
github.com/google/uuid v1.6.0
github.com/mattn/go-isatty v0.0.20
)
require (
github.com/mattn/go-colorable v0.1.13 // indirect
golang.org/x/sys v0.18.0 // indirect
)

13
go.sum Normal file
View File

@ -0,0 +1,13 @@
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

6
handler.go Normal file
View File

@ -0,0 +1,6 @@
package simplelog
// Handler defines the interface for different log outputs.
type Handler interface {
Log(event Event) error
}

18
json_handler.go Normal file
View File

@ -0,0 +1,18 @@
package simplelog
import (
"encoding/json"
"log"
)
type JSONHandler struct{}
func NewJSONHandler() *JSONHandler {
return &JSONHandler{}
}
func (j *JSONHandler) Log(event Event) error {
jsonData, _ := json.Marshal(event)
log.Println(string(jsonData))
return nil
}

169
relp_handler.go Normal file
View File

@ -0,0 +1,169 @@
package simplelog
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net"
"net/url"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
)
const (
cacheDir = "/var/cache/relploggercache"
diskBufferLimit = 100 // Write to disk after accumulating 100 failed events
diskWriteInterval = time.Second // Or write every second, whichever comes first
)
type RELPHandler struct {
relpServerURL string
conn net.Conn
ch chan Event
done chan struct{}
failedCh chan Event
timer *time.Timer
}
func NewRELPHandler(relpURL string) (*RELPHandler, error) {
parsedURL, err := url.Parse(relpURL)
if err != nil {
return nil, fmt.Errorf("Error parsing RELP URL: %v", err)
}
if parsedURL.Scheme != "tcp" {
return nil, fmt.Errorf("RELP URL must have the tcp scheme, got %s", parsedURL.Scheme)
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return nil, fmt.Errorf("Failed to create cache directory: %v", err)
}
return &RELPHandler{
relpServerURL: parsedURL.Host,
ch: make(chan Event, diskBufferLimit),
done: make(chan struct{}),
failedCh: make(chan Event, diskBufferLimit),
timer: time.NewTimer(diskWriteInterval),
}, nil
}
func (r *RELPHandler) Startup() error {
var err error
r.conn, err = net.Dial("tcp", r.relpServerURL)
if err != nil {
return fmt.Errorf("Failed to establish TCP connection: %v", err)
}
go r.receiveEventsFromChannel()
go r.processFailedEvents()
go r.watchDirectoryForFailedEventFiles()
return nil
}
func (r *RELPHandler) Log(event Event) error {
select {
case r.ch <- event:
return nil // Successfully sent event to channel
default:
return fmt.Errorf("failed to log event: channel is full")
}
}
func (r *RELPHandler) receiveEventsFromChannel() {
for {
select {
case event := <-r.ch:
if err := r.sendEventToRELPServer(event); err != nil {
r.failedCh <- event
}
case <-r.done:
return
}
}
}
func (r *RELPHandler) processFailedEvents() {
for {
select {
case event := <-r.failedCh:
if err := r.sendEventToRELPServer(event); err != nil {
// If still failing, write to disk
r.writeFailedToDisk(event)
}
case <-r.timer.C:
// Flush all events in failedCh to disk
for len(r.failedCh) > 0 {
event := <-r.failedCh
r.writeFailedToDisk(event)
}
case <-r.done:
return
}
}
}
func (r *RELPHandler) sendEventToRELPServer(event Event) error {
jsonData, _ := json.Marshal(event)
_, err := r.conn.Write(jsonData)
if err != nil {
return err
}
// Implement TCP read for the acknowledgment with a timeout
r.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
ack := make([]byte, 256)
n, err := r.conn.Read(ack)
if err != nil || string(ack[:n]) != "ACK" {
return err
}
return nil
}
func (r *RELPHandler) watchDirectoryForFailedEventFiles() {
for {
select {
case <-r.done:
return
default:
if r.isConnected() {
files, err := ioutil.ReadDir(cacheDir)
if err != nil {
log.Printf("Error reading cache directory: %v", err)
continue
}
for _, file := range files {
filePath := filepath.Join(cacheDir, file.Name())
data, err := ioutil.ReadFile(filePath)
if err != nil {
log.Printf("Error reading file %s: %v", file.Name(), err)
continue
}
var event Event
if err := json.Unmarshal(data, &event); err != nil {
log.Printf("Error unmarshalling event from file %s: %v", file.Name(), err)
continue
}
r.ch <- event
os.Remove(filePath) // Remove file after processing
}
}
time.Sleep(10 * time.Second) // Check disk every 10 seconds
}
}
}
func (r *RELPHandler) isConnected() bool {
_, err := r.conn.Write([]byte{})
return err == nil
}
func (r *RELPHandler) Shutdown() error {
close(r.done)
return r.conn.Close()
}
func (r *RELPHandler) writeFailedToDisk(event Event) {
fileName := filepath.Join(cacheDir, uuid.New().String()+".logevent")
data, _ := json.Marshal(event)
ioutil.WriteFile(fileName, data, 0644)
}

8
simplelog_test.go Normal file
View File

@ -0,0 +1,8 @@
package simplelog
import "testing"
// TestCompile checks if the package compiles successfully.
func TestCompile(t *testing.T) {
// This test ensures that the simplelog package compiles without error.
}

16
tools/relp_log_trial.go Normal file
View File

@ -0,0 +1,16 @@
package main
import (
"log/slog"
_ "git.eeqj.de/sneak/go-simplelog" // Using underscore to only invoke init()
)
func main() {
// Send some test messages with structured data
slog.Info("Starting the application", slog.String("status", "initialized"))
slog.Info("Attempting to connect to database", slog.String("host", "localhost"), slog.Int("port", 5432))
slog.Warn("Using default configuration", slog.String("configuration", "default"))
slog.Error("Failed to load module", slog.String("module", "finance"), slog.String("error", "module not found"))
slog.Info("Shutting down the application", slog.String("status", "stopped"))
}

8
tools/run_relp_log_trial.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
# Set the environment variables for the RELP server URL and optionally for the webhook URL
export LOGGER_RELP_URL="tcp://10.201.1.18:20514"
export LOGGER_WEBHOOK_URL="https://example.com/webhook"
# Run the Go program
go run relp_log_trial.go

33
webhook_handler.go Normal file
View File

@ -0,0 +1,33 @@
package simplelog
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
type WebhookHandler struct {
webhookURL string
}
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
if _, err := url.ParseRequestURI(webhookURL); err != nil {
return nil, fmt.Errorf("invalid webhook URL: %v", err)
}
return &WebhookHandler{webhookURL: webhookURL}, nil
}
func (w *WebhookHandler) Log(event Event) error {
jsonData, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("error marshaling event: %v", err)
}
response, err := http.Post(w.webhookURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return err
}
defer response.Body.Close()
return nil
}