User-defined functions in Serilog Expressions
Yesterday’s post introduced Serilog Expressions, a little library for filtering, enriching, and formatting Serilog events.
We left open the question of how to show dates and times in UTC. This is a good fit for a user-defined function: writing and plugging-in our own implementation of ToUtc()
is the subject of this post.
A recap of ExpressionTemplate
ExpressionTemplate
turns Serilog events into plain text or JSON by executing a template. Here’s an example that shows how log events might be formatted for display at the terminal:
// dotnet add package serilog.expressions -v 1.0.0-*
using Serilog;
using Serilog.Templates;
namespace UserDefinedFunctions
{
static class Program
{
static void Main()
{
using var log = new LoggerConfiguration()
.WriteTo.Console(new ExpressionTemplate(
"[{@t:o} {@l:u3}] {@m}\n{@x}"))
.CreateLogger();
log.Information("Hello, world!");
// [2020-10-01T11:03:29.0292877+10:00 INF] Hello, world!
}
}
}
Notice that the timestamp generated from @t
is a DateTimeOffset
in my local time zone (GMT +10, here in Brisbane, Australia). It’s often more convenient to record times in UTC, espeicially in log files.
Given that the template can include arbitrary expressions, what we want is something like:
.WriteTo.Console(new ExpressionTemplate(
"[{ToUtc(@t):o} {@l:u3}] {@m}\n{@x}"))
And of course, this doesn’t work, because ToUtc()
isn’t a function supported by Serilog Expressions:
System.ArgumentException: The function name `ToUtc` was not recognized.
at Serilog.Expressions.Compilation.Linq.LinqExpressionCompiler.Transform(CallExpression lx)
...
… yet.
Writing a user-defined function
Handily, the expression language can be extended with new functions. There are only a few simple requirements right now:
- Functions need to be
public static
methods of a .NET class, - They must return
LogEventPropertyValue?
, and accept parameters of typeLogEventPropertyValue?
, and - If they support case-insensitive comparisons via the
ci
modifier, they should accept aStringComparison
in the first argument position.
LogEventPropertyValue
is the basis of Serilog’s internal data model and distinguishes between regular values (ScalarValue
) and those that have been captured using Serilog’s serialization mechanisms (StructureValue
, SequenceValue
, and DictionaryValue
).
Here’s ToUtc()
, sitting in a class alongside Program
from above:
static class DateTimeFunctions
{
public static LogEventPropertyValue? ToUtc(LogEventPropertyValue? value)
{
if (value is ScalarValue scalar)
{
if (scalar.Value is DateTimeOffset dto)
return new ScalarValue(dto.UtcDateTime);
if (scalar.Value is DateTime dt)
return new ScalarValue(dt.ToUniversalTime());
}
return null;
}
}
The function returns null
rather than throwing an exception when the argument is not a DateTime
or DateTimeOffset
. Where you see null
, think ToUtc()
is not defined for this argument value.
Plugging in ToUtc()
Now that we’ve written ToUtc()
all we need to do is tell Serilog Expressions about it. We do this by passing a NameResolver
into the ExpressionTemplate
constructor:
var dateTimeFunctions = new StaticMemberNameResolver(typeof(DateTimeFunctions));
using var log = new LoggerConfiguration()
.WriteTo.Console(new ExpressionTemplate(
"[{ToUtc(@t):o} {@l:u3}] {@m}\n{@x}",
nameResolver: dateTimeFunctions))
.CreateLogger();
log.Information("Hello, world!");
// [2020-10-01T01:03:29.0292877Z INF] Hello, world!
Voila! You can see Z
at the end of the ISO 8601 formatting of the timestamp, which indicates UTC time.
User-defined functions can do a lot more, and the space has barely been explored yet. There’s more info on Serilog Expressions over on GitHub.