Repository: serilog/serilog

Issue:
Destructure.AsDictionary<T>() to override default dictionaries-are-enumerables behavior
### Description
Instances of `Dictionary<string, T>` are destructured as objects, this is also [documented](https://github.com/serilog/serilog/wiki/Structured-Data#collections). But it's inheritors does not behave the same.

I understand that class can implement multiple `IDictionary<string, T>` so such interfaces are not handled as objects. But class can not inherit from multiple `Dictionary<string, T>` so I don't think this limitation applies here.

### Reproduction
This works:
```csharp
Serilog.Log.Logger.Information("{@Instance}", new Dictionary<string, string>
{
    {"TestKey", "TestValue"}
});
```
populating **Instance** property as `{TestKey: 'TestValue'}`

But this doesn't:
```csharp
public class MyDictionary: Dictionary<string, string> {}

Serilog.Log.Logger.Information("{@Instance}", new MyDictionary
{
    {"TestKey", "TestValue"}
});
```
and populates **Instance** with `[{Key: 'TestKey', Value: 'TestValue'}]`

### Relevant package, tooling and runtime versions
Serilog 2.10.0, .NET 5.0, Windows 10



Hints:
Thanks for the note - this is by design, subclassing/inheritance isn't taken into account by destructuring rules. There are some APIs for getting around this, but `Destructure.AsDictionary<MyDictionary>()` also might be worth considering as an addition.
@nblumhardt thanks for the reply.
I don't understand the reasoning for not considering subclasses, but agree that `Destructure.AsDictionary<MyDictionary>()` would solve this issue. Would you propose to close it or update to track suggested new method?
```
var fruit = new Dictionary<string, int> { { "Apple", 1 }, { "Pear", 5 } };
  Log.ForContext("Test my fruit", fruit , destructureObjects: true)
         .Information("example");
```
`Destructure.AsDictionary<T>` sounds like a reasonable addition; let's keep this open to track :+1:
Hi folks. If you're set on `Destructure.AsDictionary<T>` as a solution, I would like to take a stab at an implementation.
Sounds good to me! :+1: Thanks @octos4murai.
@nblumhardt, what are your thoughts on the following possible implementations:

### A
```csharp
public LoggerConfiguration AsDictionary<T, TKey, TValue>() where T : IDictionary<TKey, TValue> =>
    ByTransforming<T>(customDict => customDict.ToDictionary(customDict => customDict.Key, cd => cd.Value));
```

- Simple and clean implementation
- Requires the user to specify the key and value types when using `AsDictionary()`
- Sample Usage: `Destructure.AsDictionary<MyDictionary<string, int>, string, int>()`

### B
```csharp
public LoggerConfiguration AsDictionary<T>() where T : IDictionary =>
    ByTransforming<T>(customDict =>
    {
        Type[] argTypes = customDict.GetType().GetGenericArguments();
        Type keyType = argTypes[0];
        Type valType = argTypes[1];

        // Create a standard Dictionary with the same key and value types as T
        dynamic dict = Activator.CreateInstance(typeof(Dictionary<, >).MakeGenericType(keyType, valType));

        foreach (var key in customDict.Keys)
        {
            dict.Add(
                (dynamic)Convert.ChangeType(key, keyType),
                (dynamic)Convert.ChangeType(customDict[key], valType)
            );
        }

        return dict;
    });
```

- Use of dynamic type may increase chances of runtime errors
- Able to glean the key and value type from the Dictionary when using `AsDictionary()`
- Sample Usage: `Destructure.AsDictionary<MyDictionary<string, int>>()`
Hi! Both of these options would require additional allocations and copying; instead, we can create an `IDestructuringPolicy` that detects instances of the type and converts them directly into `DictionaryValue` (no intermediate `Dictionary` step.)

Let me know if this makes sense - should be some example `IDestructuringPolicy` impls out there/already in the codebase.
It'd be cool to see this one move forward, but at present it seems to have stagnated so I'll close the ticket as stale. Please chime in if you're interested in picking it back up at any point, or if anyone following along wants to take a shot at it :-)

Patch:
diff --git a/src/Serilog/Capturing/PropertyValueConverter.cs b/src/Serilog/Capturing/PropertyValueConverter.cs
index 6f5167283..3b62b5206 100644
--- a/src/Serilog/Capturing/PropertyValueConverter.cs
+++ b/src/Serilog/Capturing/PropertyValueConverter.cs
@@ -35,6 +35,7 @@ partial class PropertyValueConverter : ILogEventPropertyFactory, ILogEventProper
     };
 
     readonly IDestructuringPolicy[] _destructuringPolicies;
+    readonly Type[] _dictionaryTypes;
     readonly IScalarConversionPolicy[] _scalarConversionPolicies;
     readonly DepthLimiter _depthLimiter;
     readonly int _maximumStringLength;
