From d1870658132afa81785f585cb392fbebc036b8a9 Mon Sep 17 00:00:00 2001
From: Lucas Trzesniewski <lucas.trzesniewski@gmail.com>
Date: Sun, 10 Dec 2023 00:49:33 +0100
Subject: [PATCH] UTF-8 WIP

---
 .../Logging/StreamAppenderBenchmarks.cs       |  48 ++
 src/ZeroLog.Benchmarks/Program.cs             |  36 +-
 .../ZeroLog.Benchmarks.csproj                 |   1 +
 src/ZeroLog.Impl.Base/Log.cs                  |   6 +
 .../Appenders/StreamAppender.cs               |  60 ++-
 .../Configuration/ZeroLogConfiguration.cs     |  37 +-
 src/ZeroLog.Impl.Full/EnumArg.cs              |  60 +++
 src/ZeroLog.Impl.Full/EnumCache.cs            |  37 +-
 .../Formatting/ByteBufferBuilder.cs           | 145 ++++++
 .../Formatting/DefaultFormatter.cs            |  42 ++
 src/ZeroLog.Impl.Full/Formatting/Formatter.cs |   6 +
 src/ZeroLog.Impl.Full/Formatting/HexUtils.cs  |  14 +
 .../Formatting/JsonWriterUtf8.cs              | 130 ++++++
 .../Formatting/LoggedMessage.cs               | 108 ++++-
 .../Formatting/PrefixWriter.cs                | 119 +++++
 .../Formatting/Utf8Formatter.cs               | 110 +++++
 src/ZeroLog.Impl.Full/LogMessage.Impl.cs      |   3 +
 .../LogMessage.OutputUtf8.cs                  | 417 ++++++++++++++++++
 .../ZeroLog.Impl.Full.csproj                  |   2 +-
 ....should_export_expected_types.verified.txt |   1 +
 ...expected_public_api.DotNet6_0.verified.txt |  14 +
 ...expected_public_api.DotNet7_0.verified.txt |  14 +
 ...expected_public_api.DotNet8_0.verified.txt |  15 +
 src/ZeroLog/ZeroLog.csproj                    |   2 +-
 24 files changed, 1384 insertions(+), 43 deletions(-)
 create mode 100644 src/ZeroLog.Benchmarks/Logging/StreamAppenderBenchmarks.cs
 create mode 100644 src/ZeroLog.Impl.Full/Formatting/ByteBufferBuilder.cs
 create mode 100644 src/ZeroLog.Impl.Full/Formatting/JsonWriterUtf8.cs
 create mode 100644 src/ZeroLog.Impl.Full/Formatting/Utf8Formatter.cs
 create mode 100644 src/ZeroLog.Impl.Full/LogMessage.OutputUtf8.cs

diff --git a/src/ZeroLog.Benchmarks/Logging/StreamAppenderBenchmarks.cs b/src/ZeroLog.Benchmarks/Logging/StreamAppenderBenchmarks.cs
new file mode 100644
index 0000000..511c350
--- /dev/null
+++ b/src/ZeroLog.Benchmarks/Logging/StreamAppenderBenchmarks.cs
@@ -0,0 +1,48 @@
+using System;
+using System.IO;
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using ZeroLog.Appenders;
+using ZeroLog.Configuration;
+using ZeroLog.Formatting;
+
+namespace ZeroLog.Benchmarks.Logging;
+
+[ShortRunJob]
+public class StreamAppenderBenchmarks
+{
+    private readonly Appender _standardFormatterAppender = new(false);
+    private readonly Appender _utf8FormatterAppender = new(true);
+    private readonly LoggedMessage _loggedMessage = new(1024, ZeroLogConfiguration.CreateTestConfiguration());
+    private readonly LogMessage _message;
+
+    public StreamAppenderBenchmarks()
+    {
+        _message = LogMessage.CreateTestMessage(LogLevel.Info, 1024, 32);
+        _message.Append("Hello, ").Append("World! ").Append(42).AppendEnum(DayOfWeek.Friday).Append(new DateTime(2023, 01, 01)).Append(1024);
+    }
+
+    [Benchmark(Baseline = true)]
+    public void StandardFormatter()
+    {
+        _loggedMessage.SetMessage(_message);
+        _standardFormatterAppender.WriteMessage(_loggedMessage);
+    }
+
+    [Benchmark]
+    public void Utf8Formatter()
+    {
+        _loggedMessage.SetMessage(_message);
+        _utf8FormatterAppender.WriteMessage(_loggedMessage);
+    }
+
+    private class Appender : StreamAppender
+    {
+        public Appender(bool utf8Formatter)
+        {
+            AllowUtf8Formatter = utf8Formatter;
+            Stream = Stream.Null;
+            Encoding = Encoding.UTF8;
+        }
+    }
+}
diff --git a/src/ZeroLog.Benchmarks/Program.cs b/src/ZeroLog.Benchmarks/Program.cs
index 104c7da..7df1f5a 100644
--- a/src/ZeroLog.Benchmarks/Program.cs
+++ b/src/ZeroLog.Benchmarks/Program.cs
@@ -2,7 +2,9 @@
 using BenchmarkDotNet.Columns;
 using BenchmarkDotNet.Configs;
 using BenchmarkDotNet.Running;
+using JetBrains.Profiler.Api;
 using ZeroLog.Benchmarks.LatencyTests;
+using ZeroLog.Benchmarks.Logging;
 using ZeroLog.Benchmarks.ThroughputTests;
 using ZeroLog.Benchmarks.Tools;
 
@@ -39,6 +41,32 @@ private static void LatencyMultiProducer(int threadCount, int warmupMessageCount
         );
     }
 
+    private static void RunProfiler()
+    {
+        var bench = new StreamAppenderBenchmarks();
+
+        for (var i = 0; i < 100; ++i)
+            bench.Utf8Formatter();
+
+        MeasureProfiler.StartCollectingData();
+
+        for (var i = 0; i < 100_000; ++i)
+            bench.Utf8Formatter();
+
+        MeasureProfiler.SaveData();
+    }
+
+    private static void RunBenchmark()
+    {
+        BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run();
+
+        while (Console.KeyAvailable)
+            Console.ReadKey(true);
+
+        Console.WriteLine("Press enter to exit");
+        Console.ReadLine();
+    }
+
     public static void Main()
     {
         //Throughput();
@@ -51,12 +79,8 @@ public static void Main()
         //EnumBenchmarksRunner.Run();
         //ThroughputToFileBench.Run();
 
-        BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run();
+        //RunProfiler();
 
-        while (Console.KeyAvailable)
-            Console.ReadKey(true);
-
-        Console.WriteLine("Press enter to exit");
-        Console.ReadLine();
+        RunBenchmark();
     }
 }
diff --git a/src/ZeroLog.Benchmarks/ZeroLog.Benchmarks.csproj b/src/ZeroLog.Benchmarks/ZeroLog.Benchmarks.csproj
index b11d13d..a283f66 100644
--- a/src/ZeroLog.Benchmarks/ZeroLog.Benchmarks.csproj
+++ b/src/ZeroLog.Benchmarks/ZeroLog.Benchmarks.csproj
@@ -16,6 +16,7 @@
     <PackageReference Include="HdrHistogram" Version="2.5.0" />
     <PackageReference Include="Fody" Version="6.8.0" PrivateAssets="all" />
     <PackageReference Include="InlineIL.Fody" Version="1.7.4" PrivateAssets="all" />
+    <PackageReference Include="JetBrains.Profiler.Api" Version="1.4.0" />
     <PackageReference Include="log4net" Version="2.0.15" />
     <PackageReference Include="NLog" Version="5.2.7" />
     <PackageReference Include="Serilog" Version="3.1.1" />
diff --git a/src/ZeroLog.Impl.Base/Log.cs b/src/ZeroLog.Impl.Base/Log.cs
index 40593fe..7ccf62e 100644
--- a/src/ZeroLog.Impl.Base/Log.cs
+++ b/src/ZeroLog.Impl.Base/Log.cs
@@ -13,12 +13,18 @@ public sealed partial class Log
     private LogLevel _logLevel = LogLevel.None;
 
     internal string Name { get; }
+    internal byte[] NameUtf8 { get; }
+
     internal string CompactName { get; }
+    internal byte[] CompactNameUtf8 { get; }
 
     internal Log(string name)
     {
         Name = name;
+        NameUtf8 = Encoding.UTF8.GetBytes(Name);
+
         CompactName = GetCompactName(name);
+        CompactNameUtf8 = Encoding.UTF8.GetBytes(CompactName);
     }
 
     /// <summary>
