diff --git a/Source/Serilog.Exceptions/Formatting/StructuredExceptionFormatter.cs b/Source/Serilog.Exceptions/Formatting/StructuredExceptionFormatter.cs new file mode 100644 index 00000000..eebd44b3 --- /dev/null +++ b/Source/Serilog.Exceptions/Formatting/StructuredExceptionFormatter.cs @@ -0,0 +1,101 @@ +namespace Serilog.Exceptions.Formatting; + +using System; +using System.IO; +using Serilog.Events; +using Serilog.Exceptions.Core; +using Serilog.Formatting; +using Serilog.Formatting.Json; + +/// +/// A JSON text formatter using structured properties for exceptions. +/// +/// +/// Avoids the redundancy of when used with . +/// +public class StructuredExceptionFormatter : ITextFormatter +{ + private readonly string rootName; + private readonly JsonValueFormatter valueFormatter; + + /// + /// Initializes a new instance of the class. + /// + /// The root name used by the enricher, if different from the default. + /// A custom JSON formatter to use for underlying properties, if any. + public StructuredExceptionFormatter(string? rootName = null, JsonValueFormatter? valueFormatter = null) + { + this.rootName = rootName ?? new DestructuringOptionsBuilder().RootName; + this.valueFormatter = valueFormatter ?? new(); + } + + /// + public void Format(LogEvent logEvent, TextWriter output) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(logEvent); + ArgumentNullException.ThrowIfNull(output); +#else + if (logEvent is null) + { + throw new ArgumentNullException(nameof(logEvent)); + } + + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } +#endif + + output.Write("{\"Timestamp\":\""); + output.Write(logEvent.Timestamp.UtcDateTime.ToString("O")); + + output.Write("\",\"Message\":"); + var message = logEvent.MessageTemplate.Render(logEvent.Properties); + JsonValueFormatter.WriteQuotedJsonString(message, output); + + output.Write(",\"Level\":\""); + output.Write(logEvent.Level); + output.Write('\"'); + + var propCount = logEvent.Properties.Count; + + if (logEvent.Properties.TryGetValue(this.rootName, out var exceptionProperty)) + { + output.Write(",\"Exception\":"); + this.valueFormatter.Format(exceptionProperty, output); + propCount--; + } + + if (propCount > 0) + { + output.Write(",\"Properties\":{"); + var comma = false; + + foreach (var property in logEvent.Properties) + { + if (property.Key == this.rootName) + { + continue; + } + + if (comma) + { + output.Write(','); + } + else + { + comma = true; + } + + JsonValueFormatter.WriteQuotedJsonString(property.Key, output); + output.Write(':'); + this.valueFormatter.Format(property.Value, output); + } + + output.Write("}"); + } + + output.WriteLine('}'); + } +} diff --git a/Tests/Serilog.Exceptions.Test/Formatting/StructuredExceptionFormatterTest.cs b/Tests/Serilog.Exceptions.Test/Formatting/StructuredExceptionFormatterTest.cs new file mode 100644 index 00000000..b565a196 --- /dev/null +++ b/Tests/Serilog.Exceptions.Test/Formatting/StructuredExceptionFormatterTest.cs @@ -0,0 +1,53 @@ +namespace Serilog.Exceptions.Test.Formatting; + +using System; +using System.IO; +using FluentAssertions.Execution; +using Serilog.Events; +using Serilog.Exceptions.Formatting; +using Serilog.Parsing; +using Xunit; + +public class StructuredExceptionFormatterTest +{ + [Fact] + public void Format_EventNoProperties_CorrectJson() => + Format_CorrectJson( + "{\"Timestamp\":\"2021-12-01T13:15:00.0000000Z\",\"Message\":\"Hello!\",\"Level\":\"Debug\"}", + new DateTimeOffset(2021, 12, 1, 12, 15, 0, 0, TimeSpan.FromHours(-1)), + LogEventLevel.Debug, + "Hello!"); + + [Fact] + public void Format_EventWithProperties_CorrectJson() => + Format_CorrectJson( + "{\"Timestamp\":\"1999-01-01T04:15:12.0550000Z\",\"Message\":\"Hello, \\\"Kathy\\\"!\",\"Level\":\"Information\",\"Properties\":{\"Person\":\"Kathy\",\"Extra\":[\"more\",\"data\"]}}", + new DateTimeOffset(1999, 1, 1, 4, 15, 12, 55, TimeSpan.Zero), + LogEventLevel.Information, + "Hello, {Person}!", + new("Person", new ScalarValue("Kathy")), + new("Extra", new SequenceValue(new ScalarValue[] { new("more"), new("data") }))); + + [Fact] + public void Format_EventWithException_CorrectJson() => + Format_CorrectJson( + "{\"Timestamp\":\"2001-01-01T22:30:12.0000000Z\",\"Message\":\"Uh, oh!\",\"Level\":\"Error\",\"Exception\":{\"Message\":\"Bad stuff\",\"HResult\":1234}}", + new DateTimeOffset(2001, 1, 2, 1, 30, 12, 0, TimeSpan.FromHours(3)), + LogEventLevel.Error, + "Uh, oh!", + new LogEventProperty("ExceptionDetail", new StructureValue(new LogEventProperty[] { new("Message", new ScalarValue("Bad stuff")), new("HResult", new ScalarValue(1234)) }))); + + private static void Format_CorrectJson( + string expected, + DateTimeOffset timestamp, + LogEventLevel level, + string template, + params LogEventProperty[] properties) + { + using var output = new StringWriter(); + var ev = new LogEvent(timestamp, level, null, new MessageTemplateParser().Parse(template), properties); + + new StructuredExceptionFormatter().Format(ev, output); + Assert.Equal(expected + Environment.NewLine, output.ToString()); + } +}