diff --git a/src/Serilog.Sinks.Console/Sinks/SystemConsole/Output/OutputTemplateRenderer.cs b/src/Serilog.Sinks.Console/Sinks/SystemConsole/Output/OutputTemplateRenderer.cs index 2f8c743..6e4f561 100644 --- a/src/Serilog.Sinks.Console/Sinks/SystemConsole/Output/OutputTemplateRenderer.cs +++ b/src/Serilog.Sinks.Console/Sinks/SystemConsole/Output/OutputTemplateRenderer.cs @@ -68,7 +68,11 @@ public OutputTemplateRenderer(ConsoleTheme theme, string outputTemplate, IFormat } else if (pt.PropertyName == OutputProperties.TimestampPropertyName) { - renderers.Add(new TimestampTokenRenderer(theme, pt, formatProvider)); + renderers.Add(new TimestampTokenRenderer(theme, pt, formatProvider, convertToUtc: false)); + } + else if (pt.PropertyName == OutputProperties.UtcTimestampPropertyName) + { + renderers.Add(new TimestampTokenRenderer(theme, pt, formatProvider, convertToUtc: true)); } else if (pt.PropertyName == OutputProperties.PropertiesPropertyName) { diff --git a/src/Serilog.Sinks.Console/Sinks/SystemConsole/Output/TimestampTokenRenderer.cs b/src/Serilog.Sinks.Console/Sinks/SystemConsole/Output/TimestampTokenRenderer.cs index 7c4de83..66bdf4d 100644 --- a/src/Serilog.Sinks.Console/Sinks/SystemConsole/Output/TimestampTokenRenderer.cs +++ b/src/Serilog.Sinks.Console/Sinks/SystemConsole/Output/TimestampTokenRenderer.cs @@ -26,63 +26,85 @@ class TimestampTokenRenderer : OutputTemplateTokenRenderer { readonly ConsoleTheme _theme; readonly PropertyToken _token; + readonly string? _format; readonly IFormatProvider? _formatProvider; + readonly bool _convertToUtc; - public TimestampTokenRenderer(ConsoleTheme theme, PropertyToken token, IFormatProvider? formatProvider) + public TimestampTokenRenderer(ConsoleTheme theme, PropertyToken token, IFormatProvider? formatProvider, bool convertToUtc) { - _theme = theme; - _token = token; - _formatProvider = formatProvider; - } + _theme = theme; + _token = token; + _format = token.Format; + _formatProvider = formatProvider; + _convertToUtc = convertToUtc; + } public override void Render(LogEvent logEvent, TextWriter output) { - var sv = new DateTimeOffsetValue(logEvent.Timestamp); - - var _ = 0; - using (_theme.Apply(output, ConsoleThemeStyle.SecondaryText, ref _)) + var _ = 0; + using (_theme.Apply(output, ConsoleThemeStyle.SecondaryText, ref _)) + { + if (_token.Alignment is null) { - if (_token.Alignment is null) - { - sv.Render(output, _token.Format, _formatProvider); - } - else - { - var buffer = new StringWriter(); - sv.Render(buffer, _token.Format, _formatProvider); - var str = buffer.ToString(); - Padding.Apply(output, str, _token.Alignment); - } + Render(output, logEvent.Timestamp); + } + else + { + var buffer = new StringWriter(); + Render(buffer, logEvent.Timestamp); + var str = buffer.ToString(); + Padding.Apply(output, str, _token.Alignment); } } + } - readonly struct DateTimeOffsetValue + private void Render(TextWriter output, DateTimeOffset timestamp) { - public DateTimeOffsetValue(DateTimeOffset value) - { - Value = value; - } + // When a DateTimeOffset is converted to a string, the default format automatically adds the "+00:00" explicit offset to the output string. + // As the TimestampTokenRenderer is also used for rendering the UtcTimestamp which is always in UTC by definition, the +00:00 suffix should be avoided. + // This is done using the same approach as Serilog's MessageTemplateTextFormatter. In case output should be converted to UTC, in order to avoid a zone specifier, + // the DateTimeOffset is converted to a DateTime which then renders as expected. - public DateTimeOffset Value { get; } + var custom = (ICustomFormatter?)_formatProvider?.GetFormat(typeof(ICustomFormatter)); + if (custom != null) + { + output.Write(custom.Format(_format, _convertToUtc ? timestamp.UtcDateTime : timestamp, _formatProvider)); + return; + } - public void Render(TextWriter output, string? format = null, IFormatProvider? formatProvider = null) + if (_convertToUtc) { - var custom = (ICustomFormatter?)formatProvider?.GetFormat(typeof(ICustomFormatter)); - if (custom != null) - { - output.Write(custom.Format(format, Value, formatProvider)); - return; - } + RenderDateTime(output, timestamp.UtcDateTime); + } + else + { + RenderDateTimeOffset(output, timestamp); + } + } + private void RenderDateTimeOffset(TextWriter output, DateTimeOffset timestamp) + { #if FEATURE_SPAN - Span buffer = stackalloc char[32]; - if (Value.TryFormat(buffer, out int written, format, formatProvider ?? CultureInfo.InvariantCulture)) - output.Write(buffer.Slice(0, written)); - else - output.Write(Value.ToString(format, formatProvider ?? CultureInfo.InvariantCulture)); + Span buffer = stackalloc char[32]; + if (timestamp.TryFormat(buffer, out int written, _format, _formatProvider ?? CultureInfo.InvariantCulture)) + output.Write(buffer.Slice(0, written)); + else + output.Write(timestamp.ToString(_format, _formatProvider ?? CultureInfo.InvariantCulture)); #else - output.Write(Value.ToString(format, formatProvider ?? CultureInfo.InvariantCulture)); + output.Write(timestamp.ToString(_format, _formatProvider ?? CultureInfo.InvariantCulture)); +#endif + } + + private void RenderDateTime(TextWriter output, DateTime utcTimestamp) + { +#if FEATURE_SPAN + Span buffer = stackalloc char[32]; + if (utcTimestamp.TryFormat(buffer, out int written, _format, _formatProvider ?? CultureInfo.InvariantCulture)) + output.Write(buffer.Slice(0, written)); + else + output.Write(utcTimestamp.ToString(_format, _formatProvider ?? CultureInfo.InvariantCulture)); +#else + output.Write(utcTimestamp.ToString(_format, _formatProvider ?? CultureInfo.InvariantCulture)); #endif - } } } \ No newline at end of file diff --git a/test/Serilog.Sinks.Console.Tests/Output/OutputTemplateRendererTests.cs b/test/Serilog.Sinks.Console.Tests/Output/OutputTemplateRendererTests.cs index 8e5f731..3297787 100644 --- a/test/Serilog.Sinks.Console.Tests/Output/OutputTemplateRendererTests.cs +++ b/test/Serilog.Sinks.Console.Tests/Output/OutputTemplateRendererTests.cs @@ -400,4 +400,34 @@ public void TraceAndSpanAreIncludedWhenPresent() formatter.Format(evt, sw); Assert.Equal($"{traceId}/{spanId}", sw.ToString()); } + + [Fact] + public void TimestampTokenRendersLocalTime() + { + var logTimestampWithTimeZoneOffset = DateTimeOffset.Parse("2024-09-03T14:15:16.079+02:00", CultureInfo.InvariantCulture); + var formatter = new OutputTemplateRenderer(ConsoleTheme.None, + "Default Format: {Timestamp} / Custom Format String: {Timestamp:yyyy-MM-dd HH:mm:ss}", + CultureInfo.InvariantCulture); + var evt = new LogEvent(logTimestampWithTimeZoneOffset, LogEventLevel.Debug, null, + new MessageTemplate(Enumerable.Empty()), Enumerable.Empty()); + var sw = new StringWriter(); + formatter.Format(evt, sw); + // expect time in local time, unchanged from the input, the +02:00 offset should not affect the output + Assert.Equal("Default Format: 09/03/2024 14:15:16 +02:00 / Custom Format String: 2024-09-03 14:15:16", sw.ToString()); + } + + [Fact] + public void UtcTimestampTokenRendersUtcTime() + { + var logTimestampWithTimeZoneOffset = DateTimeOffset.Parse("2024-09-03T14:15:16.079+02:00", CultureInfo.InvariantCulture); + var formatter = new OutputTemplateRenderer(ConsoleTheme.None, + "Default Format: {UtcTimestamp} / Custom Format String: {UtcTimestamp:yyyy-MM-dd HH:mm:ss}", + CultureInfo.InvariantCulture); + var evt = new LogEvent(logTimestampWithTimeZoneOffset, LogEventLevel.Debug, null, + new MessageTemplate(Enumerable.Empty()), Enumerable.Empty()); + var sw = new StringWriter(); + formatter.Format(evt, sw); + // expect time in UTC, the +02:00 offset must be applied to adjust the hour + Assert.Equal("Default Format: 09/03/2024 12:15:16 / Custom Format String: 2024-09-03 12:15:16", sw.ToString()); + } } \ No newline at end of file