diff --git a/README.md b/README.md index d3265a8..2d497f7 100644 --- a/README.md +++ b/README.md @@ -95,12 +95,12 @@ Log.Logger = new LoggerConfiguration() Note the use of `{Items[0]}`: "holes" in expression templates can include any valid expression. -Newline-delimited JSON (for example, emulating the [CLEF format](https://github.com/serilog/serilog-formatting-compact)) can be generated +Newline-delimited JSON (for example, replicating the [CLEF format](https://github.com/serilog/serilog-formatting-compact)) can be generated using object literals: ```csharp .WriteTo.Console(new ExpressionTemplate( - "{ {@t, @mt, @l: if @l = 'Information' then undefined() else @l, @x, ..@p} }\n")) + "{ {@t, @mt, @r, @l: if @l = 'Information' then undefined() else @l, @x, ..@p} }\n")) ``` ## Language reference @@ -116,6 +116,10 @@ The following properties are available in expressions: * `@l` - the event's level, as a `LogEventLevel` * `@x` - the exception associated with the event, if any, as an `Exception` * `@p` - a dictionary containing all first-class properties; this supports properties with non-identifier names, for example `@p['snake-case-name']` + * `@i` - event id; a 32-bit numeric hash of the event's message template + * `@r` - renderings; if any tokens in the message template include .NET-specific formatting, an array of rendered values for each such token + +The built-in properties mirror those available in the CLEF format. ### Literals @@ -175,12 +179,15 @@ calling a function will be undefined if: | `IsDefined(x)` | Returns `true` if the expression `x` has a value, including `null`, or `false` if `x` is undefined. | | `LastIndexOf(s, t)` | Returns the last index of substring `t` in string `s`, or -1 if the substring does not appear. | | `Length(x)` | Returns the length of a string or array. | +| `Now()` | Returns `DateTimeOffset.Now`. | | `Round(n, m)` | Round the number `n` to `m` decimal places. | | `StartsWith(s, t)` | Tests whether the string `s` starts with substring `t`. | | `Substring(s, start, [length])` | Return the substring of string `s` from `start` to the end of the string, or of `length` characters, if this argument is supplied. | | `TagOf(o)` | Returns the `TypeTag` field of a captured object (i.e. where `TypeOf(x)` is `'object'`). | +| `ToString(x, f)` | Applies the format string `f` to the formattable value `x`. | | `TypeOf(x)` | Returns a string describing the type of expression `x`: a .NET type name if `x` is scalar and non-null, or, `'array'`, `'object'`, `'dictionary'`, `'null'`, or `'undefined'`. | | `Undefined()` | Explicitly mark an undefined value. | +| `UtcDateTime(x)` | Convert a `DateTime` or `DateTimeOffset` into a UTC `DateTime`. | Functions that compare text accept an optional postfix `ci` modifier to select case-insensitive comparisons: diff --git a/src/Serilog.Expressions/Expressions/BuiltInProperty.cs b/src/Serilog.Expressions/Expressions/BuiltInProperty.cs index e499dda..cf1a563 100644 --- a/src/Serilog.Expressions/Expressions/BuiltInProperty.cs +++ b/src/Serilog.Expressions/Expressions/BuiltInProperty.cs @@ -1,5 +1,20 @@ +// Copyright © Serilog Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + namespace Serilog.Expressions { + // See https://github.com/serilog/serilog-formatting-compact#reified-properties static class BuiltInProperty { public const string Exception = "x"; @@ -8,5 +23,7 @@ static class BuiltInProperty public const string Message = "m"; public const string MessageTemplate = "mt"; public const string Properties = "p"; + public const string Renderings = "r"; + public const string EventId = "i"; } } \ No newline at end of file diff --git a/src/Serilog.Expressions/Expressions/Compilation/Linq/EventIdHash.cs b/src/Serilog.Expressions/Expressions/Compilation/Linq/EventIdHash.cs new file mode 100644 index 0000000..087a86f --- /dev/null +++ b/src/Serilog.Expressions/Expressions/Compilation/Linq/EventIdHash.cs @@ -0,0 +1,55 @@ +// Copyright © Serilog Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +// ReSharper disable ForCanBeConvertedToForeach + +namespace Serilog.Expressions.Compilation.Linq +{ + /// + /// Hash functions for message templates. See . + /// + public static class EventIdHash + { + /// + /// Compute a 32-bit hash of the provided . The + /// resulting hash value can be uses as an event id in lieu of transmitting the + /// full template string. + /// + /// A message template. + /// A 32-bit hash of the template. + [CLSCompliant(false)] + public static uint Compute(string messageTemplate) + { + if (messageTemplate == null) throw new ArgumentNullException(nameof(messageTemplate)); + + // Jenkins one-at-a-time https://en.wikipedia.org/wiki/Jenkins_hash_function + unchecked + { + uint hash = 0; + for (var i = 0; i < messageTemplate.Length; ++i) + { + hash += messageTemplate[i]; + hash += hash << 10; + hash ^= hash >> 6; + } + hash += hash << 3; + hash ^= hash >> 11; + hash += hash << 15; + return hash; + } + } + } +} diff --git a/src/Serilog.Expressions/Expressions/Compilation/Linq/Intrinsics.cs b/src/Serilog.Expressions/Expressions/Compilation/Linq/Intrinsics.cs index 5892758..ff4b3a5 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/Linq/Intrinsics.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/Linq/Intrinsics.cs @@ -6,6 +6,7 @@ using System.Text.RegularExpressions; using Serilog.Events; using Serilog.Formatting.Display; +using Serilog.Parsing; // ReSharper disable ParameterTypeCanBeEnumerable.Global @@ -15,6 +16,8 @@ static class Intrinsics { static readonly LogEventPropertyValue NegativeOne = new ScalarValue(-1); static readonly LogEventPropertyValue Tombstone = new ScalarValue("😬 (if you see this you have found a bug.)"); + + // TODO #19: formatting is culture-specific. static readonly MessageTemplateTextFormatter MessageFormatter = new MessageTemplateTextFormatter("{Message:lj}"); public static List CollectSequenceElements(LogEventPropertyValue?[] elements) @@ -159,5 +162,25 @@ public static string RenderMessage(LogEvent logEvent) MessageFormatter.Format(logEvent, sw); return sw.ToString(); } + + public static LogEventPropertyValue? GetRenderings(LogEvent logEvent) + { + List? elements = null; + foreach (var token in logEvent.MessageTemplate.Tokens) + { + if (token is PropertyToken pt && pt.Format != null) + { + elements ??= new List(); + + var space = new StringWriter(); + + // TODO #19: formatting is culture-specific. + pt.Render(logEvent.Properties, space); + elements.Add(new ScalarValue(space.ToString())); + } + } + + return elements == null ? null : new SequenceValue(elements); + } } } \ No newline at end of file diff --git a/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs b/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs index bfcfd85..d56fe2e 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/Linq/LinqExpressionCompiler.cs @@ -1,4 +1,18 @@ -using System; +// Copyright © Serilog Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -124,25 +138,22 @@ protected override ExpressionBody Transform(AmbientPropertyExpression px) { if (px.IsBuiltIn) { - if (px.PropertyName == BuiltInProperty.Level) - return Splice(context => new ScalarValue(context.Level)); - - if (px.PropertyName == BuiltInProperty.Message) - return Splice(context => new ScalarValue(Intrinsics.RenderMessage(context))); - - if (px.PropertyName == BuiltInProperty.Exception) - return Splice(context => context.Exception == null ? null : new ScalarValue(context.Exception)); - - if (px.PropertyName == BuiltInProperty.Timestamp) - return Splice(context => new ScalarValue(context.Timestamp)); - - if (px.PropertyName == BuiltInProperty.MessageTemplate) - return Splice(context => new ScalarValue(context.MessageTemplate.Text)); - - if (px.PropertyName == BuiltInProperty.Properties) - return Splice(context => new StructureValue(context.Properties.Select(kvp => new LogEventProperty(kvp.Key, kvp.Value)), null)); - - return LX.Constant(null, typeof(LogEventPropertyValue)); + return px.PropertyName switch + { + BuiltInProperty.Level => Splice(context => new ScalarValue(context.Level)), + BuiltInProperty.Message => Splice(context => new ScalarValue(Intrinsics.RenderMessage(context))), + BuiltInProperty.Exception => Splice(context => + context.Exception == null ? null : new ScalarValue(context.Exception)), + BuiltInProperty.Timestamp => Splice(context => new ScalarValue(context.Timestamp)), + BuiltInProperty.MessageTemplate => Splice(context => new ScalarValue(context.MessageTemplate.Text)), + BuiltInProperty.Properties => Splice(context => + new StructureValue(context.Properties.Select(kvp => new LogEventProperty(kvp.Key, kvp.Value)), + null)), + BuiltInProperty.Renderings => Splice(context => Intrinsics.GetRenderings(context)), + BuiltInProperty.EventId => Splice(context => + new ScalarValue(EventIdHash.Compute(context.MessageTemplate.Text))), + _ => LX.Constant(null, typeof(LogEventPropertyValue)) + }; } var propertyName = px.PropertyName; diff --git a/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardComprehensionTransformer.cs b/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardComprehensionTransformer.cs index 1bcaf0d..6c60f68 100644 --- a/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardComprehensionTransformer.cs +++ b/src/Serilog.Expressions/Expressions/Compilation/Wildcards/WildcardComprehensionTransformer.cs @@ -1,4 +1,5 @@ -using Serilog.Expressions.Ast; +using System.Linq; +using Serilog.Expressions.Ast; using Serilog.Expressions.Compilation.Transformations; namespace Serilog.Expressions.Compilation.Wildcards @@ -15,25 +16,34 @@ public static Expression Expand(Expression root) protected override Expression Transform(CallExpression lx) { - if (!Operators.WildcardComparators.Contains(lx.OperatorName) || lx.Operands.Length != 2) + if (!Operators.WildcardComparators.Contains(lx.OperatorName)) return base.Transform(lx); - var lhsIs = WildcardSearch.FindElementAtWildcard(lx.Operands[0]); - var rhsIs = WildcardSearch.FindElementAtWildcard(lx.Operands[1]); - if (lhsIs != null && rhsIs != null || lhsIs == null && rhsIs == null) + IndexerExpression? indexer = null; + Expression? wildcardPath = null; + var indexerOperand = -1; + for (var i = 0; i < lx.Operands.Length; ++i) + { + indexer = WildcardSearch.FindElementAtWildcard(lx.Operands[i]); + if (indexer != null) + { + indexerOperand = i; + wildcardPath = lx.Operands[i]; + break; + } + } + + if (indexer == null || wildcardPath == null) return base.Transform(lx); // N/A, or invalid - var wildcardPath = lhsIs != null ? lx.Operands[0] : lx.Operands[1]; - var comparand = lhsIs != null ? lx.Operands[1] : lx.Operands[0]; - var indexer = lhsIs ?? rhsIs!; - var px = new ParameterExpression("p" + _nextParameter++); var nestedComparand = NodeReplacer.Replace(wildcardPath, indexer, px); var coll = indexer.Receiver; var wc = ((IndexerWildcardExpression)indexer.Index).Wildcard; - var comparisonArgs = lhsIs != null ? new[] { nestedComparand, comparand } : new[] { comparand, nestedComparand }; + var comparisonArgs = lx.Operands.ToArray(); + comparisonArgs[indexerOperand] = nestedComparand; var body = new CallExpression(lx.IgnoreCase, lx.OperatorName, comparisonArgs); var lambda = new LambdaExpression(new[] { px }, body); diff --git a/src/Serilog.Expressions/Expressions/Operators.cs b/src/Serilog.Expressions/Expressions/Operators.cs index cd22499..b8b7d30 100644 --- a/src/Serilog.Expressions/Expressions/Operators.cs +++ b/src/Serilog.Expressions/Expressions/Operators.cs @@ -24,12 +24,15 @@ static class Operators public const string OpIsDefined = "IsDefined"; public const string OpLastIndexOf = "LastIndexOf"; public const string OpLength = "Length"; + public const string OpNow = "Now"; public const string OpRound = "Round"; public const string OpStartsWith = "StartsWith"; public const string OpSubstring = "Substring"; public const string OpTagOf = "TagOf"; + public const string OpToString = "ToString"; public const string OpTypeOf = "TypeOf"; public const string OpUndefined = "Undefined"; + public const string OpUtcDateTime = "UtcDateTime"; public const string IntermediateOpLike = "_Internal_Like"; public const string IntermediateOpNotLike = "_Internal_NotLike"; diff --git a/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs b/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs index 7cdc21d..580fa2b 100644 --- a/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs +++ b/src/Serilog.Expressions/Expressions/Runtime/RuntimeOperators.cs @@ -467,5 +467,38 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v { return Coerce.IsTrue(condition) ? consequent : alternative; } + + public static LogEventPropertyValue? ToString(LogEventPropertyValue? value, LogEventPropertyValue? format) + { + if (!(value is ScalarValue sv && sv.Value is IFormattable formattable) || + !Coerce.String(format, out var fmt)) + { + return null; + } + + // TODO #19: formatting is culture-specific. + return new ScalarValue(formattable.ToString(fmt, null)); + } + + public static LogEventPropertyValue? UtcDateTime(LogEventPropertyValue? dateTime) + { + if (dateTime is ScalarValue sv) + { + if (sv.Value is DateTimeOffset dto) + return new ScalarValue(dto.UtcDateTime); + + if (sv.Value is DateTime dt) + return new ScalarValue(dt.ToUniversalTime()); + } + + return null; + } + + // ReSharper disable once UnusedMember.Global + public static LogEventPropertyValue? Now() + { + // DateTimeOffset.Now is the generator for LogEvent.Timestamp. + return new ScalarValue(DateTimeOffset.Now); + } } } diff --git a/src/Serilog.Expressions/Serilog.Expressions.csproj b/src/Serilog.Expressions/Serilog.Expressions.csproj index 5fbabb4..132a9ec 100644 --- a/src/Serilog.Expressions/Serilog.Expressions.csproj +++ b/src/Serilog.Expressions/Serilog.Expressions.csproj @@ -3,7 +3,7 @@ An embeddable mini-language for filtering, enriching, and formatting Serilog events, ideal for use with JSON or XML configuration. - 1.0.1 + 1.1.0 Serilog Contributors netstandard2.1 true diff --git a/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv b/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv index a783b3e..19cf6cf 100644 --- a/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv +++ b/test/Serilog.Expressions.Tests/Cases/expression-evaluation-cases.asv @@ -217,7 +217,13 @@ if undefined() then 1 else 2 ⇶ 2 if 'string' then 1 else 2 ⇶ 2 if true then if false then 1 else 2 else 3 ⇶ 2 -// Typeof +// ToString +tostring(16, '000') ⇶ '016' +tostring('test', '000') ⇶ undefined() +tostring(16, undefined()) ⇶ undefined() +tostring(16, null) ⇶ undefined() + +// TypeOf typeof(undefined()) ⇶ 'undefined' typeof('test') ⇶ 'System.String' typeof(10) ⇶ 'System.Decimal' @@ -226,6 +232,9 @@ typeof(null) ⇶ 'null' typeof([]) ⇶ 'array' typeof({}) ⇶ 'object' +// UtcDateTime +tostring(utcdatetime(now()), 'o') like '20%' ⇶ true + // Case comparison 'test' = 'TEST' ⇶ false 'tschüß' = 'TSCHÜSS' ⇶ false diff --git a/test/Serilog.Expressions.Tests/FormatParityTests.cs b/test/Serilog.Expressions.Tests/FormatParityTests.cs new file mode 100644 index 0000000..30ff858 --- /dev/null +++ b/test/Serilog.Expressions.Tests/FormatParityTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Serilog.Events; +using Serilog.Expressions.Tests.Support; +using Serilog.Formatting; +using Serilog.Formatting.Compact; +using Serilog.Formatting.Json; +using Serilog.Parsing; +using Serilog.Templates; +using Xunit; + +namespace Serilog.Expressions.Tests +{ + /// + /// These tests track the ability of Serilog.Expressions to faithfully reproduce the JSON formats implemented in + /// Serilog and Serilog.Formatting.Compact. The tests jump through a few hoops to achieve byte-for-byte correctness; + /// in practice, valid JSON in these formats can be constructed with simpler templates. + /// + public class FormatParityTests + { + // Implements CLEF-style `@@` escaping of property names that begin with `@`. + // ReSharper disable once UnusedMember.Global + public static LogEventPropertyValue? ClefEscape(LogEventPropertyValue? logEventProperties) + { + if (!(logEventProperties is StructureValue st)) + return null; + + foreach (var check in st.Properties) + { + if (check.Name.Length > 0 && check.Name[0] == '@') + { + var properties = new List(); + + foreach (var member in st.Properties) + { + var property = new LogEventProperty( + member.Name.Length > 0 && member.Name[0] == '@' ? "@" + member.Name : member.Name, + member.Value); + + properties.Add(property); + } + + return new StructureValue(properties, st.TypeTag); + } + } + + return logEventProperties; + } + + // Renders a message template with old-style "quoted" strings (expression templates use the newer :lj formatting always). + // ReSharper disable once UnusedMember.Global + public static LogEventPropertyValue? ClassicRender(LogEventPropertyValue? messageTemplate, LogEventPropertyValue? properties) + { + if (!(messageTemplate is ScalarValue svt && svt.Value is string smt) || + !(properties is StructureValue stp)) + { + return null; + } + + var mt = new MessageTemplateParser().Parse(smt); + var space = new StringWriter(); + mt.Render(stp.Properties.ToDictionary(p => p.Name, p => p.Value), space); + return new ScalarValue(space.ToString()); + } + + // Constructs the Renderings property used in the old JSON format. + // ReSharper disable once UnusedMember.Global + public static LogEventPropertyValue? ClassicRenderings(LogEventPropertyValue? messageTemplate, LogEventPropertyValue? properties) + { + if (!(messageTemplate is ScalarValue svt && svt.Value is string smt) || + !(properties is StructureValue stp)) + { + return null; + } + + var mt = new MessageTemplateParser().Parse(smt); + var tokensWithFormat = mt.Tokens + .OfType() + .Where(pt => pt.Format != null) + .GroupBy(pt => pt.PropertyName); + + // ReSharper disable once PossibleMultipleEnumeration + if (!tokensWithFormat.Any()) + return null; + + var propertiesByName = stp.Properties.ToDictionary(p => p.Name, p => p.Value); + + var renderings = new List(); + // ReSharper disable once PossibleMultipleEnumeration + foreach (var propertyFormats in tokensWithFormat) + { + var values = new List(); + + foreach (var format in propertyFormats) + { + var sw = new StringWriter(); + + format.Render(propertiesByName, sw); + + values.Add(new StructureValue(new [] + { + new LogEventProperty("Format", new ScalarValue(format.Format)), + new LogEventProperty("Rendering", new ScalarValue(sw.ToString())), + })); + } + + renderings.Add(new LogEventProperty(propertyFormats.Key, new SequenceValue(values))); + } + + return new StructureValue(renderings); + } + + readonly ITextFormatter + _clef = new CompactJsonFormatter(), + _renderedClef = new RenderedCompactJsonFormatter(), + _classic = new JsonFormatter(), + _clefExpression = new ExpressionTemplate( + "{ {@t: UtcDateTime(@t), @mt, @r, @l: if @l = 'Information' then undefined() else @l, @x, ..ClefEscape(@p)} }" + Environment.NewLine, + null, new StaticMemberNameResolver(typeof(FormatParityTests))), + _renderedClefExpression = new ExpressionTemplate( + "{ {@t: UtcDateTime(@t), @m: ClassicRender(@mt, @p), @i: ToString(@i, 'x8'), @l: if @l = 'Information' then undefined() else @l, @x, ..ClefEscape(@p)} }" + Environment.NewLine, + null, new StaticMemberNameResolver(typeof(FormatParityTests))), + _classicExpression = new ExpressionTemplate( + "{ {Timestamp: @t, Level: @l, MessageTemplate: @mt, Exception: @x, Properties: if IsDefined(@p[?]) then @p else undefined(), Renderings: ClassicRenderings(@mt, @p)} }" + Environment.NewLine, + null, new StaticMemberNameResolver(typeof(FormatParityTests))); + + static string Render( + ITextFormatter formatter, + LogEvent logEvent) + { + var space = new StringWriter(); + formatter.Format(logEvent, space); + return space.ToString(); + } + + void AssertWriteParity( + LogEventLevel level, + Exception? exception, + string messageTemplate, + params object[] propertyValues) + { + var sink = new CollectingSink(); + using (var log = new LoggerConfiguration() + .MinimumLevel.Is(LevelAlias.Minimum) + .WriteTo.Sink(sink) + .CreateLogger()) + { + log.Write(level, exception, messageTemplate, propertyValues); + } + + var clef = Render(_clef, sink.SingleEvent); + var clefExpression = Render(_clefExpression, sink.SingleEvent); + Assert.Equal(clef, clefExpression); + + var renderedClef = Render(_renderedClef, sink.SingleEvent); + var renderedClefExpression = Render(_renderedClefExpression, sink.SingleEvent); + Assert.Equal(renderedClef, renderedClefExpression); + + var renderedClassic = Render(_classic, sink.SingleEvent); + var renderedClassicExpression = Render(_classicExpression, sink.SingleEvent); + Assert.Equal(renderedClassic, renderedClassicExpression); + } + + [Fact] + public void ParityIsMaintained() + { + AssertWriteParity(LogEventLevel.Information, null, "Hello, world!"); + AssertWriteParity(LogEventLevel.Debug, new Exception(), "Hello, {Name}, {Number:000}, {Another:#.00}", "world", 42, 3.1); + } + } +} diff --git a/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj b/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj index 1a98f8f..5b3e4ec 100644 --- a/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj +++ b/test/Serilog.Expressions.Tests/Serilog.Expressions.Tests.csproj @@ -1,6 +1,6 @@  - netcoreapp3.1 + net5.0 true ../../assets/Serilog.snk true @@ -12,6 +12,7 @@ +