From d956367e5a48065d589c32fdff35125e8c7b2dc5 Mon Sep 17 00:00:00 2001 From: Gerkin Date: Thu, 3 Oct 2024 00:35:15 +0200 Subject: [PATCH 1/3] feat: rework renderers to generate console interpolation placeholders along with console args Adds %o, %s, %c, etc in the template message (1st arg) & actual values as following parameters. More style-friendly --- README.md | 10 ++ .../Output/EventPropertyTokenRenderer.cs | 6 +- .../Output/ExceptionTokenRenderer.cs | 2 +- .../Output/LevelTokenRenderer.cs | 2 +- .../MessageTemplateOutputTokenRenderer.cs | 6 +- .../Output/NewLineTokenRenderer.cs | 4 +- .../Output/OutputTemplateRenderer.cs | 6 +- .../Output/OutputTemplateTokenRenderer.cs | 30 ++++- .../Output/PropertiesTokenRenderer.cs | 2 +- .../Output/TextTokenRenderer.cs | 27 ++++- .../Output/TimestampTokenRenderer.cs | 4 +- .../FormatterTests.cs | 108 ++++++++++++++++++ 12 files changed, 189 insertions(+), 18 deletions(-) create mode 100644 test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs diff --git a/README.md b/README.md index 1c1bef2..db38912 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,13 @@ Log.Information("Hello, browser!"); ``` A more detailed example is available [in this repository](https://github.com/serilog/serilog-sinks-browserconsole/tree/dev/example/ExampleClient). + +### Styling your stuff + +In your sink's `outputTemplate` parameter, you can leverage [`console.log` styling capabilities](https://developer.mozilla.org/en-US/docs/Web/API/console#styling_console_output) by using the `<<...>>` placeholder. Use `<<_>>` to reset styles to defaults. + +Example: + +``` +<>{Level}<<_>>: {Message} +``` diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/EventPropertyTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/EventPropertyTokenRenderer.cs index 20fda6b..17e43cd 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/EventPropertyTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/EventPropertyTokenRenderer.cs @@ -35,7 +35,7 @@ public override void Render(LogEvent logEvent, TokenEmitter emitToken) if (!logEvent.Properties.TryGetValue(_token.PropertyName, out var propertyValue)) { if (_token.Alignment is not null) - emitToken(Padding.Apply(string.Empty, _token.Alignment)); + emitToken.Literal(Padding.Apply(string.Empty, _token.Alignment)); return; } @@ -55,8 +55,8 @@ public override void Render(LogEvent logEvent, TokenEmitter emitToken) var str = writer.ToString(); if (_token.Alignment is not null) - emitToken(Padding.Apply(str, _token.Alignment)); + emitToken.Text(Padding.Apply(str, _token.Alignment)); else - emitToken(str); + emitToken.Text(str); } } \ No newline at end of file diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/ExceptionTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/ExceptionTokenRenderer.cs index bf0f9df..7bd9da3 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/ExceptionTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/ExceptionTokenRenderer.cs @@ -21,6 +21,6 @@ class ExceptionTokenRenderer : OutputTemplateTokenRenderer public override void Render(LogEvent logEvent, TokenEmitter emitToken) { if (logEvent.Exception is not null) - emitToken(logEvent.Exception.ToString()); + emitToken.Text(logEvent.Exception.ToString()); } } \ No newline at end of file diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/LevelTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/LevelTokenRenderer.cs index fff372d..6f71106 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/LevelTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/LevelTokenRenderer.cs @@ -31,6 +31,6 @@ public override void Render(LogEvent logEvent, TokenEmitter emitToken) { var moniker = LevelOutputFormat.GetLevelMoniker(logEvent.Level, _levelToken.Format); var alignedOutput = Padding.Apply(moniker, _levelToken.Alignment); - emitToken(alignedOutput); + emitToken.Text(alignedOutput); } } \ No newline at end of file diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/MessageTemplateOutputTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/MessageTemplateOutputTokenRenderer.cs index 4447165..06dda5a 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/MessageTemplateOutputTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/MessageTemplateOutputTokenRenderer.cs @@ -26,15 +26,15 @@ public override void Render(LogEvent logEvent, TokenEmitter emitToken) switch (token) { case TextToken tt: - emitToken(tt.Text); + new TextTokenRenderer(tt.Text).Render(logEvent, emitToken); break; case PropertyToken pt: if (logEvent.Properties.TryGetValue(pt.PropertyName, out var propertyValue)) - emitToken(ObjectModelInterop.ToInteropValue(propertyValue)); + emitToken.Object(ObjectModelInterop.ToInteropValue(propertyValue)); break; default: throw new InvalidOperationException(); } } } -} \ No newline at end of file +} diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/NewLineTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/NewLineTokenRenderer.cs index 307367d..d367daa 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/NewLineTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/NewLineTokenRenderer.cs @@ -30,8 +30,8 @@ public NewLineTokenRenderer(Alignment? alignment) public override void Render(LogEvent logEvent, TokenEmitter emitToken) { if (_alignment is not null) - emitToken(Padding.Apply(Environment.NewLine, _alignment.Value.Widen(Environment.NewLine.Length))); + emitToken.Literal(Padding.Apply(Environment.NewLine, _alignment.Value.Widen(Environment.NewLine.Length))); else - emitToken(Environment.NewLine); + emitToken.Literal(Environment.NewLine); } } \ No newline at end of file diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateRenderer.cs index 7e205ee..6433436 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateRenderer.cs @@ -36,11 +36,11 @@ public OutputTemplateRenderer(string outputTemplate, IFormatProvider? formatProv { ArgumentNullException.ThrowIfNull(logEvent); - var buffer = new List(_renderers.Length * 2); + var tokenEmitter = new TokenEmitter(); foreach (var renderer in _renderers) { - renderer.Render(logEvent, buffer.Add); + renderer.Render(logEvent, tokenEmitter); } - return buffer.ToArray(); + return tokenEmitter.YieldArgs(); } } \ No newline at end of file diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateTokenRenderer.cs index b0776fc..b50010c 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateTokenRenderer.cs @@ -13,10 +13,38 @@ // limitations under the License. using Serilog.Events; +using System.Text; namespace Serilog.Sinks.BrowserConsole.Output; -delegate void TokenEmitter(object? token); +class TokenEmitter +{ + private StringBuilder _template = new(); + private List _args = []; + + internal void Literal(string text) + { + _template.Append(text.Replace("%", "%%")); + } + + internal void Text(string text) { + _template.Append("%s"); + _args.Add(text); + } + + internal void Object(object? value) + { + _template.Append("%o"); + _args.Add(value); + } + internal object?[] YieldArgs() => [_template.ToString(), .. _args]; + + internal void Style(string styleContent) + { + _template.Append("%c"); + _args.Add(styleContent); + } +} abstract class OutputTemplateTokenRenderer { diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertiesTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertiesTokenRenderer.cs index ce29847..9701156 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertiesTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertiesTokenRenderer.cs @@ -36,7 +36,7 @@ public override void Render(LogEvent logEvent, TokenEmitter emitToken) foreach (var property in included) { - emitToken(ObjectModelInterop.ToInteropValue(property.Value, _token.Format)); + emitToken.Object(ObjectModelInterop.ToInteropValue(property.Value, _token.Format)); } } diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/TextTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/TextTokenRenderer.cs index c3c009d..fd80058 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/TextTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/TextTokenRenderer.cs @@ -27,6 +27,31 @@ public TextTokenRenderer(string text) public override void Render(LogEvent logEvent, TokenEmitter emitToken) { - emitToken(_text); + var textIter = _text; + while (!string.IsNullOrEmpty(textIter)) + { + var openTagIndex = textIter.IndexOf("<<"); + if (openTagIndex == -1) // If no open tag, add full text & exit loop + { + emitToken.Literal(textIter); + return; + } + var displayText = textIter[..openTagIndex]; + emitToken.Literal(displayText); + + var closeTagIndex = textIter.IndexOf(">>", openTagIndex); + if (closeTagIndex == -1) + { + throw new FormatException("Open tag found without close tag"); + } + var styleContent = textIter[(openTagIndex + 2)..closeTagIndex]; + if (styleContent.Trim() == "_") + { + styleContent = ""; + } + emitToken.Style(styleContent); + + textIter = textIter[(closeTagIndex + 2)..]; + } } } \ No newline at end of file diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/TimestampTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/TimestampTokenRenderer.cs index 75c3aec..ffd4eb8 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/TimestampTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/TimestampTokenRenderer.cs @@ -41,8 +41,8 @@ public override void Render(LogEvent logEvent, TokenEmitter emitToken) var str = buffer.ToString(); if (_token.Alignment is not null) - emitToken(Padding.Apply(str, _token.Alignment)); + emitToken.Text(Padding.Apply(str, _token.Alignment)); else - emitToken(str); + emitToken.Text(str); } } \ No newline at end of file diff --git a/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs b/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs new file mode 100644 index 0000000..08377e7 --- /dev/null +++ b/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs @@ -0,0 +1,108 @@ +using Serilog.Events; +using Serilog.Formatting.Display; +using Serilog.Parsing; +using Serilog.Sinks.BrowserConsole.Output; +using System; +using Xunit; + +namespace Serilog.Sinks.BrowserConsole.Tests +{ + public class FormatterTests + { + const string STYLE1 = "color: red;"; + const string STYLE2 = "color: blue;"; + const string STYLE3 = "color: purple;"; + [Fact] + public void SupportsStylingSimple() + { + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>World<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, MessageTemplate.Empty, Array.Empty())); + Assert.Equal(["%cHello%c %cWorld%c", STYLE1, "", STYLE2, ""], args); + } + + [Fact] + public void SupportsStylingWithTimestamp() + { + var date = DateTimeOffset.Now; + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.TimestampPropertyName}:HH:mm}}<<_>>", default); + var args = formatter.Format(new LogEvent(date, LogEventLevel.Verbose, null, MessageTemplate.Empty, Array.Empty())); + Assert.Equal([$"%cHello%c %c%s%c", STYLE1, "", STYLE2, date.ToString("HH:mm"), ""], args); + } + + [Fact] + public void SupportsStylingWithNewLine() + { + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.NewLinePropertyName}}}<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, MessageTemplate.Empty, Array.Empty())); + Assert.Equal([$"%cHello%c %c{Environment.NewLine}%c", STYLE1, "", STYLE2, ""], args); + } + + [Fact] + public void SupportsStylingWithLevel() + { + var LEVEL = LogEventLevel.Verbose; + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.LevelPropertyName}}}<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LEVEL, null, MessageTemplate.Empty, Array.Empty())); + Assert.Equal([$"%cHello%c %c%s%c", STYLE1, "", STYLE2, LEVEL.ToString(), ""], args); + } + + [Fact] + public void SupportsStylingWithException() + { + var EXCEPTION = new Exception("Foo did bar"); + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.ExceptionPropertyName}}}<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, EXCEPTION, MessageTemplate.Empty, Array.Empty())); + Assert.Equal([$"%cHello%c %c%s%c", STYLE1, "", STYLE2, EXCEPTION.ToString(), ""], args); + } + + [Fact] + public void SupportsStylingWithProperties() + { + var PROPERTIES = new[] { + new LogEventProperty("Foo", new ScalarValue(42)), + }; + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.PropertiesPropertyName}}}<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, MessageTemplate.Empty, PROPERTIES)); + Assert.Equal([$"%cHello%c %c%o%c", STYLE1, "", STYLE2, ((ScalarValue)PROPERTIES[0].Value).Value, ""], args); + } + + [Fact] + public void SupportsStylingWithSimpleMessage() + { + var MESSAGE = "and welcome"; + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.MessagePropertyName}}}<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), Array.Empty())); + Assert.Equal([$"%cHello%c %c{MESSAGE}%c", STYLE1, "", STYLE2, ""], args); + } + + [Fact] + public void SupportsStylingWithMessageContainingInterpolation() + { + var PLACE = "my tests"; + var MESSAGE = "and welcome to {Place}"; + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.MessagePropertyName}}}<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), new[]{ + new LogEventProperty("Place", new ScalarValue(PLACE)), + })); + Assert.Equal([$"%cHello%c %c{MESSAGE.Replace("{Place}", "%o")}%c", STYLE1, "", STYLE2, PLACE, ""], args); + } + + [Fact] + public void SupportsStylingWithinSimpleMessage() + { + var MESSAGE = $"and <<{STYLE3}>>welcome"; + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.MessagePropertyName}}}<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), Array.Empty())); + Assert.Equal([$"%cHello%c %c{MESSAGE.Replace($"<<{STYLE3}>>", "%c")}%c", STYLE1, "", STYLE2, STYLE3, ""], args); + } + + [Fact] + public void EscapePercentFromMessages() + { + var MESSAGE = $"and another % sign"; + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>A first<<_>> % sign <<{STYLE2}>>{{{OutputProperties.MessagePropertyName}}}<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), Array.Empty())); + Assert.Equal([$"%cA first%c %% sign %c{MESSAGE.Replace($"%", "%%")}%c", STYLE1, "", STYLE2, ""], args); + } + } +} From bf21f36e68bd2b3d15818243ba10f211c74091a0 Mon Sep 17 00:00:00 2001 From: Gerkin Date: Thu, 3 Oct 2024 01:02:24 +0200 Subject: [PATCH 2/3] feat: add `tokenStyles` dictionary parameter --- README.md | 2 + ...erConfigurationBrowserConsoleExtensions.cs | 14 ++--- .../Output/OutputTemplateRenderer.cs | 51 +++++++++++++------ .../Output/StyleTokenRenderer.cs | 34 +++++++++++++ .../FormatterTests.cs | 17 +++++++ 5 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/StyleTokenRenderer.cs diff --git a/README.md b/README.md index db38912..75490a3 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,5 @@ Example: ``` <>{Level}<<_>>: {Message} ``` + +You can also define styles for tokens via the `tokenStyles` dictionary. diff --git a/src/Serilog.Sinks.BrowserConsole/LoggerConfigurationBrowserConsoleExtensions.cs b/src/Serilog.Sinks.BrowserConsole/LoggerConfigurationBrowserConsoleExtensions.cs index a1b766b..ad79c1f 100644 --- a/src/Serilog.Sinks.BrowserConsole/LoggerConfigurationBrowserConsoleExtensions.cs +++ b/src/Serilog.Sinks.BrowserConsole/LoggerConfigurationBrowserConsoleExtensions.cs @@ -27,10 +27,10 @@ namespace Serilog; public static class LoggerConfigurationBrowserConsoleExtensions { const string SerilogToken = - "%cserilog{_}color:white;background:#8c7574;border-radius:3px;padding:1px 2px;font-weight:600;"; - - const string DefaultConsoleOutputTemplate = SerilogToken + "{Message}{NewLine}{Exception}"; - + "<>serilog<<_>>"; + + const string DefaultConsoleOutputTemplate = SerilogToken + "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"; + /// /// Writes log events to the browser console. /// @@ -38,8 +38,9 @@ public static class LoggerConfigurationBrowserConsoleExtensions /// The minimum level for /// events passed through the sink. Ignored when is specified. /// A message template describing the format used to write to the sink. - /// The default is "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}". + /// The default is "(serilog) [{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}". /// Supplies culture-specific formatting information, or null. + /// A dictionary of styles to apply to tokens. See MDN about console styling /// A switch allowing the pass-through minimum level /// to be changed at runtime. /// An instance of to interact with the browser. @@ -49,11 +50,12 @@ public static LoggerConfiguration BrowserConsole( LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum, string outputTemplate = DefaultConsoleOutputTemplate, IFormatProvider? formatProvider = null, + IReadOnlyDictionary? tokenStyles = null, LoggingLevelSwitch? levelSwitch = null, IJSRuntime? jsRuntime = null) { ArgumentNullException.ThrowIfNull(sinkConfiguration); - var formatter = new OutputTemplateRenderer(outputTemplate, formatProvider); + var formatter = new OutputTemplateRenderer(outputTemplate, formatProvider, tokenStyles); return sinkConfiguration.Sink(new BrowserConsoleSink(jsRuntime, formatter), restrictedToMinimumLevel, levelSwitch); } } \ No newline at end of file diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateRenderer.cs index 6433436..4ff3edc 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateRenderer.cs @@ -1,37 +1,56 @@ using Serilog.Events; using Serilog.Formatting.Display; using Serilog.Parsing; +using System; namespace Serilog.Sinks.BrowserConsole.Output; class OutputTemplateRenderer { readonly OutputTemplateTokenRenderer[] _renderers; + private readonly IFormatProvider? _formatProvider; + private readonly IReadOnlyDictionary? _tokenStyles; + private readonly MessageTemplate _template; - public OutputTemplateRenderer(string outputTemplate, IFormatProvider? formatProvider) + public OutputTemplateRenderer(string outputTemplate, IFormatProvider? formatProvider, IReadOnlyDictionary? tokenStyles = default) { ArgumentNullException.ThrowIfNull(outputTemplate); - var template = new MessageTemplateParser().Parse(outputTemplate); - - _renderers = template.Tokens - .Select(token => token switch + _formatProvider = formatProvider; + _tokenStyles = tokenStyles; + _template = new MessageTemplateParser().Parse(outputTemplate); + + _renderers = _template.Tokens + .SelectMany(token => token switch { - TextToken tt => new TextTokenRenderer(tt.Text), - PropertyToken pt => pt.PropertyName switch - { - OutputProperties.LevelPropertyName => new LevelTokenRenderer(pt) as OutputTemplateTokenRenderer, - OutputProperties.NewLinePropertyName => new NewLineTokenRenderer(pt.Alignment), - OutputProperties.ExceptionPropertyName => new ExceptionTokenRenderer(), - OutputProperties.MessagePropertyName => new MessageTemplateOutputTokenRenderer(), - OutputProperties.TimestampPropertyName => new TimestampTokenRenderer(pt, formatProvider), - OutputProperties.PropertiesPropertyName => new PropertiesTokenRenderer(pt, template), - _ => new EventPropertyTokenRenderer(pt, formatProvider) - }, + TextToken tt => [new TextTokenRenderer(tt.Text)], + PropertyToken pt => WrapStyle(pt), _ => throw new InvalidOperationException() }) .ToArray(); } + private OutputTemplateTokenRenderer[] WrapStyle(PropertyToken token) + { + OutputTemplateTokenRenderer renderer = token.PropertyName switch + { + OutputProperties.LevelPropertyName => new LevelTokenRenderer(token), + OutputProperties.NewLinePropertyName => new NewLineTokenRenderer(token.Alignment), + OutputProperties.ExceptionPropertyName => new ExceptionTokenRenderer(), + OutputProperties.MessagePropertyName => new MessageTemplateOutputTokenRenderer(), + OutputProperties.TimestampPropertyName => new TimestampTokenRenderer(token, _formatProvider), + OutputProperties.PropertiesPropertyName => new PropertiesTokenRenderer(token, _template), + _ => new EventPropertyTokenRenderer(token, _formatProvider) + }; + if (_tokenStyles?.TryGetValue(token.PropertyName, out var style) ?? false) + { + return [new StyleTokenRenderer(style), renderer, StyleTokenRenderer.Reset]; + } + else + { + return [renderer]; + } + } + public object?[] Format(LogEvent logEvent) { ArgumentNullException.ThrowIfNull(logEvent); diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/StyleTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/StyleTokenRenderer.cs new file mode 100644 index 0000000..1c3f22e --- /dev/null +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/StyleTokenRenderer.cs @@ -0,0 +1,34 @@ +// Copyright 2017 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 Serilog.Events; + +namespace Serilog.Sinks.BrowserConsole.Output; + +class StyleTokenRenderer : OutputTemplateTokenRenderer +{ + public static readonly StyleTokenRenderer Reset = new(""); + + private readonly string _style; + + public StyleTokenRenderer(string style) + { + _style = style; + } + + public override void Render(LogEvent logEvent, TokenEmitter emitToken) + { + emitToken.Style(_style); + } +} \ No newline at end of file diff --git a/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs b/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs index 08377e7..23a1f4d 100644 --- a/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs +++ b/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs @@ -3,6 +3,7 @@ using Serilog.Parsing; using Serilog.Sinks.BrowserConsole.Output; using System; +using System.Collections.Generic; using Xunit; namespace Serilog.Sinks.BrowserConsole.Tests @@ -104,5 +105,21 @@ public void EscapePercentFromMessages() var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), Array.Empty())); Assert.Equal([$"%cA first%c %% sign %c{MESSAGE.Replace($"%", "%%")}%c", STYLE1, "", STYLE2, ""], args); } + + [Fact] + public void SupportsTokenStyling() + { + var MESSAGE = $"Test"; + var LEVEL = LogEventLevel.Verbose; + var NOW = DateTimeOffset.Now; + var formatter = new OutputTemplateRenderer($"{{{OutputProperties.LevelPropertyName}}}@{{{OutputProperties.TimestampPropertyName}:HH:mm}}: {{{OutputProperties.MessagePropertyName}}}", default, new Dictionary + { + {OutputProperties.LevelPropertyName, STYLE1 }, + {OutputProperties.TimestampPropertyName, STYLE2 }, + {OutputProperties.MessagePropertyName, STYLE3 } + }); + var args = formatter.Format(new LogEvent(NOW, LEVEL, null, new MessageTemplateParser().Parse(MESSAGE), Array.Empty())); + Assert.Equal([$"%c%s%c@%c%s%c: %c{MESSAGE}%c", STYLE1, LEVEL.ToString(), "", STYLE2, NOW.ToString("HH:mm"), "", STYLE3, ""], args); + } } } From 20ae68001c8966ef7d9fae3cc43f9cf9f58b149a Mon Sep 17 00:00:00 2001 From: Gerkin Date: Thu, 3 Oct 2024 11:45:26 +0200 Subject: [PATCH 3/3] feat: better support numeric types in properties --- .../MessageTemplateOutputTokenRenderer.cs | 4 +- .../Output/OutputTemplateTokenRenderer.cs | 27 +++++-- .../Output/PropertiesTokenRenderer.cs | 41 ++++++++++- .../Output/PropertyTokenRenderer.cs | 72 +++++++++++++++++++ .../FormatterTests.cs | 63 ++++++++++++---- 5 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertyTokenRenderer.cs diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/MessageTemplateOutputTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/MessageTemplateOutputTokenRenderer.cs index 06dda5a..f7849a5 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/MessageTemplateOutputTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/MessageTemplateOutputTokenRenderer.cs @@ -30,7 +30,9 @@ public override void Render(LogEvent logEvent, TokenEmitter emitToken) break; case PropertyToken pt: if (logEvent.Properties.TryGetValue(pt.PropertyName, out var propertyValue)) - emitToken.Object(ObjectModelInterop.ToInteropValue(propertyValue)); + { + new PropertyTokenRenderer(pt, propertyValue).Render(logEvent, emitToken); + } break; default: throw new InvalidOperationException(); diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateTokenRenderer.cs index b50010c..ede64cf 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/OutputTemplateTokenRenderer.cs @@ -14,6 +14,7 @@ using Serilog.Events; using System.Text; +using static System.Net.Mime.MediaTypeNames; namespace Serilog.Sinks.BrowserConsole.Output; @@ -22,21 +23,35 @@ class TokenEmitter private StringBuilder _template = new(); private List _args = []; - internal void Literal(string text) + internal void Literal(string template) { - _template.Append(text.Replace("%", "%%")); + _template.Append(template.Replace("%", "%%")); } - internal void Text(string text) { + internal void Text(object @string) + { _template.Append("%s"); - _args.Add(text); + _args.Add(@string); } + internal void Text(string @string) => Text((object)@string); - internal void Object(object? value) + internal void Object(object? @object) { _template.Append("%o"); - _args.Add(value); + _args.Add(@object); + } + + internal void Integer(object @int) + { + _template.Append("%d"); + _args.Add(@int); } + + internal void Float(object @float) { + _template.Append("%f"); + _args.Add(@float); + } + internal object?[] YieldArgs() => [_template.ToString(), .. _args]; internal void Style(string styleContent) diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertiesTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertiesTokenRenderer.cs index 9701156..16f336b 100644 --- a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertiesTokenRenderer.cs +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertiesTokenRenderer.cs @@ -36,8 +36,47 @@ public override void Render(LogEvent logEvent, TokenEmitter emitToken) foreach (var property in included) { - emitToken.Object(ObjectModelInterop.ToInteropValue(property.Value, _token.Format)); + new PropertyTokenRenderer(_token, property.Value).Render(logEvent, emitToken); + } + } + private void HandleProperty(LogEventProperty property, TokenEmitter emitToken) + { + if (property.Value is ScalarValue sv) + { + if(sv.Value is null) + { + emitToken.Object(ObjectModelInterop.ToInteropValue(property.Value, _token.Format)); + return; + } + switch (Type.GetTypeCode(sv.Value.GetType())) + { + // See https://stackoverflow.com/a/1750024 + case TypeCode.Byte: + case TypeCode.SByte: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + emitToken.Integer(sv.Value); + break; + case TypeCode.Decimal: + case TypeCode.Double: + case TypeCode.Single: + emitToken.Float(sv.Value); + break; + case TypeCode.String: + case TypeCode.Char: + emitToken.Text(sv.Value); + break; + default: + emitToken.Object(ObjectModelInterop.ToInteropValue(property.Value, _token.Format)); + break; + } } + else + emitToken.Object(ObjectModelInterop.ToInteropValue(property.Value, _token.Format)); } static bool TemplateContainsPropertyName(MessageTemplate template, string propertyName) diff --git a/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertyTokenRenderer.cs b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertyTokenRenderer.cs new file mode 100644 index 0000000..0a97b10 --- /dev/null +++ b/src/Serilog.Sinks.BrowserConsole/Sinks/BrowserConsole/Output/PropertyTokenRenderer.cs @@ -0,0 +1,72 @@ +// Copyright 2017 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.Linq; +using Serilog.Events; +using Serilog.Parsing; + +namespace Serilog.Sinks.BrowserConsole.Output +{ + class PropertyTokenRenderer : OutputTemplateTokenRenderer + { + readonly PropertyToken _token; + readonly LogEventPropertyValue _propertyValue; + public PropertyTokenRenderer(PropertyToken token, LogEventPropertyValue propertyValue) + { + _token = token; + _propertyValue = propertyValue; + } + + public override void Render(LogEvent logEvent, TokenEmitter emitToken) + { + if (_propertyValue is ScalarValue sv) + { + if (sv.Value is null) + { + emitToken.Object(ObjectModelInterop.ToInteropValue(sv)); + return; + } + switch (Type.GetTypeCode(sv.Value.GetType())) + { + // See https://stackoverflow.com/a/1750024 + case TypeCode.Byte: + case TypeCode.SByte: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + emitToken.Integer(sv.Value); + break; + case TypeCode.Decimal: + case TypeCode.Double: + case TypeCode.Single: + emitToken.Float(sv.Value); + break; + case TypeCode.String: + case TypeCode.Char: + emitToken.Text(sv.Value); + break; + default: + emitToken.Object(ObjectModelInterop.ToInteropValue(sv)); + break; + } + } + else + emitToken.Object(ObjectModelInterop.ToInteropValue(_propertyValue)); + } + } +} diff --git a/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs b/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs index 23a1f4d..15eed87 100644 --- a/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs +++ b/test/Serilog.Sinks.BrowserConsole.Tests/FormatterTests.cs @@ -64,7 +64,7 @@ public void SupportsStylingWithProperties() }; var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.PropertiesPropertyName}}}<<_>>", default); var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, MessageTemplate.Empty, PROPERTIES)); - Assert.Equal([$"%cHello%c %c%o%c", STYLE1, "", STYLE2, ((ScalarValue)PROPERTIES[0].Value).Value, ""], args); + Assert.Equal([$"%cHello%c %c%d%c", STYLE1, "", STYLE2, ((ScalarValue)PROPERTIES[0].Value).Value, ""], args); } [Fact] @@ -77,24 +77,61 @@ public void SupportsStylingWithSimpleMessage() } [Fact] - public void SupportsStylingWithMessageContainingInterpolation() + public void SupportsStylingWithinSimpleMessageContainingStyle() { - var PLACE = "my tests"; - var MESSAGE = "and welcome to {Place}"; + var MESSAGE = $"and <<{STYLE3}>>welcome"; var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.MessagePropertyName}}}<<_>>", default); - var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), new[]{ - new LogEventProperty("Place", new ScalarValue(PLACE)), - })); - Assert.Equal([$"%cHello%c %c{MESSAGE.Replace("{Place}", "%o")}%c", STYLE1, "", STYLE2, PLACE, ""], args); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), Array.Empty())); + Assert.Equal([$"%cHello%c %c{MESSAGE.Replace($"<<{STYLE3}>>", "%c")}%c", STYLE1, "", STYLE2, STYLE3, ""], args); + } + + [Theory] + [InlineData("short", "%d", (short)42)] + [InlineData("int", "%d", (int)42)] + [InlineData("long", "%d", (long)42)] + [InlineData("ushort", "%d", (ushort)42)] + [InlineData("uint", "%d", (uint)42)] + [InlineData("ulong", "%d", (ulong)42)] + [InlineData("byte", "%d", (byte)42)] + [InlineData("sbyte", "%d", (sbyte)42)] + // [InlineData("decimal", "%f", (decimal)42m)] // Unsupported + [InlineData("double", "%f", (double)42m)] + [InlineData("float", "%f", (float)42m)] + [InlineData("string", "%s", (string)"foo")] + [InlineData("char", "%s", (char)'f')] + public void SupportsStylingWithMessageContainingScalarStandardValues(string propertyName, string template, object value) + { + var MESSAGE = $"where the prop is {{{propertyName}}}"; + var PROPERTIES = new[] { + new LogEventProperty(propertyName, new ScalarValue(value)), + }; + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Test {{{OutputProperties.MessagePropertyName}}} End<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), PROPERTIES)); + Assert.Equal([$"%cTest where the prop is {template} End%c", STYLE1, ((ScalarValue)PROPERTIES[0].Value).Value, ""], args); } [Fact] - public void SupportsStylingWithinSimpleMessage() + public void SupportsStylingWithMessageContainingScalarDecimal() { - var MESSAGE = $"and <<{STYLE3}>>welcome"; - var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Hello<<_>> <<{STYLE2}>>{{{OutputProperties.MessagePropertyName}}}<<_>>", default); - var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), Array.Empty())); - Assert.Equal([$"%cHello%c %c{MESSAGE.Replace($"<<{STYLE3}>>", "%c")}%c", STYLE1, "", STYLE2, STYLE3, ""], args); + var MESSAGE = $"where the prop is {{decimal}}"; + var PROPERTIES = new[] { + new LogEventProperty("decimal", new ScalarValue((decimal)42m)), + }; + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Test {{{OutputProperties.MessagePropertyName}}} End<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), PROPERTIES)); + Assert.Equal([$"%cTest where the prop is %f End%c", STYLE1, ((ScalarValue)PROPERTIES[0].Value).Value, ""], args); + } + + [Fact] + public void SupportsStylingWithMessageContainingScalarObject() + { + var MESSAGE = $"where the prop is {{object}}"; + var PROPERTIES = new[] { + new LogEventProperty("object", new ScalarValue(new { Hello = "world"})), + }; + var formatter = new OutputTemplateRenderer($"<<{STYLE1}>>Test {{{OutputProperties.MessagePropertyName}}} End<<_>>", default); + var args = formatter.Format(new LogEvent(DateTimeOffset.Now, LogEventLevel.Verbose, null, new MessageTemplateParser().Parse(MESSAGE), PROPERTIES)); + Assert.Equal([$"%cTest where the prop is %c%o%c End%c", STYLE1, "", ((ScalarValue)PROPERTIES[0].Value).Value, STYLE1, ""], args); } [Fact]