ZLogger v2 Architecture: Leveraging .NET 8 to Maximize Performance

Yoshifumi Kawai
15 min readJul 5, 2024

--

We have released ZLogger v2, a new ultra-fast and low-allocation logging library for C# and .NET. It’s been completely redesigned from v1 to align with the latest C# features. While it works best with .NET 8, it supports .NET Standard 2.0 and above, as well as Unity 2022.2 and above. Both .NET and Unity versions support text messages and structured logging(JSON and MessagePack in default).

The key point of the new design is the full adoption of String Interpolation, which achieves both clean syntax and performance.

logger.ZLogInformation($"Hello my name is {name}, {age} years old.");

Code written like this is compiled into:

if (logger.IsEnabled(LogLvel.Information))
{
var handler = new ZLoggerInformationInterpolatedStringHandler(30, 2, logger);
handler.AppendLiteral("Hello my name is ");
handler.AppendFormatted<string>(name, 0, null, "name");
handler.AppendLiteral(", ");
handler.AppendFormatted<int>(age, 0, null, "age");
handler.AppendLiteral(" years old.");
}

The efficiency is evident from the code: the format string is expanded at compile time rather than runtime, and parameters are received as generics in the form of AppendFormatted<T>, avoiding boxing. Incidentally, 30 in the constructor represents the string length, and 2 is the number of parameters, which contributes to efficiency by calculating the required initial buffer size.

String Interpolation itself has been a feature since C# 6.0, but enhanced String Interpolation from C# 10.0 allows for custom String Interpolation.

The string fragments and parameters obtained this way are ultimately written directly to the Stream as UTF8 without being stringified, through Cysharp/Utf8StringInterpolation, achieving high speed and low allocation.

For Structured Logging as well, by tightly coupling with System.Text.Json’s Utf8JsonWriter:

// For example, write {"name":"foo",age:33} to Utf8JsonWriter
// Source Generator version, very easy to understand what's actually happening
public void WriteJsonParameterKeyValues(Utf8JsonWriter writer, JsonSerializerOptions jsonSerializerOptions)
{
writer.WriteString(_jsonParameter_name, this.name);
writer.WriteNumber(_jsonParameter_age, this.age);
}

// StringInterpolation version, seems a bit roundabout but does the same thing
public void WriteJsonParameterKeyValues(Utf8JsonWriter writer, JsonSerializerOptions jsonSerializerOptions)
{
for (var i = 0; i < ParameterCount; i++)
{
ref var p = ref parameters[i];
writer.WritePropertyName(p.Name.AsSpan());
// Explanation of MagicalBox will come later
if (!magicalBox.TryReadTo(p.Type, p.BoxOffset, jsonWriter, jsonSerializerOptions))
{
// ....
}
}
}

It’s written directly as UTF8 again. Structured Logging is a recent trend, so it’s implemented in loggers of various languages, but I don’t think there’s any other implementation that achieves such clean syntax while maintaining performance!

So, how about actual benchmark results? The allocation is at least overwhelmingly low.

The reason for the hesitant statement about allocation is that NLog, which was carefully set up for high speed, was faster than expected, grrr…

Now, another feature of ZLogger is that it’s built directly on top of Microsoft.Extensions.Logging. Usually, loggers have their own systems and use a bridge to connect with Microsoft.Extensions.Logging. In realistic applications, it’s almost impossible to avoid Microsoft.Extensions.Logging, such as when using ASP.NET. From .NET 8, with enhanced OpenTelemetry support and Aspire, the importance of Microsoft.Extensions.Logging is increasing. Unlike ZLogger v1, v2 supports all features of Microsoft.Extensions.Logging, including Scope.

And for example, the quality of Serilog’s bridge library is quite low (I checked the source code as well), which is reflected in the actual performance numbers. ZLogger incurs no such overhead.

Also, default settings are very important. The standard settings of most loggers are quite slow, such as flushing each time when writing to a file stream. To speed this up, you need to properly adjust async and buffered settings, and ensure a reliable flush at the end to avoid loss, which is quite difficult. So, many people probably leave it at the default settings? ZLogger is adjusted to be the fastest by default, and the final flush is automatically applied with the lifecycle of Microsoft.Extensions’ DI, so there’s no loss when constructing applications with ApplicationBuilder, etc., without any conscious effort.