@@ -46,6 +47,7 @@ public PropertyValueConverter(
         int maximumStringLength,
         int maximumCollectionCount,
         IEnumerable<Type> additionalScalarTypes,
+        IEnumerable<Type> additionalDictionaryTypes,
         IEnumerable<IDestructuringPolicy> additionalDestructuringPolicies,
         bool propagateExceptions)
     {
@@ -77,6 +79,7 @@ public PropertyValueConverter(
             })
             .ToArray();
 
+        _dictionaryTypes = additionalDictionaryTypes.ToArray();
         _depthLimiter = new(maximumDestructuringDepth, this);
     }
 
@@ -344,17 +347,26 @@ string TruncateIfNecessary(string text)
         return text;
     }
 
-    static bool TryGetDictionary(object value, Type valueType, [NotNullWhen(true)] out IDictionary? dictionary)
+    bool TryGetDictionary(object value, Type valueType, [NotNullWhen(true)] out IDictionary? dictionary)
     {
-        if (value is IDictionary idictionary && valueType.IsConstructedGenericType)
+        if (value is IDictionary idictionary)
         {
-            var definition = valueType.GetGenericTypeDefinition();
-            if ((definition == typeof(Dictionary<,>) || definition == typeof(System.Collections.ObjectModel.ReadOnlyDictionary<,>)) &&
-                IsValidDictionaryKeyType(valueType.GenericTypeArguments[0]))
+            if (_dictionaryTypes.Contains(valueType))
             {
                 dictionary = idictionary;
                 return true;
             }
+
+            if (valueType.IsConstructedGenericType)
+            {
+                var definition = valueType.GetGenericTypeDefinition();
+                if ((definition == typeof(Dictionary<,>) || definition == typeof(System.Collections.ObjectModel.ReadOnlyDictionary<,>)) &&
+                    IsValidDictionaryKeyType(valueType.GenericTypeArguments[0]))
+                {
+                    dictionary = idictionary;
+                    return true;
+                }
+            }
         }
 
         dictionary = null;
diff --git a/src/Serilog/Configuration/LoggerDestructuringConfiguration.cs b/src/Serilog/Configuration/LoggerDestructuringConfiguration.cs
index 29d936d8b..ca1b45cfd 100644
--- a/src/Serilog/Configuration/LoggerDestructuringConfiguration.cs
+++ b/src/Serilog/Configuration/LoggerDestructuringConfiguration.cs
@@ -21,6 +21,7 @@ public class LoggerDestructuringConfiguration
 {
     readonly LoggerConfiguration _loggerConfiguration;
     readonly Action<Type> _addScalar;
+    readonly Action<Type> _addDictionaryType;
     readonly Action<IDestructuringPolicy> _addPolicy;
     readonly Action<int> _setMaximumDepth;
     readonly Action<int> _setMaximumStringLength;
@@ -29,6 +30,7 @@ public class LoggerDestructuringConfiguration
     internal LoggerDestructuringConfiguration(
         LoggerConfiguration loggerConfiguration,
         Action<Type> addScalar,
+        Action<Type> addDictionaryType,
         Action<IDestructuringPolicy> addPolicy,
         Action<int> setMaximumDepth,
         Action<int> setMaximumStringLength,
@@ -36,6 +38,7 @@ internal LoggerDestructuringConfiguration(
     {
         _loggerConfiguration = Guard.AgainstNull(loggerConfiguration);
         _addScalar = Guard.AgainstNull(addScalar);
+        _addDictionaryType = Guard.AgainstNull(addDictionaryType);
         _addPolicy = Guard.AgainstNull(addPolicy);
         _setMaximumDepth = Guard.AgainstNull(setMaximumDepth);
         _setMaximumStringLength = Guard.AgainstNull(setMaximumStringLength);
@@ -97,6 +100,19 @@ public LoggerConfiguration With<TDestructuringPolicy>()
         return With(new TDestructuringPolicy());
     }
 
+    /// <summary>
+    /// Capture instances of the specified type as dictionaries.
+    /// By default, only concrete instantiations of are considered dictionary-like.
+    /// </summary>
+    /// <typeparam name="T">Type of dictionary.</typeparam>
+    /// <returns>Configuration object allowing method chaining.</returns>
+    public LoggerConfiguration AsDictionary<T>()
+        where T : IDictionary
+    {
+        _addDictionaryType(typeof(T));
+        return _loggerConfiguration;
+    }
+
     /// <summary>
     /// When destructuring objects, transform instances of the specified type with
     /// the provided function.
diff --git a/src/Serilog/LoggerConfiguration.cs b/src/Serilog/LoggerConfiguration.cs
index 7940cb27d..d6e7f1d15 100644
--- a/src/Serilog/LoggerConfiguration.cs
+++ b/src/Serilog/LoggerConfiguration.cs
@@ -24,6 +24,7 @@ public class LoggerConfiguration
     readonly List<ILogEventEnricher> _enrichers = new();
     readonly List<ILogEventFilter> _filters = new();
     readonly List<Type> _additionalScalarTypes = new();
+    readonly HashSet<Type> _additionalDictionaryTypes = new();
     readonly List<IDestructuringPolicy> _additionalDestructuringPolicies = new();
     readonly Dictionary<string, LoggingLevelSwitch> _overrides = new();
     LogEventLevel _minimumLevel = LogEventLevel.Information;
@@ -102,6 +103,7 @@ public LoggerDestructuringConfiguration Destructure
             return new(
                 this,
                 _additionalScalarTypes.Add,
+                type => _additionalDictionaryTypes.Add(type),
                 _additionalDestructuringPolicies.Add,
                 depth => _maximumDestructuringDepth = depth,
                 length => _maximumStringLength = length,
@@ -152,6 +154,7 @@ public Logger CreateLogger()
             _maximumStringLength,
             _maximumCollectionCount,
             _additionalScalarTypes,
+            _additionalDictionaryTypes,
             _additionalDestructuringPolicies,
             auditing);
         var processor = new MessageTemplateProcessor(converter);


Test patch:
diff --git a/test/Serilog.ApprovalTests/Serilog.approved.txt b/test/Serilog.ApprovalTests/Serilog.approved.txt
index ecb422542..c42f5f578 100644
--- a/test/Serilog.ApprovalTests/Serilog.approved.txt
+++ b/test/Serilog.ApprovalTests/Serilog.approved.txt
@@ -14,6 +14,8 @@ namespace Serilog.Configuration
     }
     public class LoggerDestructuringConfiguration
     {
+        public Serilog.LoggerConfiguration AsDictionary<T>()
+            where T : System.Collections.IDictionary { }
         public Serilog.LoggerConfiguration AsScalar(System.Type scalarType) { }
         public Serilog.LoggerConfiguration AsScalar<TScalar>() { }
         public Serilog.LoggerConfiguration ByTransforming<TValue>(System.Func<TValue, object> transformation) { }
diff --git a/test/Serilog.Tests/Capturing/PropertyValueConverterTests.cs b/test/Serilog.Tests/Capturing/PropertyValueConverterTests.cs
index 0224ae3db..939c7f9ab 100644
--- a/test/Serilog.Tests/Capturing/PropertyValueConverterTests.cs
+++ b/test/Serilog.Tests/Capturing/PropertyValueConverterTests.cs
@@ -6,12 +6,12 @@ namespace Serilog.Tests.Capturing;
 public class PropertyValueConverterTests
 {
     readonly PropertyValueConverter _converter =
-        new(10, 1000, 1000, Enumerable.Empty<Type>(), Enumerable.Empty<IDestructuringPolicy>(), false);
+        new(10, 1000, 1000, Enumerable.Empty<Type>(), Enumerable.Empty<Type>(), Enumerable.Empty<IDestructuringPolicy>(), false);
 
     [Fact]
     public async Task MaximumDepthIsEffectiveAndThreadSafe()
     {
-        var converter = new PropertyValueConverter(3, 1000, 1000, Enumerable.Empty<Type>(), Enumerable.Empty<IDestructuringPolicy>(), false);
+        var converter = new PropertyValueConverter(3, 1000, 1000, Enumerable.Empty<Type>(), Enumerable.Empty<Type>(), Enumerable.Empty<IDestructuringPolicy>(), false);
 
         var barrier = new Barrier(participantCount: 3);
 
diff --git a/test/Serilog.Tests/Core/LogEventPropertyCapturingTests.cs b/test/Serilog.Tests/Core/LogEventPropertyCapturingTests.cs
index 4051ac018..43a67e4ac 100644
--- a/test/Serilog.Tests/Core/LogEventPropertyCapturingTests.cs
+++ b/test/Serilog.Tests/Core/LogEventPropertyCapturingTests.cs
@@ -167,7 +167,7 @@ static IEnumerable<LogEventProperty> Capture(string messageTemplate, params obje
     {
         var mt = new MessageTemplateParser().Parse(messageTemplate);
         var binder = new PropertyBinder(
-            new PropertyValueConverter(10, 1000, 1000, Enumerable.Empty<Type>(), Enumerable.Empty<IDestructuringPolicy>(), false));
+            new PropertyValueConverter(10, 1000, 1000, Enumerable.Empty<Type>(), Enumerable.Empty<Type>(), Enumerable.Empty<IDestructuringPolicy>(), false));
         return binder.ConstructProperties(mt, properties).Select(p => new LogEventProperty(p.Name, p.Value));
     }
 }
diff --git a/test/Serilog.Tests/Core/MessageTemplateTests.cs b/test/Serilog.Tests/Core/MessageTemplateTests.cs
index 3dc11d659..148a4536a 100755
--- a/test/Serilog.Tests/Core/MessageTemplateTests.cs
+++ b/test/Serilog.Tests/Core/MessageTemplateTests.cs
@@ -134,7 +134,7 @@ static string Render(string messageTemplate, params object[] properties)
     static string Render(IFormatProvider? formatProvider, string messageTemplate, params object[] properties)
     {
         var mt = new MessageTemplateParser().Parse(messageTemplate);
-        var binder = new PropertyBinder(new PropertyValueConverter(10, 1000, 1000, Enumerable.Empty<Type>(), Enumerable.Empty<IDestructuringPolicy>(), false));
+        var binder = new PropertyBinder(new PropertyValueConverter(10, 1000, 1000, Enumerable.Empty<Type>(), Enumerable.Empty<Type>(), Enumerable.Empty<IDestructuringPolicy>(), false));
         var props = binder.ConstructProperties(mt, properties);
         var output = new StringBuilder();
         var writer = new StringWriter(output);
diff --git a/test/Serilog.Tests/Events/LogEventPropertyValueTests.cs b/test/Serilog.Tests/Events/LogEventPropertyValueTests.cs
index decbf0b63..3f407b13d 100644
--- a/test/Serilog.Tests/Events/LogEventPropertyValueTests.cs
+++ b/test/Serilog.Tests/Events/LogEventPropertyValueTests.cs
@@ -17,7 +17,7 @@ namespace Serilog.Tests.Events;
 public class LogEventPropertyValueTests
 {
     readonly PropertyValueConverter _converter =
-        new(10, 1000, 1000, Enumerable.Empty<Type>(), Enumerable.Empty<IDestructuringPolicy>(), false);
+        new(10, 1000, 1000, Enumerable.Empty<Type>(), Enumerable.Empty<Type>(), Enumerable.Empty<IDestructuringPolicy>(), false);
 
     [Fact]
     public void AnEnumIsConvertedToANonStringScalarValue()
diff --git a/test/Serilog.Tests/Formatting/Json/JsonFormatterTests.cs b/test/Serilog.Tests/Formatting/Json/JsonFormatterTests.cs
index ec3b820a0..9d8d0ed7d 100644
--- a/test/Serilog.Tests/Formatting/Json/JsonFormatterTests.cs
+++ b/test/Serilog.Tests/Formatting/Json/JsonFormatterTests.cs
@@ -205,6 +205,23 @@ public void ReadonlyDictionariesAreDestructuredViaDictionaryValue()
         Assert.Equal(1.2, (double)f.Properties.ADictionary.nums[0]);
     }
 
+    private class MyDictionary : Dictionary<string, object> { }
+
+    [Fact]
+    public void CustomDictionariesAreDestructuredViaDictionaryValue_When_AsDictionary_Applied()
+    {
+        var dict = new MyDictionary {
+            { "hello", "world" },
+            { "nums", new[] { 1.2 } }
+        };
+
+        var e = DelegatingSink.GetLogEvent(l => l.Information("Value is {ADictionary}", dict), cfg => cfg.Destructure.AsDictionary<MyDictionary>());
+        var f = FormatJson(e);
+
+        Assert.Equal("world", (string)f.Properties.ADictionary["hello"]);
+        Assert.Equal(1.2, (double)f.Properties.ADictionary.nums[0]);
+    }
+
     [Fact]
     public void PropertyTokensWithFormatStringsAreIncludedAsRenderings()
     {
diff --git a/test/Serilog.Tests/Support/DelegatingSink.cs b/test/Serilog.Tests/Support/DelegatingSink.cs
index 19013adfc..89cff9de2 100644
--- a/test/Serilog.Tests/Support/DelegatingSink.cs
+++ b/test/Serilog.Tests/Support/DelegatingSink.cs
@@ -14,13 +14,17 @@ public void Emit(LogEvent logEvent)
         _write(logEvent);
     }
 
-    public static LogEvent GetLogEvent(Action<ILogger> writeAction)
+    public static LogEvent GetLogEvent(Action<ILogger> writeAction, Func<LoggerConfiguration, LoggerConfiguration>? configure = null)
     {
         LogEvent? result = null;
-        var l = new LoggerConfiguration()
+        var configuration = new LoggerConfiguration()
             .MinimumLevel.Verbose()
-            .WriteTo.Sink(new DelegatingSink(le => result = le))
-            .CreateLogger();
+            .WriteTo.Sink(new DelegatingSink(le => result = le));
+
+        if (configure != null)
+            configuration = configure(configuration);
+
+        var l = configuration.CreateLogger();
 
         writeAction(l);
         Assert.NotNull(result);