diff --git a/src/ZeroLog.Impl.Full/Appenders/StreamAppender.cs b/src/ZeroLog.Impl.Full/Appenders/StreamAppender.cs
index 61022cd..af9b589 100644
--- a/src/ZeroLog.Impl.Full/Appenders/StreamAppender.cs
+++ b/src/ZeroLog.Impl.Full/Appenders/StreamAppender.cs
@@ -13,9 +13,11 @@ public abstract class StreamAppender : Appender
 {
     private byte[] _byteBuffer = [];
 
+    private readonly Formatter _formatter = new DefaultFormatter();
     private Encoding _encoding = Encoding.UTF8;
+    private Utf8Formatter? _utf8Formatter;
     private bool _useSpanGetBytes;
-    private Formatter? _formatter;
+    private bool _allowUtf8Formatter = true;
 
     /// <summary>
     /// The stream to write to.
@@ -30,6 +32,9 @@ protected internal Encoding Encoding
         get => _encoding;
         set
         {
+            if (ReferenceEquals(value, _encoding))
+                return;
+
             _encoding = value;
             UpdateEncodingSpecificData();
         }
@@ -40,8 +45,28 @@ protected internal Encoding Encoding
     /// </summary>
     public Formatter Formatter
     {
-        get => _formatter ??= new DefaultFormatter();
-        init => _formatter = value;
+        get => _formatter;
+        init
+        {
+            if (ReferenceEquals(value, _formatter))
+                return;
+
+            _formatter = value;
+            UpdateEncodingSpecificData();
+        }
+    }
+
+    /// <summary>
+    /// For benchmarks.
+    /// </summary>
+    internal bool AllowUtf8Formatter
+    {
+        get => _allowUtf8Formatter;
+        set
+        {
+            _allowUtf8Formatter = value;
+            UpdateEncodingSpecificData();
+        }
     }
 
     /// <summary>
@@ -64,21 +89,25 @@ public override void Dispose()
     /// <inheritdoc/>
     public override void WriteMessage(LoggedMessage message)
     {
-        if (Stream is null)
+        if (Stream is not { } stream)
             return;
 
-        if (_useSpanGetBytes)
+        if (_utf8Formatter is { } utf8Formatter)
+        {
+            stream.Write(utf8Formatter.FormatMessage(message));
+        }
+        else if (_useSpanGetBytes)
         {
-            var chars = Formatter.FormatMessage(message);
+            var chars = _formatter.FormatMessage(message);
             var byteCount = _encoding.GetBytes(chars, _byteBuffer);
-            Stream.Write(_byteBuffer, 0, byteCount);
+            stream.Write(_byteBuffer, 0, byteCount);
         }
         else
         {
-            Formatter.FormatMessage(message);
-            var charBuffer = Formatter.GetBuffer(out var charCount);
+            _formatter.FormatMessage(message);
+            var charBuffer = _formatter.GetBuffer(out var charCount);
             var byteCount = _encoding.GetBytes(charBuffer, 0, charCount, _byteBuffer, 0);
-            Stream.Write(_byteBuffer, 0, byteCount);
+            stream.Write(_byteBuffer, 0, byteCount);
         }
     }
 
@@ -91,7 +120,14 @@ public override void Flush()
 
     private void UpdateEncodingSpecificData()
     {
-        var maxBytes = _encoding.GetMaxByteCount(LogManager.OutputBufferSize);
+        if (_encoding is UTF8Encoding && _formatter.AsUtf8Formatter() is { } utf8Formatter && _allowUtf8Formatter)
+        {
+            // Fast path
+            _utf8Formatter = utf8Formatter;
+            return;
+        }
+
+        _utf8Formatter = null;
 
         // The base Encoding class allocates buffers in all non-abstract GetBytes overloads in order to call the abstract
         // GetBytes(char[] chars, int charIndex, int charCount, byte[] bytes, int byteIndex) in the end.
@@ -99,6 +135,8 @@ private void UpdateEncodingSpecificData()
         // and it skips safety checks as those are guaranteed by the Span struct. In that case, we can call this overload directly.
         _useSpanGetBytes = OverridesSpanGetBytes(_encoding.GetType());
 
+        var maxBytes = _encoding.GetMaxByteCount(LogManager.OutputBufferSize);
+
         if (_byteBuffer.Length < maxBytes)
             _byteBuffer = GC.AllocateUninitializedArray<byte>(maxBytes);
     }
diff --git a/src/ZeroLog.Impl.Full/Configuration/ZeroLogConfiguration.cs b/src/ZeroLog.Impl.Full/Configuration/ZeroLogConfiguration.cs
index 419d9a1..2f083f9 100644
--- a/src/ZeroLog.Impl.Full/Configuration/ZeroLogConfiguration.cs
+++ b/src/ZeroLog.Impl.Full/Configuration/ZeroLogConfiguration.cs
@@ -2,6 +2,7 @@
 using System.Collections.Generic;
 using System.ComponentModel;
 using System.Linq;
+using System.Text;
 using ZeroLog.Appenders;
 using ZeroLog.Formatting;
 
@@ -16,6 +17,9 @@ public sealed class ZeroLogConfiguration
 
     private LoggerConfigurationCollection _loggers = new();
 
+    private string _nullDisplayString = "null";
+    private string _truncatedMessageSuffix = " [TRUNCATED]";
+
     internal event Action? ApplyChangesRequested;
 
     /// <summary>
@@ -86,7 +90,17 @@ public bool UseBackgroundThread
     /// <remarks>
     /// Default: "null"
     /// </remarks>
-    public string NullDisplayString { get; set; } = "null";
+    public string NullDisplayString
+    {
+        get => _nullDisplayString;
+        set
+        {
+            _nullDisplayString = value;
+            NullDisplayStringUtf8 = Encoding.UTF8.GetBytes(value);
+        }
+    }
+
+    internal byte[] NullDisplayStringUtf8 { get; private set; }
 
     /// <summary>
     /// The string which is appended to a message when it is truncated.
@@ -94,7 +108,17 @@ public bool UseBackgroundThread
     /// <remarks>
     /// Default: " [TRUNCATED]"
     /// </remarks>
-    public string TruncatedMessageSuffix { get; set; } = " [TRUNCATED]";
+    public string TruncatedMessageSuffix
+    {
+        get => _truncatedMessageSuffix;
+        set
+        {
+            _truncatedMessageSuffix = value;
+            TruncatedMessageSuffixUtf8 = Encoding.UTF8.GetBytes(value);
+        }
+    }
+
+    internal byte[] TruncatedMessageSuffixUtf8 { get; private set; }
 
     /// <summary>
     /// The time an appender will be put into quarantine (not used to log messages) after it throws an exception.
@@ -121,6 +145,15 @@ public bool UseBackgroundThread
     /// </remarks>
     public ILoggerConfigurationCollection Loggers => _loggers;
 
+    /// <summary>
+    /// Creates a new ZeroLog configuration.
+    /// </summary>
+    public ZeroLogConfiguration()
+    {
+        NullDisplayStringUtf8 = Encoding.UTF8.GetBytes(NullDisplayString);
+        TruncatedMessageSuffixUtf8 = Encoding.UTF8.GetBytes(TruncatedMessageSuffix);
+    }
+
     /// <summary>
     /// Applies the changes made to this object since the call to <see cref="LogManager.Initialize"/>
     /// or the last call to <see cref="ApplyChanges"/>.
diff --git a/src/ZeroLog.Impl.Full/EnumArg.cs b/src/ZeroLog.Impl.Full/EnumArg.cs
index c1fee23..4154d5b 100644
--- a/src/ZeroLog.Impl.Full/EnumArg.cs
+++ b/src/ZeroLog.Impl.Full/EnumArg.cs
@@ -1,8 +1,10 @@
 using System;
+using System.Buffers;
 using System.Diagnostics.CodeAnalysis;
 using System.Globalization;
 using System.Runtime.CompilerServices;
 using System.Runtime.InteropServices;
+using System.Text.Unicode;
 using ZeroLog.Configuration;
 using ZeroLog.Support;
 
@@ -39,6 +41,27 @@ public bool TryFormat(Span<char> destination, out int charsWritten, ZeroLogConfi
         return TryAppendNumericValue(destination, out charsWritten);
     }
 
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public bool TryFormat(Span<byte> destination, out int bytesWritten, ZeroLogConfiguration config)
+    {
+        var enumString = GetUtf8String(config);
+
+        if (enumString != null)
+        {
+            if (enumString.Length <= destination.Length)
+            {
+                enumString.CopyTo(destination);
+                bytesWritten = enumString.Length;
+                return true;
+            }
+
+            bytesWritten = 0;
+            return false;
+        }
+
+        return TryAppendNumericValue(destination, out bytesWritten);
+    }
+
     [MethodImpl(MethodImplOptions.NoInlining)]
     private bool TryAppendNumericValue(Span<char> destination, out int charsWritten)
     {
@@ -48,11 +71,35 @@ private bool TryAppendNumericValue(Span<char> destination, out int charsWritten)
         return unchecked((long)_value).TryFormat(destination, out charsWritten, default, CultureInfo.InvariantCulture);
     }
 
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private bool TryAppendNumericValue(Span<byte> destination, out int bytesWritten)
+    {
+#if NET8_0_OR_GREATER
+        if (_value <= long.MaxValue || !EnumCache.IsEnumSigned(_typeHandle))
+            return _value.TryFormat(destination, out bytesWritten, default, CultureInfo.InvariantCulture);
+
+        return unchecked((long)_value).TryFormat(destination, out bytesWritten, default, CultureInfo.InvariantCulture);
+#else
+        Span<char> buffer = stackalloc char[32];
+
+        if (TryAppendNumericValue(buffer, out var charsWritten))
+            return Utf8.FromUtf16(buffer.Slice(0, charsWritten), destination, out _, out bytesWritten) == OperationStatus.Done;
+
+        bytesWritten = 0;
+        return false;
+#endif
+    }
+
     [MethodImpl(MethodImplOptions.AggressiveInlining)]
     public string? GetString(ZeroLogConfiguration config)
         => EnumCache.GetString(_typeHandle, _value, out var enumRegistered)
            ?? GetStringSlow(enumRegistered, config);
 
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public byte[]? GetUtf8String(ZeroLogConfiguration config)
+        => EnumCache.GetUtf8String(_typeHandle, _value, out var enumRegistered)
+           ?? GetUtf8StringSlow(enumRegistered, config);
+
     [MethodImpl(MethodImplOptions.NoInlining)]
     private string? GetStringSlow(bool enumRegistered, ZeroLogConfiguration config)
     {
@@ -66,6 +113,19 @@ private bool TryAppendNumericValue(Span<char> destination, out int charsWritten)
         return EnumCache.GetString(_typeHandle, _value, out _);
     }
 
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private byte[]? GetUtf8StringSlow(bool enumRegistered, ZeroLogConfiguration config)
+    {
+        if (enumRegistered || !config.AutoRegisterEnums)
+            return null;
+
+        if (Type is not { } type)
+            return null;
+
+        LogManager.RegisterEnum(type);
+        return EnumCache.GetUtf8String(_typeHandle, _value, out _);
+    }
+
     public bool TryGetValue<T>(out T result)
         where T : unmanaged
     {
diff --git a/src/ZeroLog.Impl.Full/EnumCache.cs b/src/ZeroLog.Impl.Full/EnumCache.cs
index 421b7c4..517a14b 100644
--- a/src/ZeroLog.Impl.Full/EnumCache.cs
+++ b/src/ZeroLog.Impl.Full/EnumCache.cs
@@ -4,6 +4,7 @@
 using System.Diagnostics.CodeAnalysis;
 using System.Linq;
 using System.Runtime.CompilerServices;
+using System.Text;
 using InlineIL;
 using ZeroLog.Support;
 using static InlineIL.IL.Emit;
@@ -43,6 +44,18 @@ public static bool IsRegistered(Type enumType)
         return null;
     }
 
+    public static byte[]? GetUtf8String(IntPtr typeHandle, ulong value, out bool registered)
+    {
+        if (_enums.TryGetValue(typeHandle, out var values))
+        {
+            registered = true;
+            return values.TryGetUtf8String(value);
+        }
+
+        registered = false;
+        return null;
+    }
+
     [MethodImpl(MethodImplOptions.AggressiveInlining)]
     [SuppressMessage("ReSharper", "UnusedParameter.Global")]
     [SuppressMessage("ReSharper", "EntityNameCapturedOnly.Global")]
@@ -192,11 +205,13 @@ public static EnumStrings Create(Type enumType)
         }
 
         public abstract string? TryGetString(ulong value);
