diff --git a/README.md b/README.md index 1213183d..3faeda58 100644 --- a/README.md +++ b/README.md @@ -1464,6 +1464,8 @@ 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) | @@ -1636,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 365725b7..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 @@ -33,6 +34,7 @@ class SearchCommand : Command readonly DateRangeFeature _range; readonly SignalExpressionFeature _signal; readonly StoragePathFeature _storagePath; + readonly EventColumnsFeature _eventColumns; string? _filter; int _count = 1; int _httpClientTimeout = 100000; @@ -44,11 +46,13 @@ 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}", v => _count = int.Parse(v, CultureInfo.InvariantCulture)); + _eventColumns = Enable(); _range = Enable(); _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); @@ -71,10 +75,13 @@ protected override async Task Run() try { var config = RuntimeConfigurationLoader.Load(_storagePath); - var output = _output.GetOutputFormat(config); + 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)); + string? filter = null; if (!string.IsNullOrWhiteSpace(_filter)) filter = (await connection.Expressions.ToStrictAsync(_filter)).StrictExpression; diff --git a/src/SeqCli/Cli/Commands/TailCommand.cs b/src/SeqCli/Cli/Commands/TailCommand.cs index 291433ba..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; @@ -31,6 +32,7 @@ class TailCommand : Command readonly OutputFormatFeature _output; readonly SignalExpressionFeature _signal; readonly StoragePathFeature _storagePath; + readonly EventColumnsFeature _eventColumns; string? _filter; public TailCommand() @@ -40,6 +42,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 +64,8 @@ protected override async Task Run() strict = converted.StrictExpression; } - var output = _output.GetOutputFormat(config); + 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 new file mode 100644 index 00000000..68ee7116 --- /dev/null +++ b/src/SeqCli/Cli/Features/EventColumnsFeature.cs @@ -0,0 +1,72 @@ +// 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.Signals; +using SeqCli.Syntax; +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> GetColumns(SeqConnection connection, SignalExpressionPart? signal) + { + var columns = 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) + { + columns.Add(column.Expression); + } + } + } + + columns.AddRange(_columns); + + 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/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/src/SeqCli/Output/TextFormatters.cs b/src/SeqCli/Output/TextFormatters.cs index 88025fdf..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,14 +32,28 @@ 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. - static readonly string DefaultPlainTextOutputTemplate = - "[{@Timestamp:o} {@Level:u3}] {@Message}{#if @Elapsed is not null} ({TotalMilliseconds(@Elapsed):0.###} ms){#end}" + + /// + /// 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 ?? 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..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; @@ -32,20 +33,14 @@ static class TraceFormatter const string SpanConnector = "├─ ", LastSpanConnector = "└─ ", LogConnector = "┊ ", Continuation = "│ ", Gap = " "; - static string ColumnPropertyName(int index) => $"{ColumnPrefixProperty}_{index}"; + // 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}}}"); - - // `<> ''` 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(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(); @@ -106,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[ColumnPropertyName(i)] = ToSystemTextJson.FromApiValue(value); + eventJson[ColumnProperty(i)] = ToSystemTextJson.FromApiValue(value); } return eventJson; 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 @@ - + 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 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.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); + } +} 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)); + } + } +} 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