diff --git a/internal/log/handler.go b/internal/log/handler.go new file mode 100644 index 000000000..6a12de114 --- /dev/null +++ b/internal/log/handler.go @@ -0,0 +1,116 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "context" + "fmt" + "io" + "log/slog" + "sync" + "time" +) + +// ValueTextHandler is a [Handler] that writes Records to an [io.Writer] with values separated by spaces. +type ValueTextHandler struct { + h slog.Handler + mu *sync.Mutex + out io.Writer +} + +// NewValueTextHandler creates a [ValueTextHandler] that writes to out, using the given options. +func NewValueTextHandler(out io.Writer, opts *slog.HandlerOptions) *ValueTextHandler { + if opts == nil { + opts = &slog.HandlerOptions{} + } + return &ValueTextHandler{ + out: out, + h: slog.NewTextHandler(out, &slog.HandlerOptions{ + Level: opts.Level, + AddSource: opts.AddSource, + ReplaceAttr: nil, + }), + mu: &sync.Mutex{}, + } +} + +func (h *ValueTextHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.h.Enabled(ctx, level) +} + +func (h *ValueTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &ValueTextHandler{h: h.h.WithAttrs(attrs), out: h.out, mu: h.mu} +} + +func (h *ValueTextHandler) WithGroup(name string) slog.Handler { + return &ValueTextHandler{h: h.h.WithGroup(name), out: h.out, mu: h.mu} +} + +// Handle formats its argument [Record] as a single line of space-separated values. +// Example output format: 2024-11-12T15:08:11.451377-08:00 INFO "Initalized 0 sources.\n" +func (h *ValueTextHandler) Handle(ctx context.Context, r slog.Record) error { + buf := make([]byte, 0, 1024) + + // time + if !r.Time.IsZero() { + buf = h.appendAttr(buf, slog.Time(slog.TimeKey, r.Time)) + } + // level + buf = h.appendAttr(buf, slog.Any(slog.LevelKey, r.Level)) + // message + buf = h.appendAttr(buf, slog.String(slog.MessageKey, r.Message)) + + r.Attrs(func(a slog.Attr) bool { + buf = h.appendAttr(buf, a) + return true + }) + buf = append(buf, "\n"...) + + h.mu.Lock() + defer h.mu.Unlock() + _, err := h.out.Write(buf) + return err +} + +// appendAttr is reponsible for formatting a single attribute +func (h *ValueTextHandler) appendAttr(buf []byte, a slog.Attr) []byte { + // Resolve the Attr's value before doing anything else. + a.Value = a.Value.Resolve() + // Ignore empty Attrs. + if a.Equal(slog.Attr{}) { + return buf + } + switch a.Value.Kind() { + case slog.KindString: + // Quote string values, to make them easy to parse. + buf = fmt.Appendf(buf, "%q ", a.Value.String()) + case slog.KindTime: + // Write times in a standard way, without the monotonic time. + buf = fmt.Appendf(buf, "%s ", a.Value.Time().Format(time.RFC3339Nano)) + case slog.KindGroup: + attrs := a.Value.Group() + // Ignore empty groups. + if len(attrs) == 0 { + return buf + } + for _, ga := range attrs { + buf = h.appendAttr(buf, ga) + } + default: + buf = fmt.Appendf(buf, "%s ", a.Value) + } + + return buf +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 000000000..ddb36383f --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,91 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "fmt" + "io" + "log/slog" + "strings" + + "github.com/googleapis/genai-toolbox/toolbox" +) + +// StdLogger is the standard logger +type StdLogger struct { + outLogger *slog.Logger + errLogger *slog.Logger +} + +// NewStdLogger create a Logger that uses out and err for informational and error messages. +func NewStdLogger(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) + + handlerOptions := &slog.HandlerOptions{Level: programLevel} + + return &StdLogger{ + outLogger: slog.New(NewValueTextHandler(outW, handlerOptions)), + errLogger: slog.New(NewValueTextHandler(errW, handlerOptions)), + }, nil +} + +// Debug logs debug messages +func (sl *StdLogger) Debug(msg string, keysAndValues ...interface{}) { + sl.outLogger.Debug(msg, keysAndValues...) +} + +// Info logs debug messages +func (sl *StdLogger) Info(msg string, keysAndValues ...interface{}) { + sl.outLogger.Info(msg, keysAndValues...) +} + +// Warn logs warning messages +func (sl *StdLogger) Warn(msg string, keysAndValues ...interface{}) { + sl.errLogger.Warn(msg, keysAndValues...) +} + +// Error logs error messages +func (sl *StdLogger) Error(msg string, keysAndValues ...interface{}) { + sl.errLogger.Error(msg, keysAndValues...) +} + +const ( + Debug = "DEBUG" + Info = "INFO" + Warn = "WARN" + Error = "ERROR" +) + +// Returns severity level based on string. +func severityToLevel(s string) (slog.Level, error) { + switch strings.ToUpper(s) { + case Debug: + return slog.LevelDebug, nil + case Info: + return slog.LevelInfo, nil + case Warn: + return slog.LevelWarn, nil + case Error: + return slog.LevelError, nil + default: + return slog.Level(-5), fmt.Errorf("invalid log level") + } +} diff --git a/internal/log/log_test.go b/internal/log/log_test.go new file mode 100644 index 000000000..44f83643d --- /dev/null +++ b/internal/log/log_test.go @@ -0,0 +1,236 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/toolbox" +) + +func TestSeverityToLevel(t *testing.T) { + tcs := []struct { + name string + in string + want slog.Level + }{ + { + name: "test debug", + in: "Debug", + want: slog.LevelDebug, + }, + { + name: "test info", + in: "Info", + want: slog.LevelInfo, + }, + { + name: "test warn", + in: "Warn", + want: slog.LevelWarn, + }, + { + name: "test error", + in: "Error", + want: slog.LevelError, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + got, err := severityToLevel(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 TestSeverityToLevelError(t *testing.T) { + _, err := severityToLevel("fail") + if err == nil { + t.Fatalf("expected error on incorrect level") + } +} + +func runLogger(logger toolbox.Logger, logMsg string) { + switch logMsg { + case "info": + logger.Info("log info") + case "debug": + logger.Debug("log debug") + case "warn": + logger.Warn("log warn") + case "error": + logger.Error("log error") + } +} + +func TestStdLogger(t *testing.T) { + tcs := []struct { + name string + logLevel string + logMsg string + wantOut string + wantErr string + }{ + { + name: "debug logger logging debug", + logLevel: "debug", + logMsg: "debug", + wantOut: "DEBUG \"log debug\" \n", + wantErr: "", + }, + { + name: "info logger logging debug", + logLevel: "info", + logMsg: "debug", + wantOut: "", + wantErr: "", + }, + { + name: "warn logger logging debug", + logLevel: "warn", + logMsg: "debug", + wantOut: "", + wantErr: "", + }, + { + name: "error logger logging debug", + logLevel: "error", + logMsg: "debug", + wantOut: "", + wantErr: "", + }, + { + name: "debug logger logging info", + logLevel: "debug", + logMsg: "info", + wantOut: "INFO \"log info\" \n", + wantErr: "", + }, + { + name: "info logger logging info", + logLevel: "info", + logMsg: "info", + wantOut: "INFO \"log info\" \n", + wantErr: "", + }, + { + name: "warn logger logging info", + logLevel: "warn", + logMsg: "info", + wantOut: "", + wantErr: "", + }, + { + name: "error logger logging info", + logLevel: "error", + logMsg: "info", + wantOut: "", + wantErr: "", + }, + { + name: "debug logger logging warn", + logLevel: "debug", + logMsg: "warn", + wantOut: "", + wantErr: "WARN \"log warn\" \n", + }, + { + name: "info logger logging warn", + logLevel: "info", + logMsg: "warn", + wantOut: "", + wantErr: "WARN \"log warn\" \n", + }, + { + name: "warn logger logging warn", + logLevel: "warn", + logMsg: "warn", + wantOut: "", + wantErr: "WARN \"log warn\" \n", + }, + { + name: "error logger logging warn", + logLevel: "error", + logMsg: "warn", + wantOut: "", + wantErr: "", + }, + { + name: "debug logger logging error", + logLevel: "debug", + logMsg: "error", + wantOut: "", + wantErr: "ERROR \"log error\" \n", + }, + { + name: "info logger logging error", + logLevel: "info", + logMsg: "error", + wantOut: "", + wantErr: "ERROR \"log error\" \n", + }, + { + name: "warn logger logging error", + logLevel: "warn", + logMsg: "error", + wantOut: "", + wantErr: "ERROR \"log error\" \n", + }, + { + name: "error logger logging error", + logLevel: "error", + logMsg: "error", + wantOut: "", + wantErr: "ERROR \"log error\" \n", + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + outW := new(bytes.Buffer) + errW := new(bytes.Buffer) + + logger, err := NewStdLogger(outW, errW, tc.logLevel) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + runLogger(logger, tc.logMsg) + + outWString := outW.String() + spaceIndexOut := strings.Index(outWString, " ") + gotOut := outWString[spaceIndexOut+1:] + + errWString := errW.String() + spaceIndexErr := strings.Index(errWString, " ") + gotErr := errWString[spaceIndexErr+1:] + + if diff := cmp.Diff(gotOut, tc.wantOut); diff != "" { + t.Fatalf("incorrect log: diff %v", diff) + } + if diff := cmp.Diff(gotErr, tc.wantErr); diff != "" { + t.Fatalf("incorrect log: diff %v", diff) + } + }) + } +} diff --git a/toolbox/toolbox.go b/toolbox/toolbox.go new file mode 100644 index 000000000..8e551b125 --- /dev/null +++ b/toolbox/toolbox.go @@ -0,0 +1,27 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package toolbox + +// Logger is the interface used throughout the project for logging. +type Logger interface { + // Debug is for reporting additional information about internal operations. + Debug(format string, args ...interface{}) + // Info is for reporting informational messages. + Info(format string, args ...interface{}) + // Warn is for reporting warning messages. + Warn(format string, args ...interface{}) + // Error is for reporting errors. + Error(format string, args ...interface{}) +}