+        public abstract byte[]? TryGetUtf8String(ulong value);
     }
 
     private sealed class ArrayEnumStrings : EnumStrings
     {
         private readonly string[] _strings;
+        private readonly byte[][] _utf8Strings;
 
         public static bool CanHandle(IEnumerable<EnumItem> enumItems)
             => enumItems.All(i => i.Value < 32);
@@ -206,36 +221,52 @@ public ArrayEnumStrings(List<EnumItem> enumItems)
             if (enumItems.Count == 0)
             {
                 _strings = [];
+                _utf8Strings = [];
                 return;
             }
 
             var maxValue = enumItems.Select(i => i.Value).Max();
             _strings = new string[maxValue + 1];
+            _utf8Strings = new byte[maxValue + 1][];
 
             foreach (var item in enumItems)
+            {
                 _strings[item.Value] = item.Name;
+                _utf8Strings[item.Value] = Encoding.UTF8.GetBytes(item.Name);
+            }
         }
 
         public override string? TryGetString(ulong value)
             => value < (ulong)_strings.Length
                 ? _strings[unchecked((int)value)]
                 : null;
+
+        public override byte[]? TryGetUtf8String(ulong value)
+            => value < (ulong)_strings.Length
+                ? _utf8Strings[unchecked((int)value)]
+                : null;
     }
 
     private sealed class DictionaryEnumStrings : EnumStrings
     {
-        private readonly Dictionary<ulong, string> _strings = new();
+        private readonly Dictionary<ulong, (string utf16, byte[] utf8)> _strings = new();
 
         public DictionaryEnumStrings(List<EnumItem> enumItems)
         {
             foreach (var item in enumItems)
-                _strings[item.Value] = item.Name;
+                _strings[item.Value] = (item.Name, Encoding.UTF8.GetBytes(item.Name));
         }
 
         public override string? TryGetString(ulong value)
         {
             _strings.TryGetValue(value, out var str);
-            return str;
+            return str.utf16;
+        }
+
+        public override byte[]? TryGetUtf8String(ulong value)
+        {
+            _strings.TryGetValue(value, out var str);
+            return str.utf8;
         }
     }
 
diff --git a/src/ZeroLog.Impl.Full/Formatting/ByteBufferBuilder.cs b/src/ZeroLog.Impl.Full/Formatting/ByteBufferBuilder.cs
new file mode 100644
index 0000000..86cf06d
--- /dev/null
+++ b/src/ZeroLog.Impl.Full/Formatting/ByteBufferBuilder.cs
@@ -0,0 +1,145 @@
+#if NET8_0_OR_GREATER
+
+using System;
+using System.Buffers;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Unicode;
+
+namespace ZeroLog.Formatting;
+
+[SuppressMessage("ReSharper", "ReplaceSliceWithRangeIndexer")]
+internal ref struct ByteBufferBuilder
+{
+    private readonly Span<byte> _buffer;
+    private int _pos;
+
+    public int Length => _pos;
+
+    public ByteBufferBuilder(Span<byte> buffer)
+    {
+        _buffer = buffer;
+        _pos = 0;
+    }
+
+    public ReadOnlySpan<byte> GetOutput()
+        => _buffer.Slice(0, _pos);
+
+    public Span<byte> GetRemainingBuffer()
+        => _buffer.Slice(_pos);
+
+    public void IncrementPos(int chars)
+        => _pos += chars;
+
+    /// <summary>
+    /// Appends a character, but does nothing if there is no more room for it.
+    /// </summary>
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public void AppendAscii(char value)
+    {
+        if (_pos < _buffer.Length)
+            _buffer[_pos++] = (byte)value;
+    }
+
+    public bool TryAppendWhole(ReadOnlySpan<byte> value)
+    {
+        if (value.Length <= _buffer.Length - _pos)
+        {
+            value.CopyTo(_buffer.Slice(_pos));
+            _pos += value.Length;
+            return true;
+        }
+
+        return false;
+    }
+
+    public bool TryAppendWhole(ReadOnlySpan<char> value)
+    {
+        if (Utf8.FromUtf16(value, _buffer.Slice(_pos), out _, out var bytesWritten) == OperationStatus.Done)
+        {
+            _pos += bytesWritten;
+            return true;
+        }
+
+        return false;
+    }
+
+    public bool TryAppendPartial(ReadOnlySpan<byte> value)
+    {
+        if (value.Length <= _buffer.Length - _pos)
+        {
+            value.CopyTo(_buffer.Slice(_pos));
+            _pos += value.Length;
+            return true;
+        }
+
+        var length = _buffer.Length - _pos;
+        value.Slice(0, length).CopyTo(_buffer.Slice(_pos));
+        _pos += length;
+        return false;
+    }
+
+    public bool TryAppendPartialChars(scoped ReadOnlySpan<char> value)
+    {
+        var status = Utf8.FromUtf16(value, _buffer.Slice(_pos), out _, out var bytesWritten);
+        _pos += bytesWritten;
+        return status == OperationStatus.Done;
+    }
+
+    public void TryAppendPartialAscii(char value, int count)
+    {
+        if (count > 0)
+        {
+            count = Math.Min(count, _buffer.Length - _pos);
+            _buffer.Slice(_pos, count).Fill((byte)value);
+            _pos += count;
+        }
+    }
+
+    public bool TryAppendAscii(char value)
+    {
+        if (_pos < _buffer.Length)
+        {
+            _buffer[_pos] = (byte)value;
+            ++_pos;
+            return true;
+        }
+
+        return false;
+    }
+
+    public bool TryAppend(DateTime value, string? format)
+    {
+        if (!value.TryFormat(_buffer.Slice(_pos), out var charsWritten, format, CultureInfo.InvariantCulture))
+            return false;
+
+        _pos += charsWritten;
+        return true;
+    }
+
+    public bool TryAppend(TimeSpan value, string? format)
+    {
+        if (!value.TryFormat(_buffer.Slice(_pos), out var charsWritten, format, CultureInfo.InvariantCulture))
+            return false;
+
+        _pos += charsWritten;
+        return true;
+    }
+
+    public bool TryAppend<T>(T value, string? format = null)
+        where T : struct, IUtf8SpanFormattable
+    {
+        if (!value.TryFormat(_buffer.Slice(_pos), out var charsWritten, format, CultureInfo.InvariantCulture))
+            return false;
+
+        _pos += charsWritten;
+        return true;
+    }
+
+    public override string ToString()
+        => Encoding.UTF8.GetString(GetOutput());
+}
+
+#endif
diff --git a/src/ZeroLog.Impl.Full/Formatting/DefaultFormatter.cs b/src/ZeroLog.Impl.Full/Formatting/DefaultFormatter.cs
index 56c2e15..f0addd4 100644
--- a/src/ZeroLog.Impl.Full/Formatting/DefaultFormatter.cs
+++ b/src/ZeroLog.Impl.Full/Formatting/DefaultFormatter.cs
@@ -1,3 +1,4 @@
+using System.Text;
 using ZeroLog.Appenders;
 
 namespace ZeroLog.Formatting;
