diff --git a/internal/log/log.go b/internal/log/log.go index ddb36383f..aedd4a87e 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -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...) +} \ No newline at end of file diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 44f83643d..c5aae623a 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -16,6 +16,7 @@ package log import ( "bytes" + "encoding/json" "log/slog" "strings" "testing" @@ -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": @@ -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(), "") + } + } + }) + } +} \ No newline at end of file