feat(dotnet): json stdout logging

This commit is contained in:
alyssa 2025-05-18 13:14:29 +00:00
parent c56fd36023
commit 0fa0070d41
12 changed files with 348 additions and 140 deletions

View file

@ -10,8 +10,7 @@ using NodaTime;
using Serilog;
using Serilog.Events;
using Serilog.Formatting.Compact;
using Serilog.Sinks.Seq;
using Serilog.Formatting.Json;
using Serilog.Sinks.SystemConsole.Themes;
using ILogger = Serilog.ILogger;
@ -50,14 +49,9 @@ public class LoggingModule: Module
private ILogger InitLogger(CoreConfig config)
{
var consoleTemplate = "[{Timestamp:HH:mm:ss.fff}] {Level:u3} {Message:lj}{NewLine}{Exception}";
var outputTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.ffffff}] {Level:u3} {Message:lj}{NewLine}{Exception}";
var logCfg = _cfg
.Enrich.FromLogContext()
.Enrich.WithProperty("GitCommitHash", BuildInfoService.FullVersion)
.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)
.Enrich.WithProperty("Component", _component)
.MinimumLevel.Is(config.ConsoleLogLevel)
// Don't want App.Metrics/D#+ spam
@ -75,9 +69,8 @@ public class LoggingModule: Module
.Destructure.With<PatchObjectDestructuring>()
.WriteTo.Async(a =>
a.Console(
theme: AnsiConsoleTheme.Code,
outputTemplate: consoleTemplate,
restrictedToMinimumLevel: config.ConsoleLogLevel));
new CustomJsonFormatter(_component),
config.ConsoleLogLevel));
if (config.ElasticUrl != null)
{
@ -89,15 +82,6 @@ public class LoggingModule: Module
);
}
if (config.SeqLogUrl != null)
{
logCfg.WriteTo.Seq(
config.SeqLogUrl,
restrictedToMinimumLevel: LogEventLevel.Verbose
);
}
_fn.Invoke(logCfg);
return Log.Logger = logCfg.CreateLogger();
}

View file

@ -15,6 +15,10 @@
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Serilog\src\Serilog\Serilog.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="App.Metrics" Version="4.3.0" />
<PackageReference Include="App.Metrics.Reporting.InfluxDB" Version="4.3.0" />
@ -37,8 +41,7 @@
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0" />
<PackageReference Include="Npgsql" Version="9.0.2" />
<PackageReference Include="Npgsql.NodaTime" Version="9.0.2" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.NodaTime" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />

View file