@@ -84,4 +85,45 @@ protected override void WriteMessage(LoggedMessage message)
             WriteLine(message.Exception.ToString());
         }
     }
+
+#if NET8_0_OR_GREATER
+
+    /// <inheritdoc/>
+    public override Utf8Formatter AsUtf8Formatter()
+        => new DefaultUtf8Formatter(this);
+
+    private sealed class DefaultUtf8Formatter(DefaultFormatter formatter) : Utf8Formatter
+    {
+        private readonly PrefixWriter? _prefixWriter = formatter._prefixWriter;
+        private readonly byte[] _jsonSeparator = Encoding.UTF8.GetBytes(formatter.JsonSeparator);
+
+        protected override void WriteMessage(LoggedMessage message)
+        {
+            if (_prefixWriter != null)
+            {
+                _prefixWriter.WritePrefix(message, GetRemainingBuffer(), out var bytesWritten);
+                AdvanceBy(bytesWritten);
+            }
+
+            Write(message.MessageUtf8);
+
+            if (message.KeyValues.Count != 0)
+            {
+                Write(_jsonSeparator);
+
+                JsonWriterUtf8.WriteJsonToStringBuffer(message.KeyValues, GetRemainingBuffer(), out var bytesWritten);
+                AdvanceBy(bytesWritten);
+            }
+
+            WriteLine();
+
+            if (message.Exception != null)
+            {
+                // This allocates, but there's no better way to get the details.
+                WriteLine(Encoding.UTF8.GetBytes(message.Exception.ToString()));
+            }
+        }
+    }
+
+#endif
 }
diff --git a/src/ZeroLog.Impl.Full/Formatting/Formatter.cs b/src/ZeroLog.Impl.Full/Formatting/Formatter.cs
index dc790e3..826b64d 100644
--- a/src/ZeroLog.Impl.Full/Formatting/Formatter.cs
+++ b/src/ZeroLog.Impl.Full/Formatting/Formatter.cs
@@ -31,6 +31,12 @@ public ReadOnlySpan<char> FormatMessage(LoggedMessage message)
     /// <param name="message">The message to format.</param>
     protected abstract void WriteMessage(LoggedMessage message);
 
+    /// <summary>
+    /// Tries to convert this formatter to an UTF-8 formatter.
+    /// </summary>
+    public virtual Utf8Formatter? AsUtf8Formatter()
+        => null;
+
     /// <summary>
     /// Appends text to the output.
     /// </summary>
diff --git a/src/ZeroLog.Impl.Full/Formatting/HexUtils.cs b/src/ZeroLog.Impl.Full/Formatting/HexUtils.cs
index aeaa66e..afcbb7c 100644
--- a/src/ZeroLog.Impl.Full/Formatting/HexUtils.cs
+++ b/src/ZeroLog.Impl.Full/Formatting/HexUtils.cs
@@ -17,4 +17,18 @@ public static unsafe void AppendValueAsHex(byte* valuePtr, int size, Span<char>
             destination[2 * index + 1] = _hexTable[char0Index];
         }
     }
+
+    public static unsafe void AppendValueAsHex(byte* valuePtr, int size, Span<byte> destination)
+    {
+        var hexTableUtf8 = "0123456789abcdef"u8;
+
+        for (var index = 0; index < size; ++index)
+        {
+            var char0Index = valuePtr[index] & 0xf;
+            var char1Index = (valuePtr[index] & 0xf0) >> 4;
+
+            destination[2 * index] = hexTableUtf8[char1Index];
+            destination[2 * index + 1] = hexTableUtf8[char0Index];
+        }
+    }
 }
