diff --git a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs index bbbd7f59..2b7c176b 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs @@ -58,7 +58,7 @@ public RenderCommand() _range = Enable(); _signal = Enable(); _timeout = Enable(); - _output = Enable(new OutputFormatFeature(supportNative: true)); + _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); _connection = Enable(); } diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs index f4da9047..f5495a24 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs @@ -47,7 +47,7 @@ public DimensionValuesCommand() v => _count = int.Parse(v, CultureInfo.InvariantCulture)); _range = Enable(); - _output = Enable(new OutputFormatFeature(supportNative: true)); + _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); diff --git a/src/SeqCli/Cli/Commands/PrintCommand.cs b/src/SeqCli/Cli/Commands/PrintCommand.cs index 4eca68ec..505e1a10 100644 --- a/src/SeqCli/Cli/Commands/PrintCommand.cs +++ b/src/SeqCli/Cli/Commands/PrintCommand.cs @@ -20,11 +20,9 @@ using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Ingestion; -using SeqCli.Output; using SeqCli.Util; using Serilog; using Serilog.Events; -using Serilog.Sinks.SystemConsole.Themes; namespace SeqCli.Cli.Commands; @@ -34,10 +32,10 @@ class PrintCommand : Command { readonly FileInputFeature _fileInputFeature; readonly InvalidDataHandlingFeature _invalidDataHandlingFeature; + readonly OutputFormatFeature _output; readonly StoragePathFeature _storage; - string? _filter, _template = OutputFormat.DefaultOutputTemplate; - bool? _noColor, _forceColor; + string? _filter, _template; public PrintCommand() { @@ -53,11 +51,7 @@ public PrintCommand() _invalidDataHandlingFeature = Enable(); - // These should be ported to use `OutputFormatFeature`. - Options.Add("no-color", "Don't colorize text output", _ => _noColor = true); - Options.Add("force-color", - "Force redirected output to have ANSI color (unless `--no-color` is also specified)", - _ => _forceColor = true); + _output = Enable(new OutputFormatFeature(supportNative: false, supportJson: false)); _storage = Enable(); } @@ -65,35 +59,21 @@ public PrintCommand() protected override async Task Run() { var config = RuntimeConfigurationLoader.Load(_storage); - - var applyThemeToRedirectedOutput - = !(_noColor ?? config.Output.DisableColor) && (_forceColor ?? config.Output.ForceColor); - - var theme - = _noColor ?? config.Output.DisableColor ? ConsoleTheme.None - : applyThemeToRedirectedOutput ? OutputFormat.DefaultAnsiTheme - : OutputFormat.DefaultTheme; - - var outputConfiguration = new LoggerConfiguration() - .MinimumLevel.Is(LevelAlias.Minimum) - .Enrich.With() - .WriteTo.Console( - outputTemplate: _template ?? OutputFormat.DefaultOutputTemplate, - theme: theme, - applyThemeToRedirectedOutput: applyThemeToRedirectedOutput); + Func? filter = null; if (_filter != null) { - if (!SerilogExpression.TryCompile(_filter, out var filter, out var error)) + if (!SerilogExpression.TryCompile(_filter, out var compiled, out var error)) { Log.Error("The specified filter could not be compiled: {Error}", error); return 1; } - - outputConfiguration.Filter.ByIncludingOnly(evt => ExpressionResult.IsTrue(filter(evt))); + + filter = evt => ExpressionResult.IsTrue(compiled(evt)); } - await using var logger = outputConfiguration.CreateLogger(); + var output = _output.GetOutputFormat(config, _template); + foreach (var input in _fileInputFeature.OpenInputs()) { using (input) @@ -108,8 +88,8 @@ var theme var result = await reader.TryReadAsync(); isAtEnd = result.IsAtEnd; - if (result.LogEvent != null) - logger.Write(result.LogEvent); + if (result.LogEvent != null && (filter == null || filter(result.LogEvent))) + output.WriteLogEvent(result.LogEvent); } catch (Exception ex) { diff --git a/src/SeqCli/Cli/Commands/QueryCommand.cs b/src/SeqCli/Cli/Commands/QueryCommand.cs index 6dd7db67..98004805 100644 --- a/src/SeqCli/Cli/Commands/QueryCommand.cs +++ b/src/SeqCli/Cli/Commands/QueryCommand.cs @@ -41,7 +41,7 @@ public QueryCommand() _range = Enable(); _signal = Enable(); _timeout = Enable(); - _output = Enable(new OutputFormatFeature(supportNative: true)); + _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); _connection = Enable(); diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index a9b1e049..365725b7 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -50,7 +50,7 @@ public SearchCommand() v => _count = int.Parse(v, CultureInfo.InvariantCulture)); _range = Enable(); - _output = Enable(new OutputFormatFeature(supportNative: true)); + _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); _signal = Enable(); diff --git a/src/SeqCli/Cli/Commands/TailCommand.cs b/src/SeqCli/Cli/Commands/TailCommand.cs index 3da0b991..9d4d4957 100644 --- a/src/SeqCli/Cli/Commands/TailCommand.cs +++ b/src/SeqCli/Cli/Commands/TailCommand.cs @@ -38,7 +38,7 @@ public TailCommand() "An optional server-side filter to apply to the stream, for example `@Level = 'Error'`", v => _filter = v); - _output = Enable(new OutputFormatFeature(supportNative: true)); + _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); _signal = Enable(); _connection = Enable(); diff --git a/src/SeqCli/Cli/Features/OutputFormatFeature.cs b/src/SeqCli/Cli/Features/OutputFormatFeature.cs index fde2b7b8..58817640 100644 --- a/src/SeqCli/Cli/Features/OutputFormatFeature.cs +++ b/src/SeqCli/Cli/Features/OutputFormatFeature.cs @@ -17,30 +17,30 @@ namespace SeqCli.Cli.Features; -class OutputFormatFeature(bool supportNative) : CommandFeature +class OutputFormatFeature(bool supportNative, bool supportJson) : CommandFeature { OutputSyntax _syntax = OutputSyntax.Text; bool? _noColor, _forceColor; - + // ReSharper disable once UnusedMember.Global public OutputFormatFeature() - : this(false) { } + : this(supportNative: false, supportJson: true) { } - public OutputFormat GetOutputFormat(SeqCliConfig config) + public OutputFormat GetOutputFormat(SeqCliConfig config, string? outputTemplate = null) { - return new OutputFormat( - _syntax, - _noColor ?? config.Output.DisableColor, - _forceColor ?? config.Output.ForceColor); + return new OutputFormat(_syntax, _noColor, _forceColor, config.Output, outputTemplate); } public override void Enable(OptionSet options) { - options.Add( - "json", - "Print output in newline-delimited JSON (the default is plain text)", - _ => _syntax = OutputSyntax.Json); - + if (supportJson) + { + options.Add( + "json", + "Print output in newline-delimited JSON (the default is plain text)", + _ => _syntax = OutputSyntax.Json); + } + if (supportNative) { options.Add( diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index affa48ef..44535476 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -24,6 +24,7 @@ using Seq.Api.Model; using Seq.Api.Model.Data; using Seq.Api.Model.Events; +using SeqCli.Config; using SeqCli.Csv; using SeqCli.Mapping; using SeqCli.Util; @@ -38,9 +39,23 @@ namespace SeqCli.Output; sealed class OutputFormat { + // See https://no-color.org for semantics. + const string NoColorEnvironmentVariable = "NO_COLOR"; + + internal const string DefaultOutputTemplate = + "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"; + + static bool AnsiIsDefault => !OperatingSystem.IsWindows(); + + internal static readonly ConsoleTheme DefaultAnsiTheme = AnsiConsoleTheme.Code; + + internal static readonly ConsoleTheme DefaultTheme = + AnsiIsDefault ? DefaultAnsiTheme : SystemConsoleTheme.Literate; + + static readonly TemplateTheme DefaultTemplateTheme = TemplateTheme.Code; + readonly OutputSyntax _syntax; - readonly bool _noColor; - readonly bool _forceColor; + readonly string _outputTemplate; readonly Logger _formatter; readonly JsonSerializer _serializer = JsonSerializer.CreateDefault(new JsonSerializerSettings @@ -52,39 +67,95 @@ sealed class OutputFormat } }); - public const string DefaultOutputTemplate = - "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"; + public OutputFormat( + OutputSyntax syntax, + bool? noColor, + bool? forceColor, + SeqCliOutputConfig outputConfig, + string? outputTemplate = null) + : this( + syntax, + noColor, + forceColor, + outputConfig, + outputTemplate, + noColorSetInEnvironment: NoColorSetInEnvironment(), + outputIsRedirected: Console.IsOutputRedirected) + { + } - public static readonly ConsoleTheme DefaultAnsiTheme = AnsiConsoleTheme.Code; + /// The syntax to write output in. + /// The value of --no-color, if specified. + /// The value of --force-color, if specified. + /// Configured output defaults. + /// The template controlling plain-text formatting, or null for the default. + /// Whether NO_COLOR is set; see . + /// Whether STDOUT is redirected, i.e. not attached to a terminal. + internal OutputFormat( + OutputSyntax syntax, + bool? noColor, + bool? forceColor, + SeqCliOutputConfig outputConfig, + string? outputTemplate, + bool noColorSetInEnvironment, + bool outputIsRedirected) + { + _syntax = syntax; + _outputTemplate = outputTemplate ?? DefaultOutputTemplate; - public static readonly ConsoleTheme DefaultTheme = - OperatingSystem.IsWindows() ? SystemConsoleTheme.Literate : DefaultAnsiTheme; + NoColor = ResolveNoColor(noColor, forceColor, outputConfig, noColorSetInEnvironment); + ApplyThemeToRedirectedOutput = !NoColor && (forceColor ?? outputConfig.ForceColor); - static readonly TemplateTheme DefaultTemplateTheme = TemplateTheme.Code; + // Serilog's console sink applies the `NO_COLOR` convention itself, unconditionally, overriding whatever + // theme it's passed. When `--force-color` has opted back out of `NO_COLOR`, the variable is cleared (for + // this process only) so that the sink can't undo the decision made here. + if (!NoColor && noColorSetInEnvironment) + Environment.SetEnvironmentVariable(NoColorEnvironmentVariable, null); + + var colorize = !NoColor && (ApplyThemeToRedirectedOutput || !outputIsRedirected); + + Theme = !colorize ? ConsoleTheme.None + : ApplyThemeToRedirectedOutput ? DefaultAnsiTheme + : DefaultTheme; + + // Rather than emit escapes that a downlevel Windows terminal would show literally, + // JSON output stays plain there unless ANSI has been opted into with `--force-color`. + TemplateTheme = colorize && (ApplyThemeToRedirectedOutput || AnsiIsDefault) + ? DefaultTemplateTheme + : null; - public OutputFormat(OutputSyntax syntax, bool noColor, bool forceColor) - { - _syntax = syntax; - _noColor = noColor; - _forceColor = forceColor; _formatter = CreateOutputLogger(); } + static bool NoColorSetInEnvironment() + => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(NoColorEnvironmentVariable)); + + static bool ResolveNoColor( + bool? noColorFlag, + bool? forceColorFlag, + SeqCliOutputConfig config, + bool noColorSetInEnvironment) + { + if (noColorFlag != null) + return noColorFlag.Value; + + if (config.DisableColor) + return true; + + return forceColorFlag != true && noColorSetInEnvironment; + } + public bool Json => _syntax == OutputSyntax.Json; public bool Text => _syntax == OutputSyntax.Text; public bool Native => _syntax == OutputSyntax.Native; - - bool ApplyThemeToRedirectedOutput => !_noColor && _forceColor; - ConsoleTheme Theme - => _noColor ? ConsoleTheme.None - : ApplyThemeToRedirectedOutput ? DefaultAnsiTheme - : DefaultTheme; + internal bool NoColor { get; } + + bool ApplyThemeToRedirectedOutput { get; } + + internal ConsoleTheme Theme { get; } - TemplateTheme? TemplateTheme - => _noColor ? null - : ApplyThemeToRedirectedOutput ? DefaultTemplateTheme - : null; + internal TemplateTheme? TemplateTheme { get; } public bool RequiresRender => Native; @@ -101,7 +172,7 @@ Logger CreateOutputLogger() else if (Text) { outputConfiguration.WriteTo.Console( - outputTemplate: DefaultOutputTemplate, + outputTemplate: _outputTemplate, theme: Theme, applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput); } @@ -219,10 +290,15 @@ public void WriteEventEntity(EventEntity evt) } else { - _formatter.Write(ToSerilogEvent(evt)); + WriteLogEvent(ToSerilogEvent(evt)); } } + public void WriteLogEvent(LogEvent logEvent) + { + _formatter.Write(logEvent); + } + public static LogEvent ToSerilogEvent(EventEntity evt) { ActivityTraceId traceId = default; diff --git a/src/SeqCli/Output/OutputFormatter.cs b/src/SeqCli/Output/OutputFormatter.cs index 43ff994b..e3bf27cd 100644 --- a/src/SeqCli/Output/OutputFormatter.cs +++ b/src/SeqCli/Output/OutputFormatter.cs @@ -20,6 +20,8 @@ static class OutputFormatter // Emit a log or span $"{{@t, @mt, @l: coalesce({LevelMapping.SurrogateLevelProperty}, if @l = 'Information' then undefined() else @l), @x, @sp, @tr, @ps: coalesce({TraceConstants.ParentSpanIdProperty}, @ps), @st: coalesce({TraceConstants.SpanStartTimestampProperty}, @st), ..rest()}} " + $"}}\n", - theme: theme + theme: theme, + // The `OutputFormat` constructor has already decided whether to colorize. + applyThemeWhenOutputIsRedirected: true ); } diff --git a/test/SeqCli.Tests/Csv/CsvWriterTests.cs b/test/SeqCli.Tests/Csv/CsvWriterTests.cs new file mode 100644 index 00000000..6573618c --- /dev/null +++ b/test/SeqCli.Tests/Csv/CsvWriterTests.cs @@ -0,0 +1,60 @@ +#nullable enable +using System.IO; +using Seq.Api.Model.Data; +using SeqCli.Csv; +using SeqCli.Output; +using Serilog.Sinks.SystemConsole.Themes; +using Xunit; + +namespace SeqCli.Tests.Csv; + +public class CsvWriterTests +{ + const char Escape = '\x1b'; + + // `CsvWriter` writes to the console without going through Serilog's console sink, so unlike the other + // output paths it has no opportunity to suppress the theme itself. + [Fact] + public void QueryResultsAreNotColorizedWhenOutputIsRedirected() + { + var rendered = Render(RedirectedTheme(forceColor: false)); + + Assert.DoesNotContain(Escape, rendered); + Assert.Equal("\"Events\",\"Application\"\n\"852\",\"Roastery \"\"Web\"\" Frontend\"\n", rendered); + } + + [Fact] + public void QueryResultsAreColorizedWhenColorIsForcedForRedirectedOutput() + { + Assert.Contains(Escape, Render(RedirectedTheme(forceColor: true))); + } + + [Fact] + public void QueryErrorsAreNotColorizedWhenOutputIsRedirected() + { + var rendered = Render(RedirectedTheme(forceColor: false), new QueryResultPart + { + Error = "The query could not be executed.", + Reasons = [] + }); + + Assert.DoesNotContain(Escape, rendered); + Assert.Contains("The query could not be executed.", rendered); + } + + static ConsoleTheme RedirectedTheme(bool forceColor) => + forceColor ? OutputFormat.DefaultAnsiTheme : ConsoleTheme.None; + + static string Render(ConsoleTheme theme, QueryResultPart? result = null) + { + result ??= new QueryResultPart + { + Columns = ["Events", "Application"], + Rows = [[852, "Roastery \"Web\" Frontend"]] + }; + + var output = new StringWriter { NewLine = "\n" }; + CsvWriter.WriteQueryResult(result, v => v?.ToString() ?? "null", theme, output); + return output.ToString(); + } +} diff --git a/test/SeqCli.Tests/Output/OutputFormatTests.cs b/test/SeqCli.Tests/Output/OutputFormatTests.cs new file mode 100644 index 00000000..2c9a7590 --- /dev/null +++ b/test/SeqCli.Tests/Output/OutputFormatTests.cs @@ -0,0 +1,112 @@ +using System; +using SeqCli.Config; +using SeqCli.Output; +using Serilog.Sinks.SystemConsole.Themes; +using Xunit; + +namespace SeqCli.Tests.Output; + +public class OutputFormatTests +{ + static OutputFormat Create( + bool? noColor = null, + bool? forceColor = null, + bool disableColor = false, + bool noColorSetInEnvironment = false, + bool outputIsRedirected = true, + OutputSyntax syntax = OutputSyntax.Text) + => new( + syntax, + noColor, + forceColor, + new SeqCliOutputConfig { DisableColor = disableColor }, + outputTemplate: null, + noColorSetInEnvironment, + outputIsRedirected); + + [Fact] + public void RedirectedOutputIsNotThemedByDefault() + { + var format = Create(outputIsRedirected: true); + Assert.Same(ConsoleTheme.None, format.Theme); + } + + [Fact] + public void RedirectedOutputIsThemedWhenColorIsForced() + { + var format = Create(forceColor: true, outputIsRedirected: true); + Assert.Same(OutputFormat.DefaultAnsiTheme, format.Theme); + } + + [Fact] + public void TerminalOutputIsThemed() + { + var format = Create(outputIsRedirected: false); + Assert.Same(OutputFormat.DefaultTheme, format.Theme); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NoColorSuppressesTheThemeRegardlessOfRedirection(bool outputIsRedirected) + { + var format = Create(noColor: true, outputIsRedirected: outputIsRedirected); + Assert.Same(ConsoleTheme.None, format.Theme); + } + + [Fact] + public void RedirectedJsonOutputIsNotThemedByDefault() + { + var format = Create(syntax: OutputSyntax.Json, outputIsRedirected: true); + Assert.Null(format.TemplateTheme); + } + + [Fact] + public void RedirectedJsonOutputIsThemedWhenColorIsForced() + { + var format = Create(syntax: OutputSyntax.Json, forceColor: true, outputIsRedirected: true); + Assert.NotNull(format.TemplateTheme); + } + + // Template themes are ANSI-only, so JSON output is themed on a terminal wherever ANSI is the platform default. + [Fact] + public void TerminalJsonOutputIsThemedWhereverAnsiIsThePlatformDefault() + { + var format = Create(syntax: OutputSyntax.Json, outputIsRedirected: false); + + if (OperatingSystem.IsWindows()) + Assert.Null(format.TemplateTheme); + else + Assert.NotNull(format.TemplateTheme); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NoColorSuppressesTheJsonThemeRegardlessOfRedirection(bool outputIsRedirected) + { + var format = Create(syntax: OutputSyntax.Json, noColor: true, outputIsRedirected: outputIsRedirected); + Assert.Null(format.TemplateTheme); + } + + [Theory] + // noColorFlag, forceColorFlag, disableColor, noColorSetInEnvironment, expected + [InlineData(null, null, false, false, false)] // Color is on by default. + [InlineData(null, null, false, true, true)] // `NO_COLOR` disables color. + [InlineData(null, true, false, true, false)] // `--force-color` is more specific than `NO_COLOR`. + [InlineData(true, null, false, false, true)] // `--no-color` disables color. + [InlineData(true, true, false, false, true)] // `--no-color` beats `--force-color`. + [InlineData(null, null, true, false, true)] // `output.disableColor` disables color. + [InlineData(null, true, true, false, true)] // `--force-color` doesn't override configuration. + public void NoColorIsResolvedFromFlagsConfigurationAndEnvironment( + bool? noColorFlag, + bool? forceColorFlag, + bool disableColor, + bool noColorSetInEnvironment, + bool expected) + { + var format = Create(noColorFlag, forceColorFlag, disableColor, noColorSetInEnvironment); + + Assert.Equal(expected, format.NoColor); + } +} diff --git a/test/SeqCli.Tests/Output/OutputFormatterTests.cs b/test/SeqCli.Tests/Output/OutputFormatterTests.cs new file mode 100644 index 00000000..9ca0c855 --- /dev/null +++ b/test/SeqCli.Tests/Output/OutputFormatterTests.cs @@ -0,0 +1,34 @@ +#nullable enable +using System.IO; +using SeqCli.Output; +using SeqCli.Tests.Support; +using Serilog.Templates.Themes; +using Xunit; + +namespace SeqCli.Tests.Output; + +public class OutputFormatterTests +{ + const char Escape = '\x1b'; + + [Fact] + public void ThemedJsonOutputIsColorizedRegardlessOfRedirection() + { + Assert.Contains(Escape, Render(TemplateTheme.Code)); + } + + [Fact] + public void UnthemedJsonOutputIsNotColorized() + { + Assert.DoesNotContain(Escape, Render(theme: null)); + } + + static string Render(TemplateTheme? theme) + { + var evt = OutputFormat.ToSerilogEvent(Some.MakeEvent(e => e.Properties = [])); + + var output = new StringWriter(); + OutputFormatter.Json(theme).Format(evt, output); + return output.ToString(); + } +}