Note that the performance of flushing each time heavily depends on storage write performance, so you might find it’s not that slow when benchmarking locally on recent machines with M.2 SSDs, which are very fast. However, it’s better not to trust local results too much, as the storage performance of cloud servers where you actually deploy applications is unlikely to be that high.

MagicalBox

Here, I’ll introduce some tricks used to achieve performance. What’s carried over from v1 is the creation of an async asynchronous writing process utilizing System.Threading.Channels and efficient use of buffered through IBufferWriter<byte> for optimizing writing to Stream, but I'll skip the explanation.

For JSON conversion, parameters are temporarily held as values in InterpolatedStringHandler. In this case, the question arises of how to hold the value of <T>. Normally, you'd think to hold it as an object type, like List<object>.

[InterpolatedStringHandler]
public ref struct ZLoggerInterpolatedStringHandler
{
// Using object to store values of any <T> type, not good as it causes boxing
List<object> parameters = new ();

public void AppendFormatted<T>(T value, int alignment = 0, string? format = null, [CallerArgumentExpression("value")] string? argumentName = null)
{
parameters.Add((object)value);
}
}

To avoid this, ZLogger has prepared a mechanism called MagicalBox.

[InterpolatedStringHandler]
public ref struct ZLoggerInterpolatedStringHandler
{
// Pack infinitely into the magic box
MagicalBox magicalBox;
List<int> boxOffsets = new (); // Actually, this part is carefully cached

public void AppendFormatted<T>(T value, int alignment = 0, string? format = null, [CallerArgumentExpression("value")] string? argumentName = null)
{
if (magicalBox.TryWrite(value, out var offset)) // No boxing occurs!
{
boxOffsets.Add(offset);
}
}
}

MagicalBox is based on the concept that it can write any type (limited to unmanaged types) without boxing. Its actual implementation is just writing to byte[] using Unsafe.Write and reading using Unsafe.Read based on the offset.

internal unsafe partial struct MagicalBox
{
byte[] storage;
int written;

public MagicalBox(byte[] storage)
{
this.storage = storage;
}

public bool TryWrite<T>(T value, out int offset)
{
if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
offset = 0;
return false;
}
Unsafe.WriteUnaligned(ref storage[written], value);
offset = written;
written += Unsafe.SizeOf<T>();
return true;
}

public bool TryRead<T>(int offset, out T value)
{
if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
value = default!;
return false;
}
value = Unsafe.ReadUnaligned<T>(ref storage[offset]);
return true;
}
}

This is based on implementation experience from MemoryPack serializer and works well.

Note that in the actual code, it becomes a slightly more complex code including efficient reuse of byte[] storage, non-generic Read support, special handling for Enum, etc. As expected.

Custom Format Strings

A good point of ZLogger’s String Interpolation is that if you include method calls in parameter values, they are called after the LogLevel check, preventing unnecessary execution.

// This
logger.ZLogDebug($"Id {obj.GetId()}: Data: {obj.GetData()}.");

// Is checked for LogLevel validity before methods are called, like this
if (logger.IsEnabled(LogLvel.Debug))
{
// snip...
writer.AppendFormatterd(obj.GetId());
writer.AppendFormatterd(obj.GetData());
}

However, when outputting method calls to Structured Logging, ZLogger uses CallerArgumentExpression added from C# 10.0 onwards to get the parameter name, so in the case of method calls, it’s output with the rather awkward name “obj.GetId()”. Therefore, you can specify an alias with a special custom format string.

// You can give an alias with @name
logger.ZLogDebug($"Id {obj.GetId():@id}: Data: {obj.GetData():@data}.");

In ZLogger, following the original expression of String Interpolation, you can specify alignment with “,” and format string with “:”. In addition, as a special designation, if the format string starts with @, it’s output as a parameter name.

The @ parameter name specification and format string can be used together.

// Today is 2023-12-19.
// {"date":"2023-12-19T11:25:34.3642389+09:00"}
logger.ZLogDebug($"Today is {DateTime.Now:@date:yyyy-MM-dd}.");

Another common special format string is “json”, which allows output in JsonSerialized form (this feature was inspired by Serilog’s capabilities)

