Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Structured exception formatter (fixes #388) #433

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
101 changes: 101 additions & 0 deletions Source/Serilog.Exceptions/Formatting/StructuredExceptionFormatter.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A JSON text formatter using structured properties for exceptions.
/// </summary>
/// <remarks>
/// Avoids the redundancy of <see cref="JsonFormatter"/> when used with <see cref="ExceptionEnricher"/>.
/// </remarks>
public class StructuredExceptionFormatter : ITextFormatter
{
private readonly string rootName;
private readonly JsonValueFormatter valueFormatter;

/// <summary>
/// Initializes a new instance of the <see cref="StructuredExceptionFormatter"/> class.
/// </summary>
/// <param name="rootName">The root name used by the enricher, if different from the default.</param>
/// <param name="valueFormatter">A custom JSON formatter to use for underlying properties, if any.</param>
public StructuredExceptionFormatter(string? rootName = null, JsonValueFormatter? valueFormatter = null)
{
this.rootName = rootName ?? new DestructuringOptionsBuilder().RootName;
this.valueFormatter = valueFormatter ?? new();
}

/// <inheritdoc />
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('}');
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}