@ -0,0 +1,215 @@
using System.Runtime.CompilerServices;
using Serilog.Events;
using Serilog.Formatting;
using Serilog.Formatting.Json;
using Serilog.Parsing;
using Serilog.Rendering;
// Customized Serilog JSON output for PluralKit
// Copyright 2013-2015 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 PluralKit.Core;
static class Guard
{
public static T AgainstNull<T>(
T? argument,
[CallerArgumentExpression("argument")] string? paramName = null)
where T : class
{
if (argument is null)
{
throw new ArgumentNullException(paramName);
}
return argument;
}
}
/// <summary>
/// Formats log events in a simple JSON structure. Instances of this class
/// are safe for concurrent access by multiple threads.
/// </summary>
/// <remarks>New code should prefer formatters from <c>Serilog.Formatting.Compact</c>, or <c>ExpressionTemplate</c> from
/// <c>Serilog.Expressions</c>.</remarks>
public sealed class CustomJsonFormatter: ITextFormatter
{
readonly JsonValueFormatter _jsonValueFormatter = new();
readonly string _component;
/// <summary>
/// Construct a <see cref="JsonFormatter"/>.
/// </summary>
/// <param name="closingDelimiter">A string that will be written after each log event is formatted.
/// If null, <see cref="Environment.NewLine"/> will be used.</param>
/// <param name="renderMessage">If <see langword="true"/>, the message will be rendered and written to the output as a
/// property named RenderedMessage.</param>
/// <param name="formatProvider">Supplies culture-specific formatting information, or null.</param>
public CustomJsonFormatter(string component)
{
_component = component;
}
private string CustomLevelString(LogEventLevel level)
{
switch (level)
{
case LogEventLevel.Verbose:
return "TRACE";
case LogEventLevel.Debug:
return "DEBUG";
case LogEventLevel.Information:
return "INFO";
case LogEventLevel.Warning:
return "WARN";
case LogEventLevel.Error:
return "ERROR";
case LogEventLevel.Fatal:
return "FATAL";
};
return "UNKNOWN";
}
/// <summary>
/// Format the log event into the output.
/// </summary>
/// <param name="logEvent">The event to format.</param>
/// <param name="output">The output.</param>
/// <exception cref="ArgumentNullException">When <paramref name="logEvent"/> is <code>null</code></exception>
/// <exception cref="ArgumentNullException">When <paramref name="output"/> is <code>null</code></exception>
public void Format(LogEvent logEvent, TextWriter output)
{
Guard.AgainstNull(logEvent);
Guard.AgainstNull(output);
output.Write("{\"component\":\"");
output.Write(_component);
output.Write("\",\"timestamp\":\"");
output.Write(logEvent.Timestamp.ToString("O").Replace("+00:00", "Z"));
output.Write("\",\"level\":\"");
output.Write(CustomLevelString(logEvent.Level));
output.Write("\",\"message\":");
var message = logEvent.MessageTemplate.Render(logEvent.Properties);
JsonValueFormatter.WriteQuotedJsonString(message, output);
if (logEvent.TraceId != null)
{
output.Write(",\"TraceId\":");
JsonValueFormatter.WriteQuotedJsonString(logEvent.TraceId.ToString()!, output);
}
if (logEvent.SpanId != null)
{
output.Write(",\"SpanId\":");
JsonValueFormatter.WriteQuotedJsonString(logEvent.SpanId.ToString()!, output);
}
if (logEvent.Exception != null)
{
output.Write(",\"Exception\":");
JsonValueFormatter.WriteQuotedJsonString(logEvent.Exception.ToString(), output);
}
if (logEvent.Properties.Count != 0)
{
output.Write(",\"Properties\":{");
char? propertyDelimiter = null;
foreach (var property in logEvent.Properties)
{
if (propertyDelimiter != null)
output.Write(propertyDelimiter.Value);
else
propertyDelimiter = ',';
JsonValueFormatter.WriteQuotedJsonString(property.Key, output);
output.Write(':');
_jsonValueFormatter.Format(property.Value, output);
}
output.Write('}');
}
var tokensWithFormat = logEvent.MessageTemplate.Tokens
.OfType<PropertyToken>()
.Where(pt => pt.Format != null)
.GroupBy(pt => pt.PropertyName)
.ToArray();
if (tokensWithFormat.Length != 0)
{
output.Write(",\"Renderings\":{");
WriteRenderingsValues(tokensWithFormat, logEvent.Properties, output);
output.Write('}');
}
output.Write('}');
output.Write("\n");
}
void WriteRenderingsValues(IEnumerable<IGrouping<string, PropertyToken>> tokensWithFormat, IReadOnlyDictionary<string, LogEventPropertyValue> properties, TextWriter output)
{
static void WriteNameValuePair(string name, string value, ref char? precedingDelimiter, TextWriter output)
{
if (precedingDelimiter != null)
output.Write(precedingDelimiter.Value);
JsonValueFormatter.WriteQuotedJsonString(name, output);
output.Write(':');
JsonValueFormatter.WriteQuotedJsonString(value, output);
precedingDelimiter = ',';
}
char? propertyDelimiter = null;
foreach (var propertyFormats in tokensWithFormat)
{
if (propertyDelimiter != null)
output.Write(propertyDelimiter.Value);
else
propertyDelimiter = ',';
output.Write('"');
output.Write(propertyFormats.Key);
output.Write("\":[");
char? formatDelimiter = null;
foreach (var format in propertyFormats)
{
if (formatDelimiter != null)
output.Write(formatDelimiter.Value);
formatDelimiter = ',';
output.Write('{');
char? elementDelimiter = null;
// Caller ensures that `tokensWithFormat` contains only property tokens that have non-null `Format`s.
WriteNameValuePair("Format", format.Format!, ref elementDelimiter, output);
using var sw = ReusableStringWriter.GetOrCreate();
MessageTemplateRenderer.RenderPropertyToken(format, properties, sw, null, isLiteral: true, isJson: false);
WriteNameValuePair("Rendering", sw.ToString(), ref elementDelimiter, output);
output.Write('}');
}
output.Write(']');
}
}
}

View file

@ -198,20 +198,14 @@
"Npgsql": "9.0.2"
}
},
"Serilog": {
"type": "Direct",
"requested": "[4.2.0, )",
"resolved": "4.2.0",
"contentHash": "gmoWVOvKgbME8TYR+gwMf7osROiWAURterc6Rt2dQyX7wtjZYpqFiA/pY6ztjGQKKV62GGCyOcmtP1UKMHgSmA=="
},
"Serilog.Extensions.Logging": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "NwSSYqPJeKNzl5AuXVHpGbr6PkZJFlNa14CdIebVjK3k/76kYj/mz5kiTRNVSsSaxM8kAIa1kpy/qyT9E4npRQ==",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "YEAMWu1UnWgf1c1KP85l1SgXGfiVo0Rz6x08pCiPOIBt2Qe18tcZLvdBUuV5o1QHvrs8FAry9wTIhgBRtjIlEg==",
"dependencies": {
"Microsoft.Extensions.Logging": "9.0.0",
"Serilog": "4.2.0"
"Microsoft.Extensions.Logging": "8.0.0",
"Serilog": "3.1.1"
}
},
"Serilog.Formatting.Compact": {
@ -722,6 +716,9 @@
"Microsoft.NETCore.Targets": "1.1.0",
"System.Runtime": "4.3.0"
}
},
"serilog": {
"type": "Project"
}
}
}