var position = new { Latitude = 25, Longitude = 134 };
var elapsed = 34;

// {"position":{"Latitude":25,"Longitude":134},"elapsed":34}
// Processed {"Latitude":25,"Longitude":134} in 034 ms.
logger.ZLogInformation($"Processed {position:json} in {elapsed:000} ms.");

Special format strings are also prepared for PrefixFormatter/SuffixFormatter to add log levels, categories, dates to the beginning/end.

logging.AddZLoggerConsole(options =>
{
options.UsePlainTextFormatter(formatter =>
{
// 2023-12-19 02:46:14.289 [DBG]......
formatter.SetPrefixFormatter($"{0:utc-longdate} [{1:short}]", (template, info) => template.Format(info.Timestamp, info.LogLevel));
});
});

For Timestamp, there are longdate, utc-longdate, dateonly, etc. For LogLevel, short converts to a 3-character log level notation (the length of the beginning matches, making it easier to read when opened in an editor). These built-in special format strings also have a performance optimization meaning. For example, the code for LogLevel looks like this, so it's absolutely more efficient to write with pre-built UTF8 strings than to create the format manually.

static void AppendLogLevel(ref Utf8StringWriter<IBufferWriter<byte>> writer, ref LogLevel value, ref MessageTemplateChunk chunk)
{
if (!chunk.NoAlignmentAndFormat)
{
if (chunk.Format == "short")
{
switch (value)
{
case LogLevel.Trace:
writer.AppendUtf8("TRC"u8);
return;
case LogLevel.Debug:
writer.AppendUtf8("DBG"u8);
return;
case LogLevel.Information:
writer.AppendUtf8("INF"u8);
return;
case LogLevel.Warning:
writer.AppendUtf8("WRN"u8);
return;
case LogLevel.Error:
writer.AppendUtf8("ERR"u8);
return;
case LogLevel.Critical:
writer.AppendUtf8("CRI"u8);
return;
case LogLevel.None:
writer.AppendUtf8("NON"u8);
return;
default:
break;
}
}

writer.AppendFormatted(value, chunk.Alignment, chunk.Format);
return;
}

switch (value)
{
case LogLevel.Trace:
writer.AppendUtf8("Trace"u8);
break;
case LogLevel.Debug:
writer.AppendUtf8("Debug"u8);
break;
case LogLevel.Information:
writer.AppendUtf8("Information"u8);
break;
case LogLevel.Warning:
writer.AppendUtf8("Warning"u8);
break;
case LogLevel.Error:
writer.AppendUtf8("Error"u8);
break;
case LogLevel.Critical:
writer.AppendUtf8("Critical"u8);
break;
case LogLevel.None:
writer.AppendUtf8("None"u8);
break;
default:
writer.AppendFormatted(value);
break;
}
}

.NET 8 XxHash3 + Non-GC Heap

XxHash3 has been added from .NET 8. It’s the latest series of XxHash, the fastest hash algorithm, and its performance is such that it can be used for almost everything from small to large data without hesitation. Note that it requires System.IO.Hashing from NuGet, so it can be used even with .NET Standard 2.0, not just .NET 8.

ZLogger uses it in multiple places, but as one example, here’s the process of retrieving a cache from String Interpolation string literals:

// LiteralList generated by $"Hello my name is {name}, {age} years old."
// ["Hello my name is ", "name", ", ", "age", " years old."]
// Process to retrieve UTF8 converted cache (MessageSequence) from this
static readonly ConcurrentDictionary<LiteralList, MessageSequence> cache = new();

// Non-.NET 8 version
#if !NET8_0_OR_GREATER
struct LiteralList(List<string?> literals) : IEquatable<LiteralList>
{
[ThreadStatic]
static XxHash3? xxhash;

public override int GetHashCode()
{
var h = xxhash;
if (h == null)
{
h = xxhash = new XxHash3();
}
else
{
h.Reset();
}

var span = CollectionsMarshal.AsSpan(literals);
foreach (var item in span)
{
h.Append(MemoryMarshal.AsBytes(item.AsSpan()));
}

// https://github.com/Cyan4973/xxHash/issues/453
// XXH3 64bit -> 32bit, okay to simple cast answered by XXH3 author.
return unchecked((int)h.GetCurrentHashAsUInt64());
}

public bool Equals(LiteralList other)
{
var xs = CollectionsMarshal.AsSpan(literals);
var ys = CollectionsMarshal.AsSpan(other.literals);
if (xs.Length == ys.Length)
{
for (int i = 0; i < xs.Length; i++)
{
if (xs[i] != ys[i]) return false;
}
return true;
}
return false;
}
}
#endif

