Skip to content

Commit

Permalink
feat: add structured logger (#96)
Browse files Browse the repository at this point in the history
Logging support 4 different types of logging (debug, info, warn, error).

Example of structured logger:

```
{
  "timestamp":"2024-11-04T16:45:11.987299-08:00",
  "severity":"ERROR",
  "logging.googleapis.com/sourceLocation":{
    "function":"github.com/googleapis/genai-toolbox/internal/log.(*StructuredLogger).Errorf",
    "file":"/Users/yuanteoh/github/genai-toolbox/internal/log/log.go","line":157
  },
  "message":"unable to parse tool file at \"tools.yaml\": \"cloud-sql-postgres1\" is not a valid kind of data source"
}
```

```
{
  "timestamp":"2024-11-04T16:45:11.987562-08:00",
  "severity":"INFO",
  "logging.googleapis.com/sourceLocation":{
    "function":"github.com/googleapis/genai-toolbox/internal/log.(*StructuredLogger).Infof",
    "file":"/Users/yuanteoh/github/genai-toolbox/internal/log/log.go","line":147
  },
  "message":"Initalized 0 sources.\n"
}
```
  • Loading branch information
Yuan325 authored Nov 26, 2024
1 parent 6a8feb5 commit 5e20417
Show file tree
Hide file tree
Showing 2 changed files with 343 additions and 0 deletions.
95 changes: 95 additions & 0 deletions internal/log/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,98 @@ func severityToLevel(s string) (slog.Level, error) {
return slog.Level(-5), fmt.Errorf("invalid log level")
}
}

// Returns severity string based on level.
func levelToSeverity(s string) (string, error) {
switch s {
case slog.LevelDebug.String():
return Debug, nil
case slog.LevelInfo.String():
return Info, nil
case slog.LevelWarn.String():
return Warn, nil
case slog.LevelError.String():
return Error, nil
default:
return "", fmt.Errorf("invalid slog level")
}
}

type StructuredLogger struct {
outLogger *slog.Logger
errLogger *slog.Logger
}

// NewStructuredLogger create a Logger that logs messages using JSON.
func NewStructuredLogger(outW, errW io.Writer, logLevel string) (toolbox.Logger, error) {
//Set log level
var programLevel = new(slog.LevelVar)
slogLevel, err := severityToLevel(logLevel)
if err != nil {
return nil, err
}
programLevel.Set(slogLevel)

replace := func(groups []string, a slog.Attr) slog.Attr {
switch a.Key {
case slog.LevelKey:
value := a.Value.String()
sev, _ := levelToSeverity(value)
return slog.Attr{
Key: "severity",
Value: slog.StringValue(sev),
}
case slog.MessageKey:
return slog.Attr{
Key: "message",
Value: a.Value,
}
case slog.SourceKey:
return slog.Attr{
Key: "logging.googleapis.com/sourceLocation",
Value: a.Value,
}
case slog.TimeKey:
return slog.Attr{
Key: "timestamp",
Value: a.Value,
}
}
return a
}

// Configure structured logs to adhere to Cloud LogEntry format
// https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry
outHandler := slog.NewJSONHandler(outW, &slog.HandlerOptions{
AddSource: true,
Level: programLevel,
ReplaceAttr: replace,
})
errHandler := slog.NewJSONHandler(errW, &slog.HandlerOptions{
AddSource: true,
Level: programLevel,
ReplaceAttr: replace,
})

return &StructuredLogger{outLogger: slog.New(outHandler), errLogger: slog.New(errHandler)}, nil
}

// Debug logs debug messages
func (sl *StructuredLogger) Debug(msg string, keysAndValues ...interface{}) {
sl.outLogger.Debug(msg, keysAndValues...)
}

// Info logs info messages
func (sl *StructuredLogger) Info(msg string, keysAndValues ...interface{}) {
sl.outLogger.Info(msg, keysAndValues...)
}

// Warn logs warning messages
func (sl *StructuredLogger) Warn(msg string, keysAndValues ...interface{}) {
sl.errLogger.Warn(msg, keysAndValues...)
}

// Error logs error messages
func (sl *StructuredLogger) Error(msg string, keysAndValues ...interface{}) {
sl.errLogger.Error(msg, keysAndValues...)
}
248 changes: 248 additions & 0 deletions internal/log/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package log

import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
Expand Down Expand Up @@ -72,6 +73,54 @@ func TestSeverityToLevelError(t *testing.T) {
}
}

func TestLevelToSeverity(t *testing.T) {
tcs := []struct {
name string
in string
want string
}{
{
name: "test debug",
in: slog.LevelDebug.String(),
want: "DEBUG",
},
{
name: "test info",
in: slog.LevelInfo.String(),
want: "INFO",
},
{
name: "test warn",
in: slog.LevelWarn.String(),
want: "WARN",
},
{
name: "test error",
in: slog.LevelError.String(),
want: "ERROR",
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
got, err := levelToSeverity(tc.in)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got != tc.want {
t.Fatalf("incorrect level to severity: got %v, want %v", got, tc.want)
}

})
}
}

