diff --git a/src/SeqCli/Csv/CsvWriter.cs b/src/SeqCli/Csv/CsvWriter.cs index fff15271..75f6553a 100644 --- a/src/SeqCli/Csv/CsvWriter.cs +++ b/src/SeqCli/Csv/CsvWriter.cs @@ -1,20 +1,22 @@ using System; +using System.Collections.Generic; using System.IO; using Seq.Api.Model.Data; using SeqCli.Mcp.Data; -using Serilog.Sinks.SystemConsole.Themes; +using SeqCli.Output; +using Serilog.Templates.Themes; namespace SeqCli.Csv; static class CsvWriter { - public static void WriteQueryResult(QueryResultPart result, Func stringify, ConsoleTheme theme, TextWriter output) + public static void WriteQueryResult(QueryResultPart result, Func stringify, TemplateTheme? theme, TextWriter output) { if (!string.IsNullOrWhiteSpace(result.Error)) { - theme.Set(output, ConsoleThemeStyle.Text); + theme?.Set(output, TemplateThemeStyle.Text); QueryResultHelper.WriteErrorResult(output, result); - theme.Reset(output); + theme?.Reset(output); } var first = true; @@ -30,7 +32,7 @@ public static void WriteQueryResult(QueryResultPart result, Func stringify, ref bool firstCol, bool isHeadingRow = false) + static void WriteCell(TextWriter output, TemplateTheme? theme, object? value, Func stringify, ref bool firstCol, bool isHeadingRow = false) { if (firstCol) { @@ -38,39 +40,39 @@ static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, Func } else { - theme.Set(output, ConsoleThemeStyle.TertiaryText); + theme?.Set(output, TemplateThemeStyle.TertiaryText); output.Write(','); - theme.Reset(output); + theme?.Reset(output); } - theme.Set(output, ConsoleThemeStyle.TertiaryText); + theme?.Set(output, TemplateThemeStyle.TertiaryText); output.Write('"'); - theme.Reset(output); + theme?.Reset(output); var valueAsString = stringify(value); - var dataStyle = isHeadingRow ? ConsoleThemeStyle.Name : ConsoleThemeStyle.Text; + var dataStyle = isHeadingRow ? TemplateThemeStyle.Name : TemplateThemeStyle.Text; var doubleQuote = valueAsString.IndexOf('"'); while (doubleQuote != -1) { - theme.Set(output, dataStyle); + theme?.Set(output, dataStyle); output.Write(valueAsString[..doubleQuote]); - theme.Reset(output); + theme?.Reset(output); - theme.Set(output, ConsoleThemeStyle.Scalar); + theme?.Set(output, TemplateThemeStyle.Scalar); output.Write("\"\""); - theme.Reset(output); + theme?.Reset(output); valueAsString = valueAsString[(doubleQuote + 1)..]; doubleQuote = valueAsString.IndexOf('"'); } - theme.Set(output, dataStyle); + theme?.Set(output, dataStyle); output.Write(valueAsString); - theme.Reset(output); + theme?.Reset(output); - theme.Set(output, ConsoleThemeStyle.TertiaryText); + theme?.Set(output, TemplateThemeStyle.TertiaryText); output.Write('"'); - theme.Reset(output); + theme?.Reset(output); } } \ No newline at end of file diff --git a/src/SeqCli/Forwarder/ForwarderModule.cs b/src/SeqCli/Forwarder/ForwarderModule.cs index 4f97cb46..6bb7ef67 100644 --- a/src/SeqCli/Forwarder/ForwarderModule.cs +++ b/src/SeqCli/Forwarder/ForwarderModule.cs @@ -68,13 +68,13 @@ protected override void Load(ContainerBuilder builder) Log.ForContext().Warning("Configured to expose ingestion log via HTTP API"); builder.RegisterType().As(); - var ingestionLogTemplate = "[{@t:o} {@l:u3}] {@m}\n"; + var ingestionLogTemplate = $"[{{@t:o}} {{@l:u3}}] {{@m}}{Environment.NewLine}"; if (_config.Forwarder.Diagnostics.IngestionLogShowDetail) { Log.ForContext().Warning("Including full client, payload, and error detail in the ingestion log"); ingestionLogTemplate += - "{#if ClientHostIP is not null}Client IP address: {ClientHostIP}\n{#end}" + - "{#if DocumentStart is not null}First {StartToLog} characters of payload: {DocumentStart:l}\n{#end}" + + $"{{#if ClientHostIP is not null}}Client IP address: {{ClientHostIP}}{Environment.NewLine}{{#end}}" + + $"{{#if DocumentStart is not null}}First {{StartToLog}} characters of payload: {{DocumentStart:l}}{Environment.NewLine}{{#end}}" + "{@x}"; } diff --git a/src/SeqCli/Ingestion/LogShipper.cs b/src/SeqCli/Ingestion/LogShipper.cs index c5738604..f0a19741 100644 --- a/src/SeqCli/Ingestion/LogShipper.cs +++ b/src/SeqCli/Ingestion/LogShipper.cs @@ -33,7 +33,7 @@ namespace SeqCli.Ingestion; static class LogShipper { - static readonly ITextFormatter JsonFormatter = OutputFormatter.Json(null); + static readonly ITextFormatter JsonFormatter = TextFormatters.Json(null); public static async Task ShipBufferAsync( SeqConnection connection, diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index 52bc4596..1160aa33 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -87,13 +87,13 @@ static class McpServerInstaller "mcpServers"), ["codex"] = Unsupported( - "Codex reads MCP servers from ~/.codex/config.toml (TOML), which seqcli can't edit automatically. Add this block:\n\n[mcp_servers.seq]\ncommand = \"seqcli\"\nargs = [\"mcp\", \"run\"]"), + $"Codex reads MCP servers from ~/.codex/config.toml (TOML), which seqcli can't edit automatically. Add this block:{Environment.NewLine}{Environment.NewLine}[mcp_servers.seq]{Environment.NewLine}command = \"seqcli\"{Environment.NewLine}args = [\"mcp\", \"run\"]"), ["goose"] = Unsupported( - "Goose reads MCP servers from ~/.config/goose/config.yaml (YAML) under `extensions`, which seqcli can't edit automatically. Add:\n\nextensions:\n seq:\n type: stdio\n cmd: seqcli\n args: [mcp, run]\n enabled: true"), + $"Goose reads MCP servers from ~/.config/goose/config.yaml (YAML) under `extensions`, which seqcli can't edit automatically. Add:{Environment.NewLine}{Environment.NewLine}extensions:{Environment.NewLine} seq:{Environment.NewLine} type: stdio{Environment.NewLine} cmd: seqcli{Environment.NewLine} args: [mcp, run]{Environment.NewLine} enabled: true"), ["continue"] = Unsupported( - "Continue reads MCP servers from YAML, which seqcli can't edit automatically. Create .continue/mcpServers/seq.yaml with:\n\nname: Seq\nversion: 0.0.1\nschema: v1\nmcpServers:\n - name: seq\n command: seqcli\n args:\n - mcp\n - run"), + $"Continue reads MCP servers from YAML, which seqcli can't edit automatically. Create .continue/mcpServers/seq.yaml with:{Environment.NewLine}{Environment.NewLine}name: Seq{Environment.NewLine}version: 0.0.1{Environment.NewLine}schema: v1{Environment.NewLine}mcpServers:{Environment.NewLine} - name: seq{Environment.NewLine} command: seqcli{Environment.NewLine} args:{Environment.NewLine} - mcp{Environment.NewLine} - run"), }; static readonly IReadOnlyDictionary AgentAliases = diff --git a/src/SeqCli/Mcp/Tools/Search/SearchTools.cs b/src/SeqCli/Mcp/Tools/Search/SearchTools.cs index 42343c8a..72663a49 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchTools.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchTools.cs @@ -43,7 +43,7 @@ class SearchTools(McpSession session, SeqConnection connection) { const string ResultIdPropertyName = "__seqcli_ResultId"; static readonly ExpressionTemplate SearchResultFormatter = new ( - $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}...\n{{#end}}" + $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}{Environment.NewLine}{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}...{Environment.NewLine}{{#end}}" ); [McpServerTool(Name = "seq_new_session", ReadOnly = true, Title = "Begin a new Search/Query Session")] diff --git a/src/SeqCli/Output/FlareTheme.cs b/src/SeqCli/Output/FlareTheme.cs new file mode 100644 index 00000000..38d1e578 --- /dev/null +++ b/src/SeqCli/Output/FlareTheme.cs @@ -0,0 +1,70 @@ +// Copyright © Datalust Pty Ltd +// +// 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.Collections.Generic; +using System.IO; +using Serilog.Templates.Themes; + +namespace SeqCli.Output; + +/// +/// Flare is Seq's embedded stream/columnar database. This theme is derived from one build originally +/// for the flaretl command-line tooling used there. +/// +static class FlareTheme +{ + static readonly Dictionary FlareThemeStyles = new() + { + [TemplateThemeStyle.Name] = "\e[38;5;0215m", + [TemplateThemeStyle.Number] = "\e[38;5;0200m", + [TemplateThemeStyle.Boolean] = "\e[38;5;0039m", + [TemplateThemeStyle.Null] = "\e[38;5;0039m", + [TemplateThemeStyle.String] = "\e[38;5;0217m", + [TemplateThemeStyle.Scalar] = "\e[38;5;0217m", + [TemplateThemeStyle.LevelError] = "\e[38;5;0197m", + [TemplateThemeStyle.TertiaryText] = "\e[38;5;0244m", + // Based on `TemplateTheme.Code` + [TemplateThemeStyle.Text] = "\e[38;5;0253m", + [TemplateThemeStyle.SecondaryText] = "\e[38;5;0246m", + [TemplateThemeStyle.Invalid] = "\e[33;1m", + [TemplateThemeStyle.LevelVerbose] = "\e[37m", + [TemplateThemeStyle.LevelDebug] = "\e[37m", + [TemplateThemeStyle.LevelInformation] = "\e[37;1m", + [TemplateThemeStyle.LevelWarning] = "\e[38;5;0229m", + [TemplateThemeStyle.LevelFatal] = "\e[38;5;0197m\e[48;5;0238m" + }; + + public static readonly TemplateTheme SeqCli = new(FlareThemeStyles); + + // `CsvWriter` implements its own theming behavior because the required APIs are not public in Serilog.Expressions. + // The best way forward for this is likely to be porting theming to Seq.Syntax, and exposing the required APIs there. + + const string AnsiStyleResetSequence = "\e[0m"; + + // The passed-in theme is ignored because SerilogExpressions themes are opaque. All formatting uses the SeqCli theme. + // ReSharper disable once UnusedParameter.Global + extension(TemplateTheme theme) + { + public void Set(TextWriter output, TemplateThemeStyle style) + { + if (FlareThemeStyles.TryGetValue(style, out var styleSequence)) + output.Write(styleSequence); + } + + public void Reset(TextWriter output) + { + output.Write(AnsiStyleResetSequence); + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index 44535476..e84b7026 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -32,7 +32,6 @@ using Serilog.Core; using Serilog.Events; using Serilog.Parsing; -using Serilog.Sinks.SystemConsole.Themes; using Serilog.Templates.Themes; namespace SeqCli.Output; @@ -41,21 +40,9 @@ 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 string _outputTemplate; + readonly string? _outputTemplate; readonly Logger _formatter; readonly JsonSerializer _serializer = JsonSerializer.CreateDefault(new JsonSerializerSettings @@ -80,7 +67,8 @@ public OutputFormat( outputConfig, outputTemplate, noColorSetInEnvironment: NoColorSetInEnvironment(), - outputIsRedirected: Console.IsOutputRedirected) + outputIsRedirected: Console.IsOutputRedirected, + allowAnsiEscapes: TerminalFeatures.TryEnableAnsiEscapes()) { } @@ -91,6 +79,8 @@ public OutputFormat( /// 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. + /// Whether ANSI escape sequences are allowed; generally false for interactive + /// legacy Windows terminals and true otherwise. internal OutputFormat( OutputSyntax syntax, bool? noColor, @@ -98,30 +88,18 @@ internal OutputFormat( SeqCliOutputConfig outputConfig, string? outputTemplate, bool noColorSetInEnvironment, - bool outputIsRedirected) + bool outputIsRedirected, + bool allowAnsiEscapes) { _syntax = syntax; - _outputTemplate = outputTemplate ?? DefaultOutputTemplate; - - NoColor = ResolveNoColor(noColor, forceColor, outputConfig, noColorSetInEnvironment); - ApplyThemeToRedirectedOutput = !NoColor && (forceColor ?? outputConfig.ForceColor); - - // 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); + _outputTemplate = outputTemplate; - var colorize = !NoColor && (ApplyThemeToRedirectedOutput || !outputIsRedirected); + var resolvedNoColor = ResolveNoColor(noColor, forceColor, outputConfig, noColorSetInEnvironment, allowAnsiEscapes); + var applyThemeToRedirectedOutput = !resolvedNoColor && (forceColor ?? outputConfig.ForceColor); + var colorize = !resolvedNoColor && (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 + TemplateTheme = colorize + ? FlareTheme.SeqCli : null; _formatter = CreateOutputLogger(); @@ -129,13 +107,17 @@ internal OutputFormat( static bool NoColorSetInEnvironment() => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(NoColorEnvironmentVariable)); - - static bool ResolveNoColor( + + internal static bool ResolveNoColor( bool? noColorFlag, bool? forceColorFlag, SeqCliOutputConfig config, - bool noColorSetInEnvironment) + bool noColorSetInEnvironment, + bool supportsAnsiEscapes) { + if (!supportsAnsiEscapes) + return true; + if (noColorFlag != null) return noColorFlag.Value; @@ -149,12 +131,6 @@ static bool ResolveNoColor( public bool Text => _syntax == OutputSyntax.Text; public bool Native => _syntax == OutputSyntax.Native; - internal bool NoColor { get; } - - bool ApplyThemeToRedirectedOutput { get; } - - internal ConsoleTheme Theme { get; } - internal TemplateTheme? TemplateTheme { get; } public bool RequiresRender => Native; @@ -167,14 +143,11 @@ Logger CreateOutputLogger() if (Json) { - outputConfiguration.WriteTo.Console(OutputFormatter.Json(TemplateTheme)); + outputConfiguration.WriteTo.Console(TextFormatters.Json(TemplateTheme)); } else if (Text) { - outputConfiguration.WriteTo.Console( - outputTemplate: _outputTemplate, - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput); + outputConfiguration.WriteTo.Console(TextFormatters.Plain(TemplateTheme, _outputTemplate)); } // The logger is not configured for Native output, which avoids it. Ideally we'll shift away from using @@ -198,10 +171,7 @@ public void WriteEntity(Entity entity) var writer = new LoggerConfiguration() .Destructure.With() .Enrich.With() - .WriteTo.Console( - outputTemplate: "{@Message:j}{NewLine}", - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) + .WriteTo.Console(TextFormatters.Plain(TemplateTheme, "{@m}" + Environment.NewLine)) .CreateLogger(); writer.Information("{@Entity}", jo); } @@ -231,10 +201,7 @@ public void WriteObject(object value) var writer = new LoggerConfiguration() .Destructure.With() .Enrich.With() - .WriteTo.Console( - outputTemplate: "{@Message:j}{NewLine}", - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) + .WriteTo.Console(TextFormatters.Plain(TemplateTheme, "{@m}" + Environment.NewLine)) .CreateLogger(); writer.Information("{@Entity}", jo); } @@ -277,7 +244,7 @@ public void WriteQueryResult(QueryResultPart result) } else { - CsvWriter.WriteQueryResult(result, Stringify, Theme, Console.Out); + CsvWriter.WriteQueryResult(result, Stringify, TemplateTheme, Console.Out); } } diff --git a/src/SeqCli/Output/OutputFormatter.cs b/src/SeqCli/Output/OutputFormatter.cs deleted file mode 100644 index e3bf27cd..00000000 --- a/src/SeqCli/Output/OutputFormatter.cs +++ /dev/null @@ -1,27 +0,0 @@ -using SeqCli.Ingestion; -using SeqCli.Mapping; -using Serilog.Formatting; -using Serilog.Templates; -using Serilog.Templates.Themes; - -namespace SeqCli.Output; - -static class OutputFormatter -{ - // This is the only usage of Serilog.Expressions remaining in seqcli; the upstream Seq.Syntax doesn't yet support - // the `@sp` property, because it needs to load on older Seq installs with older Serilog versions embedded in the - // app runner. Once we've updated it, we can switch this to a Seq.Syntax template. - internal static ITextFormatter Json(TemplateTheme? theme) => new ExpressionTemplate( - $"{{ " + - $"if {MetricsMapping.SurrogateDefinitionsProperty} is not null then " + - // Emit a metric sample - $"{{@t, @l: undefined(), @d: {MetricsMapping.SurrogateDefinitionsProperty}, ..rest()}} " + - $"else " + - // 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, - // The `OutputFormat` constructor has already decided whether to colorize. - applyThemeWhenOutputIsRedirected: true - ); -} diff --git a/src/SeqCli/Output/TerminalFeatures.cs b/src/SeqCli/Output/TerminalFeatures.cs new file mode 100644 index 00000000..7262de8a --- /dev/null +++ b/src/SeqCli/Output/TerminalFeatures.cs @@ -0,0 +1,52 @@ +// Copyright © Datalust Pty Ltd +// +// 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.Runtime.InteropServices; + +namespace SeqCli.Output; + +static class TerminalFeatures +{ + const int StandardOutputHandleId = -11; + const uint EnableVirtualTerminalProcessingMode = 4; + const long InvalidHandleValue = -1; + + public static bool TryEnableAnsiEscapes() + { + if (Console.IsOutputRedirected || !OperatingSystem.IsWindows()) + return true; + + var stdout = GetStdHandle(StandardOutputHandleId); + if (stdout == InvalidHandleValue) + return false; + + if (!GetConsoleMode(stdout, out var mode)) + return false; + + if ((mode & EnableVirtualTerminalProcessingMode) != 0) + return true; + + return SetConsoleMode(stdout, mode | EnableVirtualTerminalProcessingMode); + } + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr GetStdHandle(int handleId); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetConsoleMode(IntPtr handle, out uint mode); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetConsoleMode(IntPtr handle, uint mode); +} diff --git a/src/SeqCli/Output/TextFormatters.cs b/src/SeqCli/Output/TextFormatters.cs new file mode 100644 index 00000000..86cfbee7 --- /dev/null +++ b/src/SeqCli/Output/TextFormatters.cs @@ -0,0 +1,54 @@ +// Copyright © Datalust Pty Ltd +// +// 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 SeqCli.Ingestion; +using SeqCli.Mapping; +using Serilog.Expressions; +using Serilog.Formatting; +using Serilog.Templates; +using Serilog.Templates.Themes; + +namespace SeqCli.Output; + +// This is the only usage of Serilog.Expressions remaining in seqcli; the upstream Seq.Syntax doesn't yet support +// tracing properties or theming. +static class TextFormatters +{ + public static ITextFormatter Json(TemplateTheme? theme) => new ExpressionTemplate( + $"{{ " + + $"if {MetricsMapping.SurrogateDefinitionsProperty} is not null then " + + // Emit a metric sample + $"{{@t, @l: undefined(), @d: {MetricsMapping.SurrogateDefinitionsProperty}, ..rest()}} " + + $"else " + + // 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()}} " + + $"}}" + + Environment.NewLine, + theme: theme, + // The `OutputFormat` constructor has already decided whether to colorize. + applyThemeWhenOutputIsRedirected: true + ); + + static readonly string DefaultPlainTextOutputTemplate = + "[{@t:o} {@l:u3}] {@m}{#if IsSpan()} ({Milliseconds(Elapsed()):0.###} ms){#end}" + Environment.NewLine + "{@x}"; + + public static ITextFormatter Plain(TemplateTheme? theme, string? outputTemplate) => new ExpressionTemplate( + outputTemplate ?? DefaultPlainTextOutputTemplate, + theme: theme, + nameResolver: new StaticMemberNameResolver(typeof(TracingFunctions)), + // The `OutputFormat` constructor has already decided whether to colorize. + applyThemeWhenOutputIsRedirected: true + ); +} \ No newline at end of file diff --git a/src/SeqCli/Output/TracingFunctions.cs b/src/SeqCli/Output/TracingFunctions.cs new file mode 100644 index 00000000..5e2ba112 --- /dev/null +++ b/src/SeqCli/Output/TracingFunctions.cs @@ -0,0 +1,55 @@ +// Copyright © Datalust Pty Ltd +// +// 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.Globalization; +using SeqCli.Ingestion; +using Serilog.Events; + +namespace SeqCli.Output; + +static class TracingFunctions +{ + public static LogEventPropertyValue? Elapsed(LogEvent logEvent) + { + if (logEvent.Properties.TryGetValue(TraceConstants.SpanStartTimestampProperty, out var sst) && + sst is ScalarValue { Value: DateTime spanStart }) + { + return new ScalarValue(logEvent.Timestamp - spanStart); + } + + if (logEvent.Properties.TryGetValue("@st", out var st) && + st is ScalarValue { Value: string spanStartIso } && + DateTimeOffset.TryParse(spanStartIso, CultureInfo.InvariantCulture, out var spanStartDto)) + { + return new ScalarValue(logEvent.Timestamp - spanStartDto); + } + + return null; + } + + public static LogEventPropertyValue? IsSpan(LogEvent logEvent) + { + return new ScalarValue(Elapsed(logEvent) != null); + } + + public static LogEventPropertyValue? Milliseconds(LogEventPropertyValue? timeSpan) + { + // Truncates instead of rounding. + if (timeSpan is ScalarValue { Value: TimeSpan ts }) + return new ScalarValue((decimal)ts.Ticks / TimeSpan.TicksPerMillisecond); + + return null; + } +} \ No newline at end of file diff --git a/src/SeqCli/Program.cs b/src/SeqCli/Program.cs index 5363b98f..f6a0a11f 100644 --- a/src/SeqCli/Program.cs +++ b/src/SeqCli/Program.cs @@ -26,8 +26,6 @@ namespace SeqCli; class Program { - public const string WindowsBinaryName = "seqcli.exe"; - static async Task Main(string[] args) { var levelSwitch = new LoggingLevelSwitch(LogEventLevel.Error); diff --git a/test/SeqCli.EndToEnd/ApiKey/ApiKeyCreateTestCase.cs b/test/SeqCli.EndToEnd/ApiKey/ApiKeyCreateTestCase.cs index 4bffb7a3..d3d16c31 100644 --- a/test/SeqCli.EndToEnd/ApiKey/ApiKeyCreateTestCase.cs +++ b/test/SeqCli.EndToEnd/ApiKey/ApiKeyCreateTestCase.cs @@ -13,11 +13,11 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun var exit = runner.Exec("apikey create", "-t Test"); Assert.Equal(0, exit); - exit = runner.Exec("apikey list", "-t Test --json --no-color"); + exit = runner.Exec("apikey list", "-t Test --json"); Assert.Equal(0, exit); var output = runner.LastRunProcess!.Output; - Assert.Contains("\"AssignedPermissions\": [\"Ingest\"]", output); + Assert.Contains("\"AssignedPermissions\":[\"Ingest\"]", output); return Task.CompletedTask; } diff --git a/test/SeqCli.EndToEnd/ApiKey/ApiKeyDelegatePermissionsTestCase.cs b/test/SeqCli.EndToEnd/ApiKey/ApiKeyDelegatePermissionsTestCase.cs index fe583ad6..dad598e3 100644 --- a/test/SeqCli.EndToEnd/ApiKey/ApiKeyDelegatePermissionsTestCase.cs +++ b/test/SeqCli.EndToEnd/ApiKey/ApiKeyDelegatePermissionsTestCase.cs @@ -21,11 +21,11 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun "-t Setup --permissions=Setup,Write --connect-username=carol --connect-password=\"test@1234\""); Assert.Equal(0, exit); - exit = runner.Exec("apikey list", "-t Setup --json --no-color"); + exit = runner.Exec("apikey list", "-t Setup --json"); Assert.Equal(0, exit); var output = runner.LastRunProcess.Output; - Assert.Contains("\"AssignedPermissions\": [\"Setup\", \"Write\"]", output); + Assert.Contains("\"AssignedPermissions\":[\"Setup\",\"Write\"]", output); return Task.CompletedTask; } diff --git a/test/SeqCli.Tests/Csv/CsvWriterTests.cs b/test/SeqCli.Tests/Csv/CsvWriterTests.cs index 6573618c..cf4dbeb9 100644 --- a/test/SeqCli.Tests/Csv/CsvWriterTests.cs +++ b/test/SeqCli.Tests/Csv/CsvWriterTests.cs @@ -1,9 +1,9 @@ #nullable enable +using System; using System.IO; using Seq.Api.Model.Data; using SeqCli.Csv; -using SeqCli.Output; -using Serilog.Sinks.SystemConsole.Themes; +using Serilog.Templates.Themes; using Xunit; namespace SeqCli.Tests.Csv; @@ -20,7 +20,7 @@ public void QueryResultsAreNotColorizedWhenOutputIsRedirected() var rendered = Render(RedirectedTheme(forceColor: false)); Assert.DoesNotContain(Escape, rendered); - Assert.Equal("\"Events\",\"Application\"\n\"852\",\"Roastery \"\"Web\"\" Frontend\"\n", rendered); + Assert.Equal($"\"Events\",\"Application\"{Environment.NewLine}\"852\",\"Roastery \"\"Web\"\" Frontend\"{Environment.NewLine}", rendered); } [Fact] @@ -42,10 +42,10 @@ public void QueryErrorsAreNotColorizedWhenOutputIsRedirected() Assert.Contains("The query could not be executed.", rendered); } - static ConsoleTheme RedirectedTheme(bool forceColor) => - forceColor ? OutputFormat.DefaultAnsiTheme : ConsoleTheme.None; + static TemplateTheme? RedirectedTheme(bool forceColor) => + forceColor ? TemplateTheme.Code : null; - static string Render(ConsoleTheme theme, QueryResultPart? result = null) + static string Render(TemplateTheme? theme, QueryResultPart? result = null) { result ??= new QueryResultPart { @@ -53,7 +53,7 @@ static string Render(ConsoleTheme theme, QueryResultPart? result = null) Rows = [[852, "Roastery \"Web\" Frontend"]] }; - var output = new StringWriter { NewLine = "\n" }; + var output = new StringWriter(); 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 index 2c9a7590..af2e5059 100644 --- a/test/SeqCli.Tests/Output/OutputFormatTests.cs +++ b/test/SeqCli.Tests/Output/OutputFormatTests.cs @@ -1,7 +1,6 @@ using System; using SeqCli.Config; using SeqCli.Output; -using Serilog.Sinks.SystemConsole.Themes; using Xunit; namespace SeqCli.Tests.Output; @@ -14,6 +13,7 @@ static OutputFormat Create( bool disableColor = false, bool noColorSetInEnvironment = false, bool outputIsRedirected = true, + bool supportsAnsiEscapes = true, OutputSyntax syntax = OutputSyntax.Text) => new( syntax, @@ -22,27 +22,28 @@ static OutputFormat Create( new SeqCliOutputConfig { DisableColor = disableColor }, outputTemplate: null, noColorSetInEnvironment, - outputIsRedirected); + outputIsRedirected, + supportsAnsiEscapes); [Fact] public void RedirectedOutputIsNotThemedByDefault() { var format = Create(outputIsRedirected: true); - Assert.Same(ConsoleTheme.None, format.Theme); + Assert.Null(format.TemplateTheme); } [Fact] public void RedirectedOutputIsThemedWhenColorIsForced() { var format = Create(forceColor: true, outputIsRedirected: true); - Assert.Same(OutputFormat.DefaultAnsiTheme, format.Theme); + Assert.NotNull(format.TemplateTheme); } [Fact] public void TerminalOutputIsThemed() { var format = Create(outputIsRedirected: false); - Assert.Same(OutputFormat.DefaultTheme, format.Theme); + Assert.NotNull(format.TemplateTheme); } [Theory] @@ -51,7 +52,7 @@ public void TerminalOutputIsThemed() public void NoColorSuppressesTheThemeRegardlessOfRedirection(bool outputIsRedirected) { var format = Create(noColor: true, outputIsRedirected: outputIsRedirected); - Assert.Same(ConsoleTheme.None, format.Theme); + Assert.Null(format.TemplateTheme); } [Fact] @@ -68,18 +69,6 @@ public void RedirectedJsonOutputIsThemedWhenColorIsForced() 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)] @@ -90,23 +79,31 @@ public void NoColorSuppressesTheJsonThemeRegardlessOfRedirection(bool outputIsRe } [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. + // noColorFlag, forceColorFlag, disableColor, noColorSetInEnvironment, supportsAnsiEscapes, expected + [InlineData(null, null, false, false, true, false)] // Color is on by default. + [InlineData(null, null, false, true, true, true)] // `NO_COLOR` disables color. + [InlineData(null, true, false, true, true, false)] // `--force-color` is more specific than `NO_COLOR`. + [InlineData(true, null, false, false, true, true)] // `--no-color` disables color. + [InlineData(true, true, false, false, true, true)] // `--no-color` beats `--force-color`. + [InlineData(null, null, true, false, true, true)] // `output.disableColor` disables color. + [InlineData(null, true, true, false, true, true)] // `--force-color` doesn't override configuration. + [InlineData(null, null, false, false, false, true)] // No ANSI escape support disables color. + [InlineData(null, true, false, false, false, true)] // ...and `--force-color` can't override it. public void NoColorIsResolvedFromFlagsConfigurationAndEnvironment( bool? noColorFlag, bool? forceColorFlag, bool disableColor, bool noColorSetInEnvironment, + bool supportsAnsiEscapes, bool expected) { - var format = Create(noColorFlag, forceColorFlag, disableColor, noColorSetInEnvironment); + Assert.Equal(expected, OutputFormat.ResolveNoColor(noColorFlag, forceColorFlag, new SeqCliOutputConfig { DisableColor = disableColor }, noColorSetInEnvironment, supportsAnsiEscapes)); + } - Assert.Equal(expected, format.NoColor); + [Fact] + public void TerminalOutputIsNotThemedWithoutAnsiEscapeSupport() + { + var format = Create(outputIsRedirected: false, supportsAnsiEscapes: false); + Assert.Null(format.TemplateTheme); } } diff --git a/test/SeqCli.Tests/Output/OutputFormatterTests.cs b/test/SeqCli.Tests/Output/OutputFormatterTests.cs deleted file mode 100644 index 9ca0c855..00000000 --- a/test/SeqCli.Tests/Output/OutputFormatterTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -#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(); - } -} diff --git a/test/SeqCli.Tests/Output/TextFormattersTests.cs b/test/SeqCli.Tests/Output/TextFormattersTests.cs new file mode 100644 index 00000000..f3548e0d --- /dev/null +++ b/test/SeqCli.Tests/Output/TextFormattersTests.cs @@ -0,0 +1,124 @@ +#nullable enable +using System; +using System.IO; +using SeqCli.Output; +using SeqCli.Tests.Support; +using Serilog.Events; +using Serilog.Parsing; +using Serilog.Templates.Themes; +using Xunit; + +namespace SeqCli.Tests.Output; + +public class TextFormattersTests +{ + const char Escape = '\x1b'; + static readonly DateTimeOffset FixedTimestamp = new(2024, 1, 1, 10, 0, 1, 250, TimeSpan.Zero); + + [Fact] + public void ThemedJsonOutputIsColorizedRegardlessOfRedirection() + { + Assert.Contains(Escape, RenderJson(TemplateTheme.Code)); + } + + [Fact] + public void UnthemedJsonOutputIsNotColorized() + { + Assert.DoesNotContain(Escape, RenderJson(theme: null)); + } + + [Fact] + public void LogEventsAreFormattedWithTheDefaultTextTemplate() + { + var evt = SomeLogEvent( + level: LogEventLevel.Warning, + properties: new LogEventProperty("Name", new ScalarValue("world"))); + + Assert.Equal( + $"[2024-01-01T10:00:01.2500000+00:00 WRN] Hello, world!{Environment.NewLine}", + RenderText(evt)); + } + + [Fact] + public void ExceptionsAreIncludedInTextOutput() + { + var evt = SomeLogEvent( + FixedTimestamp, + LogEventLevel.Error, + new Exception("Boom!"), + new LogEventProperty("Name", new ScalarValue("world"))); + + Assert.Equal( + $"[2024-01-01T10:00:01.2500000+00:00 ERR] Hello, world!{Environment.NewLine}System.Exception: Boom!{Environment.NewLine}", + RenderText(evt)); + } + + [Fact] + public void SpanElapsedTimeIsComputedFromTheStartTimestampProperty() + { + // Events retrieved from the Seq API carry span start timestamps in ISO-8601 `@st` properties. + var evt = SomeLogEvent(FixedTimestamp, properties: + [ + new LogEventProperty("Name", new ScalarValue("world")), + new LogEventProperty("@st", new ScalarValue("2024-01-01T10:00:00.0000000Z")) + ]); + + Assert.Equal( + $"[2024-01-01T10:00:01.2500000+00:00 INF] Hello, world! (1250 ms){Environment.NewLine}", + RenderText(evt)); + } + + [Fact] + public void SpanElapsedTimeIsComputedFromTheSurrogateStartTimestampProperty() + { + // Ingested spans carry a surrogate `SpanStartTimestamp` property with a `DateTime` value. + var evt = SomeLogEvent(FixedTimestamp, properties: + [ + new LogEventProperty("Name", new ScalarValue("world")), + new LogEventProperty("SpanStartTimestamp", new ScalarValue( + FixedTimestamp.UtcDateTime.AddMilliseconds(-1.5))) + ]); + + Assert.Equal( + $"[2024-01-01T10:00:01.2500000+00:00 INF] Hello, world! (1.5 ms){Environment.NewLine}", + RenderText(evt)); + } + + [Fact] + public void ACustomOutputTemplateReplacesTheDefault() + { + var evt = SomeLogEvent(properties: new LogEventProperty("Name", new ScalarValue("world"))); + + Assert.Equal($"INF Hello, world!{Environment.NewLine}", RenderText(evt, $"{{@l:u3}} {{@m}}{Environment.NewLine}")); + } + + static LogEvent SomeLogEvent( + DateTimeOffset? timestamp = null, + LogEventLevel level = LogEventLevel.Information, + Exception? exception = null, + params LogEventProperty[] properties) + { + return new LogEvent( + timestamp ?? FixedTimestamp, + level, + exception, + new MessageTemplateParser().Parse("Hello, {Name}!"), + properties); + } + + static string RenderText(LogEvent evt, string? outputTemplate = null) + { + var output = new StringWriter(); + TextFormatters.Plain(theme: null, outputTemplate).Format(evt, output); + return output.ToString(); + } + + static string RenderJson(TemplateTheme? theme) + { + var evt = OutputFormat.ToSerilogEvent(Some.MakeEvent(e => e.Properties = [])); + + var output = new StringWriter(); + TextFormatters.Json(theme).Format(evt, output); + return output.ToString(); + } +}