XxHash3 is a class (it would have been nice if it was a struct like System.HashCode), so it’s being reused with ThreadStatic while generating GetHashCode. XxHash3 only outputs ulong, but according to the author, when dropping to 32 bits, it’s okay to drop directly without XOR or anything.

This is the normal usage, but for the .NET 8 version, we implemented an extreme optimization.

#if NET8_0_OR_GREATER

struct LiteralList(List<string?> literals) : IEquatable<LiteralList>
{
// literals are all const string, in .NET 8 it is allocated in Non-GC Heap so can compare by address.
// https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#non-gc-heap
static ReadOnlySpan<byte> AsBytes(ReadOnlySpan<string?> literals)
{
return MemoryMarshal.CreateSpan(
ref Unsafe.As<string?, byte>(ref MemoryMarshal.GetReference(literals)),
literals.Length * Unsafe.SizeOf<string>());
}

public override int GetHashCode()
{
return unchecked((int)XxHash3.HashToUInt64(AsBytes(CollectionsMarshal.AsSpan(literals))));
}

public bool Equals(LiteralList other)
{
var xs = CollectionsMarshal.AsSpan(literals);
var ys = CollectionsMarshal.AsSpan(other.literals);
return AsBytes(xs).SequenceEqual(AsBytes(ys));
}
}
#endif

It converts List<string>? to ReadOnlySpan<byte>, and then calls XxHash3.HashToUInt64 or SequenceEqual in one go. This is visibly more efficient, but is it legal to convert List<string>? to ReadOnlySpan<byte>? In this case, the conversion of string means converting to ReadOnlySpan<IntPtr>, that is, it's intended to convert to a list of addresses of strings in the heap.

That’s fine so far, but the problem is whether comparing addresses isn’t too dangerous. First, even if strings are identical as strings, they can often be at different addresses. Second, the addresses of strings in the heap are not fixed, they can move. If we’re asking for GetHashCode or Equals as a dictionary key, it must be completely fixed during application execution.

However, focusing on this usage example, AppendLiteral called by String Interpolation is always passed as a constant at compile time, like handler.AppendLiteral("Hello my name is ");. Therefore, it's guaranteed to point to the same entity.

[InterpolatedStringHandler]
public ref struct ZLoggerInterpolatedStringHandler
{
public void AppendLiteral([ConstantExpected] string s)
}

As a precaution, we explicitly state that only constants should be passed using ConstantExpected, which has been enabled from .NET 8.

Another point is that such constant strings are already interned, but it wasn’t guaranteed that the place where they were interned wouldn’t move until .NET 8. However, with the introduction of Non-GC Heap from .NET 8, it can be said that it’s guaranteed not to move.

// From .NET 8, the result of GC.GetGeneration for constants is int.MaxValue (in Non-GC Heap)
var str = "foo";
Console.WriteLine(GC.GetGeneration(str)); // 2147483647

This allowed us to maximize the speed of conversion from UTF16 String to UTF8 String, which is unavoidable in C#. Note that the Source Generator version can eliminate this lookup cost itself, so as the benchmark results showed, it’s even faster.

.NET 8 IUtf8SpanFormattable

ZLogger uses writing directly to UTF8 without going through strings as a pillar of performance. From .NET 8, IUtf8SpanFormattable has been added, which allows for generic direct conversion of values to UTF8. ZLogger supports .NET Standard 2.0 before .NET 8, so basic primitives like int and double are directly written to UTF8 through special handling, but in the case of .NET 8, the range of support is wider, so .NET 8 is recommended if possible.

Note that IUtf8SpanFormattable doesn’t care about the alignment of format strings, so Cysharp/Utf8StringInterpolation, which is a separate library, is a library that adds alignment support while supporting .NET Standard 2.0.

.NET 8 TimeProvider

