From 479c2a19cdeabc33eb40ac09d21b4db1d1a7f105 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 21 Aug 2026 15:04:02 +1000 Subject: [PATCH 1/6] Replicate the `trace` command's `--column` feature for `search`. Assisted-by: Claude:claude-fable-5 --- README.md | 1 + src/SeqCli/Cli/Commands/SearchCommand.cs | 21 ++++- .../Cli/Features/OutputFormatFeature.cs | 5 +- src/SeqCli/Output/EventColumns.cs | 91 +++++++++++++++++++ src/SeqCli/Output/OutputFormat.cs | 12 ++- src/SeqCli/Output/TextFormatters.cs | 7 +- src/SeqCli/Output/TraceFormatter.cs | 15 +-- .../Events/SearchColumnsTestCase.cs | 45 +++++++++ test/SeqCli.Tests/Output/EventColumnsTests.cs | 89 ++++++++++++++++++ test/SeqCli.Tests/Output/OutputFormatTests.cs | 1 + 10 files changed, 267 insertions(+), 20 deletions(-) create mode 100644 src/SeqCli/Output/EventColumns.cs create mode 100644 test/SeqCli.EndToEnd/Events/SearchColumnsTestCase.cs create mode 100644 test/SeqCli.Tests/Output/EventColumnsTests.cs diff --git a/README.md b/README.md index 1213183d..a5da031f 100644 --- a/README.md +++ b/README.md @@ -1464,6 +1464,7 @@ seqcli search -f "@Exception like '%TimeoutException%'" -c 30 | ------ | ----------- | | `-f`, `--filter=VALUE` | A filter to apply to the search, for example `Host = 'xmpweb-01.example.com'` | | `-c`, `--count=VALUE` | The maximum number of events to retrieve; the default is 1 | +| `--column=VALUE` | A column to display preceding each event's message; any Seq expression can be supplied, for example `OrderId`, `@SpanKind`, or `@Resource['service.name']`; this argument can be used multiple times, adding columns in order; applies to plain-text output only | | `--start=VALUE` | ISO 8601 date/time to query from | | `--end=VALUE` | ISO 8601 date/time to query to | | `--json` | Print output in newline-delimited JSON (the default is plain text) | diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index 365725b7..6604892d 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -13,11 +13,14 @@ // limitations under the License. using System; +using System.Collections.Generic; using System.Globalization; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; +using SeqCli.Output; +using SeqCli.Util; using Serilog; // ReSharper disable UnusedType.Global @@ -33,6 +36,7 @@ class SearchCommand : Command readonly DateRangeFeature _range; readonly SignalExpressionFeature _signal; readonly StoragePathFeature _storagePath; + readonly List _columns = []; string? _filter; int _count = 1; int _httpClientTimeout = 100000; @@ -49,6 +53,13 @@ public SearchCommand() $"The maximum number of events to retrieve; the default is {_count}", v => _count = int.Parse(v, CultureInfo.InvariantCulture)); + Options.Add( + "column=", + "A column to display preceding each event's message; any Seq expression can be supplied, for " + + "example `OrderId`, `@SpanKind`, or `@Resource['service.name']`; this argument can be used multiple " + + "times, adding columns in order; applies to plain-text output only", + c => _columns.Add(ArgumentString.Normalize(c) ?? throw new ArgumentException("Columns require a value."))); + _range = Enable(); _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); @@ -71,7 +82,15 @@ protected override async Task Run() try { var config = RuntimeConfigurationLoader.Load(_storagePath); - var output = _output.GetOutputFormat(config); + + EventColumns? columns = null; + if (_columns.Count > 0 && !EventColumns.TryCreate(_columns, out columns, out var error)) + { + Log.Error("The column expression could not be compiled: {Error}", error); + return 1; + } + + var output = _output.GetOutputFormat(config, columns?.OutputTemplate(), columns); var connection = SeqConnectionFactory.Connect(_connection, config); connection.Client.HttpClient.Timeout = TimeSpan.FromMilliseconds(_httpClientTimeout); diff --git a/src/SeqCli/Cli/Features/OutputFormatFeature.cs b/src/SeqCli/Cli/Features/OutputFormatFeature.cs index 792766b2..05190b7b 100644 --- a/src/SeqCli/Cli/Features/OutputFormatFeature.cs +++ b/src/SeqCli/Cli/Features/OutputFormatFeature.cs @@ -13,6 +13,7 @@ // limitations under the License. using SeqCli.Config; +using SeqCli.Data; using SeqCli.Output; namespace SeqCli.Cli.Features; @@ -26,9 +27,9 @@ class OutputFormatFeature(bool supportNative, bool supportJson) : CommandFeature public OutputFormatFeature() : this(supportNative: false, supportJson: true) { } - public OutputFormat GetOutputFormat(SeqCliConfig config, string? outputTemplate = null) + public OutputFormat GetOutputFormat(SeqCliConfig config, string? outputTemplate = null, IEventEnricher? textEnricher = null) { - return new OutputFormat(_syntax, _noColor, _forceColor, config.Output, outputTemplate); + return new OutputFormat(_syntax, _noColor, _forceColor, config.Output, outputTemplate, textEnricher); } public string JsonArgumentHelp { get; init; } = "Print output in newline-delimited JSON (the default is plain text)"; diff --git a/src/SeqCli/Output/EventColumns.cs b/src/SeqCli/Output/EventColumns.cs new file mode 100644 index 00000000..e26c97f3 --- /dev/null +++ b/src/SeqCli/Output/EventColumns.cs @@ -0,0 +1,91 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json.Nodes; +using Seq.Syntax.Expressions; +using SeqCli.Data; +using SeqCli.Syntax; + +namespace SeqCli.Output; + +/// +/// Evaluates a list of column expressions against each event, storing the results in synthetic properties that +/// the plain-text output template shows ahead of the message. +/// +class EventColumns : IEventEnricher +{ + static readonly string ColumnPrefixProperty = $"_SeqcliColumn_{Guid.NewGuid():N}"; + + internal static string ColumnPropertyName(int index) => $"{ColumnPrefixProperty}_{index}"; + + internal static string TemplateColumnsFragment(int columnCount) + { + // `<> ''` is undefined, and hence falsy, when the property is missing; the guard thus + // drops the column, and its trailing space, for both missing and empty values. + var fragment = new StringBuilder(); + for (var i = 0; i < columnCount; ++i) + { + var column = ColumnPropertyName(i); + fragment.Append($"{{#if {column} <> ''}}{{{column}}} {{#end}}"); + } + + return fragment.ToString(); + } + + readonly CompiledExpression[] _columns; + + EventColumns(CompiledExpression[] columns) + { + _columns = columns; + } + + public static bool TryCreate( + IReadOnlyList expressions, + [NotNullWhen(true)] out EventColumns? columns, + [NotNullWhen(false)] out string? error) + { + var compiled = new CompiledExpression[expressions.Count]; + for (var i = 0; i < expressions.Count; ++i) + { + if (!SeqSyntax.TryCompileExpression(expressions[i], out var expression, out error)) + { + columns = null; + return false; + } + + compiled[i] = expression; + } + + columns = new EventColumns(compiled); + error = null; + return true; + } + + public string OutputTemplate() => TextFormatters.PlainOutputTemplate(_columns.Length); + + public void Enrich(JsonObject eventJson) + { + for (var i = 0; i < _columns.Length; ++i) + { + // Property accessors return nodes still attached to the event, so they're cloned before being + // re-parented. + if (_columns[i](eventJson).TryGetValue(out var value)) + eventJson[ColumnPropertyName(i)] = value?.DeepClone(); + } + } +} diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index ae2492fb..741b5750 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -29,6 +29,7 @@ using SeqCli.Api; using SeqCli.Config; using SeqCli.Csv; +using SeqCli.Data; namespace SeqCli.Output; @@ -39,6 +40,7 @@ sealed class OutputFormat readonly OutputSyntax _syntax; readonly ExpressionTemplate? _eventFormatter; + readonly IEventEnricher? _textEnricher; readonly ExpressionTemplate _jsonValueFormatter; readonly JsonSerializer _serializer = JsonSerializer.CreateDefault(new JsonSerializerSettings @@ -55,13 +57,15 @@ public OutputFormat( bool? noColor, bool? forceColor, SeqCliOutputConfig outputConfig, - string? plainTextTemplate = null) + string? plainTextTemplate = null, + IEventEnricher? textEnricher = null) : this( syntax, noColor, forceColor, outputConfig, plainTextTemplate, + textEnricher, noColorSetInEnvironment: NoColorSetInEnvironment(), outputIsRedirected: Console.IsOutputRedirected, allowAnsiEscapes: TerminalFeatures.TryEnableAnsiEscapes()) @@ -73,6 +77,7 @@ public OutputFormat( /// The value of --force-color, if specified. /// Configured output defaults. /// The template controlling plain-text formatting, or null for the default. + /// An enricher applied to events written as plain text, or null. /// 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 @@ -83,12 +88,16 @@ internal OutputFormat( bool? forceColor, SeqCliOutputConfig outputConfig, string? plainTextTemplate, + IEventEnricher? textEnricher, bool noColorSetInEnvironment, bool outputIsRedirected, bool allowAnsiEscapes) { _syntax = syntax; + // Enrichment supports plain-text templates, so JSON output shows events verbatim. + _textEnricher = Text ? textEnricher : null; + var resolvedNoColor = ResolveNoColor(noColor, forceColor, outputConfig, noColorSetInEnvironment, allowAnsiEscapes); var applyThemeToRedirectedOutput = !resolvedNoColor && (forceColor ?? outputConfig.ForceColor); var colorize = !resolvedNoColor && (applyThemeToRedirectedOutput || !outputIsRedirected); @@ -237,6 +246,7 @@ public void WriteEventEntity(EventEntity evt) public void WriteEvent(JsonObject eventJson) { + _textEnricher?.Enrich(eventJson); _eventFormatter?.Format(eventJson, Console.Out); } diff --git a/src/SeqCli/Output/TextFormatters.cs b/src/SeqCli/Output/TextFormatters.cs index 88025fdf..c4c3e7cc 100644 --- a/src/SeqCli/Output/TextFormatters.cs +++ b/src/SeqCli/Output/TextFormatters.cs @@ -32,12 +32,13 @@ static class TextFormatters // Guarding on `@Elapsed` rather than the built-in `IsSpan()` shows elapsed time for any // event carrying a span start timestamp, whether or not trace and span ids accompany it. - static readonly string DefaultPlainTextOutputTemplate = - "[{@Timestamp:o} {@Level:u3}] {@Message}{#if @Elapsed is not null} ({TotalMilliseconds(@Elapsed):0.###} ms){#end}" + + internal static string PlainOutputTemplate(int columnCount = 0) => + "[{@Timestamp:o} {@Level:u3}] " + EventColumns.TemplateColumnsFragment(columnCount) + + "{@Message}{#if @Elapsed is not null} ({TotalMilliseconds(@Elapsed):0.###} ms){#end}" + Environment.NewLine + "{@Exception}"; public static ExpressionTemplate Plain(TemplateTheme? theme, string? outputTemplate) => - SeqSyntax.ParseTemplate(outputTemplate ?? DefaultPlainTextOutputTemplate, Encoder(theme)); + SeqSyntax.ParseTemplate(outputTemplate ?? PlainOutputTemplate(), Encoder(theme)); static TemplateOutputEncoder? Encoder(TemplateTheme? theme) => theme != null ? TemplateOutputEncoder.Ansi(theme) : null; diff --git a/src/SeqCli/Output/TraceFormatter.cs b/src/SeqCli/Output/TraceFormatter.cs index a69a1324..0f770604 100644 --- a/src/SeqCli/Output/TraceFormatter.cs +++ b/src/SeqCli/Output/TraceFormatter.cs @@ -27,25 +27,14 @@ static class TraceFormatter { static readonly string TreePrefixProperty = $"_SeqcliTraceTreePrefix_{Guid.NewGuid():N}"; static readonly string ElapsedProperty = $"_SeqcliTraceElapsed_{Guid.NewGuid():N}"; - static readonly string ColumnPrefixProperty = $"_SeqcliTraceColumn_{Guid.NewGuid():N}"; const string SpanConnector = "├─ ", LastSpanConnector = "└─ ", LogConnector = "┊ ", Continuation = "│ ", Gap = " "; - static string ColumnPropertyName(int index) => $"{ColumnPrefixProperty}_{index}"; - public static string OutputTemplate(int columnCount) { var template = new StringBuilder($"[{{@Timestamp:o}} {{@Level:u3}}] {{{TreePrefixProperty}}}"); - - // `<> ''` is undefined, and hence falsy, when the property is missing; the guard thus - // drops the column, and its trailing space, for both missing and empty values. - for (var i = 0; i < columnCount; ++i) - { - var column = ColumnPropertyName(i); - template.Append($"{{#if {column} <> ''}}{{{column}}} {{#end}}"); - } - + template.Append(EventColumns.TemplateColumnsFragment(columnCount)); template.Append($"{{@Message}}{{#if {ElapsedProperty} is not null}} ({{TotalMilliseconds({ElapsedProperty}):0.###}} ms){{#end}}"); template.Append(Environment.NewLine).Append("{@Exception}"); return template.ToString(); @@ -106,7 +95,7 @@ static JsonObject ToEventJson(TraceTreeNode treeNode, string treePrefix) for (var i = 0; i < evt.Columns.Count; ++i) { if (evt.Columns[i] is { } value) - eventJson[ColumnPropertyName(i)] = ToSystemTextJson.FromApiValue(value); + eventJson[EventColumns.ColumnPropertyName(i)] = ToSystemTextJson.FromApiValue(value); } return eventJson; diff --git a/test/SeqCli.EndToEnd/Events/SearchColumnsTestCase.cs b/test/SeqCli.EndToEnd/Events/SearchColumnsTestCase.cs new file mode 100644 index 00000000..036bec56 --- /dev/null +++ b/test/SeqCli.EndToEnd/Events/SearchColumnsTestCase.cs @@ -0,0 +1,45 @@ +using System.IO; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +#nullable enable + +namespace SeqCli.EndToEnd.Events; + +public class SearchColumnsTestCase : ICliTestCase +{ + const string TraceId = "7d4dedcc73b18e449e0e4ea08cbe346d"; + + public Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var inputFile = Path.Combine("Data", "trace-tree.clef"); + Assert.True(File.Exists(inputFile)); + + var exit = runner.Exec("ingest", $"--json -i {inputFile}"); + Assert.Equal(0, exit); + + var filter = $"--filter=\"@TraceId = '{TraceId}' and Customer is not null\""; + + exit = runner.Exec("search", $"{filter} -c 10 --column Customer --column RowCount"); + Assert.Equal(0, exit); + Assert.Contains("] scott GET /orders", runner.LastRunProcess!.Output); + + // Columns apply to plain-text output only. + exit = runner.Exec("search", $"{filter} -c 10 --column Customer --json"); + Assert.Equal(0, exit); + Assert.Contains("GET {Route}", runner.LastRunProcess!.Output); + Assert.DoesNotContain("_SeqcliColumn", runner.LastRunProcess!.Output); + + exit = runner.Exec("search", $"{filter} -c 10 --column \"not a valid (\""); + Assert.Equal(1, exit); + Assert.Contains("could not be compiled", runner.LastRunProcess!.Output); + + return Task.CompletedTask; + } +} diff --git a/test/SeqCli.Tests/Output/EventColumnsTests.cs b/test/SeqCli.Tests/Output/EventColumnsTests.cs new file mode 100644 index 00000000..80a46551 --- /dev/null +++ b/test/SeqCli.Tests/Output/EventColumnsTests.cs @@ -0,0 +1,89 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Seq.Api.Model.Events; +using SeqCli.Api; +using SeqCli.Output; +using SeqCli.Tests.Support; +using Xunit; + +namespace SeqCli.Tests.Output; + +public class EventColumnsTests +{ + static EventColumns Create(params string[] expressions) + { + Assert.True(EventColumns.TryCreate(expressions, out var columns, out var error), error); + return columns; + } + + static string Render(EventEntity evt, EventColumns columns) + { + var eventJson = EventEntityJson.ToEventJson(evt); + columns.Enrich(eventJson); + + var output = new StringWriter(); + TextFormatters.Plain(theme: null, columns.OutputTemplate()).Format(eventJson, output); + return output.ToString(); + } + + static string At(EventEntity evt) => + DateTimeOffset.ParseExact(evt.Timestamp, "o", CultureInfo.InvariantCulture).ToLocalTime().ToString("o"); + + [Fact] + public void ColumnsPrecedeTheMessageInOrder() + { + var evt = Some.MakeEvent(e => e.Properties = Some.MakeProperties(("Customer", "scott"), ("OrderId", 42))); + + Assert.Equal( + $"[{At(evt)} INF] scott 42 Hello{Environment.NewLine}", + Render(evt, Create("Customer", "OrderId"))); + } + + [Fact] + public void MissingAndEmptyColumnValuesLeaveNoRedundantSpace() + { + var evt = Some.MakeEvent(e => e.Properties = Some.MakeProperties(("Empty", ""), ("OrderId", 42))); + + Assert.Equal( + $"[{At(evt)} INF] 42 Hello{Environment.NewLine}", + Render(evt, Create("Missing", "Empty", "OrderId"))); + } + + [Fact] + public void SeqStyleNamesResolveAgainstApiEvents() + { + var evt = Some.MakeEvent(e => + { + e.Properties = []; + e.Level = "Warning"; + e.SpanKind = "Server"; + e.Resource = Some.MakeProperties(("service.name", "frontend")); + }); + + Assert.Equal( + $"[{At(evt)} WRN] frontend Server Hello{Environment.NewLine}", + Render(evt, Create("@Resource['service.name']", "@SpanKind"))); + } + + [Fact] + public void ComputedColumnValuesAreRendered() + { + var evt = Some.MakeEvent(e => e.Properties = Some.MakeProperties(("OrderId", 42))); + + Assert.Equal( + $"[{At(evt)} INF] order-42 Hello{Environment.NewLine}", + Render(evt, Create("concat('order-', tostring(OrderId))"))); + } + + [Fact] + public void InvalidExpressionsAreReportedByTryCreate() + { + Assert.False(EventColumns.TryCreate(new List {"OrderId", "not a valid ("}, out var columns, out var error)); + Assert.Null(columns); + Assert.NotNull(error); + Assert.NotEmpty(error); + } +} diff --git a/test/SeqCli.Tests/Output/OutputFormatTests.cs b/test/SeqCli.Tests/Output/OutputFormatTests.cs index e323aa60..9361a8f2 100644 --- a/test/SeqCli.Tests/Output/OutputFormatTests.cs +++ b/test/SeqCli.Tests/Output/OutputFormatTests.cs @@ -27,6 +27,7 @@ static OutputFormat Create( forceColor, new SeqCliOutputConfig { DisableColor = disableColor }, plainTextTemplate: null, + textEnricher: null, noColorSetInEnvironment, outputIsRedirected, supportsAnsiEscapes); From c1ec28be13f1476fec3f2fd63b08e7c52b17c27b Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 21 Aug 2026 16:24:16 +1000 Subject: [PATCH 2/6] Include signal columns in search command output --- src/SeqCli/Cli/Commands/SearchCommand.cs | 31 ++++++++++++++--- .../Signals/SignalExpressionPartExtensions.cs | 34 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 src/SeqCli/Signals/SignalExpressionPartExtensions.cs diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index 6604892d..2ae85f4a 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -16,10 +16,12 @@ using System.Collections.Generic; using System.Globalization; using System.Threading.Tasks; +using Seq.Api.Model.Signals; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Output; +using SeqCli.Signals; using SeqCli.Util; using Serilog; @@ -40,7 +42,7 @@ class SearchCommand : Command string? _filter; int _count = 1; int _httpClientTimeout = 100000; - bool _trace, _noWebSockets; + bool _trace, _noWebSockets, _noSignalColumns; public SearchCommand() { @@ -48,6 +50,7 @@ public SearchCommand() "f=|filter=", "A filter to apply to the search, for example `Host = 'xmpweb-01.example.com'`", v => _filter = v); + Options.Add( "c=|count=", $"The maximum number of events to retrieve; the default is {_count}", @@ -56,7 +59,7 @@ public SearchCommand() Options.Add( "column=", "A column to display preceding each event's message; any Seq expression can be supplied, for " + - "example `OrderId`, `@SpanKind`, or `@Resource['service.name']`; this argument can be used multiple " + + "example `OrderId`, `@SpanKind`, or `@Resource.service.name`; this argument can be used multiple " + "times, adding columns in order; applies to plain-text output only", c => _columns.Add(ArgumentString.Normalize(c) ?? throw new ArgumentException("Columns require a value."))); @@ -74,6 +77,8 @@ public SearchCommand() Options.Add("no-websockets", "Do not use WebSocket-driven streaming searches", _ => _noWebSockets = true); + Options.Add("no-signal-columns", "Do not show columns associated with the specified signal expression", _ => _noSignalColumns = true); + _connection = Enable(); } @@ -83,16 +88,32 @@ protected override async Task Run() { var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + connection.Client.HttpClient.Timeout = TimeSpan.FromMilliseconds(_httpClientTimeout); + + var collectedColumns = new List(); + if (!_noSignalColumns && _signal.Signal is { } signalExpression) + { + foreach (var signalId in signalExpression.ReferencedSignalIds()) + { + var signal = await connection.Signals.FindAsync(signalId); + foreach (var column in signal.Columns) + { + collectedColumns.Add(column.Expression); + } + } + } + + collectedColumns.AddRange(_columns); + EventColumns? columns = null; - if (_columns.Count > 0 && !EventColumns.TryCreate(_columns, out columns, out var error)) + if (collectedColumns.Count > 0 && !EventColumns.TryCreate(collectedColumns, out columns, out var error)) { Log.Error("The column expression could not be compiled: {Error}", error); return 1; } var output = _output.GetOutputFormat(config, columns?.OutputTemplate(), columns); - var connection = SeqConnectionFactory.Connect(_connection, config); - connection.Client.HttpClient.Timeout = TimeSpan.FromMilliseconds(_httpClientTimeout); string? filter = null; if (!string.IsNullOrWhiteSpace(_filter)) diff --git a/src/SeqCli/Signals/SignalExpressionPartExtensions.cs b/src/SeqCli/Signals/SignalExpressionPartExtensions.cs new file mode 100644 index 00000000..d99ddb39 --- /dev/null +++ b/src/SeqCli/Signals/SignalExpressionPartExtensions.cs @@ -0,0 +1,34 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Seq.Api.Model.Signals; + +namespace SeqCli.Signals; + +static class SignalExpressionPartExtensions +{ + public static IEnumerable ReferencedSignalIds(this SignalExpressionPart expr) + { + return expr.Kind switch + { + SignalExpressionKind.Signal => [expr.SignalId], + SignalExpressionKind.Intersection or SignalExpressionKind.Union => expr.Left.ReferencedSignalIds() + .Concat(expr.Right.ReferencedSignalIds()), + _ => throw new ArgumentOutOfRangeException(nameof(expr)) + }; + } +} \ No newline at end of file From f1bcdf1db99b0c99bdf0425b1bba10e7e50d9397 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 21 Aug 2026 16:35:00 +1000 Subject: [PATCH 3/6] Signal columns tests. Assisted-by: Claude:claude-opus-5 --- .../Cli/Features/SignalExpressionFeature.cs | 5 +- .../Events/SearchSignalColumnsTestCase.cs | 101 ++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 test/SeqCli.EndToEnd/Events/SearchSignalColumnsTestCase.cs diff --git a/src/SeqCli/Cli/Features/SignalExpressionFeature.cs b/src/SeqCli/Cli/Features/SignalExpressionFeature.cs index 54522c15..f7c45467 100644 --- a/src/SeqCli/Cli/Features/SignalExpressionFeature.cs +++ b/src/SeqCli/Cli/Features/SignalExpressionFeature.cs @@ -13,6 +13,7 @@ // limitations under the License. using Seq.Api.Model.Signals; +using SeqCli.Signals; namespace SeqCli.Cli.Features; @@ -27,9 +28,7 @@ public SignalExpressionPart? Signal if (string.IsNullOrWhiteSpace(_signalExpression)) return null; - // This is a hack that just happens to work because of the way - // signal ids are passed through ToString() as literals - return SignalExpressionPart.Signal(_signalExpression.Trim()); + return SignalExpressionParser.ParseExpression(_signalExpression); } } diff --git a/test/SeqCli.EndToEnd/Events/SearchSignalColumnsTestCase.cs b/test/SeqCli.EndToEnd/Events/SearchSignalColumnsTestCase.cs new file mode 100644 index 00000000..dabfb028 --- /dev/null +++ b/test/SeqCli.EndToEnd/Events/SearchSignalColumnsTestCase.cs @@ -0,0 +1,101 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +#nullable enable + +namespace SeqCli.EndToEnd.Events; + +public class SearchSignalColumnsTestCase : ICliTestCase +{ + const string TraceId = "7d4dedcc73b18e449e0e4ea08cbe346d"; + + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var inputFile = Path.Combine("Data", "trace-tree.clef"); + Assert.True(File.Exists(inputFile)); + + var exit = runner.Exec("ingest", $"--json -i {inputFile}"); + Assert.Equal(0, exit); + + exit = runner.Exec("signal create", "-t Orders -f \"@TraceId is not null\" -c Customer -c RowCount"); + Assert.Equal(0, exit); + + exit = runner.Exec("signal create", "-t Rows -f \"RowCount is not null\" -c \"RowCount * 2\""); + Assert.Equal(0, exit); + + exit = runner.Exec("signal create", "-t Unadorned -f \"@TraceId is not null\""); + Assert.Equal(0, exit); + + var signals = await connection.Signals.ListAsync(shared: true); + var orders = signals.Single(s => s.Title == "Orders").Id; + var rows = signals.Single(s => s.Title == "Rows").Id; + var unadorned = signals.Single(s => s.Title == "Unadorned").Id; + + var filter = $"--filter=\"@TraceId = '{TraceId}'\""; + + // The signal's columns are displayed, in the order the signal declares them. + exit = runner.Exec("search", $"--signal {orders} {filter} -c 10"); + Assert.Equal(0, exit); + var output = runner.LastRunProcess!.Output; + Assert.Contains("] scott GET /orders", output); + Assert.Contains("] 42 42 rows retrieved", output); + + // Signal columns precede any specified with `--column`. + exit = runner.Exec("search", $"--signal {orders} {filter} -c 10 --column \"@Level\""); + Assert.Equal(0, exit); + output = runner.LastRunProcess!.Output; + Assert.Contains("] scott Information GET /orders", output); + Assert.Contains("] 42 Warning 42 rows retrieved", output); + + // `--no-signal-columns` drops the signal's columns, but not those specified with `--column`. + exit = runner.Exec("search", $"--signal {orders} {filter} -c 10 --no-signal-columns"); + Assert.Equal(0, exit); + output = runner.LastRunProcess!.Output; + Assert.Contains("] GET /orders", output); + Assert.DoesNotContain("scott", output); + + exit = runner.Exec("search", $"--signal {orders} {filter} -c 10 --no-signal-columns --column \"@Level\""); + Assert.Equal(0, exit); + output = runner.LastRunProcess!.Output; + Assert.Contains("] Information GET /orders", output); + Assert.DoesNotContain("scott", output); + + // Signal columns apply to plain-text output only. + exit = runner.Exec("search", $"--signal {orders} {filter} -c 10 --json"); + Assert.Equal(0, exit); + output = runner.LastRunProcess!.Output; + Assert.Contains("GET {Route}", output); + Assert.DoesNotContain("_SeqcliColumn", output); + + // Columns are collected from every signal referenced by the expression. + exit = runner.Exec("search", $"--signal {orders},{rows} {filter} -c 10"); + Assert.Equal(0, exit); + Assert.Contains("] 42 84 42 rows retrieved", runner.LastRunProcess!.Output); + + exit = runner.Exec("search", $"--signal \"{orders}~{rows}\" {filter} -c 10"); + Assert.Equal(0, exit); + output = runner.LastRunProcess!.Output; + Assert.Contains("] scott GET /orders", output); + Assert.Contains("] 42 84 42 rows retrieved", output); + + // A signal without columns contributes none. + exit = runner.Exec("search", $"--signal {unadorned} {filter} -c 10"); + Assert.Equal(0, exit); + output = runner.LastRunProcess!.Output; + Assert.Contains("] GET /orders", output); + Assert.DoesNotContain("scott", output); + + // A signal that can't be found is reported, rather than silently ignored. + exit = runner.Exec("search", $"--signal signal-999999 {filter} -c 10"); + Assert.Equal(1, exit); + Assert.Contains("Could not retrieve search result", runner.LastRunProcess!.Output); + } +} From 142f019f88ab720d8bbfa3d773b652163c33b96c Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 25 Aug 2026 16:44:35 +1000 Subject: [PATCH 4/6] Add `--column` support to `seqcli tail`. Assisted-by: Claude:claude-fable-5 --- README.md | 3 + src/SeqCli/Cli/Commands/SearchCommand.cs | 42 ++--------- src/SeqCli/Cli/Commands/TailCommand.cs | 5 +- .../Cli/Features/EventColumnsFeature.cs | 71 +++++++++++++++++++ .../Events/TailColumnsTestCase.cs | 67 +++++++++++++++++ 5 files changed, 149 insertions(+), 39 deletions(-) create mode 100644 src/SeqCli/Cli/Features/EventColumnsFeature.cs create mode 100644 test/SeqCli.EndToEnd/Events/TailColumnsTestCase.cs diff --git a/README.md b/README.md index a5da031f..3faeda58 100644 --- a/README.md +++ b/README.md @@ -1465,6 +1465,7 @@ seqcli search -f "@Exception like '%TimeoutException%'" -c 30 | `-f`, `--filter=VALUE` | A filter to apply to the search, for example `Host = 'xmpweb-01.example.com'` | | `-c`, `--count=VALUE` | The maximum number of events to retrieve; the default is 1 | | `--column=VALUE` | A column to display preceding each event's message; any Seq expression can be supplied, for example `OrderId`, `@SpanKind`, or `@Resource['service.name']`; this argument can be used multiple times, adding columns in order; applies to plain-text output only | +| `--no-signal-columns` | Do not show columns associated with the specified signal expression | | `--start=VALUE` | ISO 8601 date/time to query from | | `--end=VALUE` | ISO 8601 date/time to query to | | `--json` | Print output in newline-delimited JSON (the default is plain text) | @@ -1637,6 +1638,8 @@ Stream log events matching a filter. | Option | Description | | ------ | ----------- | | `-f`, `--filter=VALUE` | An optional server-side filter to apply to the stream, for example `@Level = 'Error'` | +| `--column=VALUE` | A column to display preceding each event's message; any Seq expression can be supplied, for example `OrderId`, `@SpanKind`, or `@Resource['service.name']`; this argument can be used multiple times, adding columns in order; applies to plain-text output only | +| `--no-signal-columns` | Do not show columns associated with the specified signal expression | | `--json` | Print output in newline-delimited JSON (the default is plain text) | | `--no-color` | Don't colorize text output | | `--force-color` | Force redirected output to have ANSI color (unless `--no-color` is also specified) | diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index 2ae85f4a..d7b2898c 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -13,16 +13,11 @@ // limitations under the License. using System; -using System.Collections.Generic; using System.Globalization; using System.Threading.Tasks; -using Seq.Api.Model.Signals; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; -using SeqCli.Output; -using SeqCli.Signals; -using SeqCli.Util; using Serilog; // ReSharper disable UnusedType.Global @@ -38,11 +33,11 @@ class SearchCommand : Command readonly DateRangeFeature _range; readonly SignalExpressionFeature _signal; readonly StoragePathFeature _storagePath; - readonly List _columns = []; + readonly EventColumnsFeature _eventColumns; string? _filter; int _count = 1; int _httpClientTimeout = 100000; - bool _trace, _noWebSockets, _noSignalColumns; + bool _trace, _noWebSockets; public SearchCommand() { @@ -56,13 +51,7 @@ public SearchCommand() $"The maximum number of events to retrieve; the default is {_count}", v => _count = int.Parse(v, CultureInfo.InvariantCulture)); - Options.Add( - "column=", - "A column to display preceding each event's message; any Seq expression can be supplied, for " + - "example `OrderId`, `@SpanKind`, or `@Resource.service.name`; this argument can be used multiple " + - "times, adding columns in order; applies to plain-text output only", - c => _columns.Add(ArgumentString.Normalize(c) ?? throw new ArgumentException("Columns require a value."))); - + _eventColumns = Enable(); _range = Enable(); _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); @@ -77,8 +66,6 @@ public SearchCommand() Options.Add("no-websockets", "Do not use WebSocket-driven streaming searches", _ => _noWebSockets = true); - Options.Add("no-signal-columns", "Do not show columns associated with the specified signal expression", _ => _noSignalColumns = true); - _connection = Enable(); } @@ -91,28 +78,7 @@ protected override async Task Run() var connection = SeqConnectionFactory.Connect(_connection, config); connection.Client.HttpClient.Timeout = TimeSpan.FromMilliseconds(_httpClientTimeout); - var collectedColumns = new List(); - if (!_noSignalColumns && _signal.Signal is { } signalExpression) - { - foreach (var signalId in signalExpression.ReferencedSignalIds()) - { - var signal = await connection.Signals.FindAsync(signalId); - foreach (var column in signal.Columns) - { - collectedColumns.Add(column.Expression); - } - } - } - - collectedColumns.AddRange(_columns); - - EventColumns? columns = null; - if (collectedColumns.Count > 0 && !EventColumns.TryCreate(collectedColumns, out columns, out var error)) - { - Log.Error("The column expression could not be compiled: {Error}", error); - return 1; - } - + var columns = await _eventColumns.GetEventColumns(connection, _signal.Signal); var output = _output.GetOutputFormat(config, columns?.OutputTemplate(), columns); string? filter = null; diff --git a/src/SeqCli/Cli/Commands/TailCommand.cs b/src/SeqCli/Cli/Commands/TailCommand.cs index 291433ba..88253a70 100644 --- a/src/SeqCli/Cli/Commands/TailCommand.cs +++ b/src/SeqCli/Cli/Commands/TailCommand.cs @@ -31,6 +31,7 @@ class TailCommand : Command readonly OutputFormatFeature _output; readonly SignalExpressionFeature _signal; readonly StoragePathFeature _storagePath; + readonly EventColumnsFeature _eventColumns; string? _filter; public TailCommand() @@ -40,6 +41,7 @@ public TailCommand() "An optional server-side filter to apply to the stream, for example `@Level = 'Error'`", v => _filter = v); + _eventColumns = Enable(); _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); _signal = Enable(); @@ -61,7 +63,8 @@ protected override async Task Run() strict = converted.StrictExpression; } - var output = _output.GetOutputFormat(config); + var columns = await _eventColumns.GetEventColumns(connection, _signal.Signal); + var output = _output.GetOutputFormat(config, columns?.OutputTemplate(), columns); try { diff --git a/src/SeqCli/Cli/Features/EventColumnsFeature.cs b/src/SeqCli/Cli/Features/EventColumnsFeature.cs new file mode 100644 index 00000000..22346a90 --- /dev/null +++ b/src/SeqCli/Cli/Features/EventColumnsFeature.cs @@ -0,0 +1,71 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Seq.Api; +using Seq.Api.Model.Signals; +using SeqCli.Output; +using SeqCli.Signals; +using SeqCli.Util; + +namespace SeqCli.Cli.Features; + +class EventColumnsFeature : CommandFeature +{ + readonly List _columns = []; + bool _noSignalColumns; + + public override void Enable(OptionSet options) + { + options.Add( + "column=", + "A column to display preceding each event's message; any Seq expression can be supplied, for " + + "example `OrderId`, `@SpanKind`, or `@Resource.service.name`; this argument can be used multiple " + + "times, adding columns in order; applies to plain-text output only", + c => _columns.Add(ArgumentString.Normalize(c) ?? throw new ArgumentException("Columns require a value."))); + + options.Add( + "no-signal-columns", + "Do not show columns associated with the specified signal expression", + _ => _noSignalColumns = true); + } + + public async Task GetEventColumns(SeqConnection connection, SignalExpressionPart? signal) + { + var collectedColumns = new List(); + if (!_noSignalColumns && signal is { } signalExpression) + { + foreach (var signalId in signalExpression.ReferencedSignalIds()) + { + var signalEntity = await connection.Signals.FindAsync(signalId); + foreach (var column in signalEntity.Columns) + { + collectedColumns.Add(column.Expression); + } + } + } + + collectedColumns.AddRange(_columns); + + if (collectedColumns.Count == 0) + return null; + + if (!EventColumns.TryCreate(collectedColumns, out var columns, out var error)) + throw new ArgumentException($"The column expression could not be compiled: {error}"); + + return columns; + } +} diff --git a/test/SeqCli.EndToEnd/Events/TailColumnsTestCase.cs b/test/SeqCli.EndToEnd/Events/TailColumnsTestCase.cs new file mode 100644 index 00000000..02fa26de --- /dev/null +++ b/test/SeqCli.EndToEnd/Events/TailColumnsTestCase.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +#nullable enable + +namespace SeqCli.EndToEnd.Events; + +public class TailColumnsTestCase : ICliTestCase +{ + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var inputFile = Path.Combine("Data", "trace-tree.clef"); + Assert.True(File.Exists(inputFile)); + + var exit = runner.Exec("signal create", "-t Orders -f \"@TraceId is not null\" -c Customer -c RowCount"); + Assert.Equal(0, exit); + + var signals = await connection.Signals.ListAsync(shared: true); + var orders = signals.Single(s => s.Title == "Orders").Id; + + var filter = "--filter=\"Customer is not null\""; + + // A column expression that can't be compiled is reported. + exit = runner.Exec("tail", "--column \"not a valid (\""); + Assert.Equal(1, exit); + Assert.Contains("could not be compiled", runner.LastRunProcess!.Output); + + // Signal columns precede those specified with `--column`. + using (var tail = runner.Spawn("tail", $"--signal {orders} {filter} --column \"@Level\"")) + { + await IngestUntilTailWrites(runner, tail, inputFile, "] scott Information GET /orders"); + } + + // `--no-signal-columns` drops the signal's columns, but not those specified with `--column`. + using (var tail = runner.Spawn("tail", $"--signal {orders} {filter} --no-signal-columns --column \"@Level\"")) + { + await IngestUntilTailWrites(runner, tail, inputFile, "] Information GET /orders"); + Assert.DoesNotContain("scott", tail.Output); + } + } + + // Events ingested before the tail command's streaming connection is established won't be + // observed, so ingest the test data repeatedly until the expected line appears. + static async Task IngestUntilTailWrites(CliCommandRunner runner, CaptiveProcess tail, string inputFile, string expected) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (!tail.Output.Contains(expected)) + { + if (DateTime.UtcNow > deadline) + Assert.Fail($"Timed out waiting for `{expected}` in: {tail.Output}"); + + var exit = runner.Exec("ingest", $"--json -i {inputFile}"); + Assert.Equal(0, exit); + + await Task.Delay(TimeSpan.FromSeconds(1)); + } + } +} From f987446b43cb4a8e2798d14fe6445220b7511577 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 3 Sep 2026 14:58:44 +1000 Subject: [PATCH 5/6] Include updated Seq.Syntax --- src/SeqCli/SeqCli.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index 16f10fb8..346d0ed8 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -42,7 +42,7 @@ - + From 2388cc1234a123ba4cb635a1881d487e2c494da6 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 3 Sep 2026 15:27:54 +1000 Subject: [PATCH 6/6] Rework now that Seq.Syntax can evaluate column expressions directly in the formatter --- src/SeqCli/Cli/Commands/SearchCommand.cs | 5 +- src/SeqCli/Cli/Commands/TailCommand.cs | 5 +- .../Cli/Features/EventColumnsFeature.cs | 21 +++-- .../Cli/Features/OutputFormatFeature.cs | 5 +- src/SeqCli/Output/EventColumns.cs | 91 ------------------- src/SeqCli/Output/OutputFormat.cs | 12 +-- src/SeqCli/Output/TextFormatters.cs | 23 ++++- src/SeqCli/Output/TraceFormatter.cs | 10 +- test/SeqCli.Tests/Output/EventColumnsTests.cs | 89 ------------------ test/SeqCli.Tests/Output/OutputFormatTests.cs | 1 - .../Output/TextFormattersTests.cs | 69 ++++++++++++++ 11 files changed, 116 insertions(+), 215 deletions(-) delete mode 100644 src/SeqCli/Output/EventColumns.cs delete mode 100644 test/SeqCli.Tests/Output/EventColumnsTests.cs diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index d7b2898c..37c9c95e 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -18,6 +18,7 @@ using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; +using SeqCli.Output; using Serilog; // ReSharper disable UnusedType.Global @@ -78,8 +79,8 @@ protected override async Task Run() var connection = SeqConnectionFactory.Connect(_connection, config); connection.Client.HttpClient.Timeout = TimeSpan.FromMilliseconds(_httpClientTimeout); - var columns = await _eventColumns.GetEventColumns(connection, _signal.Signal); - var output = _output.GetOutputFormat(config, columns?.OutputTemplate(), columns); + var columns = await _eventColumns.GetColumns(connection, _signal.Signal); + var output = _output.GetOutputFormat(config, TextFormatters.PlainOutputTemplate(columns)); string? filter = null; if (!string.IsNullOrWhiteSpace(_filter)) diff --git a/src/SeqCli/Cli/Commands/TailCommand.cs b/src/SeqCli/Cli/Commands/TailCommand.cs index 88253a70..b4670112 100644 --- a/src/SeqCli/Cli/Commands/TailCommand.cs +++ b/src/SeqCli/Cli/Commands/TailCommand.cs @@ -20,6 +20,7 @@ using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; +using SeqCli.Output; namespace SeqCli.Cli.Commands; @@ -63,8 +64,8 @@ protected override async Task Run() strict = converted.StrictExpression; } - var columns = await _eventColumns.GetEventColumns(connection, _signal.Signal); - var output = _output.GetOutputFormat(config, columns?.OutputTemplate(), columns); + var columns = await _eventColumns.GetColumns(connection, _signal.Signal); + var output = _output.GetOutputFormat(config, TextFormatters.PlainOutputTemplate(columns)); try { diff --git a/src/SeqCli/Cli/Features/EventColumnsFeature.cs b/src/SeqCli/Cli/Features/EventColumnsFeature.cs index 22346a90..68ee7116 100644 --- a/src/SeqCli/Cli/Features/EventColumnsFeature.cs +++ b/src/SeqCli/Cli/Features/EventColumnsFeature.cs @@ -17,8 +17,8 @@ using System.Threading.Tasks; using Seq.Api; using Seq.Api.Model.Signals; -using SeqCli.Output; using SeqCli.Signals; +using SeqCli.Syntax; using SeqCli.Util; namespace SeqCli.Cli.Features; @@ -43,9 +43,9 @@ public override void Enable(OptionSet options) _ => _noSignalColumns = true); } - public async Task GetEventColumns(SeqConnection connection, SignalExpressionPart? signal) + public async Task> GetColumns(SeqConnection connection, SignalExpressionPart? signal) { - var collectedColumns = new List(); + var columns = new List(); if (!_noSignalColumns && signal is { } signalExpression) { foreach (var signalId in signalExpression.ReferencedSignalIds()) @@ -53,18 +53,19 @@ public override void Enable(OptionSet options) var signalEntity = await connection.Signals.FindAsync(signalId); foreach (var column in signalEntity.Columns) { - collectedColumns.Add(column.Expression); + columns.Add(column.Expression); } } } - collectedColumns.AddRange(_columns); + columns.AddRange(_columns); - if (collectedColumns.Count == 0) - return null; - - if (!EventColumns.TryCreate(collectedColumns, out var columns, out var error)) - throw new ArgumentException($"The column expression could not be compiled: {error}"); + foreach (var column in columns) + { + // A better error than a failed output template parse. + if (!SeqSyntax.TryCompileExpression(column, out _, out var error)) + throw new ArgumentException($"The column expression `{column}` could not be compiled: {error}"); + } return columns; } diff --git a/src/SeqCli/Cli/Features/OutputFormatFeature.cs b/src/SeqCli/Cli/Features/OutputFormatFeature.cs index 05190b7b..792766b2 100644 --- a/src/SeqCli/Cli/Features/OutputFormatFeature.cs +++ b/src/SeqCli/Cli/Features/OutputFormatFeature.cs @@ -13,7 +13,6 @@ // limitations under the License. using SeqCli.Config; -using SeqCli.Data; using SeqCli.Output; namespace SeqCli.Cli.Features; @@ -27,9 +26,9 @@ class OutputFormatFeature(bool supportNative, bool supportJson) : CommandFeature public OutputFormatFeature() : this(supportNative: false, supportJson: true) { } - public OutputFormat GetOutputFormat(SeqCliConfig config, string? outputTemplate = null, IEventEnricher? textEnricher = null) + public OutputFormat GetOutputFormat(SeqCliConfig config, string? outputTemplate = null) { - return new OutputFormat(_syntax, _noColor, _forceColor, config.Output, outputTemplate, textEnricher); + return new OutputFormat(_syntax, _noColor, _forceColor, config.Output, outputTemplate); } public string JsonArgumentHelp { get; init; } = "Print output in newline-delimited JSON (the default is plain text)"; diff --git a/src/SeqCli/Output/EventColumns.cs b/src/SeqCli/Output/EventColumns.cs deleted file mode 100644 index e26c97f3..00000000 --- a/src/SeqCli/Output/EventColumns.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright © Datalust and contributors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text; -using System.Text.Json.Nodes; -using Seq.Syntax.Expressions; -using SeqCli.Data; -using SeqCli.Syntax; - -namespace SeqCli.Output; - -/// -/// Evaluates a list of column expressions against each event, storing the results in synthetic properties that -/// the plain-text output template shows ahead of the message. -/// -class EventColumns : IEventEnricher -{ - static readonly string ColumnPrefixProperty = $"_SeqcliColumn_{Guid.NewGuid():N}"; - - internal static string ColumnPropertyName(int index) => $"{ColumnPrefixProperty}_{index}"; - - internal static string TemplateColumnsFragment(int columnCount) - { - // `<> ''` is undefined, and hence falsy, when the property is missing; the guard thus - // drops the column, and its trailing space, for both missing and empty values. - var fragment = new StringBuilder(); - for (var i = 0; i < columnCount; ++i) - { - var column = ColumnPropertyName(i); - fragment.Append($"{{#if {column} <> ''}}{{{column}}} {{#end}}"); - } - - return fragment.ToString(); - } - - readonly CompiledExpression[] _columns; - - EventColumns(CompiledExpression[] columns) - { - _columns = columns; - } - - public static bool TryCreate( - IReadOnlyList expressions, - [NotNullWhen(true)] out EventColumns? columns, - [NotNullWhen(false)] out string? error) - { - var compiled = new CompiledExpression[expressions.Count]; - for (var i = 0; i < expressions.Count; ++i) - { - if (!SeqSyntax.TryCompileExpression(expressions[i], out var expression, out error)) - { - columns = null; - return false; - } - - compiled[i] = expression; - } - - columns = new EventColumns(compiled); - error = null; - return true; - } - - public string OutputTemplate() => TextFormatters.PlainOutputTemplate(_columns.Length); - - public void Enrich(JsonObject eventJson) - { - for (var i = 0; i < _columns.Length; ++i) - { - // Property accessors return nodes still attached to the event, so they're cloned before being - // re-parented. - if (_columns[i](eventJson).TryGetValue(out var value)) - eventJson[ColumnPropertyName(i)] = value?.DeepClone(); - } - } -} diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index 741b5750..ae2492fb 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -29,7 +29,6 @@ using SeqCli.Api; using SeqCli.Config; using SeqCli.Csv; -using SeqCli.Data; namespace SeqCli.Output; @@ -40,7 +39,6 @@ sealed class OutputFormat readonly OutputSyntax _syntax; readonly ExpressionTemplate? _eventFormatter; - readonly IEventEnricher? _textEnricher; readonly ExpressionTemplate _jsonValueFormatter; readonly JsonSerializer _serializer = JsonSerializer.CreateDefault(new JsonSerializerSettings @@ -57,15 +55,13 @@ public OutputFormat( bool? noColor, bool? forceColor, SeqCliOutputConfig outputConfig, - string? plainTextTemplate = null, - IEventEnricher? textEnricher = null) + string? plainTextTemplate = null) : this( syntax, noColor, forceColor, outputConfig, plainTextTemplate, - textEnricher, noColorSetInEnvironment: NoColorSetInEnvironment(), outputIsRedirected: Console.IsOutputRedirected, allowAnsiEscapes: TerminalFeatures.TryEnableAnsiEscapes()) @@ -77,7 +73,6 @@ public OutputFormat( /// The value of --force-color, if specified. /// Configured output defaults. /// The template controlling plain-text formatting, or null for the default. - /// An enricher applied to events written as plain text, or null. /// 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 @@ -88,16 +83,12 @@ internal OutputFormat( bool? forceColor, SeqCliOutputConfig outputConfig, string? plainTextTemplate, - IEventEnricher? textEnricher, bool noColorSetInEnvironment, bool outputIsRedirected, bool allowAnsiEscapes) { _syntax = syntax; - // Enrichment supports plain-text templates, so JSON output shows events verbatim. - _textEnricher = Text ? textEnricher : null; - var resolvedNoColor = ResolveNoColor(noColor, forceColor, outputConfig, noColorSetInEnvironment, allowAnsiEscapes); var applyThemeToRedirectedOutput = !resolvedNoColor && (forceColor ?? outputConfig.ForceColor); var colorize = !resolvedNoColor && (applyThemeToRedirectedOutput || !outputIsRedirected); @@ -246,7 +237,6 @@ public void WriteEventEntity(EventEntity evt) public void WriteEvent(JsonObject eventJson) { - _textEnricher?.Enrich(eventJson); _eventFormatter?.Format(eventJson, Console.Out); } diff --git a/src/SeqCli/Output/TextFormatters.cs b/src/SeqCli/Output/TextFormatters.cs index c4c3e7cc..92d07c2f 100644 --- a/src/SeqCli/Output/TextFormatters.cs +++ b/src/SeqCli/Output/TextFormatters.cs @@ -13,6 +13,8 @@ // limitations under the License. using System; +using System.Collections.Generic; +using System.Text; using Seq.Syntax.Templates; using Seq.Syntax.Templates.Encoding; using Seq.Syntax.Templates.Themes; @@ -30,13 +32,26 @@ static class TextFormatters "{@Data}" + Environment.NewLine, encoder: Encoder(theme)); - // Guarding on `@Elapsed` rather than the built-in `IsSpan()` shows elapsed time for any - // event carrying a span start timestamp, whether or not trace and span ids accompany it. - internal static string PlainOutputTemplate(int columnCount = 0) => - "[{@Timestamp:o} {@Level:u3}] " + EventColumns.TemplateColumnsFragment(columnCount) + + /// + /// The default plain-text template, showing ahead of each + /// event's message. + /// + /// Column expressions, evaluated against each event; any Seq expression can be + /// supplied. + internal static string PlainOutputTemplate(IEnumerable? columns = null) => + "[{@Timestamp:o} {@Level:u3}] " + ColumnsFragment(columns ?? []) + "{@Message}{#if @Elapsed is not null} ({TotalMilliseconds(@Elapsed):0.###} ms){#end}" + Environment.NewLine + "{@Exception}"; + internal static string ColumnsFragment(IEnumerable columns) + { + var fragment = new StringBuilder(); + foreach (var column in columns) + fragment.Append($"{{#if ({column}) <> ''}}{{({column})}} {{#end}}"); + + return fragment.ToString(); + } + public static ExpressionTemplate Plain(TemplateTheme? theme, string? outputTemplate) => SeqSyntax.ParseTemplate(outputTemplate ?? PlainOutputTemplate(), Encoder(theme)); diff --git a/src/SeqCli/Output/TraceFormatter.cs b/src/SeqCli/Output/TraceFormatter.cs index 0f770604..b74b2651 100644 --- a/src/SeqCli/Output/TraceFormatter.cs +++ b/src/SeqCli/Output/TraceFormatter.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Text; using System.Text.Json.Nodes; using SeqCli.Api; @@ -27,14 +28,19 @@ static class TraceFormatter { static readonly string TreePrefixProperty = $"_SeqcliTraceTreePrefix_{Guid.NewGuid():N}"; static readonly string ElapsedProperty = $"_SeqcliTraceElapsed_{Guid.NewGuid():N}"; + static readonly string ColumnPrefixProperty = $"_SeqcliTraceColumn_{Guid.NewGuid():N}"; const string SpanConnector = "├─ ", LastSpanConnector = "└─ ", LogConnector = "┊ ", Continuation = "│ ", Gap = " "; + // The trace query evaluates column expressions server-side, so their results are carried in + // surrogate properties rather than being recomputed by the output template. + static string ColumnProperty(int index) => $"{ColumnPrefixProperty}_{index}"; + public static string OutputTemplate(int columnCount) { var template = new StringBuilder($"[{{@Timestamp:o}} {{@Level:u3}}] {{{TreePrefixProperty}}}"); - template.Append(EventColumns.TemplateColumnsFragment(columnCount)); + template.Append(TextFormatters.ColumnsFragment(Enumerable.Range(0, columnCount).Select(ColumnProperty))); template.Append($"{{@Message}}{{#if {ElapsedProperty} is not null}} ({{TotalMilliseconds({ElapsedProperty}):0.###}} ms){{#end}}"); template.Append(Environment.NewLine).Append("{@Exception}"); return template.ToString(); @@ -95,7 +101,7 @@ static JsonObject ToEventJson(TraceTreeNode treeNode, string treePrefix) for (var i = 0; i < evt.Columns.Count; ++i) { if (evt.Columns[i] is { } value) - eventJson[EventColumns.ColumnPropertyName(i)] = ToSystemTextJson.FromApiValue(value); + eventJson[ColumnProperty(i)] = ToSystemTextJson.FromApiValue(value); } return eventJson; diff --git a/test/SeqCli.Tests/Output/EventColumnsTests.cs b/test/SeqCli.Tests/Output/EventColumnsTests.cs deleted file mode 100644 index 80a46551..00000000 --- a/test/SeqCli.Tests/Output/EventColumnsTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -#nullable enable -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using Seq.Api.Model.Events; -using SeqCli.Api; -using SeqCli.Output; -using SeqCli.Tests.Support; -using Xunit; - -namespace SeqCli.Tests.Output; - -public class EventColumnsTests -{ - static EventColumns Create(params string[] expressions) - { - Assert.True(EventColumns.TryCreate(expressions, out var columns, out var error), error); - return columns; - } - - static string Render(EventEntity evt, EventColumns columns) - { - var eventJson = EventEntityJson.ToEventJson(evt); - columns.Enrich(eventJson); - - var output = new StringWriter(); - TextFormatters.Plain(theme: null, columns.OutputTemplate()).Format(eventJson, output); - return output.ToString(); - } - - static string At(EventEntity evt) => - DateTimeOffset.ParseExact(evt.Timestamp, "o", CultureInfo.InvariantCulture).ToLocalTime().ToString("o"); - - [Fact] - public void ColumnsPrecedeTheMessageInOrder() - { - var evt = Some.MakeEvent(e => e.Properties = Some.MakeProperties(("Customer", "scott"), ("OrderId", 42))); - - Assert.Equal( - $"[{At(evt)} INF] scott 42 Hello{Environment.NewLine}", - Render(evt, Create("Customer", "OrderId"))); - } - - [Fact] - public void MissingAndEmptyColumnValuesLeaveNoRedundantSpace() - { - var evt = Some.MakeEvent(e => e.Properties = Some.MakeProperties(("Empty", ""), ("OrderId", 42))); - - Assert.Equal( - $"[{At(evt)} INF] 42 Hello{Environment.NewLine}", - Render(evt, Create("Missing", "Empty", "OrderId"))); - } - - [Fact] - public void SeqStyleNamesResolveAgainstApiEvents() - { - var evt = Some.MakeEvent(e => - { - e.Properties = []; - e.Level = "Warning"; - e.SpanKind = "Server"; - e.Resource = Some.MakeProperties(("service.name", "frontend")); - }); - - Assert.Equal( - $"[{At(evt)} WRN] frontend Server Hello{Environment.NewLine}", - Render(evt, Create("@Resource['service.name']", "@SpanKind"))); - } - - [Fact] - public void ComputedColumnValuesAreRendered() - { - var evt = Some.MakeEvent(e => e.Properties = Some.MakeProperties(("OrderId", 42))); - - Assert.Equal( - $"[{At(evt)} INF] order-42 Hello{Environment.NewLine}", - Render(evt, Create("concat('order-', tostring(OrderId))"))); - } - - [Fact] - public void InvalidExpressionsAreReportedByTryCreate() - { - Assert.False(EventColumns.TryCreate(new List {"OrderId", "not a valid ("}, out var columns, out var error)); - Assert.Null(columns); - Assert.NotNull(error); - Assert.NotEmpty(error); - } -} diff --git a/test/SeqCli.Tests/Output/OutputFormatTests.cs b/test/SeqCli.Tests/Output/OutputFormatTests.cs index 9361a8f2..e323aa60 100644 --- a/test/SeqCli.Tests/Output/OutputFormatTests.cs +++ b/test/SeqCli.Tests/Output/OutputFormatTests.cs @@ -27,7 +27,6 @@ static OutputFormat Create( forceColor, new SeqCliOutputConfig { DisableColor = disableColor }, plainTextTemplate: null, - textEnricher: null, noColorSetInEnvironment, outputIsRedirected, supportsAnsiEscapes); diff --git a/test/SeqCli.Tests/Output/TextFormattersTests.cs b/test/SeqCli.Tests/Output/TextFormattersTests.cs index 340b18fc..c3412725 100644 --- a/test/SeqCli.Tests/Output/TextFormattersTests.cs +++ b/test/SeqCli.Tests/Output/TextFormattersTests.cs @@ -1,7 +1,9 @@ #nullable enable using System; +using System.Globalization; using System.IO; using System.Text.Json.Nodes; +using Seq.Api.Model.Events; using Seq.Syntax.Templates.Themes; using SeqCli.Api; using SeqCli.Output; @@ -74,6 +76,73 @@ public void ACustomOutputTemplateReplacesTheDefault() RenderText(SomeEventJson(), $"{{@l:u3}} {{@m}}{Environment.NewLine}")); } + [Fact] + public void ColumnsPrecedeTheMessageInOrder() + { + var evt = Some.MakeEvent(e => e.Properties = Some.MakeProperties(("Customer", "scott"), ("OrderId", 42))); + + Assert.Equal( + $"[{At(evt)} INF] scott 42 Hello{Environment.NewLine}", + RenderText(evt, "Customer", "OrderId")); + } + + [Fact] + public void MissingAndEmptyColumnValuesLeaveNoRedundantSpace() + { + var evt = Some.MakeEvent(e => e.Properties = Some.MakeProperties(("Empty", ""), ("OrderId", 42))); + + Assert.Equal( + $"[{At(evt)} INF] 42 Hello{Environment.NewLine}", + RenderText(evt, "Missing", "Empty", "OrderId")); + } + + [Fact] + public void SeqStyleNamesResolveAgainstApiEvents() + { + var evt = Some.MakeEvent(e => + { + e.Properties = []; + e.Level = "Warning"; + e.SpanKind = "Server"; + e.Resource = Some.MakeProperties(("service.name", "frontend")); + }); + + Assert.Equal( + $"[{At(evt)} WRN] frontend Server Hello{Environment.NewLine}", + RenderText(evt, "@Resource['service.name']", "@SpanKind")); + } + + [Fact] + public void ComputedColumnValuesAreRendered() + { + var evt = Some.MakeEvent(e => e.Properties = Some.MakeProperties(("OrderId", 42))); + + Assert.Equal( + $"[{At(evt)} INF] order-42 Hello{Environment.NewLine}", + RenderText(evt, "concat('order-', tostring(OrderId))")); + } + + [Theory] + [InlineData("if OrderId > 40 then 'big' else 'small'", "big")] + [InlineData("{id: OrderId}.id", "42")] + [InlineData("concat('{', tostring(OrderId), '}')", "{42}")] + [InlineData("Missing or OrderId = 42", "true")] + [InlineData("[OrderId, 'x'][0]", "42")] + public void ColumnExpressionsUsingTemplateDelimitersAreRendered(string column, string expected) + { + var evt = Some.MakeEvent(e => e.Properties = Some.MakeProperties(("OrderId", 42))); + + Assert.Equal( + $"[{At(evt)} INF] {expected} Hello{Environment.NewLine}", + RenderText(evt, column)); + } + + static string At(EventEntity evt) => + DateTimeOffset.ParseExact(evt.Timestamp, "o", CultureInfo.InvariantCulture).ToLocalTime().ToString("o"); + + static string RenderText(EventEntity evt, params string[] columns) => + RenderText(EventEntityJson.ToEventJson(evt), TextFormatters.PlainOutputTemplate(columns)); + static JsonObject SomeEventJson(string? level = null, string? exception = null) { var evt = new JsonObject