diff --git a/logger/options.go b/logger/options.go index 25a15dd..7859e80 100644 --- a/logger/options.go +++ b/logger/options.go @@ -15,6 +15,9 @@ package logger import ( "fmt" + "io" + "os" + "sync" ) const ( @@ -23,6 +26,12 @@ const ( undefinedAppID = "" ) +var ( + // logOutputMu protects logOutputFile from concurrent access. + logOutputMu sync.Mutex + logOutputFile *os.File +) + // Options defines the sets of options for Dapr logging. type Options struct { // appID is the unique id of Dapr Application @@ -33,6 +42,9 @@ type Options struct { // OutputLevel is the level of logging OutputLevel string + + // OutputFile is the destination file path for logs. + OutputFile string } // SetOutputLevel sets the log output level. @@ -62,6 +74,11 @@ func (o *Options) AttachCmdFlags( "log-level", defaultOutputLevel, "Options are debug, info, warn, error, or fatal (default info)") + stringVar( + &o.OutputFile, + "log-file", + "", + "Path to a file where logs will be written") } if boolVar != nil { @@ -79,6 +96,7 @@ func DefaultOptions() Options { JSONFormatEnabled: defaultJSONOutput, appID: undefinedAppID, OutputLevel: defaultOutputLevel, + OutputFile: "", } } @@ -104,5 +122,45 @@ func ApplyOptionsToLoggers(options *Options) error { v.SetOutputLevel(daprLogLevel) } + err := setLogOutput(options.OutputFile, internalLoggers) + if err != nil { + return err + } + + return nil +} + +// setLogOutput configures log output destination. If path is non-empty, logs +// are written to the file at that path. If empty, output reverts to stdout. +// Any previously opened log file is closed before opening a new one. +func setLogOutput(path string, loggers map[string]Logger) error { + logOutputMu.Lock() + defer logOutputMu.Unlock() + + // Close any previously opened log file. + if logOutputFile != nil { + logOutputFile.Close() + logOutputFile = nil + } + + var out io.Writer = os.Stdout + + if path != "" { + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("failed to open log file %q: %w", path, err) + } + + logOutputFile = file + } + + if logOutputFile != nil { + out = logOutputFile + } + + for _, v := range loggers { + v.SetOutput(out) + } + return nil } diff --git a/logger/options_test.go b/logger/options_test.go index e16ff89..ef3823d 100644 --- a/logger/options_test.go +++ b/logger/options_test.go @@ -14,6 +14,8 @@ limitations under the License. package logger import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -26,6 +28,7 @@ func TestOptions(t *testing.T) { assert.Equal(t, defaultJSONOutput, o.JSONFormatEnabled) assert.Equal(t, undefinedAppID, o.appID) assert.Equal(t, defaultOutputLevel, o.OutputLevel) + assert.Empty(t, o.OutputFile) }) t.Run("set dapr ID", func(t *testing.T) { @@ -40,10 +43,15 @@ func TestOptions(t *testing.T) { o := DefaultOptions() logLevelAsserted := false + logFileAsserted := false testStringVarFn := func(p *string, name string, value string, usage string) { if name == "log-level" && value == defaultOutputLevel { logLevelAsserted = true } + + if name == "log-file" && value == "" { + logFileAsserted = true + } } logAsJSONAsserted := false @@ -57,6 +65,7 @@ func TestOptions(t *testing.T) { // assert assert.True(t, logLevelAsserted) + assert.True(t, logFileAsserted) assert.True(t, logAsJSONAsserted) }) } @@ -92,3 +101,72 @@ func TestApplyOptionsToLoggers(t *testing.T) { (l.(*daprLogger)).logger.Logger.GetLevel()) } } + +func TestApplyOptionsToLoggersFileOutput(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "dapr.log") + + testOptions := Options{ + OutputLevel: "debug", + OutputFile: logPath, + } + + l := NewLogger("testLoggerFileOutput") + + require.NoError(t, ApplyOptionsToLoggers(&testOptions)) + + dl, ok := l.(*daprLogger) + require.True(t, ok) + fileOut, ok := dl.logger.Logger.Out.(*os.File) + require.True(t, ok) + assert.Equal(t, logPath, fileOut.Name()) + t.Cleanup(func() { + // Revert to stdout, which also closes the log file. + require.NoError(t, ApplyOptionsToLoggers(&Options{ + OutputLevel: "info", + })) + }) + + msg := "log-file-test-message" + l.Info(msg) + + b, err := os.ReadFile(logPath) + require.NoError(t, err) + assert.Contains(t, string(b), msg) +} + +func TestApplyOptionsToLoggersFileOutputReapply(t *testing.T) { + dir := t.TempDir() + logPath1 := filepath.Join(dir, "dapr1.log") + logPath2 := filepath.Join(dir, "dapr2.log") + + l := NewLogger("testLoggerReapply") + + // Apply first file output. + require.NoError(t, ApplyOptionsToLoggers(&Options{ + OutputLevel: "debug", + OutputFile: logPath1, + })) + l.Info("message-one") + + // Re-apply with a different file — should close the first. + require.NoError(t, ApplyOptionsToLoggers(&Options{ + OutputLevel: "debug", + OutputFile: logPath2, + })) + l.Info("message-two") + + t.Cleanup(func() { + require.NoError(t, ApplyOptionsToLoggers(&Options{ + OutputLevel: "info", + })) + }) + + b1, err := os.ReadFile(logPath1) + require.NoError(t, err) + assert.Contains(t, string(b1), "message-one") + assert.NotContains(t, string(b1), "message-two") + + b2, err := os.ReadFile(logPath2) + require.NoError(t, err) + assert.Contains(t, string(b2), "message-two") +}