Skip to content

fix: Custom exception JSON converter #152

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

Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

using System;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace AWS.Lambda.Powertools.Logging.Internal;

/// <summary>
/// Converts an exception to JSON.
/// </summary>
internal class ExceptionConverter : JsonConverter<Exception>
{
/// <summary>
/// Determines whether the type can be converted.
/// </summary>
/// <param name="typeToConvert">The type which should be converted.</param>
/// <returns>True if the type can be converted, False otherwise.</returns>
public override bool CanConvert(Type typeToConvert)
{
return typeof(Exception).IsAssignableFrom(typeToConvert);
}

/// <summary>
/// Converter throws NotSupportedException. Deserializing exception is not allowed.
/// </summary>
/// <param name="reader">Reference to the JsonReader</param>
/// <param name="typeToConvert">The type which should be converted.</param>
/// <param name="options">The Json serializer options.</param>
/// <returns></returns>
/// <exception cref="NotSupportedException"></exception>
public override Exception Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
throw new NotSupportedException("Deserializing exception is not allowed");
}

/// <summary>
/// Write the exception value as JSON.
/// </summary>
/// <param name="writer">The unicode JsonWriter.</param>
/// <param name="value">The exception instance.</param>
/// <param name="options">The JsonSerializer options.</param>
public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options)
{
var exceptionType = value.GetType();
var properties = exceptionType.GetProperties()
.Where(prop => prop.Name != nameof(Exception.TargetSite))
.Select(prop => new { prop.Name, Value = prop.GetValue(value) });

if (options.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull)
properties = properties.Where(prop => prop.Value != null);

var props = properties.ToArray();
if (!props.Any())
return;

writer.WriteStartObject();
writer.WriteString(ApplyPropertyNamingPolicy("Type", options), exceptionType.FullName);

foreach (var prop in props)
{
switch (prop.Value)
{
case IntPtr intPtr:
writer.WriteNumber(ApplyPropertyNamingPolicy(prop.Name, options), intPtr.ToInt64());
break;
case UIntPtr uIntPtr:
writer.WriteNumber(ApplyPropertyNamingPolicy(prop.Name, options), uIntPtr.ToUInt64());
break;
case Type propType:
writer.WriteString(ApplyPropertyNamingPolicy(prop.Name, options), propType.FullName);
break;
default:
writer.WritePropertyName(ApplyPropertyNamingPolicy(prop.Name, options));
JsonSerializer.Serialize(writer, prop.Value, options);
break;
}
}

writer.WriteEndObject();
}

/// <summary>
/// Applying the property naming policy to property name
/// </summary>
/// <param name="propertyName">The name of the property</param>
/// <param name="options">The JsonSerializer options.</param>
/// <returns></returns>
private static string ApplyPropertyNamingPolicy(string propertyName, JsonSerializerOptions options)
{
return !string.IsNullOrWhiteSpace(propertyName) && options?.PropertyNamingPolicy is not null
? options.PropertyNamingPolicy.ConvertName(propertyName)
: propertyName;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ internal sealed class PowertoolsLogger : ILogger
/// The current configuration
/// </summary>
private LoggerConfiguration _currentConfig;

/// <summary>
/// The JsonSerializer options
/// </summary>
private JsonSerializerOptions _jsonSerializerOptions;

/// <summary>
/// Initializes a new instance of the <see cref="PowertoolsLogger" /> class.
Expand Down Expand Up @@ -94,6 +99,13 @@ public PowertoolsLogger(
? CurrentConfig.Service
: _powertoolsConfigurations.Service;

/// <summary>
/// Get JsonSerializer options.
/// </summary>
/// <value>The current configuration.</value>
private JsonSerializerOptions JsonSerializerOptions =>
_jsonSerializerOptions ??= BuildJsonSerializerOptions();

internal PowertoolsLoggerScope CurrentScope { get; private set; }

/// <summary>
Expand Down Expand Up @@ -227,35 +239,9 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
if (CurrentConfig.SamplingRate.HasValue)
message.TryAdd(LoggingConstants.KeySamplingRate, CurrentConfig.SamplingRate.Value);
if (exception != null)
message.TryAdd(LoggingConstants.KeyException, exception.Message);
message.TryAdd(LoggingConstants.KeyException, exception);

var options = BuildCaseSerializerOptions();
_systemWrapper.LogLine(JsonSerializer.Serialize(message, options));
}

private JsonSerializerOptions BuildCaseSerializerOptions()
{
switch (CurrentConfig.LoggerOutputCase)
{
case LoggerOutputCase.CamelCase:
return new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase
};
case LoggerOutputCase.PascalCase:
return new()
{
PropertyNamingPolicy = PascalCaseNamingPolicy.Instance,
DictionaryKeyPolicy = PascalCaseNamingPolicy.Instance
};
default: // Snake case is the default
return new()
{
PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance,
DictionaryKeyPolicy = SnakeCaseNamingPolicy.Instance
};
}
_systemWrapper.LogLine(JsonSerializer.Serialize(message, JsonSerializerOptions));
}

