From 22527ed6adf79f71fb9f78bbc09c4aa3cde11f55 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 8 Sep 2026 12:49:44 +1000 Subject: [PATCH 1/2] Tidy exception reporting up a bit; log full exception details at outer handler when `--verbose` is specified --- src/SeqCli/Cli/Commands/IngestCommand.cs | 98 +++++++------- .../Cli/Commands/Metrics/SearchCommand.cs | 81 +++++------- src/SeqCli/Cli/Commands/SearchCommand.cs | 84 ++++++------ src/SeqCli/Cli/Commands/TraceCommand.cs | 124 ++++++++---------- src/SeqCli/Program.cs | 7 +- src/SeqCli/Util/Presentation.cs | 10 +- 6 files changed, 188 insertions(+), 216 deletions(-) diff --git a/src/SeqCli/Cli/Commands/IngestCommand.cs b/src/SeqCli/Cli/Commands/IngestCommand.cs index e0ad35a6..be413c56 100644 --- a/src/SeqCli/Cli/Commands/IngestCommand.cs +++ b/src/SeqCli/Cli/Commands/IngestCommand.cs @@ -81,66 +81,58 @@ public IngestCommand() protected override async Task Run() { - try - { - var enrichers = new List(); + var enrichers = new List(); - if (_level != null) - enrichers.Add(new LevelEnricher(_level)); + if (_level != null) + enrichers.Add(new LevelEnricher(_level)); - foreach (var (name, value) in _properties.FlatProperties) - enrichers.Add(new ScalarPropertyEnricher(name, value)); + foreach (var (name, value) in _properties.FlatProperties) + enrichers.Add(new ScalarPropertyEnricher(name, value)); - Func? filter = null; - if (_filter != null) - { - var eval = SeqSyntax.CompileExpression(_filter); - filter = evt => eval(evt).IsTrue(); - } + Func? filter = null; + if (_filter != null) + { + var eval = SeqSyntax.CompileExpression(_filter); + filter = evt => eval(evt).IsTrue(); + } - var config = RuntimeConfigurationLoader.Load(_storagePath); - var connection = SeqConnectionFactory.Connect(_connection, config); - - // The API key is passed through separately because `SeqConnection` doesn't expose a batched ingestion - // mechanism and so we manually construct `HttpRequestMessage`s deeper in the stack. Nice feature gap to - // close at some point! - var (_, apiKey) = SeqConnectionFactory.GetConnectionDetails(_connection, config); - var batchSize = _batchSize.Value; + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + // The API key is passed through separately because `SeqConnection` doesn't expose a batched ingestion + // mechanism and so we manually construct `HttpRequestMessage`s deeper in the stack. Nice feature gap to + // close at some point! + var (_, apiKey) = SeqConnectionFactory.GetConnectionDetails(_connection, config); + var batchSize = _batchSize.Value; - foreach (var input in _fileInputFeature.OpenInputs()) + foreach (var input in _fileInputFeature.OpenInputs()) + { + using (input) { - using (input) - { - IEventReader reader = _json - ? new JsonEventReader(input) - : new PlainTextEventReader(input, _pattern); - - reader = new EnrichingReader(reader, enrichers); - - if (_message != null) - reader = new StaticMessageTemplateReader(reader, _message); - - var exit = await LogShipper.ShipEventsAsync( - connection, - apiKey, - reader, - _invalidDataHandlingFeature.InvalidDataHandling, - _sendFailureHandlingFeature.SendFailureHandling, - batchSize, - filter, - CancellationToken.None); - - if (exit != 0) - return exit; - } + IEventReader reader = _json + ? new JsonEventReader(input) + : new PlainTextEventReader(input, _pattern); + + reader = new EnrichingReader(reader, enrichers); + + if (_message != null) + reader = new StaticMessageTemplateReader(reader, _message); + + var exit = await LogShipper.ShipEventsAsync( + connection, + apiKey, + reader, + _invalidDataHandlingFeature.InvalidDataHandling, + _sendFailureHandlingFeature.SendFailureHandling, + batchSize, + filter, + CancellationToken.None); + + if (exit != 0) + return exit; } - - return 0; - } - catch (Exception ex) - { - Log.Error(ex, "Ingestion failed: {ErrorMessage}", ex.Message); - return 1; } + + return 0; } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs b/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs index 5cc1c3ee..9b86cd6b 100644 --- a/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs @@ -22,7 +22,6 @@ using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Util; -using Serilog; namespace SeqCli.Cli.Commands.Metrics; @@ -69,56 +68,48 @@ public SearchCommand() protected override async Task Run() { - try - { - var config = RuntimeConfigurationLoader.Load(_storagePath); - var output = _output.GetOutputFormat(config); - var connection = SeqConnectionFactory.Connect(_connection, config); + var config = RuntimeConfigurationLoader.Load(_storagePath); + var output = _output.GetOutputFormat(config); + var connection = SeqConnectionFactory.Connect(_connection, config); - string? filter = null; - if (!string.IsNullOrWhiteSpace(_filter)) - filter = (await connection.Expressions.ToStrictAsync(_filter)).StrictExpression; + string? filter = null; + if (!string.IsNullOrWhiteSpace(_filter)) + filter = (await connection.Expressions.ToStrictAsync(_filter)).StrictExpression; - var result = await connection.Metrics.SearchAsync( - _groups, - filter, - _count, - rangeStartUtc: _range.Start, - rangeEndUtc: _range.End, - trace: _trace); - - // We convert the metric into a query result to improve formatting consistency. Room for an abstraction of - // some kind here. - var rows = new List(); - foreach (var metric in result.Metrics) - { - var row = new List - { - metric.Name ?? metric.Accessor, - metric.Kind, - metric.Unit, - metric.Description - }; - - foreach (var value in metric.GroupKey) - row.Add(value); - - rows.Add(row.ToArray()); - } - var asRowset = new QueryResultPart + var result = await connection.Metrics.SearchAsync( + _groups, + filter, + _count, + rangeStartUtc: _range.Start, + rangeEndUtc: _range.End, + trace: _trace); + + // We convert the metric into a query result to improve formatting consistency. Room for an abstraction of + // some kind here. + var rows = new List(); + foreach (var metric in result.Metrics) + { + var row = new List { - Columns = new[] { "Name", "Kind", "Unit", "Description" }.Concat(_groups).ToArray(), - Rows = rows.ToArray() + metric.Name ?? metric.Accessor, + metric.Kind, + metric.Unit, + metric.Description }; - output.WriteQueryResult(asRowset); - - return 0; + foreach (var value in metric.GroupKey) + row.Add(value); + + rows.Add(row.ToArray()); } - catch (Exception ex) + var asRowset = new QueryResultPart { - Log.Error(ex, "Could not retrieve metrics: {ErrorMessage}", ex.Message); - return 1; - } + Columns = new[] { "Name", "Kind", "Unit", "Description" }.Concat(_groups).ToArray(), + Rows = rows.ToArray() + }; + + output.WriteQueryResult(asRowset); + + return 0; } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index 37c9c95e..1ce602e7 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -72,62 +72,54 @@ public SearchCommand() protected override async Task Run() { - try - { - var config = RuntimeConfigurationLoader.Load(_storagePath); + var config = RuntimeConfigurationLoader.Load(_storagePath); - var connection = SeqConnectionFactory.Connect(_connection, config); - connection.Client.HttpClient.Timeout = TimeSpan.FromMilliseconds(_httpClientTimeout); + var connection = SeqConnectionFactory.Connect(_connection, config); + connection.Client.HttpClient.Timeout = TimeSpan.FromMilliseconds(_httpClientTimeout); - var columns = await _eventColumns.GetColumns(connection, _signal.Signal); - var output = _output.GetOutputFormat(config, TextFormatters.PlainOutputTemplate(columns)); + var columns = await _eventColumns.GetColumns(connection, _signal.Signal); + var output = _output.GetOutputFormat(config, TextFormatters.PlainOutputTemplate(columns)); - string? filter = null; - if (!string.IsNullOrWhiteSpace(_filter)) - filter = (await connection.Expressions.ToStrictAsync(_filter)).StrictExpression; + string? filter = null; + if (!string.IsNullOrWhiteSpace(_filter)) + filter = (await connection.Expressions.ToStrictAsync(_filter)).StrictExpression; - try + try + { + if (!_noWebSockets) { - if (!_noWebSockets) + await foreach (var evt in connection.Events.EnumerateAsync(null, + _signal.Signal, + filter, + _count, + fromDateUtc: _range.Start, + toDateUtc: _range.End, + trace: _trace, + render: output.RequiresRender)) { - await foreach (var evt in connection.Events.EnumerateAsync(null, - _signal.Signal, - filter, - _count, - fromDateUtc: _range.Start, - toDateUtc: _range.End, - trace: _trace, - render: output.RequiresRender)) - { - output.WriteEventEntity(evt); - } - - return 0; + output.WriteEventEntity(evt); } - } - catch (NotSupportedException nse) - { - Log.Information(nse, "WebSockets not supported; falling back to paged search"); - } - - await foreach (var evt in connection.Events.PagedEnumerateAsync(null, - _signal.Signal, - filter, - _count, - fromDateUtc: _range.Start, - toDateUtc: _range.End, - trace: _trace, - render: output.RequiresRender)) - { - output.WriteEventEntity(evt); - } - return 0; + return 0; + } } - catch (Exception ex) + catch (NotSupportedException nse) { - Log.Error(ex, "Could not retrieve search result: {ErrorMessage}", ex.Message); - return 1; + Log.Information(nse, "WebSockets not supported; falling back to paged search"); } + + await foreach (var evt in connection.Events.PagedEnumerateAsync(null, + _signal.Signal, + filter, + _count, + fromDateUtc: _range.Start, + toDateUtc: _range.End, + trace: _trace, + render: output.RequiresRender)) + { + output.WriteEventEntity(evt); + } + + return 0; } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/TraceCommand.cs b/src/SeqCli/Cli/Commands/TraceCommand.cs index 67541f3b..348e40f9 100644 --- a/src/SeqCli/Cli/Commands/TraceCommand.cs +++ b/src/SeqCli/Cli/Commands/TraceCommand.cs @@ -81,86 +81,78 @@ public TraceCommand() protected override async Task Run() { - try + if (_id == null) { - if (_id == null) - { - Log.Error("A trace id must be specified"); - return 1; - } - - var traceId = _id.ToLowerInvariant(); - if (!TraceQuery.IsValidTraceId(traceId)) - { - Log.Error("The trace id {TraceId} is not valid; trace ids are 32 hexadecimal digits", _id); - return 1; - } + Log.Error("A trace id must be specified"); + return 1; + } - var spanId = _spanId?.ToLowerInvariant(); - if (spanId != null && !TraceQuery.IsValidSpanId(spanId)) - { - Log.Error("The span id {SpanId} is not valid; span ids are 16 hexadecimal digits", _spanId); - return 1; - } + var traceId = _id.ToLowerInvariant(); + if (!TraceQuery.IsValidTraceId(traceId)) + { + Log.Error("The trace id {TraceId} is not valid; trace ids are 32 hexadecimal digits", _id); + return 1; + } - var config = RuntimeConfigurationLoader.Load(_storagePath); - var connection = SeqConnectionFactory.Connect(_connection, config); + var spanId = _spanId?.ToLowerInvariant(); + if (spanId != null && !TraceQuery.IsValidSpanId(spanId)) + { + Log.Error("The span id {SpanId} is not valid; span ids are 16 hexadecimal digits", _spanId); + return 1; + } - var result = await connection.Data.TryQueryAsync(TraceQuery.Build(traceId, _includeLogs, _includeExceptions, _columns)); - if (!string.IsNullOrWhiteSpace(result.Error)) - { - Log.Error("Could not retrieve trace: {ErrorMessage}", result.Error); - foreach (var reason in result.Reasons) - Log.Error("{Reason}", reason); - return 1; - } + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); - var traceEvents = TraceQuery.ReadEvents(result, _includeExceptions, _columns); - if (traceEvents.Count == 0) - { - Log.Error("No events found for trace {TraceId}", traceId); - return 1; - } + var result = await connection.Data.TryQueryAsync(TraceQuery.Build(traceId, _includeLogs, _includeExceptions, _columns)); + if (!string.IsNullOrWhiteSpace(result.Error)) + { + Log.Error("Could not retrieve trace: {ErrorMessage}", result.Error); + foreach (var reason in result.Reasons) + Log.Error("{Reason}", reason); + return 1; + } - var complete = traceEvents.Count != TraceQuery.MaxEvents; - if (!complete) - Log.Warning("Only the first {Count} events in the trace were retrieved; the tree may be incomplete", - TraceQuery.MaxEvents); + var traceEvents = TraceQuery.ReadEvents(result, _includeExceptions, _columns); + if (traceEvents.Count == 0) + { + Log.Error("No events found for trace {TraceId}", traceId); + return 1; + } - var roots = TraceTreeBuilder.Build(traceEvents); + var complete = traceEvents.Count != TraceQuery.MaxEvents; + if (!complete) + Log.Warning("Only the first {Count} events in the trace were retrieved; the tree may be incomplete", + TraceQuery.MaxEvents); - TraceTreeNode? subtreeRoot = null; - if (spanId != null) - { - subtreeRoot = TraceTreeBuilder.FindSpan(roots, spanId); - if (subtreeRoot == null) - { - Log.Error("The span {SpanId} does not appear in trace {TraceId}", spanId, traceId); - return 1; - } - } + var roots = TraceTreeBuilder.Build(traceEvents); - var output = _output.GetOutputFormat(config, TraceFormatter.OutputTemplate(_columns.Count)); - if (output.Json) - { - var document = subtreeRoot != null ? - TraceTreeJObjectConverter.FromSubtree(traceId, subtreeRoot, complete, _includeLogs, _columns) : - TraceTreeJObjectConverter.FromRoots(traceId, roots, complete, _includeLogs, _columns); - - output.WriteObject(document); - } - else + TraceTreeNode? subtreeRoot = null; + if (spanId != null) + { + subtreeRoot = TraceTreeBuilder.FindSpan(roots, spanId); + if (subtreeRoot == null) { - foreach (var eventJson in TraceFormatter.ToEventJson(subtreeRoot != null ? [subtreeRoot] : roots)) - output.WriteEvent(eventJson); + Log.Error("The span {SpanId} does not appear in trace {TraceId}", spanId, traceId); + return 1; } + } - return 0; + var output = _output.GetOutputFormat(config, TraceFormatter.OutputTemplate(_columns.Count)); + if (output.Json) + { + var document = subtreeRoot != null ? + TraceTreeJObjectConverter.FromSubtree(traceId, subtreeRoot, complete, _includeLogs, _columns) : + TraceTreeJObjectConverter.FromRoots(traceId, roots, complete, _includeLogs, _columns); + + output.WriteObject(document); } - catch (Exception ex) + else { - Log.Error(ex, "Could not retrieve trace: {ErrorMessage}", ex.Message); - return 1; + foreach (var eventJson in TraceFormatter.ToEventJson(subtreeRoot != null ? [subtreeRoot] : roots)) + output.WriteEvent(eventJson); } + + return 0; } } diff --git a/src/SeqCli/Program.cs b/src/SeqCli/Program.cs index f6a0a11f..0c42c5ff 100644 --- a/src/SeqCli/Program.cs +++ b/src/SeqCli/Program.cs @@ -54,8 +54,11 @@ static async Task Main(string[] args) } catch (Exception ex) { - Log.Debug(ex, "Unhandled command exception"); - Log.Fatal("The command failed: {UnhandledExceptionMessage}", Presentation.FormattedMessage(ex)); + // The `--verbose` flag flips the level switch from `Error` to `Information`; we use that as a signal to + // include full stack traces, it's a bit of a sneaky backchannel but saves adding yet more infrastructure. + var reportedException = levelSwitch.MinimumLevel < LogEventLevel.Error ? ex : null; + + Log.Fatal(reportedException, "The command failed: {UnhandledExceptionMessage}", Presentation.FormattedMessage(ex)); return 1; } finally diff --git a/src/SeqCli/Util/Presentation.cs b/src/SeqCli/Util/Presentation.cs index 125ee15b..7a4df543 100644 --- a/src/SeqCli/Util/Presentation.cs +++ b/src/SeqCli/Util/Presentation.cs @@ -29,7 +29,7 @@ static class Presentation /// and causal chain. public static string FormattedMessage(Exception ex) { - if (ex == null) throw new ArgumentNullException(nameof(ex)); + ArgumentNullException.ThrowIfNull(ex); static Exception Unwrap(Exception outer) { @@ -38,8 +38,10 @@ static Exception Unwrap(Exception outer) static string Describe(Exception toDescribe) { - // :-) - return toDescribe.Message.Replace(", see inner exception", ""); + var described = toDescribe.Message.Replace(", see inner exception", "").Trim(); + if (!described.EndsWith('.')) + described += "."; + return described; } var unwrapped = Unwrap(ex); @@ -49,7 +51,7 @@ static string Describe(Exception toDescribe) { unwrapped = Unwrap(unwrapped.InnerException); - message.Append(' '); + message.Append(" → "); message.Append(Describe(unwrapped)); } From 17a88358df696314f6e33831c7e0614db43860a3 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 8 Sep 2026 13:05:14 +1000 Subject: [PATCH 2/2] Test case --- test/SeqCli.EndToEnd/Events/EventsDeleteTestCase.cs | 2 +- .../Events/EventsDeleteWithDateRangeAllTestCase.cs | 2 +- test/SeqCli.EndToEnd/Events/SearchSignalColumnsTestCase.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/SeqCli.EndToEnd/Events/EventsDeleteTestCase.cs b/test/SeqCli.EndToEnd/Events/EventsDeleteTestCase.cs index 61d8281b..1a28fd48 100644 --- a/test/SeqCli.EndToEnd/Events/EventsDeleteTestCase.cs +++ b/test/SeqCli.EndToEnd/Events/EventsDeleteTestCase.cs @@ -5,7 +5,7 @@ using Serilog; using Xunit; -namespace SeqCli.EndToEnd.Delete; +namespace SeqCli.EndToEnd.Events; public class EventsDeleteTestCase : ICliTestCase { diff --git a/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeAllTestCase.cs b/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeAllTestCase.cs index ed56ac08..72688e94 100644 --- a/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeAllTestCase.cs +++ b/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeAllTestCase.cs @@ -6,7 +6,7 @@ using Serilog; using Xunit; -namespace SeqCli.EndToEnd.Delete; +namespace SeqCli.EndToEnd.Events; public class EventsDeleteWithDateRangeAllTestCase : ICliTestCase { diff --git a/test/SeqCli.EndToEnd/Events/SearchSignalColumnsTestCase.cs b/test/SeqCli.EndToEnd/Events/SearchSignalColumnsTestCase.cs index dabfb028..2f874c7b 100644 --- a/test/SeqCli.EndToEnd/Events/SearchSignalColumnsTestCase.cs +++ b/test/SeqCli.EndToEnd/Events/SearchSignalColumnsTestCase.cs @@ -96,6 +96,6 @@ public async Task ExecuteAsync( // 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); + Assert.Contains("The command failed", runner.LastRunProcess!.Output); } }