diff --git a/src/ZeroLog.Impl.Full/Formatting/JsonWriterUtf8.cs b/src/ZeroLog.Impl.Full/Formatting/JsonWriterUtf8.cs
new file mode 100644
index 0000000..74c05b6
--- /dev/null
+++ b/src/ZeroLog.Impl.Full/Formatting/JsonWriterUtf8.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Runtime.CompilerServices;
+
+namespace ZeroLog.Formatting;
+
+#if NET8_0_OR_GREATER
+
+internal static unsafe class JsonWriterUtf8
+{
+    public static void WriteJsonToStringBuffer(KeyValueList keyValueList, Span<byte> destination, out int charsWritten)
+    {
+        var builder = new ByteBufferBuilder(destination);
+
+        builder.AppendAscii('{');
+        builder.AppendAscii(' ');
+
+        var first = true;
+
+        foreach (var keyValue in keyValueList)
+        {
+            if (!first)
+            {
+                builder.AppendAscii(',');
+                builder.AppendAscii(' ');
+            }
+
+            AppendString(ref builder, keyValue.Key);
+
+            builder.AppendAscii(':');
+            builder.AppendAscii(' ');
+
+            AppendJsonValue(ref builder, keyValue);
+
+            first = false;
+        }
+
+        builder.AppendAscii(' ');
+        builder.AppendAscii('}');
+
+        charsWritten = builder.Length;
+    }
+
+    private static void AppendJsonValue(ref ByteBufferBuilder builder, in LoggedKeyValue keyValue)
+    {
+        if (keyValue.IsBoolean)
+            builder.TryAppendWhole(keyValue.Value.SequenceEqual(bool.TrueString) ? "true"u8 : "false"u8);
+        else if (keyValue.IsNumeric)
+            builder.TryAppendWhole(keyValue.Value);
+        else if (keyValue.IsNull)
+            builder.TryAppendWhole("null"u8);
+        else
+            AppendString(ref builder, keyValue.Value);
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private static void AppendString(ref ByteBufferBuilder builder, ReadOnlySpan<char> value)
+    {
+        builder.AppendAscii('"');
+
+        foreach (var c in value)
+            AppendEscapedChar(c, ref builder);
+
+        builder.AppendAscii('"');
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private static void AppendEscapedChar(char c, ref ByteBufferBuilder builder)
+    {
+        // Escape characters based on https://tools.ietf.org/html/rfc7159
+
+        if (c is '\\' or '"' or <= '\u001F')
+            AppendControlChar(c, ref builder);
+        else
+            builder.TryAppendPartialChars(new ReadOnlySpan<char>(in c));
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private static void AppendControlChar(char c, ref ByteBufferBuilder builder)
+    {
+        switch (c)
+        {
+            case '"':
+                builder.TryAppendWhole(@"\"""u8);
+                break;
+
+            case '\\':
+                builder.TryAppendWhole(@"\\"u8);
+                break;
+
+            case '\b':
+                builder.TryAppendWhole(@"\b"u8);
+                break;
+
+            case '\t':
+                builder.TryAppendWhole(@"\t"u8);
+                break;
+
+            case '\n':
+                builder.TryAppendWhole(@"\n"u8);
+                break;
+
+            case '\f':
+                builder.TryAppendWhole(@"\f"u8);
+                break;
+
+            case '\r':
+                builder.TryAppendWhole(@"\r"u8);
+                break;
+
+            default:
+            {
+                var prefix = @"\u00"u8;
+                var destination = builder.GetRemainingBuffer();
+
+                if (destination.Length >= prefix.Length + 2)
+                {
+                    builder.TryAppendWhole(prefix);
+
+                    var byteValue = unchecked((byte)c);
+                    HexUtils.AppendValueAsHex(&byteValue, 1, builder.GetRemainingBuffer());
+                    builder.IncrementPos(2);
+                }
+
+                break;
+            }
+        }
+    }
+}
+
+#endif
diff --git a/src/ZeroLog.Impl.Full/Formatting/LoggedMessage.cs b/src/ZeroLog.Impl.Full/Formatting/LoggedMessage.cs
index a5e03f7..462ed48 100644
--- a/src/ZeroLog.Impl.Full/Formatting/LoggedMessage.cs
+++ b/src/ZeroLog.Impl.Full/Formatting/LoggedMessage.cs
@@ -1,5 +1,6 @@
 using System;
 using System.Runtime.CompilerServices;
+using System.Text;
 using System.Threading;
 using ZeroLog.Configuration;
 
@@ -10,8 +11,13 @@ namespace ZeroLog.Formatting;
 /// </summary>
 public sealed class LoggedMessage
 {
-    private readonly char[] _messageBuffer;
-    private int _messageLength;
+    private readonly char[] _charBuffer;
+    private readonly byte[] _byteBuffer;
+    private readonly KeyValueList _keyValues;
+
+    private int _charBufferLength;
+    private int _byteBufferLength;
+
     private ZeroLogConfiguration _config;
 
     private LogMessage _message = LogMessage.Empty;
@@ -38,18 +44,25 @@ public sealed class LoggedMessage
     /// <summary>
     /// The logged message text.
     /// </summary>
-    public ReadOnlySpan<char> Message => _messageBuffer.AsSpan(0, _messageLength);
+    public ReadOnlySpan<char> Message => GetMessageUtf16();
+
+    /// <summary>
+    /// The logged message text, encoded in UTF-8.
+    /// </summary>
+    public ReadOnlySpan<byte> MessageUtf8 => GetMessageUtf8();
 
     /// <summary>
     /// The logged message metadata as a list of key/value pairs.
     /// </summary>
-    public KeyValueList KeyValues { get; }
+    public KeyValueList KeyValues => GetKeyValues();
 
     internal LoggedMessage(int bufferSize, ZeroLogConfiguration config)
     {
         _config = config;
-        _messageBuffer = GC.AllocateUninitializedArray<char>(bufferSize);
-        KeyValues = new KeyValueList(bufferSize);
+        _charBuffer = GC.AllocateUninitializedArray<char>(bufferSize);
+        _byteBuffer = GC.AllocateUninitializedArray<byte>(bufferSize * Utf8Formatter.MaxUtf8BytesPerChar);
+
+        _keyValues = new KeyValueList(bufferSize);
 
         SetMessage(LogMessage.Empty);
     }
@@ -58,31 +71,88 @@ private LoggedMessage(LoggedMessage other)
     {
         _config = other._config;
 
-        _messageBuffer = other._messageBuffer.AsSpan(0, other._messageLength).ToArray();
-        _messageLength = other._messageLength;
+        _charBuffer = other.Message.ToArray();
+        _charBufferLength = other._charBufferLength;
 
-        KeyValues = new KeyValueList(other.KeyValues);
+        _byteBuffer = other.MessageUtf8.ToArray();
+        _byteBufferLength = other._byteBufferLength;
+
+        _keyValues = new KeyValueList(other.KeyValues);
         _message = other._message.CloneMetadata();
     }
 
     internal void SetMessage(LogMessage message)
     {
         _message = message;
+        _charBufferLength = -1;
+        _byteBufferLength = -1;
+        _keyValues.Clear();
 
-        try
-        {
 #if DEBUG
-            _messageBuffer.AsSpan().Fill((char)0);
+        _charBuffer.AsSpan().Clear();
+        _byteBuffer.AsSpan().Clear();
 #endif
+    }
+
+    private ReadOnlySpan<char> GetMessageUtf16()
+    {
+        if (_charBufferLength < 0)
+            FormatMessage();
 
-            _messageLength = _message.WriteTo(_messageBuffer, _config, LogMessage.FormatType.Formatted, KeyValues);
+        return _charBuffer.AsSpan(0, _charBufferLength);
+
+        [MethodImpl(MethodImplOptions.NoInlining)]
+        void FormatMessage()
+        {
+            try
+            {
+                _charBufferLength = _byteBufferLength >= 0
+                    ? Encoding.UTF8.GetChars(_byteBuffer.AsSpan(0, _byteBufferLength), _charBuffer)
+                    : _message.WriteTo(_charBuffer, _config, LogMessage.FormatType.Formatted, _keyValues);
+            }
+            catch (Exception ex)
+            {
+                HandleFormattingError(ex);
+            }
         }
-        catch (Exception ex)
+    }
+
+    private ReadOnlySpan<byte> GetMessageUtf8()
+    {
+        if (_byteBufferLength < 0)
+            FormatMessage();
+
+        return _byteBuffer.AsSpan(0, _byteBufferLength);
+
+        [MethodImpl(MethodImplOptions.NoInlining)]
+        void FormatMessage()
         {
-            HandleFormattingError(ex);
+            try
+            {
+#if NET8_0_OR_GREATER
+                _byteBufferLength = _charBufferLength >= 0
+                    ? Encoding.UTF8.GetBytes(_charBuffer.AsSpan(0, _charBufferLength), _byteBuffer)
+                    : _message.WriteTo(_byteBuffer, _config, LogMessage.FormatType.Formatted, _keyValues);
+#else
+                _byteBufferLength = Encoding.UTF8.GetBytes(GetMessageUtf16(), _byteBuffer);
+#endif
+            }
+            catch (Exception ex)
+            {
+                HandleFormattingError(ex);
+                _byteBufferLength = Encoding.UTF8.GetBytes(_charBuffer.AsSpan(0, _charBufferLength), _byteBuffer);
+            }
         }
     }
 
+    private KeyValueList GetKeyValues()
+    {
+        if (_charBufferLength < 0 && _byteBufferLength < 0)
+            GetMessageUtf8();
+
+        return _keyValues;
+    }
+
     internal void UpdateConfiguration(ZeroLogConfiguration config)
     {
         _config = config;
@@ -93,20 +163,20 @@ private void HandleFormattingError(Exception ex)
     {
         try
         {
-            var builder = new CharBufferBuilder(_messageBuffer);
+            var builder = new CharBufferBuilder(_charBuffer);
             builder.TryAppendPartial("An error occurred during formatting: ");
             builder.TryAppendPartial(ex.Message);
             builder.TryAppendPartial(" - Unformatted message: ");
 
             var length = _message.WriteTo(builder.GetRemainingBuffer(), _config, LogMessage.FormatType.Unformatted, null);
-            _messageLength = builder.Length + length;
+            _charBufferLength = builder.Length + length;
         }
         catch
         {
-            var builder = new CharBufferBuilder(_messageBuffer);
+            var builder = new CharBufferBuilder(_charBuffer);
             builder.TryAppendPartial("An error occurred during formatting: ");
             builder.TryAppendPartial(ex.Message);
-            _messageLength = builder.Length;
+            _charBufferLength = builder.Length;
         }
     }
 
diff --git a/src/ZeroLog.Impl.Full/Formatting/PrefixWriter.cs b/src/ZeroLog.Impl.Full/Formatting/PrefixWriter.cs
index 469f425..472074b 100644
--- a/src/ZeroLog.Impl.Full/Formatting/PrefixWriter.cs
+++ b/src/ZeroLog.Impl.Full/Formatting/PrefixWriter.cs
@@ -311,6 +311,123 @@ public void WritePrefix(LoggedMessage message, Span<char> destination, out int c
         charsWritten = builder.Length;
     }
 
+#if NET8_0_OR_GREATER
+
+    public void WritePrefix(LoggedMessage message, Span<byte> destination, out int bytesWritten)
+    {
+        var builder = new ByteBufferBuilder(destination);
+
+        foreach (var part in _parts)
+        {
+            var partStartOffset = builder.Length;
+
+            switch (part.Type)
+            {
+                case PatternPartType.String:
+                {
+                    if (!builder.TryAppendPartial(part.ValueUtf8))
+                        goto endOfLoop;
+
+                    break;
+                }
+
+                case PatternPartType.Date:
+                {
+                    if (!builder.TryAppend(message.Timestamp, part.Format))
+                        goto endOfLoop;
+
+                    break;
+                }
+
+                case PatternPartType.Time:
+                {
+                    if (!builder.TryAppend(message.Timestamp.TimeOfDay, part.Format))
+                        goto endOfLoop;
+
+                    break;
+                }
+
+                case PatternPartType.Thread:
+                {
+                    var thread = message.Thread;
+
+                    if (thread != null)
+                    {
+                        if (thread.Name != null)
+                        {
+                            if (!builder.TryAppendPartialChars(thread.Name))
+                                goto endOfLoop;
+                        }
+                        else
+                        {
+                            if (!builder.TryAppend(thread.ManagedThreadId))
+                                goto endOfLoop;
+                        }
+                    }
+                    else
+                    {
+                        if (!builder.TryAppendAscii('0'))
+                            goto endOfLoop;
+                    }
+
+                    break;
+                }
+
+                case PatternPartType.Level:
+                {
+                    var levelString = message.Level switch
+                    {
+                        LogLevel.Trace => "TRACE"u8,
+                        LogLevel.Debug => "DEBUG"u8,
+                        LogLevel.Info  => "INFO"u8,
+                        LogLevel.Warn  => "WARN"u8,
+                        LogLevel.Error => "ERROR"u8,
+                        LogLevel.Fatal => "FATAL"u8,
+                        _              => "???"u8
+                    };
+
+                    if (!builder.TryAppendWhole(levelString))
+                        goto endOfLoop;
+
+                    break;
+                }
+
+                case PatternPartType.Logger:
+                {
+                    if (!builder.TryAppendPartial(message.Logger?.NameUtf8))
+                        goto endOfLoop;
+
+                    break;
+                }
+
+                case PatternPartType.LoggerCompact:
+                {
+                    if (!builder.TryAppendPartial(message.Logger?.CompactNameUtf8))
+                        goto endOfLoop;
+
+                    break;
+                }
+
+                case PatternPartType.Column:
+                {
+                    if (part.FormatInt is { } column)
+                        builder.TryAppendPartialAscii(' ', column - builder.Length);
+
+                    continue;
+                }
+            }
+
+            if (part.FormatInt is { } fieldLength)
+                builder.TryAppendPartialAscii(' ', fieldLength - builder.Length + partStartOffset);
+        }
+
+        endOfLoop:
+
+        bytesWritten = builder.Length;
+    }
+
+#endif
+
 #endif
 
     private enum PatternPartType
@@ -331,6 +448,7 @@ private readonly struct PatternPart
         public PatternPartType Type { get; }
         public string? Format { get; }
         public int? FormatInt { get; }
+        public byte[]? ValueUtf8 { get; }
 
         public PatternPart(PatternPartType type, string? format = null)
         {
@@ -350,6 +468,7 @@ public PatternPart(string value)
         {
             Type = PatternPartType.String;
             Format = value;
+            ValueUtf8 = Encoding.UTF8.GetBytes(value);
         }
     }
 }
diff --git a/src/ZeroLog.Impl.Full/Formatting/Utf8Formatter.cs b/src/ZeroLog.Impl.Full/Formatting/Utf8Formatter.cs
new file mode 100644
index 0000000..57b568d
--- /dev/null
+++ b/src/ZeroLog.Impl.Full/Formatting/Utf8Formatter.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Text;
+
+namespace ZeroLog.Formatting;
+
+/// <summary>
+/// A formatter which converts a logged message to UTF-8 encoded text.
+/// </summary>
+public abstract class Utf8Formatter
+{
+    /// <summary>
+    /// This is equal to <c>UTF8Encoding.MaxUtf8BytesPerChar</c>.
+    /// </summary>
+    /// <remarks>
+    /// Code points encoded as 4 bytes in UTF-8 are represented by a surrogate pair in UTF-16.
+    /// </remarks>
+    internal const int MaxUtf8BytesPerChar = 3;
+
+    private static readonly byte[] _newLineBytes = Encoding.UTF8.GetBytes(Environment.NewLine);
+
+    private readonly byte[] _buffer = GC.AllocateUninitializedArray<byte>(LogManager.OutputBufferSize * MaxUtf8BytesPerChar);
+    private int _position;
+
+    /// <summary>
+    /// Formats the given message to text.
+    /// </summary>
+    /// <param name="message">The message to format.</param>
+    /// <returns>A span representing the text to log.</returns>
+    public ReadOnlySpan<byte> FormatMessage(LoggedMessage message)
+    {
+        _position = 0;
+        WriteMessage(message);
+        return GetOutput();
+    }
+
+    /// <summary>
+    /// Formats the given message to text.
+    /// </summary>
+    /// <remarks>
+    /// Call <see cref="Write"/> to append text to the output.
+    /// </remarks>
+    /// <param name="message">The message to format.</param>
+    protected abstract void WriteMessage(LoggedMessage message);
+
+    /// <summary>
+    /// Appends text to the output.
+    /// </summary>
+    /// <param name="value">The value to write.</param>
+    protected internal void Write(ReadOnlySpan<byte> value)
+    {
+        var charCount = Math.Min(value.Length, _buffer.Length - _position);
+        value.Slice(0, charCount).CopyTo(_buffer.AsSpan(_position));
+        _position += charCount;
+    }
+
+    /// <summary>
+    /// Appends text followed by a newline to the output.
+    /// </summary>
+    /// <param name="value">The value to write.</param>
+    protected internal void WriteLine(ReadOnlySpan<byte> value)
+    {
+        Write(value);
+        WriteLine();
+    }
+
+    /// <summary>
+    /// Appends a newline to the output.
+    /// </summary>
+    /// <remarks>
+    /// If the buffer is full, the newline will be inserted by overwriting the last characters.
+    /// </remarks>
+    protected internal void WriteLine()
+    {
+        if (_position <= _buffer.Length - _newLineBytes.Length)
+        {
+            _newLineBytes.AsSpan().CopyTo(_buffer.AsSpan(_position));
+            _position += _newLineBytes.Length;
+        }
+        else
+        {
+            // Make sure to end the string with a newline
+            _newLineBytes.AsSpan().CopyTo(_buffer.AsSpan(_buffer.Length - _newLineBytes.Length));
+        }
+    }
+
+    /// <summary>
+    /// Returns a span of the current output.
+    /// </summary>
+    protected internal Span<byte> GetOutput()
+        => _buffer.AsSpan(0, _position);
+
+    /// <summary>
+    /// Returns a span of the remaining buffer. Call <see cref="AdvanceBy"/> after modifying it.
+    /// </summary>
+    protected Span<byte> GetRemainingBuffer()
+        => _buffer.AsSpan(_position);
+
+    /// <summary>
+    /// Advances the position on the buffer returned by <see cref="GetRemainingBuffer"/> by <paramref name="charCount"/>.
+    /// </summary>
+    /// <param name="charCount">The character count to advance the position by.</param>
+    protected void AdvanceBy(int charCount)
+        => _position += charCount;
+
+    internal byte[] GetBuffer(out int length)
+    {
+        length = _position;
+        return _buffer;
+    }
+}
diff --git a/src/ZeroLog.Impl.Full/LogMessage.Impl.cs b/src/ZeroLog.Impl.Full/LogMessage.Impl.cs
index bd1e9c6..c04ac68 100644
--- a/src/ZeroLog.Impl.Full/LogMessage.Impl.cs
+++ b/src/ZeroLog.Impl.Full/LogMessage.Impl.cs
@@ -1,5 +1,6 @@
 using System;
 using System.Diagnostics.CodeAnalysis;
+using System.Text;
 using System.Threading;
 using ZeroLog.Configuration;
 
@@ -24,11 +25,13 @@ unsafe partial class LogMessage
     internal bool IsTruncated => _isTruncated;
 
     internal string? ConstantMessage { get; }
+    internal byte[]? ConstantMessageUtf8 { get; }
     internal bool ReturnToPool { get; set; }
 
     internal LogMessage(string message)
     {
         ConstantMessage = message;
+        ConstantMessageUtf8 = Encoding.UTF8.GetBytes(message);
         _strings = [];
     }
 
diff --git a/src/ZeroLog.Impl.Full/LogMessage.OutputUtf8.cs b/src/ZeroLog.Impl.Full/LogMessage.OutputUtf8.cs
new file mode 100644
index 0000000..8317bdf
--- /dev/null
+++ b/src/ZeroLog.Impl.Full/LogMessage.OutputUtf8.cs
@@ -0,0 +1,417 @@
+using System;
+using System.Buffers;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Text.Unicode;
+using ZeroLog.Configuration;
+using ZeroLog.Formatting;
+
+namespace ZeroLog;
+
+#if NET8_0_OR_GREATER
+
+unsafe partial class LogMessage
+{
+    [SuppressMessage("ReSharper", "ReplaceSliceWithRangeIndexer")]
+    internal int WriteTo(Span<byte> outputBuffer,
+                         ZeroLogConfiguration config,
+                         FormatType formatType,
+                         KeyValueList? keyValueList)
+    {
+        keyValueList?.Clear();
+
+        if (ConstantMessageUtf8 is not null)
+        {
+            var length = Math.Min(ConstantMessageUtf8.Length, outputBuffer.Length);
+            ConstantMessageUtf8.AsSpan(0, length).CopyTo(outputBuffer);
+            return length;
+        }
+
+        var dataPointer = _startOfBuffer;
+        var endOfData = _dataPointer;
+        var bufferIndex = 0;
+
+        while (dataPointer < endOfData)
+        {
+            if (keyValueList != null)
+            {
+                var argType = *(ArgumentType*)dataPointer;
+                if (argType == ArgumentType.KeyString) // KeyString never has a format flag
+                {
+                    if (!TryWriteKeyValue(ref dataPointer, keyValueList, config)) // TODO UTF-8
+                        goto outputTruncated;
+
+                    continue;
+                }
+            }
+
+            var isTruncated = !TryWriteArg(ref dataPointer, outputBuffer.Slice(bufferIndex), out var charsWritten, formatType, config);
+            bufferIndex += charsWritten;
+
+            if (isTruncated)
+                goto outputTruncated;
+        }
+
+        if (_isTruncated)
+            goto outputTruncated;
+
+        return bufferIndex;
+
+        outputTruncated:
+        {
+            var suffix = config.TruncatedMessageSuffixUtf8;
+
+            if (bufferIndex + suffix.Length <= outputBuffer.Length)
+            {
+                // The suffix fits in the remaining buffer.
+                suffix.CopyTo(outputBuffer.Slice(bufferIndex));
+                return bufferIndex + suffix.Length;
+            }
+
+            var idx = outputBuffer.Length - suffix.Length;
+
+            if (idx >= 0)
+            {
+                // The suffix fits at the end of the buffer, but overwrites output data.
+                suffix.CopyTo(outputBuffer.Slice(idx));
+            }
+            else
+            {
+                // The suffix is too large to fit in the buffer.
+                suffix.AsSpan(0, outputBuffer.Length).CopyTo(outputBuffer);
+            }
+
+            return outputBuffer.Length;
+        }
+    }
+
+    private bool TryWriteArg(ref byte* dataPointer, Span<byte> outputBuffer, out int bytesWritten, FormatType formatType, ZeroLogConfiguration config)
+    {
+        var argType = *(ArgumentType*)dataPointer;
+        dataPointer += sizeof(ArgumentType);
+
+        var format = default(string);
+
+        if ((argType & ArgumentType.FormatFlag) != 0)
+        {
+            argType &= ~ArgumentType.FormatFlag;
+
+            var stringIndex = *dataPointer;
+            ++dataPointer;
+
+            if (formatType == FormatType.Formatted)
+                format = _strings[stringIndex];
+        }
+
+        if (formatType == FormatType.KeyValue)
+        {
+            format = argType switch
+            {
+                ArgumentType.DateTime       => "yyyy-MM-dd HH:mm:ss",
+                ArgumentType.TimeSpan       => @"hh\:mm\:ss\.fffffff",
+                ArgumentType.DateOnly       => @"yyyy-MM-dd",
+                ArgumentType.TimeOnly       => @"HH\:mm\:ss\.fffffff",
+                ArgumentType.DateTimeOffset => @"yyyy-MM-dd HH:mm:ss zzz",
+                _                           => null
+            };
+        }
+
+        switch (argType)
+        {
+            case ArgumentType.None:
+            {
+                bytesWritten = 0;
+                return true;
+            }
+
+            case ArgumentType.String:
+            {
+                var stringIndex = *dataPointer;
+                ++dataPointer;
+
+                var value = _strings[stringIndex] ?? string.Empty;
+                var status = Utf8.FromUtf16(value, outputBuffer, out _, out bytesWritten);
+                return status == OperationStatus.Done;
+            }
+
+            case ArgumentType.Null:
+            {
+                var value = config.NullDisplayStringUtf8;
+
+                if (value.Length <= outputBuffer.Length)
+                {
+                    value.CopyTo(outputBuffer);
+                    bytesWritten = value.Length;
+                }
+                else
+                {
+                    bytesWritten = 0;
+                    return false;
+                }
+
+                return true;
+            }
+
+            case ArgumentType.Boolean:
+            {
+                var valuePtr = (bool*)dataPointer;
+                dataPointer += sizeof(bool);
+
+                var status = Utf8.FromUtf16(*valuePtr ? bool.TrueString : bool.FalseString, outputBuffer, out _, out var valueBytesWritten);
+                if (status == OperationStatus.Done)
+                {
+                    bytesWritten = valueBytesWritten;
+                    return true;
+                }
+
+                bytesWritten = 0;
+                return false;
+            }
+
+            case ArgumentType.Byte:
+            {
+                var valuePtr = dataPointer;
+                dataPointer += sizeof(byte);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.SByte:
+            {
+                var valuePtr = (sbyte*)dataPointer;
+                dataPointer += sizeof(sbyte);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.Char:
+            {
+                var valuePtr = (char*)dataPointer;
+                dataPointer += sizeof(char);
+
+                var status = Utf8.FromUtf16(new ReadOnlySpan<char>(in *valuePtr), outputBuffer, out _, out var valueBytesWritten);
+                if (status == OperationStatus.Done)
+                {
+                    bytesWritten = valueBytesWritten;
+                    return true;
+                }
+
+                bytesWritten = 0;
+                return false;
+            }
+
+            case ArgumentType.Int16:
+            {
+                var valuePtr = (short*)dataPointer;
+                dataPointer += sizeof(short);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.UInt16:
+            {
+                var valuePtr = (ushort*)dataPointer;
+                dataPointer += sizeof(ushort);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.Int32:
+            {
+                var valuePtr = (int*)dataPointer;
+                dataPointer += sizeof(int);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.UInt32:
+            {
+                var valuePtr = (uint*)dataPointer;
+                dataPointer += sizeof(uint);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.Int64:
+            {
+                var valuePtr = (long*)dataPointer;
+                dataPointer += sizeof(long);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.UInt64:
+            {
+                var valuePtr = (ulong*)dataPointer;
+                dataPointer += sizeof(ulong);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.IntPtr:
+            {
+                var valuePtr = (nint*)dataPointer;
+                dataPointer += sizeof(nint);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.UIntPtr:
+            {
+                var valuePtr = (nuint*)dataPointer;
+                dataPointer += sizeof(nuint);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.Single:
+            {
+                var valuePtr = (float*)dataPointer;
+                dataPointer += sizeof(float);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.Double:
+            {
+                var valuePtr = (double*)dataPointer;
+                dataPointer += sizeof(double);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.Decimal:
+            {
+                var valuePtr = (decimal*)dataPointer;
+                dataPointer += sizeof(decimal);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.Guid:
+            {
+                var valuePtr = (Guid*)dataPointer;
+                dataPointer += sizeof(Guid);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format);
+            }
+
+            case ArgumentType.DateTime:
+            {
+                var valuePtr = (DateTime*)dataPointer;
+                dataPointer += sizeof(DateTime);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.TimeSpan:
+            {
+                var valuePtr = (TimeSpan*)dataPointer;
+                dataPointer += sizeof(TimeSpan);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.DateOnly:
+            {
+                var valuePtr = (DateOnly*)dataPointer;
+                dataPointer += sizeof(DateOnly);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.TimeOnly:
+            {
+                var valuePtr = (TimeOnly*)dataPointer;
+                dataPointer += sizeof(TimeOnly);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.DateTimeOffset:
+            {
+                var valuePtr = (DateTimeOffset*)dataPointer;
+                dataPointer += sizeof(DateTimeOffset);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, format, CultureInfo.InvariantCulture);
+            }
+
+            case ArgumentType.Enum:
+            {
+                var valuePtr = (EnumArg*)dataPointer;
+                dataPointer += sizeof(EnumArg);
+
+                return valuePtr->TryFormat(outputBuffer, out bytesWritten, config);
+            }
+
+            case ArgumentType.StringSpan:
+            {
+                var lengthInChars = *(int*)dataPointer;
+                dataPointer += sizeof(int);
+
+                var status = Utf8.FromUtf16(new ReadOnlySpan<char>(dataPointer, lengthInChars), outputBuffer, out _, out bytesWritten);
+                dataPointer += lengthInChars * sizeof(char);
+                return status == OperationStatus.Done;
+            }
+
+            case ArgumentType.Utf8StringSpan:
+            {
+                var lengthInBytes = *(int*)dataPointer;
+                dataPointer += sizeof(int);
+
+                var valueBytes = new ReadOnlySpan<byte>(dataPointer, lengthInBytes);
+                var lengthToCopy = Math.Min(lengthInBytes, outputBuffer.Length);
+
+                valueBytes.CopyTo(outputBuffer.Slice(0, lengthToCopy));
+                dataPointer += lengthInBytes;
+                bytesWritten = lengthToCopy;
+                return lengthInBytes == lengthToCopy;
+            }
+
+            case ArgumentType.Unmanaged:
+            {
+                var headerPtr = (UnmanagedArgHeader*)dataPointer;
+                dataPointer += sizeof(UnmanagedArgHeader);
+
+                // TODO UTF-8
+                bytesWritten = 0;
+
+                // if (formatType == FormatType.Formatted)
+                // {
+                //     if (!headerPtr->TryAppendTo(dataPointer, outputBuffer, out bytesWritten, format, config))
+                //         return false;
+                // }
+                // else
+                // {
+                //     if (!headerPtr->TryAppendUnformattedTo(dataPointer, outputBuffer, out bytesWritten))
+                //         return false;
+                // }
+
+                dataPointer += headerPtr->Size;
+                return true;
+            }
+
+            case ArgumentType.KeyString:
+            {
+                ++dataPointer;
+
+                if (dataPointer < _dataPointer)
+                    SkipArg(ref dataPointer);
+
+                bytesWritten = 0;
+                return true;
+            }
+
+            case ArgumentType.EndOfTruncatedMessage:
+            {
+                bytesWritten = 0;
+                return false;
+            }
+
+            default:
+                throw new ArgumentOutOfRangeException();
+        }
+    }
+}
+
+#endif
diff --git a/src/ZeroLog.Impl.Full/ZeroLog.Impl.Full.csproj b/src/ZeroLog.Impl.Full/ZeroLog.Impl.Full.csproj
index 43c308f..7b55c7f 100644
--- a/src/ZeroLog.Impl.Full/ZeroLog.Impl.Full.csproj
+++ b/src/ZeroLog.Impl.Full/ZeroLog.Impl.Full.csproj
@@ -1,6 +1,6 @@
 <Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
-    <TargetFrameworks>net7.0;net6.0</TargetFrameworks>
+    <TargetFrameworks>net8.0;net7.0;net6.0</TargetFrameworks>
     <RootNamespace>ZeroLog</RootNamespace>
     <Nullable>enable</Nullable>
   </PropertyGroup>
diff --git a/src/ZeroLog.Tests/SanityChecks.should_export_expected_types.verified.txt b/src/ZeroLog.Tests/SanityChecks.should_export_expected_types.verified.txt
index 816d514..896ce66 100644
--- a/src/ZeroLog.Tests/SanityChecks.should_export_expected_types.verified.txt
+++ b/src/ZeroLog.Tests/SanityChecks.should_export_expected_types.verified.txt
@@ -18,6 +18,7 @@
   ZeroLog.Formatting.KeyValueList+Enumerator,
   ZeroLog.Formatting.LoggedKeyValue,
   ZeroLog.Formatting.LoggedMessage,
+  ZeroLog.Formatting.Utf8Formatter,
   ZeroLog.Log,
   ZeroLog.Log+DebugInterpolatedStringHandler,
   ZeroLog.Log+ErrorInterpolatedStringHandler,
diff --git a/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet6_0.verified.txt b/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet6_0.verified.txt
index f985c59..b5f5dee 100644
--- a/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet6_0.verified.txt
+++ b/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet6_0.verified.txt
@@ -130,6 +130,7 @@ namespace ZeroLog.Formatting
     {
         protected Formatter() { }
         protected void AdvanceBy(int charCount) { }
+        public virtual ZeroLog.Formatting.Utf8Formatter? AsUtf8Formatter() { }
         public System.ReadOnlySpan<char> FormatMessage(ZeroLog.Formatting.LoggedMessage message) { }
         protected System.Span<char> GetOutput() { }
         protected System.Span<char> GetRemainingBuffer() { }
@@ -170,11 +171,24 @@ namespace ZeroLog.Formatting
         public ZeroLog.LogLevel Level { get; }
         public string? LoggerName { get; }
         public System.ReadOnlySpan<char> Message { get; }
+        public System.ReadOnlySpan<byte> MessageUtf8 { get; }
         public System.Threading.Thread? Thread { get; }
         public System.DateTime Timestamp { get; }
         public ZeroLog.Formatting.LoggedMessage Clone() { }
         public override string ToString() { }
     }
+    public abstract class Utf8Formatter
+    {
+        protected Utf8Formatter() { }
+        protected void AdvanceBy(int charCount) { }
+        public System.ReadOnlySpan<byte> FormatMessage(ZeroLog.Formatting.LoggedMessage message) { }
+        protected System.Span<byte> GetOutput() { }
+        protected System.Span<byte> GetRemainingBuffer() { }
+        protected void Write(System.ReadOnlySpan<byte> value) { }
+        protected void WriteLine() { }
+        protected void WriteLine(System.ReadOnlySpan<byte> value) { }
+        protected abstract void WriteMessage(ZeroLog.Formatting.LoggedMessage message);
+    }
 }
 namespace ZeroLog
 {
diff --git a/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet7_0.verified.txt b/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet7_0.verified.txt
index f985c59..b5f5dee 100644
--- a/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet7_0.verified.txt
+++ b/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet7_0.verified.txt
@@ -130,6 +130,7 @@ namespace ZeroLog.Formatting
     {
         protected Formatter() { }
         protected void AdvanceBy(int charCount) { }
+        public virtual ZeroLog.Formatting.Utf8Formatter? AsUtf8Formatter() { }
         public System.ReadOnlySpan<char> FormatMessage(ZeroLog.Formatting.LoggedMessage message) { }
         protected System.Span<char> GetOutput() { }
         protected System.Span<char> GetRemainingBuffer() { }
@@ -170,11 +171,24 @@ namespace ZeroLog.Formatting
         public ZeroLog.LogLevel Level { get; }
         public string? LoggerName { get; }
         public System.ReadOnlySpan<char> Message { get; }
+        public System.ReadOnlySpan<byte> MessageUtf8 { get; }
         public System.Threading.Thread? Thread { get; }
         public System.DateTime Timestamp { get; }
         public ZeroLog.Formatting.LoggedMessage Clone() { }
         public override string ToString() { }
     }
+    public abstract class Utf8Formatter
+    {
+        protected Utf8Formatter() { }
+        protected void AdvanceBy(int charCount) { }
+        public System.ReadOnlySpan<byte> FormatMessage(ZeroLog.Formatting.LoggedMessage message) { }
+        protected System.Span<byte> GetOutput() { }
+        protected System.Span<byte> GetRemainingBuffer() { }
+        protected void Write(System.ReadOnlySpan<byte> value) { }
+        protected void WriteLine() { }
+        protected void WriteLine(System.ReadOnlySpan<byte> value) { }
+        protected abstract void WriteMessage(ZeroLog.Formatting.LoggedMessage message);
+    }
 }
 namespace ZeroLog
 {
diff --git a/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet8_0.verified.txt b/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet8_0.verified.txt
index f985c59..9df00e7 100644
--- a/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet8_0.verified.txt
+++ b/src/ZeroLog.Tests/SanityChecks.should_have_expected_public_api.DotNet8_0.verified.txt
@@ -124,12 +124,14 @@ namespace ZeroLog.Formatting
         public DefaultFormatter() { }
         public string JsonSeparator { get; init; }
         public string PrefixPattern { get; init; }
+        public override ZeroLog.Formatting.Utf8Formatter AsUtf8Formatter() { }
         protected override void WriteMessage(ZeroLog.Formatting.LoggedMessage message) { }
     }
     public abstract class Formatter
     {
         protected Formatter() { }
         protected void AdvanceBy(int charCount) { }
+        public virtual ZeroLog.Formatting.Utf8Formatter? AsUtf8Formatter() { }
         public System.ReadOnlySpan<char> FormatMessage(ZeroLog.Formatting.LoggedMessage message) { }
         protected System.Span<char> GetOutput() { }
         protected System.Span<char> GetRemainingBuffer() { }
@@ -170,11 +172,24 @@ namespace ZeroLog.Formatting
         public ZeroLog.LogLevel Level { get; }
         public string? LoggerName { get; }
         public System.ReadOnlySpan<char> Message { get; }
+        public System.ReadOnlySpan<byte> MessageUtf8 { get; }
         public System.Threading.Thread? Thread { get; }
         public System.DateTime Timestamp { get; }
         public ZeroLog.Formatting.LoggedMessage Clone() { }
         public override string ToString() { }
     }
+    public abstract class Utf8Formatter
+    {
+        protected Utf8Formatter() { }
+        protected void AdvanceBy(int charCount) { }
+        public System.ReadOnlySpan<byte> FormatMessage(ZeroLog.Formatting.LoggedMessage message) { }
+        protected System.Span<byte> GetOutput() { }
+        protected System.Span<byte> GetRemainingBuffer() { }
+        protected void Write(System.ReadOnlySpan<byte> value) { }
+        protected void WriteLine() { }
+        protected void WriteLine(System.ReadOnlySpan<byte> value) { }
+        protected abstract void WriteMessage(ZeroLog.Formatting.LoggedMessage message);
+    }
 }
 namespace ZeroLog
 {
diff --git a/src/ZeroLog/ZeroLog.csproj b/src/ZeroLog/ZeroLog.csproj
index 6f793f8..585a482 100644
--- a/src/ZeroLog/ZeroLog.csproj
+++ b/src/ZeroLog/ZeroLog.csproj
@@ -1,6 +1,6 @@
 <Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
-    <TargetFrameworks>net7.0;net6.0;netstandard2.0</TargetFrameworks>
+    <TargetFrameworks>net8.0;net7.0;net6.0;netstandard2.0</TargetFrameworks>
     <Nullable>enable</Nullable>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
   </PropertyGroup>