/// <summary>
Expand Down Expand Up @@ -340,4 +326,31 @@ private static bool CustomFormatter<TState>(TState state, Exception exception, o
message = stateKeys.First(k => k.Key != "{OriginalFormat}").Value;
return true;
}

/// <summary>
/// Builds JsonSerializer options.
/// </summary>
private JsonSerializerOptions BuildJsonSerializerOptions()
{
var jsonOptions = CurrentConfig.LoggerOutputCase switch
{
LoggerOutputCase.CamelCase => new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase
},
LoggerOutputCase.PascalCase => new JsonSerializerOptions
{
PropertyNamingPolicy = PascalCaseNamingPolicy.Instance,
DictionaryKeyPolicy = PascalCaseNamingPolicy.Instance
},
_ => new JsonSerializerOptions
{
PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance,
DictionaryKeyPolicy = SnakeCaseNamingPolicy.Instance
}
};
jsonOptions.Converters.Add(new ExceptionConverter());
return jsonOptions;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,9 @@ public static void LogCritical<T>(this ILogger logger, T extraKeys, string messa
public static void Log<T>(this ILogger logger, LogLevel logLevel, T extraKeys, EventId eventId, Exception exception,
string message, params object[] args) where T : class
{
if (extraKeys is not null)
if (extraKeys is Exception ex && exception is null)
logger.Log(logLevel, eventId, ex, message, args);
else if (extraKeys is not null)
using (logger.BeginScope(extraKeys))
logger.Log(logLevel, eventId, exception, message, args);
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1008,5 +1008,91 @@ public void Log_WhenExtraKeysAsObject_AppendExtraKeys(LogLevel logLevel, bool lo

Assert.Null(logger.CurrentScope?.ExtraKeys);
}

[Fact]
public void Log_WhenException_LogsExceptionDetails()
{
// Arrange
var loggerName = Guid.NewGuid().ToString();
var service = Guid.NewGuid().ToString();
var error = new InvalidOperationException("TestError");
var logLevel = LogLevel.Information;
var randomSampleRate = 0.5;

var configurations = new Mock<IPowertoolsConfigurations>();
configurations.Setup(c => c.Service).Returns(service);
configurations.Setup(c => c.LogLevel).Returns(logLevel.ToString);

var systemWrapper = new Mock<ISystemWrapper>();
systemWrapper.Setup(c => c.GetRandom()).Returns(randomSampleRate);

var logger = new PowertoolsLogger(loggerName, configurations.Object, systemWrapper.Object, () =>
new LoggerConfiguration
{
Service = null,
MinimumLevel = null
});

try
{
throw error;
}
catch (Exception ex)
{
logger.LogError(ex);
}

// Assert
systemWrapper.Verify(v =>
v.LogLine(
It.Is<string>
(s =>
s.Contains("\"exception\":{\"type\":\"" + error.GetType().FullName + "\",\"message\":\"" + error.Message + "\"")
)
), Times.Once);
}

[Fact]
public void Log_WhenNestedException_LogsExceptionDetails()
{
// Arrange
var loggerName = Guid.NewGuid().ToString();
var service = Guid.NewGuid().ToString();
var error = new InvalidOperationException("TestError");
var logLevel = LogLevel.Information;
var randomSampleRate = 0.5;

var configurations = new Mock<IPowertoolsConfigurations>();
configurations.Setup(c => c.Service).Returns(service);
configurations.Setup(c => c.LogLevel).Returns(logLevel.ToString);

var systemWrapper = new Mock<ISystemWrapper>();
systemWrapper.Setup(c => c.GetRandom()).Returns(randomSampleRate);

var logger = new PowertoolsLogger(loggerName, configurations.Object, systemWrapper.Object, () =>
new LoggerConfiguration
{
Service = null,
MinimumLevel = null
});

try
{
throw error;
}
catch (Exception ex)
{
logger.LogInformation(new { Name = "Test Object", Error = ex });
}

// Assert
systemWrapper.Verify(v =>
v.LogLine(
It.Is<string>
(s =>
s.Contains("\"error\":{\"type\":\"" + error.GetType().FullName + "\",\"message\":\"" + error.Message + "\"")
)
), Times.Once);
}
}
}