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 type LogEventPropertyValue?, and
  • If they support case-insensitive comparisons via the ci modifier, they should accept a StringComparison 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.