TimeProvider is an abstraction of time-related APIs (including TimeZone, Timer, etc.) added from .NET 8, and it’s very useful for unit testing, etc., and will be an essential class in the future. TimeProvider is also available for .NET Standard 2.0 and Unity through Microsoft.Bcl.TimeProvider, even for versions below .NET 8.

So in ZLogger, you can fix the time of log output by specifying TimerProvider in ZLoggerOptions.

// It's better to use FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing
class FakeTime : TimeProvider
{
public override DateTimeOffset GetUtcNow()
{
return new DateTimeOffset(1999, 12, 30, 11, 12, 33, TimeSpan.Zero);
}

public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}

public class TimestampTest
{
[Fact]
public void LogInfoTimestamp()
{
var result = new List<string>();
using var factory = LoggerFactory.Create(builder =>
{
builder.AddZLoggerInMemory((options, _) =>
{
options.TimeProvider = new FakeTime(); // Set TimeProvider to a custom one
options.UsePlainTextFormatter(formatter =>
{
// Add Timestamp to the beginning
formatter.SetPrefixFormatter($"{0} | ", (template, info) => template.Format(info.Timestamp));
});
}, x =>
{
x.MessageReceived += msg => result.Add(msg);
});
});

var logger = factory.CreateLogger<TimestampTest>();
logger.ZLogInformation($"Foo");

Assert.Equal("1999-12-30 11:12:33.000 | Foo", result[0]);
}
}

This can be effectively used when you need to test with exact matches of log output.

Source Generator

Microsoft.Extensions.Logging provides LoggerMessageAttribute and Source Generator as standard for high-performance log output.

While this is indeed excellent for generating UTF16 strings, there’s a question mark over the Structured Logging generation part.

// This partial method
[LoggerMessage(LogLevel.Information, "My name is {name}, age is {age}.")]
public static partial void MSLog(this ILogger logger, string name, int age, int other);

// Generates this class
private readonly struct __MSLogStruct : global::System.Collections.Generic.IReadOnlyList<global::System.Collections.Generic.KeyValuePair<string, object?>>
{
private readonly global::System.String _name;
private readonly global::System.Int32 _age;

public __MSLogStruct(global::System.String name, global::System.Int32 age)
{
this._name = name;
this._age = age;
}

public override string ToString()
{
var name = this._name;
var age = this._age;
return $"My name is {name}, age is {age}."; // String generation seems fast (it's riding on C# 10.0's String Interpolation Improvements, so no complaints!)
}

public static readonly global::System.Func<__MSLogStruct, global::System.Exception?, string> Format = (state, ex) => state.ToString();

public int Count => 4;

// This is the code for Structured Logging, but hmm...?
public global::System.Collections.Generic.KeyValuePair<string, object?> this[int index]
{
get => index switch
{
0 => new global::System.Collections.Generic.KeyValuePair<string, object?>("name", this._name),
1 => new global::System.Collections.Generic.KeyValuePair<string, object?>("age", this._age),
2 => new global::System.Collections.Generic.KeyValuePair<string, object?>("other", this._other),
3 => new global::System.Collections.Generic.KeyValuePair<string, object?>("{OriginalFormat}", "My name is {name}, age is {age}."),
_ => throw new global::System.IndexOutOfRangeException(nameof(index)), // return the same exception LoggerMessage.Define returns in this case
};
}

public global::System.Collections.Generic.IEnumerator<global::System.Collections.Generic.KeyValuePair<string, object?>> GetEnumerator()
{
for (int i = 0; i < 4; i++)
{
yield return this[i];
}
}

global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}

[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "8.0.9.3103")]
public static partial void MSLog(this global::Microsoft.Extensions.Logging.ILogger logger, global::System.String name, global::System.Int32 age)
{
if (logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information))
{
logger.Log(
global::Microsoft.Extensions.Logging.LogLevel.Information,
new global::Microsoft.Extensions.Logging.EventId(764917357, nameof(MSLog)),
new __MSLogStruct(name, age),
null,
__MSLogStruct.Format);
}
}

With KeyValuePair<string, object?>, boxing can't be avoided when created normally, can't be helped.

So, ZLogger provides a similar Source Generator attribute called ZLoggerMessageAttribute. This enables UTF8 optimization and boxing-less JSON logging.

