Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions logger/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ package logger

import (
"fmt"
"io"
"os"
"sync"
)

const (
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -79,6 +96,7 @@ func DefaultOptions() Options {
JSONFormatEnabled: defaultJSONOutput,
appID: undefinedAppID,
OutputLevel: defaultOutputLevel,
OutputFile: "",
}
}

Expand All @@ -104,5 +122,49 @@ 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.
// The new file is opened before closing the previous one so that loggers are
// never left pointing at a closed file descriptor.
func setLogOutput(path string, loggers map[string]Logger) error {
logOutputMu.Lock()
defer logOutputMu.Unlock()

var (
out io.Writer = os.Stdout
newFile *os.File
)

if path != "" {
var err error

newFile, 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)
}

out = newFile
}

// Switch all loggers to the new output before closing the old file.
for _, v := range loggers {
v.SetOutput(out)
}

// Close the previous log file after loggers have been redirected.
if logOutputFile != nil {
logOutputFile.Close()
}

logOutputFile = newFile

return nil
}
78 changes: 78 additions & 0 deletions logger/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ limitations under the License.
package logger

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -57,6 +65,7 @@ func TestOptions(t *testing.T) {

// assert
assert.True(t, logLevelAsserted)
assert.True(t, logFileAsserted)
assert.True(t, logAsJSONAsserted)
})
}
Expand Down Expand Up @@ -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))
t.Cleanup(func() {
// Revert to stdout, which also closes the log file.
require.NoError(t, ApplyOptionsToLoggers(&Options{
OutputLevel: "info",
}))
})

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())

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")

t.Cleanup(func() {
require.NoError(t, ApplyOptionsToLoggers(&Options{
OutputLevel: "info",
}))
})

// 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")

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")
}
Loading