func TestLevelToSeverityError(t *testing.T) {
_, err := levelToSeverity("fail")
if err == nil {
t.Fatalf("expected error on incorrect slog level")
}
}

func runLogger(logger toolbox.Logger, logMsg string) {
switch logMsg {
case "info":
Expand Down Expand Up @@ -234,3 +283,202 @@ func TestStdLogger(t *testing.T) {
})
}
}

func TestStructuredLoggerDebugLog(t *testing.T) {
tcs := []struct {
name string
logLevel string
logMsg string
wantOut map[string]string
wantErr map[string]string
}{
{
name: "debug logger logging debug",
logLevel: "debug",
logMsg: "debug",
wantOut: map[string]string{
"severity": "DEBUG",
"message": "log debug",
},
wantErr: map[string]string{},
},
{
name: "info logger logging debug",
logLevel: "info",
logMsg: "debug",
wantOut: map[string]string{},
wantErr: map[string]string{},
},
{
name: "warn logger logging debug",
logLevel: "warn",
logMsg: "debug",
wantOut: map[string]string{},
wantErr: map[string]string{},
},
{
name: "error logger logging debug",
logLevel: "error",
logMsg: "debug",
wantOut: map[string]string{},
wantErr: map[string]string{},
},
{
name: "debug logger logging info",
logLevel: "debug",
logMsg: "info",
wantOut: map[string]string{
"severity": "INFO",
"message": "log info",
},
wantErr: map[string]string{},
},
{
name: "info logger logging info",
logLevel: "info",
logMsg: "info",
wantOut: map[string]string{
"severity": "INFO",
"message": "log info",
},
wantErr: map[string]string{},
},
{
name: "warn logger logging info",
logLevel: "warn",
logMsg: "info",
wantOut: map[string]string{},
wantErr: map[string]string{},
},
{
name: "error logger logging info",
logLevel: "error",
logMsg: "info",
wantOut: map[string]string{},
wantErr: map[string]string{},
},
{
name: "debug logger logging warn",
logLevel: "debug",
logMsg: "warn",
wantOut: map[string]string{},
wantErr: map[string]string{
"severity": "WARN",
"message": "log warn",
},
},
{
name: "info logger logging warn",
logLevel: "info",
logMsg: "warn",
wantOut: map[string]string{},
wantErr: map[string]string{
"severity": "WARN",
"message": "log warn",
},
},
{
name: "warn logger logging warn",
logLevel: "warn",
logMsg: "warn",
wantOut: map[string]string{},
wantErr: map[string]string{
"severity": "WARN",
"message": "log warn",
},
},
{
name: "error logger logging warn",
logLevel: "error",
logMsg: "warn",
wantOut: map[string]string{},
wantErr: map[string]string{},
},
{
name: "debug logger logging error",
logLevel: "debug",
logMsg: "error",
wantOut: map[string]string{},
wantErr: map[string]string{
"severity": "ERROR",
"message": "log error",
},
},
{
name: "info logger logging error",
logLevel: "info",
logMsg: "error",
wantOut: map[string]string{},
wantErr: map[string]string{
"severity": "ERROR",
"message": "log error",
},
},
{
name: "warn logger logging error",
logLevel: "warn",
logMsg: "error",
wantOut: map[string]string{},
wantErr: map[string]string{
"severity": "ERROR",
"message": "log error",
},
},
{
name: "error logger logging error",
logLevel: "error",
logMsg: "error",
wantOut: map[string]string{},
wantErr: map[string]string{
"severity": "ERROR",
"message": "log error",
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
outW := new(bytes.Buffer)
errW := new(bytes.Buffer)

logger, err := NewStructuredLogger(outW, errW, tc.logLevel)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
runLogger(logger, tc.logMsg)

if len(tc.wantOut) != 0 {
got := make(map[string]interface{})

if err := json.Unmarshal(outW.Bytes(), &got); err != nil {
t.Fatalf("failed to parse writer")
}

if got["severity"] != tc.wantOut["severity"] {
t.Fatalf("incorrect severity: got %v, want %v", got["severity"], tc.wantOut["severity"])
}

} else {
if outW.String() != "" {
t.Fatalf("incorrect log. got %v, want %v", outW.String(), "")
}
}

if len(tc.wantErr) != 0 {
got := make(map[string]interface{})

if err := json.Unmarshal(errW.Bytes(), &got); err != nil {
t.Fatalf("failed to parse writer")
}

if got["severity"] != tc.wantErr["severity"] {
t.Fatalf("incorrect severity: got %v, want %v", got["severity"], tc.wantErr["severity"])
}

} else {
if errW.String() != "" {
t.Fatalf("incorrect log. got %v, want %v", errW.String(), "")
}
}
})
}
}

0 comments on commit 5e20417

Please sign in to comment.