// Just change LoggerMessage to ZLoggerMessage
// Note that in the format string part of ZLoggerMessage, you can use @ for aliases and json for JSON conversion, just like in the String Interpolation version
[ZLoggerMessage(LogLevel.Information, "My name is {name}, age is {age}.")]
static partial void ZLoggerLog(this ILogger logger, string name, int age);

// This kind of code is generated
readonly struct ZLoggerLogState : IZLoggerFormattable
{
// Pre-generate JsonEncodedText for JSON
static readonly JsonEncodedText _jsonParameter_name = JsonEncodedText.Encode("name");
static readonly JsonEncodedText _jsonParameter_age = JsonEncodedText.Encode("age");

readonly string name;
readonly int age;

public ZLoggerLogState(string name, int age)
{
this.name = name;
this.age = age;
}

public IZLoggerEntry CreateEntry(LogInfo info)
{
return ZLoggerEntry<ZLoggerLogState>.Create(info, this);
}

public int ParameterCount => 2;
public bool IsSupportUtf8ParameterKey => true;
public override string ToString() => $"My name is {name}, age is {age}.";

// Text messages are directly written to UTF8
public void ToString(IBufferWriter<byte> writer)
{
var stringWriter = new Utf8StringWriter<IBufferWriter<byte>>(literalLength: 21, formattedCount: 2, bufferWriter: writer);
stringWriter.AppendUtf8("My name is "u8); // Write literals directly with u8
stringWriter.AppendFormatted(name, 0, null);
stringWriter.AppendUtf8(", age is "u8);
stringWriter.AppendFormatted(age, 0, null);
stringWriter.AppendUtf8("."u8);
stringWriter.Flush();
}

// For JSON output, write directly to Utf8JsonWriter to completely avoid boxing
public void WriteJsonParameterKeyValues(Utf8JsonWriter writer, JsonSerializerOptions jsonSerializerOptions, IKeyNameMutator? keyNameMutator = null)
{
// The method called differs depending on the type (WriteString, WriteNumber, etc...)
writer.WriteString(_jsonParameter_name, this.name);
writer.WriteNumber(_jsonParameter_age, this.age);
}

// Methods for extensions such as MessagePack support are actually generated below, but omitted
}

static partial void ZLoggerLog(this global::Microsoft.Extensions.Logging.ILogger logger, string name, int age)
{
if (!logger.IsEnabled(LogLevel.Information)) return;
logger.Log(
LogLevel.Information,
new EventId(-1, nameof(ZLoggerLog)),
new ZLoggerLogState(name, age),
null,
(state, ex) => state.ToString()
);
}

By writing directly to Utf8JsonWriter and pre-generating key names as JsonEncodedText, we maximize the performance of JSON conversion.

Also, Structured Logging is not limited to JSON, other formats are possible. For example, using MessagePack could make it smaller and faster. ZLogger defines interfaces to avoid boxing even for output to protocols that are not built-in like JSON-specific ones.

public interface IZLoggerFormattable : IZLoggerEntryCreatable
{
int ParameterCount { get; }

// Used for message output
void ToString(IBufferWriter<byte> writer);

// Used for JSON output
void WriteJsonParameterKeyValues(Utf8JsonWriter jsonWriter, JsonSerializerOptions jsonSerializerOptions, IKeyNameMutator? keyNameMutator = null);

// Used for other structured log outputs
ReadOnlySpan<byte> GetParameterKey(int index);
ReadOnlySpan<char> GetParameterKeyAsString(int index);
object? GetParameterValue(int index);
T? GetParameterValue<T>(int index);
Type GetParameterType(int index);
}

It’s a bit of an unusual interface, but by running a loop like this, we can eliminate the occurrence of boxing:

for (var i in ParameterCount)
{
var key = GetParameterKey(i);
var value = GetParameterValue<int>();
}

This design is the same as the usage of IDataRecord in ADO.NET. Also, in Unity, it’s common to retrieve via index to avoid allocation of arrays from native to managed.

Unity

Even with Unity 2023, the officially supported C# version is 9.0. ZLogger assumes C# 10.0 or higher String Interpolation as a prerequisite, so it won’t work normally. Normally. However, although it hasn’t been officially announced, we discovered that from Unity 2022.2, the version of the included compiler has been raised, and internally it's possible to compile with C# 10.0.

