From 3e7faf8241c3b6cb66a47c8de2cee908056df7b8 Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Mon, 31 Aug 2026 11:25:34 +0100 Subject: [PATCH] feat: add --log-timestamp-format to configure log timestamp layout Log timestamps are hard-coded to RFC3339Nano in both logrus formatters. Operators feeding logs into analytics pipelines need to match an existing timestamp convention, so this makes the format configurable as a Go time layout via --log-timestamp-format. Empty keeps the current default, so behaviour is unchanged unless the flag is set. The format is stored on the logger and the formatter is rebuilt through a shared applyFormatter, so the configured layout survives toggling --log-as-json. The option is applied via a type assertion in ApplyOptionsToLoggers rather than by widening the exported Logger interface, keeping this a non-breaking change for external implementers. Both in-tree implementations provide the method. This recreates dapr/kit#164 with fresh authorship so the DCO sign-off matches the commit author; the implementation and tests are Mirel's work. Co-authored-by: Mirel <15373565+MyMirelHub@users.noreply.github.com> Signed-off-by: Nelson Parente --- logger/dapr_logger.go | 66 ++++++++++++++++++++---------- logger/dapr_logger_test.go | 83 ++++++++++++++++++++++++++++++++++++++ logger/nop_logger.go | 3 ++ logger/options.go | 26 ++++++++++-- logger/options_test.go | 11 +++++ 5 files changed, 164 insertions(+), 25 deletions(-) diff --git a/logger/dapr_logger.go b/logger/dapr_logger.go index 22772a6..2a8b523 100644 --- a/logger/dapr_logger.go +++ b/logger/dapr_logger.go @@ -16,7 +16,6 @@ package logger import ( "io" "os" - "time" "github.com/sirupsen/logrus" ) @@ -27,6 +26,10 @@ type daprLogger struct { name string // loger is the instance of logrus logger logger *logrus.Entry + // jsonOutput indicates whether the logger emits JSON formatted logs + jsonOutput bool + // timestampFormat is the format used for log timestamps + timestampFormat string } var DaprVersion = "unknown" @@ -41,6 +44,7 @@ func newDaprLogger(name string) *daprLogger { logFieldScope: name, logFieldType: LogTypeLog, }), + timestampFormat: defaultTimestampFormat, } dl.EnableJSONOutput(defaultJSONOutput) @@ -50,16 +54,6 @@ func newDaprLogger(name string) *daprLogger { // EnableJSONOutput enables JSON formatted output log. func (l *daprLogger) EnableJSONOutput(enabled bool) { - var formatter logrus.Formatter - - fieldMap := logrus.FieldMap{ - // If time field name is conflicted, logrus adds "fields." prefix. - // So rename to unused field @time to avoid the confliction. - logrus.FieldKeyTime: logFieldTimeStamp, - logrus.FieldKeyLevel: logFieldLevel, - logrus.FieldKeyMsg: logFieldMessage, - } - hostname, _ := os.Hostname() l.logger.Data = logrus.Fields{ logFieldScope: l.logger.Data[logFieldScope], @@ -68,19 +62,19 @@ func (l *daprLogger) EnableJSONOutput(enabled bool) { logFieldDaprVer: DaprVersion, } - if enabled { - formatter = &logrus.JSONFormatter{ //nolint: exhaustruct - TimestampFormat: time.RFC3339Nano, - FieldMap: fieldMap, - } - } else { - formatter = &logrus.TextFormatter{ //nolint: exhaustruct - TimestampFormat: time.RFC3339Nano, - FieldMap: fieldMap, - } + l.jsonOutput = enabled + l.applyFormatter() +} + +// SetTimestampFormat sets the format used for log timestamps. An empty format +// resets it to the default (RFC3339 with nanoseconds). +func (l *daprLogger) SetTimestampFormat(format string) { + if format == "" { + format = defaultTimestampFormat } - l.logger.Logger.SetFormatter(formatter) + l.timestampFormat = format + l.applyFormatter() } // SetAppID sets app_id field in the log. Default value is empty string. @@ -174,3 +168,31 @@ func (l *daprLogger) Fatal(args ...any) { func (l *daprLogger) Fatalf(format string, args ...any) { l.logger.Fatalf(format, args...) } + +// applyFormatter builds and applies the logrus formatter based on the current +// output format and timestamp format. +func (l *daprLogger) applyFormatter() { + var formatter logrus.Formatter + + fieldMap := logrus.FieldMap{ + // If time field name is conflicted, logrus adds "fields." prefix. + // So rename to unused field @time to avoid the confliction. + logrus.FieldKeyTime: logFieldTimeStamp, + logrus.FieldKeyLevel: logFieldLevel, + logrus.FieldKeyMsg: logFieldMessage, + } + + if l.jsonOutput { + formatter = &logrus.JSONFormatter{ //nolint: exhaustruct + TimestampFormat: l.timestampFormat, + FieldMap: fieldMap, + } + } else { + formatter = &logrus.TextFormatter{ //nolint: exhaustruct + TimestampFormat: l.timestampFormat, + FieldMap: fieldMap, + } + } + + l.logger.Logger.SetFormatter(formatter) +} diff --git a/logger/dapr_logger_test.go b/logger/dapr_logger_test.go index b9e03a6..9be11cb 100644 --- a/logger/dapr_logger_test.go +++ b/logger/dapr_logger_test.go @@ -59,6 +59,89 @@ func TestEnableJSON(t *testing.T) { assert.Equal(t, expectedHost, testLogger.logger.Data[logFieldInstance]) } +func TestSetTimestampFormat(t *testing.T) { + const customFormat = "2006/01/02 15:04:05.000" + + t.Run("default is RFC3339Nano", func(t *testing.T) { + var buf bytes.Buffer + + testLogger := getTestLogger(&buf) + testLogger.EnableJSONOutput(true) + + formatter, ok := testLogger.logger.Logger.Formatter.(*logrus.JSONFormatter) + require.True(t, ok) + assert.Equal(t, time.RFC3339Nano, formatter.TimestampFormat) + }) + + t.Run("custom format applied to JSON formatter", func(t *testing.T) { + var buf bytes.Buffer + + testLogger := getTestLogger(&buf) + testLogger.EnableJSONOutput(true) + testLogger.SetTimestampFormat(customFormat) + + formatter, ok := testLogger.logger.Logger.Formatter.(*logrus.JSONFormatter) + require.True(t, ok) + assert.Equal(t, customFormat, formatter.TimestampFormat) + + testLogger.Info("Hello, dapr") + + b, _ := buf.ReadBytes('\n') + + var o map[string]any + require.NoError(t, json.Unmarshal(b, &o)) + + timeVal, ok := o[logFieldTimeStamp].(string) + require.True(t, ok) + + _, err := time.Parse(customFormat, timeVal) + require.NoError(t, err) + }) + + t.Run("custom format applied to text formatter", func(t *testing.T) { + var buf bytes.Buffer + + testLogger := getTestLogger(&buf) + testLogger.EnableJSONOutput(false) + testLogger.SetTimestampFormat(customFormat) + + formatter, ok := testLogger.logger.Logger.Formatter.(*logrus.TextFormatter) + require.True(t, ok) + assert.Equal(t, customFormat, formatter.TimestampFormat) + }) + + t.Run("format is preserved when toggling JSON output", func(t *testing.T) { + var buf bytes.Buffer + + testLogger := getTestLogger(&buf) + testLogger.SetTimestampFormat(customFormat) + testLogger.EnableJSONOutput(true) + + jsonFormatter, ok := testLogger.logger.Logger.Formatter.(*logrus.JSONFormatter) + require.True(t, ok) + assert.Equal(t, customFormat, jsonFormatter.TimestampFormat) + + testLogger.EnableJSONOutput(false) + + textFormatter, ok := testLogger.logger.Logger.Formatter.(*logrus.TextFormatter) + require.True(t, ok) + assert.Equal(t, customFormat, textFormatter.TimestampFormat) + }) + + t.Run("empty format resets to default", func(t *testing.T) { + var buf bytes.Buffer + + testLogger := getTestLogger(&buf) + testLogger.EnableJSONOutput(true) + testLogger.SetTimestampFormat(customFormat) + testLogger.SetTimestampFormat("") + + formatter, ok := testLogger.logger.Logger.Formatter.(*logrus.JSONFormatter) + require.True(t, ok) + assert.Equal(t, time.RFC3339Nano, formatter.TimestampFormat) + }) +} + func TestJSONLoggerFields(t *testing.T) { tests := []struct { name string diff --git a/logger/nop_logger.go b/logger/nop_logger.go index e295f51..e9e063c 100644 --- a/logger/nop_logger.go +++ b/logger/nop_logger.go @@ -33,6 +33,9 @@ func (n *nopLogger) SetOutputLevel(_ LogLevel) {} // SetOutput sets the destination for the logs func (n *nopLogger) SetOutput(_ io.Writer) {} +// SetTimestampFormat sets the format used for log timestamps. +func (n *nopLogger) SetTimestampFormat(_ string) {} + // IsOutputLevelEnabled returns true if the logger will output this LogLevel. func (n *nopLogger) IsOutputLevelEnabled(_ LogLevel) bool { return true } diff --git a/logger/options.go b/logger/options.go index a0149c9..e84d3de 100644 --- a/logger/options.go +++ b/logger/options.go @@ -18,12 +18,14 @@ import ( "io" "os" "sync" + "time" ) const ( - defaultJSONOutput = false - defaultOutputLevel = "info" - undefinedAppID = "" + defaultJSONOutput = false + defaultOutputLevel = "info" + defaultTimestampFormat = time.RFC3339Nano + undefinedAppID = "" ) var ( @@ -45,6 +47,11 @@ type Options struct { // OutputFile is the destination file path for logs. OutputFile string + + // TimestampFormat is the format used for log timestamps, expressed as a + // Go time layout. An empty value means the default (RFC3339 with + // nanoseconds). + TimestampFormat string } // SetOutputLevel sets the log output level. @@ -79,6 +86,11 @@ func (o *Options) AttachCmdFlags( "log-file", "", "Path to a file where logs will be written") + stringVar( + &o.TimestampFormat, + "log-timestamp-format", + "", + "Format for log timestamps, expressed as a Go time layout, e.g. '2006/01/02 15:04:05.000' (default RFC3339 with nanoseconds)") } if boolVar != nil { @@ -97,6 +109,7 @@ func DefaultOptions() Options { appID: undefinedAppID, OutputLevel: defaultOutputLevel, OutputFile: "", + TimestampFormat: "", } } @@ -108,6 +121,13 @@ func ApplyOptionsToLoggers(options *Options) error { for _, v := range internalLoggers { v.EnableJSONOutput(options.JSONFormatEnabled) + // Applied via type assertion rather than through the Logger interface, + // so this stays a non-breaking change for external implementers of + // Logger. Both in-tree implementations provide the method. + if s, ok := v.(interface{ SetTimestampFormat(string) }); ok { + s.SetTimestampFormat(options.TimestampFormat) + } + if options.appID != undefinedAppID { v.SetAppID(options.appID) } diff --git a/logger/options_test.go b/logger/options_test.go index 1d4810f..b4c95ce 100644 --- a/logger/options_test.go +++ b/logger/options_test.go @@ -44,6 +44,7 @@ func TestOptions(t *testing.T) { logLevelAsserted := false logFileAsserted := false + logTimestampFormatAsserted := false testStringVarFn := func(p *string, name string, value string, usage string) { if name == "log-level" && value == defaultOutputLevel { logLevelAsserted = true @@ -52,6 +53,10 @@ func TestOptions(t *testing.T) { if name == "log-file" && value == "" { logFileAsserted = true } + + if name == "log-timestamp-format" && value == "" { + logTimestampFormatAsserted = true + } } logAsJSONAsserted := false @@ -66,6 +71,7 @@ func TestOptions(t *testing.T) { // assert assert.True(t, logLevelAsserted) assert.True(t, logFileAsserted) + assert.True(t, logTimestampFormatAsserted) assert.True(t, logAsJSONAsserted) }) } @@ -75,6 +81,7 @@ func TestApplyOptionsToLoggers(t *testing.T) { JSONFormatEnabled: true, appID: "dapr-app", OutputLevel: "debug", + TimestampFormat: "2006/01/02 15:04:05.000", } // Create two loggers @@ -99,6 +106,10 @@ func TestApplyOptionsToLoggers(t *testing.T) { t, toLogrusLevel(DebugLevel), (l.(*daprLogger)).logger.Logger.GetLevel()) + assert.Equal( + t, + "2006/01/02 15:04:05.000", + (l.(*daprLogger)).timestampFormat) } }