You can pass compiler options through the csc.rsp file, so if you explicitly specify the language version there, all C# 10.0 syntax becomes available.

-langVersion:10

As it is, the output csproj still specifies <LangVersion>9.0</LangVersion>, so you can't write in C# 10.0 on the IDE. So let's overwrite the LangVersion using Cysharp/CsprojModifier. If you create a file called LangVersion.props like this and have CsprojModifier mix it in, you'll be able to write as C# 10.0 on the IDE as well.

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<LangVersion>10</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

For Unity, we’ve added an extension called AddZLoggerUnityDebug, so

// Prepare such a global utility
public static class LogManager
{
static ILoggerFactory loggerFactory;

public static ILogger<T> CreateLogger<T>() => loggerFactory.CreateLogger<T>();
public static readonly Microsoft.Extensions.Logging.ILogger Global;

static LogManager()
{
loggerFactory = LoggerFactory.Create(logging =>
{
logging.SetMinimumLevel(LogLevel.Trace);
logging.AddZLoggerUnityDebug(); // log to UnityDebug
});
Global = loggerFactory.CreateLogger("Logger");
Application.exitCancellationToken.Register(() =>
{
loggerFactory.Dispose(); // flush when application exit.
});
}
}

// Try using it like this, for example
public class NewBehaviourScript : MonoBehaviour
{
static readonly ILogger<NewBehaviourScript> logger = LogManager.CreateLogger<NewBehaviourScript>();

void Start()
{
var name = "foo";
var hp = 100;
logger.ZLogInformation($"{name} HP is {hp}.");
}
}

Note that the performance improvement of C# 10.0 String Interpolation is only applicable when using ZLog, and using String Interpolation for normal String generation will not improve performance. This is because DefaultInterpolatedStringHandler is needed in the runtime for string generation performance improvement, which is only included in .NET 6 and above. If DefaultInterpolatedStringHandler doesn’t exist, it falls back to the traditional string.Format, so boxing occurs as usual.

It supports all JSON structured logging, output customization, file output, etc.

var loggerFactory = LoggerFactory.Create(logging =>
{
logging.AddZLoggerFile("/path/to/logfile", options =>
{
options.UseJsonFormatter();
});
});

And as one more bonus, with Unity 2022.3.12f1 and above, the C# compiler version is a bit higher, and if you specify -langVersion:preview, you can use C# 11.0. Also, ZLogger's Source Generator is automatically enabled, so you can use [ZLoggerMessage] to generate.

public static partial class LogExtensions
{
[ZLoggerMessage(LogLevel.Debug, "Hello, {name}")]
public static partial void Hello(this ILogger<NewBehaviourScript> logger, string name);
}

Since the code generated by the Source Generator requires C# 11.0 (because it uses UTF8 String Literal extensively), [ZLoggerMessage] is limited to Unity 2022.3.12f1 and above.

By the way, Unity has released com.unity.logging as a standard logging library of the same kind. It allows structured logging and file output in the same way, and it had an interesting design of using Source Generator to automatically generate the class itself and generate method overloads according to arguments to avoid boxing of values. There’s a lot of talk about Burst, but I think this bold use of Source Generator is the key to performance. ZLogger is utilizing C# 10.0’s String Interpolation, but I hadn’t thought about such an approach as a workaround. It’s quite eye-opening. The performance is also quite refined.

ZLogger has better writing feel due to String Interpolation, and I’d like to think the performance is a good match… what do you think?

Conclusion

By the way, in creating ZLogger v2, @hadashiA, famous for VContainer and VYaml, helped me from idea generation to detailed implementation, and put up with repeated specification overhauls. I think this v2 has become very complete, but I wouldn’t have reached this point alone, so I’m very grateful.

Anyway, I think ZLogger has become the strongest logger in terms of both ease of use and performance, so please give it a try.

--

--

Yoshifumi Kawai

a.k.a. neuecc. Creator of UniRx, UniTask, MessagePack for C#, MagicOnion etc. Microsoft MVP for C#. CEO/CTO of Cysharp Inc. Live and work in Tokyo, Japan.