From d343c764723bcb2ce2c78437295e306862b55ce5 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 2 Jun 2026 16:26:24 +1000 Subject: [PATCH 1/6] Update skill, fix some CLI bugs and paper cuts --- src/SeqCli/Cli/Command.cs | 2 +- src/SeqCli/Cli/CommandLineHost.cs | 46 +-- .../Cli/Commands/Cluster/HealthCommand.cs | 1 + src/SeqCli/Cli/Commands/CommandAliases.cs | 81 +++++ src/SeqCli/Cli/Commands/Node/HealthCommand.cs | 1 + src/SeqCli/Cli/Commands/QueryCommand.cs | 17 +- src/SeqCli/Cli/Commands/SearchCommand.cs | 68 +--- src/SeqCli/Cli/Commands/TailCommand.cs | 14 +- src/SeqCli/Cli/Features/OutputFormat.cs | 176 ---------- .../Cli/Features/OutputFormatFeature.cs | 24 +- src/SeqCli/Mcp/Schema/EventEntitySchema.cs | 4 +- .../Tools/Search/SearchAndQueryToolType.cs | 45 +-- .../NativeFormatter.cs} | 47 ++- src/SeqCli/Output/OutputFormat.cs | 311 ++++++++++++++++++ src/SeqCli/Output/OutputSyntax.cs | 32 ++ .../Resources/seq-search-and-query/SKILL.md | 136 ++++---- .../NativeFormatterTests.cs} | 12 +- 17 files changed, 597 insertions(+), 420 deletions(-) create mode 100644 src/SeqCli/Cli/Commands/CommandAliases.cs delete mode 100644 src/SeqCli/Cli/Features/OutputFormat.cs rename src/SeqCli/{Mcp/Formatting/SeqSyntaxFormatter.cs => Output/NativeFormatter.cs} (84%) create mode 100644 src/SeqCli/Output/OutputFormat.cs create mode 100644 src/SeqCli/Output/OutputSyntax.cs rename test/SeqCli.Tests/{Mcp/SeqSyntaxFormatterTests.cs => Output/NativeFormatterTests.cs} (94%) diff --git a/src/SeqCli/Cli/Command.cs b/src/SeqCli/Cli/Command.cs index 4b366b49..d715cc70 100644 --- a/src/SeqCli/Cli/Command.cs +++ b/src/SeqCli/Cli/Command.cs @@ -82,7 +82,7 @@ protected virtual async Task Run(string[] unrecognized) { if (unrecognized.Any()) { - ShowUsageErrors(new [] { "Unrecognized options: " + string.Join(", ", unrecognized) }); + ShowUsageErrors(["Unrecognized options: " + string.Join(", ", unrecognized)]); return 1; } diff --git a/src/SeqCli/Cli/CommandLineHost.cs b/src/SeqCli/Cli/CommandLineHost.cs index 8b1298fe..24508499 100644 --- a/src/SeqCli/Cli/CommandLineHost.cs +++ b/src/SeqCli/Cli/CommandLineHost.cs @@ -19,43 +19,28 @@ using System.Runtime.InteropServices; using System.Threading.Tasks; using Autofac.Features.Metadata; +using SeqCli.Cli.Commands; using Serilog.Core; using Serilog.Events; namespace SeqCli.Cli; -class CommandLineHost +class CommandLineHost(IEnumerable, CommandMetadata>> availableCommands) { - readonly List, CommandMetadata>> _availableCommands; - - public CommandLineHost(IEnumerable, CommandMetadata>> availableCommands) - { - _availableCommands = availableCommands.ToList(); - } + readonly List, CommandMetadata>> _availableCommands = availableCommands.ToList(); public async Task Run(string[] args, LoggingLevelSwitch levelSwitch) { var ea = Assembly.GetEntryAssembly(); var name = ea!.GetName().Name; - if (args.Length > 0) + if (CommandAliases.RewriteArgs( + ref args, + out var commandName, + out var subCommandName, + out var featureVisibility, + out var verbose)) { - const string prereleaseArg = "--pre", verboseArg = "--verbose"; - - var commandName = args[0].ToLowerInvariant(); - var subCommandName = args.Length > 1 && !args[1].Contains('-') ? args[1].ToLowerInvariant() : null; - - var hiddenLegacyCommand = false; - if (subCommandName == null && commandName == "config") - { - hiddenLegacyCommand = true; - subCommandName = "legacy"; - } - - var featureVisibility = FeatureVisibility.Visible | FeatureVisibility.Hidden; - if (args.Any(a => a.Trim() is prereleaseArg)) - featureVisibility |= FeatureVisibility.Preview; - var currentPlatform = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? SupportedPlatforms.Windows : SupportedPlatforms.Posix; @@ -67,23 +52,16 @@ public async Task Run(string[] args, LoggingLevelSwitch levelSwitch) if (cmd != null) { - var amountToSkip = cmd.Metadata.SubCommand == null || hiddenLegacyCommand ? 1 : 2; - var commandSpecificArgs = args.Skip(amountToSkip).Where(arg => cmd.Metadata.Name == "help" || arg is not prereleaseArg).ToArray(); - - var verbose = commandSpecificArgs.Any(arg => arg == verboseArg); if (verbose) - { levelSwitch.MinimumLevel = LogEventLevel.Information; - commandSpecificArgs = commandSpecificArgs.Where(arg => arg != verboseArg).ToArray(); - } var impl = cmd.Value.Value; - return await impl.Invoke(commandSpecificArgs); + return await impl.Invoke(args); } } - + Console.WriteLine($"Usage: {name} []"); Console.WriteLine($"Type `{name} help` for available commands"); return 1; } -} \ No newline at end of file +} diff --git a/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs b/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs index a239739e..0d77a1dc 100644 --- a/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs +++ b/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs @@ -21,6 +21,7 @@ using SeqCli.Util; using Seq.Api.Model.Cluster; using SeqCli.Api; +using SeqCli.Output; using Serilog; namespace SeqCli.Cli.Commands.Cluster; diff --git a/src/SeqCli/Cli/Commands/CommandAliases.cs b/src/SeqCli/Cli/Commands/CommandAliases.cs new file mode 100644 index 00000000..f139393e --- /dev/null +++ b/src/SeqCli/Cli/Commands/CommandAliases.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace SeqCli.Cli.Commands; + +static class CommandAliases +{ + public static bool RewriteArgs( + ref string[] args, + [NotNullWhen(true)] out string? commandName, + out string? subCommandName, + out FeatureVisibility featureVisibility, + out bool verbose) + { + if (args.Length == 0) + { + commandName = null; + subCommandName = null; + featureVisibility = FeatureVisibility.None; + verbose = false; + return false; + } + + featureVisibility = FeatureVisibility.Visible | FeatureVisibility.Hidden; + if (args.Any(arg => IsFlag(arg, "pre"))) + { + featureVisibility |= FeatureVisibility.Preview; + args = args.Where(arg => !IsFlag(arg, "pre")).ToArray(); + } + + verbose = args.Any(arg => IsFlag(arg, "verbose")); + if (verbose) + args = args.Where(arg => !IsFlag(arg, "verbose")).ToArray(); + + commandName = args[0].ToLowerInvariant(); + args = args.Skip(1).ToArray(); + + if (commandName == "--version") + { + commandName = "version"; + } + else if (commandName == "--help") + { + commandName = "help"; + } + + subCommandName = commandName != "help" && args.Length != 0 && !args[0].StartsWith('-') ? args[0].ToLowerInvariant() : null; + if (subCommandName != null) + { + args = args.Skip(1).ToArray(); + } + + if (Array.FindIndex(args, arg => IsFlag(arg, "help")) is var index and not -1) + { + args = args.Where((_, i) => i != index).ToArray(); + if (subCommandName != null) + { + args = [subCommandName, ..args]; + subCommandName = null; + } + args = [commandName, ..args]; + commandName = "help"; + } + + if (subCommandName == null && commandName == "config") + { + subCommandName = "legacy"; + } + + return true; + } + + static bool IsFlag(string flag, string flagName) + { + return flag.EndsWith(flagName, StringComparison.OrdinalIgnoreCase) && + flag[0] == '-' && + (flag.Length == flagName.Length + 1 || + flag.Length == flagName.Length + 2 && flag[1] == '-'); + } +} diff --git a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs index 5a3fcc37..6cabd844 100644 --- a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs +++ b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs @@ -22,6 +22,7 @@ using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; +using SeqCli.Output; using Serilog; namespace SeqCli.Cli.Commands.Node; diff --git a/src/SeqCli/Cli/Commands/QueryCommand.cs b/src/SeqCli/Cli/Commands/QueryCommand.cs index 17381bea..2f7b4bb5 100644 --- a/src/SeqCli/Cli/Commands/QueryCommand.cs +++ b/src/SeqCli/Cli/Commands/QueryCommand.cs @@ -12,9 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Threading.Tasks; -using Newtonsoft.Json; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; @@ -43,7 +41,7 @@ public QueryCommand() _range = Enable(); _signal = Enable(); _timeout = Enable(); - _output = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); _storagePath = Enable(); Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); _connection = Enable(); @@ -63,17 +61,16 @@ protected override async Task Run() var timeout = _timeout.ApplyTimeout(connection.Client.HttpClient); var output = _output.GetOutputFormat(config); - if (output.Json) + if (output.Text) { - var result = await connection.Data.QueryAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); - - // Some friendlier JSON output is definitely possible here - Console.WriteLine(JsonConvert.SerializeObject(result)); + // We can fold this into the `WriteQueryResult` case once that path supports themes. + var result = await connection.Data.QueryCsvAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); + output.WriteCsv(result); } else { - var result = await connection.Data.QueryCsvAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); - output.WriteCsv(result); + var result = await connection.Data.QueryAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); + output.WriteQueryResult(result); } return 0; diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index cb8166b8..a9b1e049 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -14,18 +14,12 @@ using System; using System.Globalization; -using System.Linq; using System.Threading.Tasks; -using Newtonsoft.Json.Linq; -using Seq.Api.Model.Events; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; -using SeqCli.Mapping; -using SeqCli.Util; using Serilog; -using Serilog.Events; -using Serilog.Parsing; + // ReSharper disable UnusedType.Global namespace SeqCli.Cli.Commands; @@ -56,7 +50,7 @@ public SearchCommand() v => _count = int.Parse(v, CultureInfo.InvariantCulture)); _range = Enable(); - _output = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); _storagePath = Enable(); _signal = Enable(); @@ -77,7 +71,7 @@ protected override async Task Run() try { var config = RuntimeConfigurationLoader.Load(_storagePath); - await using var output = _output.GetOutputFormat(config).CreateOutputLogger(); + var output = _output.GetOutputFormat(config); var connection = SeqConnectionFactory.Connect(_connection, config); connection.Client.HttpClient.Timeout = TimeSpan.FromMilliseconds(_httpClientTimeout); @@ -95,9 +89,10 @@ protected override async Task Run() _count, fromDateUtc: _range.Start, toDateUtc: _range.End, - trace: _trace)) + trace: _trace, + render: output.RequiresRender)) { - output.Write(ToSerilogEvent(evt)); + output.WriteEventEntity(evt); } return 0; @@ -114,9 +109,10 @@ protected override async Task Run() _count, fromDateUtc: _range.Start, toDateUtc: _range.End, - trace: _trace)) + trace: _trace, + render: output.RequiresRender)) { - output.Write(ToSerilogEvent(evt)); + output.WriteEventEntity(evt); } return 0; @@ -127,50 +123,4 @@ protected override async Task Run() return 1; } } - - internal static LogEvent ToSerilogEvent(EventEntity evt) - { - return new LogEvent( - DateTimeOffset.ParseExact(evt.Timestamp, "o", CultureInfo.InvariantCulture).ToLocalTime(), - LevelMapping.ToSerilogLevel(evt.Level), - string.IsNullOrWhiteSpace(evt.Exception) ? null : new TextException(evt.Exception), - new MessageTemplate(evt.MessageTemplateTokens.Select(ToMessageTemplateToken)), - evt.Properties - .Select(p => CreateProperty(p.Name, p.Value)) - ); - } - - static MessageTemplateToken ToMessageTemplateToken(MessageTemplateTokenPart token) - { - // Not ideal, we lose renderings, alignment etc. here. - - if (token.Text != null) - return new TextToken(token.Text); - return new PropertyToken(token.PropertyName, token.RawText ?? $"{{{token.PropertyName}}}"); - } - - static LogEventProperty CreateProperty(string name, object value) - { - return LogEventPropertyFactory.SafeCreate(name, CreatePropertyValue(value)); - } - - static LogEventPropertyValue CreatePropertyValue(object value) - { - switch (value) - { - case JObject jo: - jo.TryGetValue("$typeTag", out var tt); - return new StructureValue( - jo.Properties() - .Where(kvp => kvp.Name != "$typeTag") - .Select(kvp => CreateProperty(kvp.Name, kvp.Value)), - (tt as JValue)?.Value as string); - - case JArray ja: - return new SequenceValue(ja.Select(CreatePropertyValue)); - - default: - return new ScalarValue(value); - } - } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/TailCommand.cs b/src/SeqCli/Cli/Commands/TailCommand.cs index 75fa33b4..3da0b991 100644 --- a/src/SeqCli/Cli/Commands/TailCommand.cs +++ b/src/SeqCli/Cli/Commands/TailCommand.cs @@ -18,7 +18,6 @@ using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; -using SeqCli.Ingestion; namespace SeqCli.Cli.Commands; @@ -39,7 +38,7 @@ public TailCommand() "An optional server-side filter to apply to the stream, for example `@Level = 'Error'`", v => _filter = v); - _output = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); _storagePath = Enable(); _signal = Enable(); _connection = Enable(); @@ -60,18 +59,17 @@ protected override async Task Run() strict = converted.StrictExpression; } - await using var output = _output.GetOutputFormat(config).CreateOutputLogger(); - + var output = _output.GetOutputFormat(config); + try { - await foreach (var json in connection.Events.StreamDocumentsAsync( + await foreach (var evt in connection.Events.StreamAsync( filter: strict, signal: _signal.Signal, - clef: true, + render: true, cancellationToken: cancel.Token)) { - var evt = JsonLogEventReader.ReadFromJson(json); - output.Write(evt); + output.WriteEventEntity(evt); } } catch (OperationCanceledException) diff --git a/src/SeqCli/Cli/Features/OutputFormat.cs b/src/SeqCli/Cli/Features/OutputFormat.cs deleted file mode 100644 index 3af5b881..00000000 --- a/src/SeqCli/Cli/Features/OutputFormat.cs +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright © Datalust Pty Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Collections.Generic; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; -using Seq.Api.Model; -using SeqCli.Csv; -using SeqCli.Output; -using SeqCli.Util; -using Serilog; -using Serilog.Core; -using Serilog.Events; -using Serilog.Sinks.SystemConsole.Themes; -using Serilog.Templates.Themes; - -namespace SeqCli.Cli.Features; - -sealed class OutputFormat(bool json, bool noColor, bool forceColor) -{ - public const string DefaultOutputTemplate = - "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"; - - public static readonly ConsoleTheme DefaultAnsiTheme = AnsiConsoleTheme.Code; - - public static readonly ConsoleTheme DefaultTheme = - OperatingSystem.IsWindows() ? SystemConsoleTheme.Literate : DefaultAnsiTheme; - - static readonly TemplateTheme DefaultTemplateTheme = Serilog.Templates.Themes.TemplateTheme.Code; - - public bool Json => json; - - bool ApplyThemeToRedirectedOutput => noColor == false && forceColor; - - ConsoleTheme Theme - => noColor ? ConsoleTheme.None - : ApplyThemeToRedirectedOutput ? DefaultAnsiTheme - : DefaultTheme; - - TemplateTheme? TemplateTheme - => noColor ? null - : ApplyThemeToRedirectedOutput ? DefaultTemplateTheme - : null; - - public Logger CreateOutputLogger() - { - var outputConfiguration = new LoggerConfiguration() - .MinimumLevel.Is(LevelAlias.Minimum) - .Enrich.With(); - - if (json) - { - outputConfiguration.WriteTo.Console(OutputFormatter.Json(TemplateTheme)); - } - else - { - outputConfiguration.WriteTo.Console( - outputTemplate: DefaultOutputTemplate, - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput); - } - - return outputConfiguration.CreateLogger(); - } - - public void WriteCsv(string csv) - { - if (noColor ) - { - Console.Write(csv); - } - else - { - var tokens = new CsvTokenizer().Tokenize(csv); - CsvWriter.WriteCsv(tokens, Theme, Console.Out, true); - } - } - - public void WriteEntity(Entity entity) - { - if (entity == null) throw new ArgumentNullException(nameof(entity)); - - var jo = JObject.FromObject( - entity, - JsonSerializer.CreateDefault(new JsonSerializerSettings { - DateParseHandling = DateParseHandling.None, - Converters = { - new StringEnumConverter() - } - })); - - if (json) - { - jo.Remove("Links"); - // Proof-of-concept; this is a very inefficient - // way to write colorized JSON ;) - - var writer = new LoggerConfiguration() - .Destructure.With() - .Enrich.With() - .WriteTo.Console( - outputTemplate: "{@Message:j}{NewLine}", - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) - .CreateLogger(); - writer.Information("{@Entity}", jo); - } - else - { - var dyn = (dynamic) jo; - Console.WriteLine($"{entity.Id} {dyn.Title ?? dyn.Name ?? dyn.Username ?? dyn.Expression}"); - } - } - - public void WriteObject(object value) - { - if (value == null) throw new ArgumentNullException(nameof(value)); - - if (json) - { - var jo = JObject.FromObject( - value, - JsonSerializer.CreateDefault(new JsonSerializerSettings { - DateParseHandling = DateParseHandling.None, - Converters = { - new StringEnumConverter() - } - })); - - // Using the same method of JSON colorization as above - - var writer = new LoggerConfiguration() - .Destructure.With() - .Enrich.With() - .WriteTo.Console( - outputTemplate: "{@Message:j}{NewLine}", - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) - .CreateLogger(); - writer.Information("{@Entity}", jo); - } - else - { - Console.WriteLine(value.ToString()); - } - } - - public void ListEntities(IEnumerable list) - { - foreach (var entity in list) - { - WriteEntity(entity); - } - } - - // ReSharper disable once MemberCanBeMadeStatic.Global -#pragma warning disable CA1822 - public void WriteText(string? text) -#pragma warning restore CA1822 - { - Console.WriteLine(text?.TrimEnd()); - } -} diff --git a/src/SeqCli/Cli/Features/OutputFormatFeature.cs b/src/SeqCli/Cli/Features/OutputFormatFeature.cs index ded3629f..fde2b7b8 100644 --- a/src/SeqCli/Cli/Features/OutputFormatFeature.cs +++ b/src/SeqCli/Cli/Features/OutputFormatFeature.cs @@ -13,17 +13,25 @@ // limitations under the License. using SeqCli.Config; +using SeqCli.Output; namespace SeqCli.Cli.Features; -class OutputFormatFeature : CommandFeature +class OutputFormatFeature(bool supportNative) : CommandFeature { - bool _json; + OutputSyntax _syntax = OutputSyntax.Text; bool? _noColor, _forceColor; + + // ReSharper disable once UnusedMember.Global + public OutputFormatFeature() + : this(false) { } public OutputFormat GetOutputFormat(SeqCliConfig config) { - return new OutputFormat(_json, _noColor ?? config.Output.DisableColor, _forceColor ?? config.Output.ForceColor); + return new OutputFormat( + _syntax, + _noColor ?? config.Output.DisableColor, + _forceColor ?? config.Output.ForceColor); } public override void Enable(OptionSet options) @@ -31,7 +39,15 @@ public override void Enable(OptionSet options) options.Add( "json", "Print output in newline-delimited JSON (the default is plain text)", - _ => _json = true); + _ => _syntax = OutputSyntax.Json); + + if (supportNative) + { + options.Add( + "native", + "Print output using Seq's native value syntax (ideal for agent usage)", + _ => _syntax = OutputSyntax.Native); + } options.Add("no-color", "Don't colorize text output", _ => _noColor = true); diff --git a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs index d9b9f9d7..1a72bc75 100644 --- a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs +++ b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs @@ -15,7 +15,7 @@ using System.Collections.Generic; using Newtonsoft.Json.Linq; using Seq.Api.Model.Events; -using SeqCli.Mcp.Formatting; +using SeqCli.Output; namespace SeqCli.Mcp.Schema; @@ -46,7 +46,7 @@ public static IEnumerable EnumeratePropertyAccessorPaths(EventEntity evt static IEnumerable EnumerateAccessorPaths(string prefixPath, bool optionalPrefix, string propertyName, object? propertyValue, int depth) { - var name = SeqSyntaxFormatter.MakeIdentifier(prefixPath, propertyName, optionalPrefix); + var name = NativeFormatter.MakeIdentifier(prefixPath, propertyName, optionalPrefix); yield return name; if (depth < MaxAccessorPathDepth && propertyValue is JObject jo) diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index 23520eeb..a1250459 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -28,12 +28,12 @@ using Seq.Api.Model.Events; using Seq.Api.Model.Expressions; using Seq.Syntax.Templates; -using SeqCli.Cli.Commands; using SeqCli.Mapping; using SeqCli.Mcp.Data; -using SeqCli.Mcp.Formatting; +using SeqCli.Output; using Serilog; using Serilog.Events; +using NativeFormatter = SeqCli.Output.NativeFormatter; // ReSharper disable UnusedMember.Global @@ -198,7 +198,7 @@ public async Task SearchEventsAsync( foreach (var result in takenResults) { var resultId = session.ImportSearchResult(result); - var serilogEvent = SearchCommand.ToSerilogEvent(result); + var serilogEvent = OutputFormat.ToSerilogEvent(result); serilogEvent.AddOrUpdateProperty(new LogEventProperty(ResultIdPropertyName, new ScalarValue(resultId))); serilogEvent.AddOrUpdateProperty(new LogEventProperty(LevelMapping.SurrogateLevelProperty, new ScalarValue(result.Level ?? "Information"))); SearchResultFormatter.Format(serilogEvent, responseText); @@ -231,14 +231,15 @@ public Task ReadSearchResultJsonAsync( } var resultText = new StringWriter(); - SeqSyntaxFormatter.WriteEvent(resultText, result); + NativeFormatter.WriteEvent(resultText, result); return Task.FromResult(SimpleTextResult(resultText.ToString())); } [McpServerTool(Name = "seq_inspect_result_schema", ReadOnly = true, Title = "Inspect Search Result Schema")] [Description("List the user-defined top-level, scope, and resource property names observed on events " + - "in search results so far in this session. Only events retrieved in search results are considered.")] + "in search results so far in this session. Only events retrieved in search results are considered. " + + "Critically important for task accuracy.")] [return: Description("A list containing Seq syntax-formatted property names.")] public Task InspectSchemaAsync(CancellationToken cancellationToken) { @@ -304,39 +305,7 @@ public async Task QueryAsync( } var output = new StringWriter(); - var first = true; - QueryResultHelper.Flatten(result, row => - { - if (first) - { - first = false; - var firstCol = true; - foreach (var heading in row) - { - if (firstCol) - firstCol = false; - else - output.Write(' '); - output.Write(heading); - } - output.WriteLine(); - } - else - { - var firstCol = true; - foreach (var value in row) - { - if (firstCol) - firstCol = false; - else - output.Write(' '); - SeqSyntaxFormatter.WriteValue(output, value); - } - } - - output.WriteLine(); - }); - + NativeFormatter.WriteQueryResult(output, result); return SimpleTextResult(output.ToString()); } diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Output/NativeFormatter.cs similarity index 84% rename from src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs rename to src/SeqCli/Output/NativeFormatter.cs index 4a73939f..c906ce4d 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Output/NativeFormatter.cs @@ -19,17 +19,19 @@ using System.Linq; using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; +using Seq.Api.Model.Data; using Seq.Api.Model.Events; using Seq.Api.Model.Shared; +using SeqCli.Mcp.Data; using SeqCli.Syntax; -namespace SeqCli.Mcp.Formatting; +namespace SeqCli.Output; /// /// Constructs Seq syntax literals from API events. This provides a language model client with strong cues as /// to how the properties of an event should be incorporated into future queries/expressions. /// -static partial class SeqSyntaxFormatter +static partial class NativeFormatter { static readonly object UndefinedValue = new(); @@ -188,7 +190,10 @@ static uint ParseEventType(string dollarPrefixedHex) static string ReconstructTemplate(IEnumerable tokens) { - return string.Concat(tokens.Select(t => t.RawText ?? t.Text ?? $"{{{t.PropertyName}}}")); + return string.Concat(tokens.Select(t => + t.RawText ?? + t.Text?.Replace("{", "{{").Replace("}", "}}") ?? + $"{{{t.PropertyName}}}")); } static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(string, object?)> members) @@ -227,4 +232,40 @@ static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(st } output.Write('}'); } + + public static void WriteQueryResult(TextWriter output, QueryResultPart result) + { + var first = true; + QueryResultHelper.Flatten(result, row => + { + if (first) + { + first = false; + var firstCol = true; + foreach (var heading in row) + { + if (firstCol) + firstCol = false; + else + output.Write(' '); + output.Write(heading); + } + output.WriteLine(); + } + else + { + var firstCol = true; + foreach (var value in row) + { + if (firstCol) + firstCol = false; + else + output.Write(' '); + WriteValue(output, value); + } + } + + output.WriteLine(); + }); + } } diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs new file mode 100644 index 00000000..9a922b85 --- /dev/null +++ b/src/SeqCli/Output/OutputFormat.cs @@ -0,0 +1,311 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using Seq.Api.Model; +using Seq.Api.Model.Data; +using Seq.Api.Model.Events; +using SeqCli.Csv; +using SeqCli.Mapping; +using SeqCli.Util; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Parsing; +using Serilog.Sinks.SystemConsole.Themes; +using Serilog.Templates.Themes; + +namespace SeqCli.Output; + +sealed class OutputFormat +{ + readonly OutputSyntax _syntax; + readonly bool _noColor; + readonly bool _forceColor; + readonly Logger _formatter; + + public const string DefaultOutputTemplate = + "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"; + + public static readonly ConsoleTheme DefaultAnsiTheme = AnsiConsoleTheme.Code; + + public static readonly ConsoleTheme DefaultTheme = + OperatingSystem.IsWindows() ? SystemConsoleTheme.Literate : DefaultAnsiTheme; + + static readonly TemplateTheme DefaultTemplateTheme = TemplateTheme.Code; + + public OutputFormat(OutputSyntax syntax, bool noColor, bool forceColor) + { + _syntax = syntax; + _noColor = noColor; + _forceColor = forceColor; + _formatter = CreateOutputLogger(); + } + + public bool Json => _syntax == OutputSyntax.Json; + public bool Text => _syntax == OutputSyntax.Text; + public bool Native => _syntax == OutputSyntax.Native; + + bool ApplyThemeToRedirectedOutput => !_noColor && _forceColor; + + ConsoleTheme Theme + => _noColor ? ConsoleTheme.None + : ApplyThemeToRedirectedOutput ? DefaultAnsiTheme + : DefaultTheme; + + TemplateTheme? TemplateTheme + => _noColor ? null + : ApplyThemeToRedirectedOutput ? DefaultTemplateTheme + : null; + + public bool RequiresRender => Native; + + Logger CreateOutputLogger() + { + var outputConfiguration = new LoggerConfiguration() + .MinimumLevel.Is(LevelAlias.Minimum) + .Enrich.With(); + + if (Json) + { + outputConfiguration.WriteTo.Console(OutputFormatter.Json(TemplateTheme)); + } + else if (Text) + { + outputConfiguration.WriteTo.Console( + outputTemplate: DefaultOutputTemplate, + theme: Theme, + applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput); + } + + // The logger is not configured for Native output, which avoids it. Ideally we'll shift away from using + // Serilog here, and move Text/Json over to EventEntity-driven formatters, too. + + return outputConfiguration.CreateLogger(); + } + + public void WriteCsv(string csv) + { + if (_noColor ) + { + Console.Write(csv); + } + else + { + var tokens = new CsvTokenizer().Tokenize(csv); + CsvWriter.WriteCsv(tokens, Theme, Console.Out, true); + } + } + + public void WriteEntity(Entity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var jo = JObject.FromObject( + entity, + JsonSerializer.CreateDefault(new JsonSerializerSettings { + DateParseHandling = DateParseHandling.None, + Converters = { + new StringEnumConverter() + } + })); + + if (Json) + { + jo.Remove("Links"); + + var writer = new LoggerConfiguration() + .Destructure.With() + .Enrich.With() + .WriteTo.Console( + outputTemplate: "{@Message:j}{NewLine}", + theme: Theme, + applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) + .CreateLogger(); + writer.Information("{@Entity}", jo); + } + else if (Text) + { + var dyn = (dynamic) jo; + Console.WriteLine($"{entity.Id} {dyn.Title ?? dyn.Name ?? dyn.Username ?? dyn.Expression}"); + } + else + { + throw new InvalidOperationException("Native formatting not supported for entities."); + } + } + + public void WriteObject(object value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + + if (Json) + { + var jo = JObject.FromObject( + value, + JsonSerializer.CreateDefault(new JsonSerializerSettings { + DateParseHandling = DateParseHandling.None, + Converters = { + new StringEnumConverter() + } + })); + + // Using the same method of JSON colorization as above + + var writer = new LoggerConfiguration() + .Destructure.With() + .Enrich.With() + .WriteTo.Console( + outputTemplate: "{@Message:j}{NewLine}", + theme: Theme, + applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) + .CreateLogger(); + writer.Information("{@Entity}", jo); + } + else if (Text) + { + Console.WriteLine(value.ToString()); + } + else + { + throw new InvalidOperationException("Native formatting not supported for raw objects."); + } + } + + public void ListEntities(IEnumerable list) + { + foreach (var entity in list) + { + WriteEntity(entity); + } + } + + // ReSharper disable once MemberCanBeMadeStatic.Global +#pragma warning disable CA1822 + public void WriteText(string? text) +#pragma warning restore CA1822 + { + Console.WriteLine(text?.TrimEnd()); + } + + public void WriteQueryResult(QueryResultPart result) + { + if (Json) + { + // Some friendlier JSON output is definitely possible here + Console.WriteLine(JsonConvert.SerializeObject(result)); + } + else if (Native) + { + NativeFormatter.WriteQueryResult(Console.Out, result); + } + else + { + throw new InvalidOperationException("Plain text formatting not supported for query results."); + } + } + + public void WriteEventEntity(EventEntity evt) + { + if (Native) + { + NativeFormatter.WriteEvent(Console.Out, evt); + Console.Out.WriteLine(); + } + else + { + _formatter.Write(ToSerilogEvent(evt)); + } + } + + public static LogEvent ToSerilogEvent(EventEntity evt) + { + ActivityTraceId traceId = default; + if (!string.IsNullOrWhiteSpace(evt.TraceId)) + traceId = ActivityTraceId.CreateFromString(evt.TraceId); + + ActivitySpanId spanId = default; + if (!string.IsNullOrWhiteSpace(evt.SpanId)) + spanId = ActivitySpanId.CreateFromString(evt.SpanId); + + var serilogEvent = new LogEvent( + DateTimeOffset.ParseExact(evt.Timestamp, "o", CultureInfo.InvariantCulture).ToLocalTime(), + LevelMapping.ToSerilogLevel(evt.Level), + string.IsNullOrWhiteSpace(evt.Exception) ? null : new TextException(evt.Exception), + new MessageTemplate(evt.MessageTemplateTokens.Select(ToMessageTemplateToken)), + evt.Properties + .Select(p => CreateProperty(p.Name, p.Value)), + traceId, + spanId + ); + + if (evt.Scope?.Count > 0) + serilogEvent.AddOrUpdateProperty(new("@sa", new StructureValue(evt.Scope.Select(p => CreateProperty(p.Name, p.Value))))); + + if (evt.Resource?.Count > 0) + serilogEvent.AddOrUpdateProperty(new("@ra", new StructureValue(evt.Resource.Select(p => CreateProperty(p.Name, p.Value))))); + + if (!string.IsNullOrWhiteSpace(evt.ParentId)) + serilogEvent.AddOrUpdateProperty(new("@ps", new ScalarValue(evt.ParentId))); + + if (!string.IsNullOrWhiteSpace(evt.Start)) + serilogEvent.AddOrUpdateProperty(new("@st", new ScalarValue(evt.Start))); + + if (!string.IsNullOrWhiteSpace(evt.SpanKind)) + serilogEvent.AddOrUpdateProperty(new("@sk", new ScalarValue(evt.SpanKind))); + + return serilogEvent; + } + + static MessageTemplateToken ToMessageTemplateToken(MessageTemplateTokenPart token) + { + // Not ideal, we lose renderings, alignment etc. here. + + if (token.Text != null) + return new TextToken(token.Text); + return new PropertyToken(token.PropertyName, token.RawText ?? $"{{{token.PropertyName}}}"); + } + + static LogEventProperty CreateProperty(string name, object value) + { + return LogEventPropertyFactory.SafeCreate(name, CreatePropertyValue(value)); + } + + static LogEventPropertyValue CreatePropertyValue(object value) + { + switch (value) + { + case JObject jo: + jo.TryGetValue("$typeTag", out var tt); + return new StructureValue( + jo.Properties() + .Where(kvp => kvp.Name != "$typeTag") + .Select(kvp => CreateProperty(kvp.Name, kvp.Value)), + (tt as JValue)?.Value as string); + + case JArray ja: + return new SequenceValue(ja.Select(CreatePropertyValue)); + + default: + return new ScalarValue(value); + } + } +} diff --git a/src/SeqCli/Output/OutputSyntax.cs b/src/SeqCli/Output/OutputSyntax.cs new file mode 100644 index 00000000..1171f643 --- /dev/null +++ b/src/SeqCli/Output/OutputSyntax.cs @@ -0,0 +1,32 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace SeqCli.Output; + +enum OutputSyntax +{ + /// + /// Plain, human-readable text. + /// + Text, + /// + /// JSON (newline-delimited for JSON streams). + /// + Json, + /// + /// Seq's native value syntax. This is intended for agent use: values presented + /// in Seq's native syntax are more reliably fed back into searches/queries. + /// + Native +} \ No newline at end of file diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index d203729a..e86430eb 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -7,11 +7,13 @@ metadata: --- Seq is a storage service for log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to -compute tabular, aggregate results from the same data. +compute tabular, aggregate results from log events and spans. -> This skill does not currently cover interactions with metrics (the `series` storage object). +Seq's query language is **not SQL** — assuming SQL semantics will produce errors. Do NOT rely on prior knowledge of +Seq's query language. **Always** use the grammar, rules, and examples in this document as a basis for Seq queries and +searches. -## Data Model +## Event Data Model All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the `@Start` property. The following built-in properties are supported. @@ -19,14 +21,13 @@ All events stored in Seq use the same data model. Spans are only distinguished f | Built in property name | Type | Description | |---|---|---| | `@Arrived` | `number` | An integer indicating the order in which the event arrived at the Seq server relative to other events in the same batch. | -| `@Data` | `object` | A compact internal representation of the event as a single structured object. | -| `@Definitions` | `object?` | Metadata attached to metric samples, not present on log events or spans. | -| `@Elapsed` | `number?` | The elapsed duration of a span, expressed in 100 nanosecond ticks. This is in the same domain as Seq's duration literals such as `1s`, `23ms`, or `3d`. Only present on spans, not present on log events. | +| `@Data` | `object` | A compact internal representation of the event as a single structured object. High runtime cost, should be avoided when possible. | +| `@Elapsed` | `number?` | The elapsed duration of a span, expressed in 100 nanosecond ticks. In the same domain as Seq's duration literals such as `1s`, `23ms`, or `3d`. Only present on spans, not present on log events. | | `@EventType` | `number` | A numeric hash of the message template that was used to generate the event. The message template itself is in the `@MessageTemplate` property. | | `@Exception` | `string?` | The exception associated with the event if any, as a string. This normally incorporates the exception type, message, and stack trace. | | `@Id` | `string` | The event's unique id in Seq. | | `@Level` | `string` | The severity of a log event, or completion status of a span. Values are source-dependent, so for example `'Error'`, `'ERROR'`, and `'err'` would all be typical values. | -| `@Message` | `string` | Human-readable text associated with the event. This is often the result of substituting `@Properties` values into `@MessageTemplate`. For spans, this property carries the span name. | +| `@Message` | `string` | Human-readable text associated with the event. Often the result of substituting `@Properties` values into `@MessageTemplate`. For spans, this property carries the span name. | | `@MessageTemplate` | `string` | A message template, following the `messagetemplates.org` syntax. Message templates collectively identify events generated from the same line of logging/tracing code. | | `@ParentId` | `string?` | The `@SpanId` of the parent of a given span, if any. The parent span will always belong to the same trace, that is, share a `@TraceId` value. Only present on spans, not log events. | | `@Properties` | `object?` | An object containing the user-defined properties of a log event or span. Properties with names that are valid C-style identifiers can be accessed implicitly, so `RequestPath` is syntactically equivalent to `@Properties['RequestPath']`. Properties generally conform to naming conventions used throughout the Seq server - sometimes simple PascalCase names, and at other times using the OpenTelemetry semantic conventions. See also `@Resource` and `@Scope`. | @@ -34,10 +35,12 @@ All events stored in Seq use the same data model. Spans are only distinguished f | `@Scope` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry scope. These may match definitions in the OTel semantic conventions, but may also be domain-specific or user-defined. | | `@SpanId` | `string?` | The W3C span id that uniquely identifies a span within a trace. Log events recorded during the span carry the same `@SpanId` value as the span itself. | | `@SpanKind` | `string?` | The OpenTelemetry span kind. Only present on spans, not log events. | -| `@Start` | `number?` | The time at which the span started. The difference between the start time and `@Timestamp` is the `@Elapsed` time of the span. In the same units as Seq's duration literal syntax. | +| `@Start` | `number?` | The time at which the span started. `@Timestamp` minus `@Start` is the `@Elapsed` time of the span. In the same units as Seq's duration literal syntax. | | `@Timestamp` | `number` | The time at which an event was recorded (completion time, for spans). Carried on all log events and spans. In the same units as Seq's duration literal syntax. | | `@TraceId` | `string?` | The W3C trace id that uniquely identifies a trace. All spans and log events within a trace carry the same trace id value. | +> Tip: if searching or querying with `seqcli`, pass `--native` to show results in round-trippable, agent-friendly syntax. Consider `--json` instead for programmatic scenarios, but take care: in JSON output mode, built-in property names are abbreviated. + ## Type System Stored data and intermediate values in expression evaluation are typed dynamically. Values are always one of the following types. @@ -53,7 +56,7 @@ Stored data and intermediate values in expression evaluation are typed dynamical In expression evaluation, Seq does not perform any type coercion. The results of functions and operators that receive invalid arguments are undefined, which is the absence of a value -(_undefined_ has roughly the same "poison" semantics as `NULL` in standard SQL). +(_undefined_ has the same "poison" semantics as `NULL` in standard SQL). Type notation in this document column uses the suffix `?` on a type name to indicate values that may be undefined. The synthetic type name `any` is used as an alias for `null | boolean | number | array | object`. @@ -68,24 +71,24 @@ These built-in functions and operators work with individual values. See Aggregat | `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | | `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | | `Concat(str0: string, str1: string, ...): string` | Concatenate all string arguments. No type coercion is performed. | -| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Supports the `ci` modifier. | | `DatePart(datetime: number, part: string, offset: number): number?` | Compute the value of `part` for the date/time `datetime` at time zone offset `offset`. Both `datetime` and `offset` are 100-nanosecond tick values. If `part` is not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. | | `DateTime(str: string): number?` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. | | `ElementAt(collection: array \| object, index: number \| string): any?` | Access the element of the array or object `collection` at the index or key `index`. Supports the `ci` modifier. | -| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Supports the `ci` modifier. | | `Every(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object `collection`. | -| `FromJson(json: string): any?` | Parse the JSON-encoded string `json`. If `json` is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. | +| `FromJson(json: string): any?` | Parse JSON, producing a value of the corresponding Seq native type. If `json` is not valid JSON, the result is undefined. High runtime cost, should be avoided when possible. | | `Has(arg: any?): boolean` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | -| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | | `Keys(obj: object): array` | Evaluates to an array containing the keys of the object `obj`. | -| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | | `Length(arg: string \| array): number` | Evaluates to the length of the string or array `arg`. | | `Now(): number` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. | | `OffsetIn(timezone: string, instant: number): number?` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. | | `Replace(text: string, substring: string, replacement: string): string` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. Supports the `ci` modifier. | | `Round(value: number, places: number): number` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. | | `Some(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object `collection`. | -| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Supports the `ci` modifier. | | `Substring(str: string, start: number, length: number?): string?` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. | | `TimeOfDay(datetime: number, offsetHours: number): number` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. | | `TimeSpan(str: string): number?` | Attempt to parse the `d.HH:mm:ss.f` formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. | @@ -100,22 +103,16 @@ These built-in functions and operators work with individual values. See Aggregat | `ToUpper(str: string): string` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | | `TypeOf(arg: any?): string` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'boolean'`, `'null'`, or `'undefined'`. | | `Values(obj: object): array` | Evaluates to an array containing the values of the members of object `obj`. | -| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | +| Operator `+`, `-`, `*`, `/` | Arithmetic operators. If any argument is non-numeric, or if a divisor is zero, the result is undefined. | | Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | -| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | -| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | -| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | +| Operator `%` | Remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | | Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | -| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | -| Operator `<` | Compare two values and return `true` if the left-hand operand is less than the right-hand operand. | -| Operator `<=` | Compare two values and return `true` if the left-hand operand is less than or equal to the right-hand operand. | -| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a `/regular expression/`, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | -| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | -| Operator `>` | Compare two values and return `true` if the left-hand operand is greater than the right-hand operand. | -| Operator `>=` | Compare two values and return `true` if the left-hand operand is greater than or equal to the right-hand operand. | -| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a `/regular expression/`, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `<`, `<=`, `>`, `>=` | Compare two values. If any argument is undefined, the result is undefined. | +| Operator `and` | The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `not` (prefix) | Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `or` | The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | | Operator `like` | Determine if the left-hand operand is a string matching the right-hand pattern. The pattern can contain `%` and `?` wildcards for zero-or-many, or zero-or-one characters. `%` and `?` are escaped by doubling. The inverse `not like` is also supported. | | Operator `is null` | Determine if the left-hand operand is `null` or undefined. The result is always a defined `boolean`. The inverse `is not null` is also supported. | | Operator `in` | Determine if the left-hand operand is an element of the right-hand array. | @@ -141,9 +138,19 @@ These built-in functions and operators work with individual values. See Aggregat | `sum(expr: number): number` | Calculates the sum of `expr`. Non-numeric values are ignored. | `sum(ItemsOrdered)` | | `top(expr: any, n: number): rowset` | Select the first `n` values of `expr`. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | -## Grammar +## Schema + +The `stream` source contains log events and spans. The `series` source contains +metric samples (not discussed in this skill). + +Seq servers are compatible with a vast array of data sources. They may use a mix of OpenTelemetry and +framework/ecosystem-specific property names, and may do so inconsistently. + +When available, **always use the MCP schema tool** to inspect the actual properties appearing on search results. In +particular, don't skip schema checks early in investigations just because you've seen a few events. Events are +inconsistent! Use the schema tool at least once just to be safe. -### Base +## Base Grammar ```ebnf identifier = ( letter | '_' ) , { letter | digit | '_' } ; @@ -164,7 +171,7 @@ regular_expression = '/' , { regex_char } , '/' ; regex_char = '\/' | ? any character except '/' ? ; ``` -### Expression +### Expression Grammar ```ebnf Expr = Disjunction ; @@ -225,13 +232,7 @@ Lambda = '|' , [ identifier , { ',' , identifier } ] , '|' , Expr ; Variable = variable ; ``` -**Disambiguation:** The `/` character introduces a regular expression when it appears at the -start of input, or when the preceding token is an operator or opening delimiter — specifically -one of: `and`, `or`, `not`, `(`, `[`, `,`, `=`, `<>`, `like`, `>`, `>=`, `<`, `<=`, `in`, -`is`, `!`, `if`, `then`, `else`, `:`. In all other positions, `/` is -the division operator. - -### Queries +### Query Grammar ```ebnf Query = [ ExplainClause ] @@ -251,7 +252,7 @@ SelectColumn = '*' IntoClause = 'into' , variable ; FromClause = 'from' , source , { LateralJoin } ; source = 'stream' | 'series' ; -LateralJoin = 'lateral' , Expr , 'as' , identifier ; +LateralJoin = 'lateral' , 'unnest' , '(' , Expr , ')' , 'as' , identifier ; WhereClause = 'where' , Expr ; GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; Grouping = TimeGrouping @@ -267,23 +268,13 @@ ForClause = 'for' , ForOption , { ',' , ForOption } ; ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; ``` -Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains -metric samples. - -## Schema - -Seq servers are compatible with a vast array of data sources. They may use a mix of OpenTelemetry and -framework/ecosystem-specific property names, and may do so inconsistently. When exploring, **always use the MCP schema -tool** to inspect the actual properties appearing on search results, cross-referencing with source code where necessary. - -In particular, don't skip using the schema tool early in investigations just because you've seen a few events. Events are -inconsistent! Use the schema tool at least once just to be safe. +Keywords are case-insensitive. ## Example Expressions | Example | Purpose | |--------------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | +| `@Timestamp >= Now() - 10m` | Match events that occurred in the last ten minutes. | | `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | | `Has(@Start)` | Match all spans (excludes log events). | | `@Message like '%overflow%' ci or @Exception like '%overflow%' ci` | Given a piece of text, find events with that text in their message or exception/stack trace. | @@ -349,39 +340,24 @@ limit 100 ## Gotchas - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. - - To order by group keys, apply an alias with `group by as ` and use `order by `. Never add the - group key to the `select` list, this will fail. - - OpenTelemetry dotted property names correspond to property accessor paths in Seq, so `@Resource.service.name` and - `http.response.status_code` are written exactly like this. - - **Never** put a dotted OTel name inside `[...]`. `@Resource['a.b.c']` is a single literal key (almost always undefined); - use `@Resource.a.b.c` for path navigation. + - To order by group keys, apply an alias with `group by as ` and use `order by `. Never add the group key to the `select` list, this will fail. + - OpenTelemetry dotted property names correspond to property accessor paths in Seq, so `@Resource.service.name` and `http.response.status_code` are written exactly like this. + - **Never** put a dotted OTel name inside `[...]`. `@Resource['a.b.c']` is a single literal key (almost always undefined); use `@Resource.a.b.c` for path navigation. - Seq expression literals are not JSON, take care to use the Seq expression syntax when formatting literal values. - - Seq queries are not SQL. Don't expect standard SQL syntax, operators, or semantics to apply, always use the grammar - and built-ins described above. - - Seq searches work backwards through the event stream and always return results in reverse-chronological order, from - **most recent** to least recent. - - Data in Seq servers doesn't always use OpenTelemetry semantic conventions. When searching or querying, only use property - names from the built-ins described above, that appear on search results, or that are returned from the schema tool. - - Bare identifiers like `SomeName` are synonymous with `@Properties['SomeName']`. The latter form allows irregular names - to be used. - - The only escape sequence allowed and required in Seq strings is a doubled single quote - `''` - which evaluates to an - embedded literal single quote. Backslash escaping is not recognized. - - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (100 ns - resolution) in order to support consistent timestamp/duration math. Comparing these properties with strings will - fail: use duration literals for durations, and the `DateTime` function - to convert from ISO-8601 strings. - - Although Seq's types resemble those from JavaScript, Seq does not support JavaScript operators and does not use - JavaScript's system of comparisons. + - Seq queries are not SQL. Don't expect standard SQL syntax, operators, or semantics to apply, always use the grammar and built-ins described above. + - Seq searches work backwards through the event stream and always return results in reverse-chronological order, from **most recent** to least recent. + - Data in Seq servers doesn't always use OpenTelemetry semantic conventions. When searching or querying, only use property names from the built-ins described above, that appear on search results, or that are returned from the schema tool. + - Bare identifiers like `SomeName` are synonymous with `@Properties['SomeName']`. The latter form allows irregular names to be used. + - The only escape sequence allowed and required in Seq strings is a doubled single quote - `''` - which evaluates to an embedded literal single quote. Backslash escaping is not recognized. + - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (100 ns resolution) in order to support consistent timestamp/duration math. Comparing these properties with strings will fail: use duration literals for durations, and the `DateTime` function to convert from ISO-8601 strings. + - Although Seq's types resemble those from JavaScript, Seq does not support JavaScript operators and does not use JavaScript's system of comparisons. - The expression `null = null` is `true` in Seq's type system; `null` is just a regular value. - Timestamp bounds with inclusive starts and exclusive ends are the most efficient for Seq to work with. - Regular expression evaluation is extremely expensive, avoid these as much as possible. - Queries without `from stream` or `from series` are scalar (can't project out fields or compute aggregations). - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. - - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to - conserve resources when speculatively exploring. - - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, - you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. - - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't - re-specified. + - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to conserve resources when speculatively exploring. + - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. + - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't re-specified. - All function calls and operators are case-sensitive unless the `ci` modifier is appended. diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Output/NativeFormatterTests.cs similarity index 94% rename from test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs rename to test/SeqCli.Tests/Output/NativeFormatterTests.cs index 0a4db67a..82251913 100644 --- a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs +++ b/test/SeqCli.Tests/Output/NativeFormatterTests.cs @@ -4,13 +4,13 @@ using System.IO; using Newtonsoft.Json.Linq; using Seq.Api.Model.Events; -using SeqCli.Mcp.Formatting; using SeqCli.Tests.Support; using Xunit; +using NativeFormatter = SeqCli.Output.NativeFormatter; -namespace SeqCli.Tests.Mcp; +namespace SeqCli.Tests.Output; -public class SeqSyntaxFormatterTests +public class NativeFormatterTests { [Theory] [InlineData("@Properties", "a", true, "a")] @@ -21,7 +21,7 @@ public class SeqSyntaxFormatterTests [InlineData("@Resource", "and", false, "@Resource.and")] public void IdentifiersAreIdiomaticallyFormatted(string prefix, string name, bool prefixIsOptional, string expected) { - var actual = SeqSyntaxFormatter.MakeIdentifier(prefix, name, prefixIsOptional); + var actual = NativeFormatter.MakeIdentifier(prefix, name, prefixIsOptional); Assert.Equal(expected, actual); } @@ -63,6 +63,8 @@ public static IEnumerable EventPropertyCases() => ], [Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" ], + [Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "{bracketed}" }]), "@MessageTemplate: '{{bracketed}}'" + ], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("request id", 5))), "@Properties: {'request id': 5}"], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("n", 42))), "@Properties: {n: 42}"], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("s", "x"))), "@Properties: {s: 'x'}"], @@ -141,7 +143,7 @@ public void EventFormatIsAnObjectLiteral() static string Render(EventEntity evt) { var output = new StringWriter(); - SeqSyntaxFormatter.WriteEvent(output, evt); + NativeFormatter.WriteEvent(output, evt); return output.ToString(); } } From 0d6eede8a52a540fca5784fe3785119aabf6f0a0 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 2 Jun 2026 19:53:28 +1000 Subject: [PATCH 2/6] seq_list_signals tool --- .../Mcp/Data/DataResourceGroupHelper.cs | 10 +++++-- .../Tools/Search/SearchAndQueryToolType.cs | 27 +++++++++++++++++-- src/SeqCli/Mcp/Tools/Search/SignalSummary.cs | 27 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 src/SeqCli/Mcp/Tools/Search/SignalSummary.cs diff --git a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs index ff498d68..8b506ef1 100644 --- a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs +++ b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs @@ -34,16 +34,22 @@ public static class DataResourceGroupHelper FloatParseHandling = FloatParseHandling.Decimal, }); - public static async Task QueryPreserveErrorResponsesAsync(SeqConnection connection, string query, CancellationToken cancellationToken = default) + public static async Task QueryPreserveErrorResponsesAsync(SeqConnection connection, string? signal, string query, CancellationToken cancellationToken = default) { // Unfortunately, the `Data.QueryAsync()` API throws when the server 400s, making this case tricky. Suggests // we should make some API client improvements... + var queryUri = "api/data?q=" + Uri.EscapeDataString(query); + if (signal != null) + queryUri += "&" + Uri.EscapeDataString(signal); + var request = new HttpRequestMessage { - RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query), UriKind.Relative), + RequestUri = new Uri(queryUri, UriKind.Relative), Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json") }; + var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken); + return Serializer.Deserialize( new JsonTextReader(new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))))!; } diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index a1250459..d5300195 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -27,10 +27,12 @@ using Seq.Api.Model.Data; using Seq.Api.Model.Events; using Seq.Api.Model.Expressions; +using Seq.Api.Model.Signals; using Seq.Syntax.Templates; using SeqCli.Mapping; using SeqCli.Mcp.Data; using SeqCli.Output; +using SeqCli.Signals; using Serilog; using Serilog.Events; using NativeFormatter = SeqCli.Output.NativeFormatter; @@ -59,6 +61,9 @@ public async Task SearchEventsAsync( int limit, [Description("A Seq search expression evaluated over event properties.")] string? predicate = null, + [Description("A signal expression restricting the search space. Multiple " + + "signals are intersected with commas, and unioned with tilde, for example, `signal-1,(signal-2~signal-3)`.")] + string? signal = null, CancellationToken cancellationToken = default) { if (!string.IsNullOrWhiteSpace(predicate)) @@ -101,6 +106,10 @@ public async Task SearchEventsAsync( } } + SignalExpressionPart? parsedSignalExpression = null; + if (!string.IsNullOrWhiteSpace(signal)) + parsedSignalExpression = SignalExpressionParser.ParseExpression(signal); + var resultsLock = new Lock(); string? error = null; var results = new List(); @@ -115,6 +124,7 @@ public async Task SearchEventsAsync( filter: predicate, count: limit, render: true, + signal: parsedSignalExpression, cancellationToken: cancelEnumerateToken)) { lock (resultsLock) @@ -253,7 +263,10 @@ public Task InspectSchemaAsync(CancellationToken cancellationToken) public async Task QueryAsync( [Description("A Seq query language query.")] string query, - CancellationToken cancellationToken) + [Description("A signal expression identifying the events over which the query will run. Multiple " + + "signals are intersected with commas, and unioned with `|`, for example, `signal-1,(signal-2|signal-3)`.")] + string? signal = null, + CancellationToken cancellationToken = default) { if (query.Contains("from", StringComparison.OrdinalIgnoreCase) && (!query.Contains("where", StringComparison.OrdinalIgnoreCase) || @@ -268,7 +281,7 @@ public async Task QueryAsync( QueryResultPart result; try { - result = await DataResourceGroupHelper.QueryPreserveErrorResponsesAsync(connection, query, cancellationToken); + result = await DataResourceGroupHelper.QueryPreserveErrorResponsesAsync(connection, signal, query, cancellationToken); } catch (Exception ex) { @@ -317,6 +330,16 @@ public Task NewSessionAsync(CancellationToken cancellationToken) session.Clear(); return Task.CompletedTask; } + + [McpServerTool(Name = "seq_list_signals", ReadOnly = true, Title = "List Signals")] + [Description("List available signals. Use signals when searching and querying to efficiently work with well-known " + + "event streams while dramatically improving response times.")] + public async Task ListSignalsAsync(CancellationToken cancellationToken) + { + return (await connection.Signals.ListAsync(shared: true, partial: true, cancellationToken: cancellationToken)) + .Select(s => new SignalSummary { Id = s.Id, Title = s.Title }) + .ToArray(); + } static CallToolResult SimpleTextResult(string resultText, bool isError = false) { diff --git a/src/SeqCli/Mcp/Tools/Search/SignalSummary.cs b/src/SeqCli/Mcp/Tools/Search/SignalSummary.cs new file mode 100644 index 00000000..4f1a048f --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Search/SignalSummary.cs @@ -0,0 +1,27 @@ +// 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.ComponentModel; + +namespace SeqCli.Mcp.Tools.Search; + +[Description("A signal is a saved, indexed filter over log events and spans.")] +class SignalSummary +{ + [Description("The signal id.")] + public required string Id { get; init; } + + [Description("A descriptive title.")] + public required string Title { get; init; } +} \ No newline at end of file From 7952cc936f5fd0fec152ad642e454580765c0c8a Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 2 Jun 2026 20:15:02 +1000 Subject: [PATCH 3/6] Fixes --- src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs | 2 +- .../Mcp/Tools/Search/SearchAndQueryToolType.cs | 4 ++-- .../Skills/Resources/seq-search-and-query/SKILL.md | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs index 8b506ef1..d6d0559e 100644 --- a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs +++ b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs @@ -40,7 +40,7 @@ public static async Task QueryPreserveErrorResponsesAsync(SeqCo // we should make some API client improvements... var queryUri = "api/data?q=" + Uri.EscapeDataString(query); if (signal != null) - queryUri += "&" + Uri.EscapeDataString(signal); + queryUri += "&signal=" + Uri.EscapeDataString(signal); var request = new HttpRequestMessage { diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index d5300195..eab2da99 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -263,8 +263,8 @@ public Task InspectSchemaAsync(CancellationToken cancellationToken) public async Task QueryAsync( [Description("A Seq query language query.")] string query, - [Description("A signal expression identifying the events over which the query will run. Multiple " + - "signals are intersected with commas, and unioned with `|`, for example, `signal-1,(signal-2|signal-3)`.")] + [Description("A signal expression restricting the search space. Multiple " + + "signals are intersected with commas, and unioned with tilde, for example, `signal-1,(signal-2~signal-3)`.")] string? signal = null, CancellationToken cancellationToken = default) { diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index e86430eb..ef25ffa4 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -13,6 +13,20 @@ Seq's query language is **not SQL** — assuming SQL semantics will produce erro Seq's query language. **Always** use the grammar, rules, and examples in this document as a basis for Seq queries and searches. +## Starting a Diagnostic Session + +Being "confidently wrong" is the most common and worst failure mode when working with diagnostic data. EVERY diagnostic +session MUST begin with the following steps: + +1. Check for relevant signals. Many event filtering problems have already been solved and the resulting filters saved as efficiently indexed signals. +2. Retrieve a sample of relevant events. +3. Confirm the schema of the search results (important!). +4. Inspect a subset of relevant events in full. +5. Determine how the correctness of any conclusions can be verified using real diagnostic data. + +DO NOT skip steps just because early results suggest a quicker path to a solution: this is often the path to being +confidently wrong! + ## Event Data Model All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the From b012068e76616519b4786e818e9f94c04aca1b76 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 06:31:36 +1000 Subject: [PATCH 4/6] Signal tool usage tests. Assisted-by: Claude Opus 4.8 --- src/SeqCli/Cli/Commands/QueryCommand.cs | 4 +- .../Mcp/McpSessionBasicsTestCase.cs | 35 +++------ .../Mcp/McpSignalUsageTestCase.cs | 72 +++++++++++++++++++ test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs | 49 +++++++++++++ 4 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs create mode 100644 test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs diff --git a/src/SeqCli/Cli/Commands/QueryCommand.cs b/src/SeqCli/Cli/Commands/QueryCommand.cs index 2f7b4bb5..768d6293 100644 --- a/src/SeqCli/Cli/Commands/QueryCommand.cs +++ b/src/SeqCli/Cli/Commands/QueryCommand.cs @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; using System.Threading.Tasks; +using Seq.Api.Client; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; @@ -72,7 +74,7 @@ protected override async Task Run() var result = await connection.Data.QueryAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); output.WriteQueryResult(result); } - + return 0; } } \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs index 66dcdf2f..080af02f 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -1,21 +1,18 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.RegularExpressions; using System.Threading.Tasks; using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; using Seq.Api; -using SeqCli.EndToEnd.Support; using Serilog; using Xunit; namespace SeqCli.EndToEnd.Mcp; // ReSharper disable once UnusedType.Global -public partial class McpSessionBasicsTestCase : ICliTestCase +public class McpSessionBasicsTestCase : McpToolTestCase { - public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + protected override async Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client) { var runId = "mcp-" + Guid.NewGuid().ToString("n"); @@ -32,20 +29,11 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm order.Number, runId, order.Customer, order.Amount); } - var transport = new StdioClientTransport(new StdioClientTransportOptions - { - Name = "seqcli mcp run", - Command = "dotnet", - Arguments = [TestConfiguration.TestedBinary, "mcp", "run", $"--server={connection.Client.ServerUrl}"] - }); - - await using var client = await McpClient.CreateAsync(transport); - var predicate = $"RunId = '{runId}' and Customer.Tier = 'gold' and @Timestamp >= Now() - 1d"; var searchResult = AssertTextResult(await client.CallToolAsync( "seq_search", new Dictionary { ["limit"] = 10, ["predicate"] = predicate })); - var resultIds = ResultIdRegex().Matches(searchResult).Select(m => m.Value).Distinct().ToArray(); + var resultIds = OrderedSearchResultIds(searchResult); Assert.Equal(orders.Count(o => o.Customer.Tier == "gold"), resultIds.Length); var detailResult = AssertTextResult(await client.CallToolAsync( @@ -56,8 +44,13 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm var schemaResult = AssertTextResult(await client.CallToolAsync("seq_inspect_result_schema")); foreach (var expectedPath in new[] - { "OrderNumber", "RunId", "Amount", "Customer", "Customer.Name", "Customer.Tier", "Customer.Address.City" }) + { + "OrderNumber", "RunId", "Amount", "Customer", "Customer.Name", "Customer.Tier", + "Customer.Address.City" + }) + { Assert.Contains(expectedPath, schemaResult); + } var query = $"select sum(Amount) as Total from stream where RunId = '{runId}' and @Timestamp >= Now() - 1d"; var queryResult = AssertTextResult(await client.CallToolAsync( @@ -73,14 +66,4 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm new Dictionary { ["result_id"] = resultIds[0] }); Assert.True(staleResult.IsError ?? false); } - - static string AssertTextResult(CallToolResult callToolResult) - { - var text = string.Join("\n", callToolResult.Content.OfType().Select(c => c.Text)); - Assert.False(callToolResult.IsError ?? false, text); - return text; - } - - [GeneratedRegex("R[0-9a-zA-Z]+")] - private static partial Regex ResultIdRegex(); } diff --git a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs new file mode 100644 index 00000000..2cd29f3e --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using ModelContextProtocol.Client; +using Seq.Api; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Mcp; + +// ReSharper disable once UnusedType.Global +public class McpSignalUsageTestCase : McpToolTestCase +{ + // Default signals included in every Seq installation. + const string Errors = "signal-m33301"; + const string Warnings = "signal-m33302"; + const string Spans = "signal-m20231011"; + const string Logs = "signal-m20231211"; + + protected override async Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client) + { + var runId = "mcp-" + Guid.NewGuid().ToString("n"); + + logger.Information("Item {ItemNumber} processed in run {RunId}", 1, runId); + logger.Information("Item {ItemNumber} processed in run {RunId}", 2, runId); + logger.Warning("Item {ItemNumber} delayed in run {RunId}", 3, runId); + logger.Warning("Item {ItemNumber} delayed in run {RunId}", 4, runId); + logger.Error("Item {ItemNumber} failed in run {RunId}", 5, runId); + + var signalsResult = AssertTextResult(await client.CallToolAsync("seq_list_signals")); + foreach (var signal in new[] { Errors, Warnings, Spans, Logs }) + { + Assert.Contains(signal, signalsResult); + } + + var predicate = $"RunId = '{runId}' and @Timestamp >= Now() - 1d"; + + // Union: the two warnings plus the error. + Assert.Equal(3, await CountSearchResultsAsync(client, predicate, $"{Errors}~{Warnings}")); + + // Intersection: all of the warnings are log events, not spans. + Assert.Equal(2, await CountSearchResultsAsync(client, predicate, $"{Warnings},{Logs}")); + + var query = $"select count(*) as total from stream where {predicate}"; + + // Union: no spans were written, so only the error is counted. + Assert.Equal(1, await CountQueryResultAsync(client, query, $"{Spans}~{Errors}")); + + // Intersection with a grouped union: warnings and errors, all of which are log events. + Assert.Equal(3, await CountQueryResultAsync(client, query, $"({Errors}~{Warnings}),{Logs}")); + } + + static async Task CountSearchResultsAsync(McpClient client, string predicate, string signal) + { + var searchResult = AssertTextResult(await client.CallToolAsync( + "seq_search", + new Dictionary { ["limit"] = 10, ["predicate"] = predicate, ["signal"] = signal })); + return OrderedSearchResultIds(searchResult).Length; + } + + static async Task CountQueryResultAsync(McpClient client, string query, string signal) + { + var queryResult = AssertTextResult(await client.CallToolAsync( + "seq_query", + new Dictionary { ["query"] = query, ["signal"] = signal })); + var lines = queryResult.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + Assert.Equal(2, lines.Length); + Assert.Equal("total", lines[0]); + return int.Parse(lines[1], CultureInfo.InvariantCulture); + } +} diff --git a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs new file mode 100644 index 00000000..ca3d77d9 --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs @@ -0,0 +1,49 @@ +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Mcp; + +/// +/// Base class for test cases exercising the tools provided by seqcli mcp run. The MCP server +/// is spawned over stdio and supplied to the subclass as a connected . +/// +public abstract partial class McpToolTestCase : ICliTestCase +{ + public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "seqcli mcp run", + Command = "dotnet", + Arguments = [TestConfiguration.TestedBinary, "mcp", "run", $"--server={connection.Client.ServerUrl}"] + }); + + await using var client = await McpClient.CreateAsync(transport); + + await ExecuteAsync(connection, logger, client); + } + + protected abstract Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client); + + protected static string AssertTextResult(CallToolResult callToolResult) + { + var text = string.Join("\n", callToolResult.Content.OfType().Select(c => c.Text)); + Assert.False(callToolResult.IsError ?? false, text); + return text; + } + + protected static string[] OrderedSearchResultIds(string searchResult) + { + return ResultIdRegex().Matches(searchResult).Select(m => m.Value).Distinct().ToArray(); + } + + [GeneratedRegex("R[0-9A-F]+")] + private static partial Regex ResultIdRegex(); +} From 3aeb12be3ba8397476200e761fd5a30f5d869afd Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 08:19:37 +1000 Subject: [PATCH 5/6] Imporove signal list tests, use structured tool results for signal listing --- .../Tools/Search/SearchAndQueryToolType.cs | 2 +- .../Mcp/McpSignalUsageTestCase.cs | 22 ++++++++++--------- test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs | 12 ++++++++++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index eab2da99..fb51e7e6 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -331,7 +331,7 @@ public Task NewSessionAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - [McpServerTool(Name = "seq_list_signals", ReadOnly = true, Title = "List Signals")] + [McpServerTool(Name = "seq_list_signals", ReadOnly = true, Title = "List Signals", UseStructuredContent = true)] [Description("List available signals. Use signals when searching and querying to efficiently work with well-known " + "event streams while dramatically improving response times.")] public async Task ListSignalsAsync(CancellationToken cancellationToken) diff --git a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs index 2cd29f3e..f38ad8da 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs @@ -12,11 +12,13 @@ namespace SeqCli.EndToEnd.Mcp; // ReSharper disable once UnusedType.Global public class McpSignalUsageTestCase : McpToolTestCase { + record SignalSummary(string Id, string Title); + // Default signals included in every Seq installation. - const string Errors = "signal-m33301"; - const string Warnings = "signal-m33302"; - const string Spans = "signal-m20231011"; - const string Logs = "signal-m20231211"; + static readonly SignalSummary Errors = new("signal-m33301", "Errors"); + static readonly SignalSummary Warnings = new("signal-m33302", "Warnings"); + static readonly SignalSummary Spans = new("signal-m20231011", "Spans"); + static readonly SignalSummary Logs = new("signal-m20231211", "Logs"); protected override async Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client) { @@ -28,27 +30,27 @@ protected override async Task ExecuteAsync(SeqConnection connection, ILogger log logger.Warning("Item {ItemNumber} delayed in run {RunId}", 4, runId); logger.Error("Item {ItemNumber} failed in run {RunId}", 5, runId); - var signalsResult = AssertTextResult(await client.CallToolAsync("seq_list_signals")); + var signals = AssertStructuredResult(await client.CallToolAsync("seq_list_signals")); foreach (var signal in new[] { Errors, Warnings, Spans, Logs }) { - Assert.Contains(signal, signalsResult); + Assert.Contains(signal, signals); } var predicate = $"RunId = '{runId}' and @Timestamp >= Now() - 1d"; // Union: the two warnings plus the error. - Assert.Equal(3, await CountSearchResultsAsync(client, predicate, $"{Errors}~{Warnings}")); + Assert.Equal(3, await CountSearchResultsAsync(client, predicate, $"{Errors.Id}~{Warnings.Id}")); // Intersection: all of the warnings are log events, not spans. - Assert.Equal(2, await CountSearchResultsAsync(client, predicate, $"{Warnings},{Logs}")); + Assert.Equal(2, await CountSearchResultsAsync(client, predicate, $"{Warnings.Id},{Logs.Id}")); var query = $"select count(*) as total from stream where {predicate}"; // Union: no spans were written, so only the error is counted. - Assert.Equal(1, await CountQueryResultAsync(client, query, $"{Spans}~{Errors}")); + Assert.Equal(1, await CountQueryResultAsync(client, query, $"{Spans.Id}~{Errors.Id}")); // Intersection with a grouped union: warnings and errors, all of which are log events. - Assert.Equal(3, await CountQueryResultAsync(client, query, $"({Errors}~{Warnings}),{Logs}")); + Assert.Equal(3, await CountQueryResultAsync(client, query, $"({Errors.Id}~{Warnings.Id}),{Logs.Id}")); } static async Task CountSearchResultsAsync(McpClient client, string predicate, string signal) diff --git a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs index ca3d77d9..ecd633fc 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using ModelContextProtocol.Client; @@ -39,6 +40,17 @@ protected static string AssertTextResult(CallToolResult callToolResult) return text; } + protected static T AssertStructuredResult(CallToolResult callToolResult) + { + AssertTextResult(callToolResult); + Assert.NotNull(callToolResult.StructuredContent); + + // Tools returning non-object values have them wrapped in a `result` property by the MCP + // SDK, because the protocol requires `structuredContent` to be an object. + var result = callToolResult.StructuredContent.Value.GetProperty("result"); + return JsonSerializer.Deserialize(result, JsonSerializerOptions.Web)!; + } + protected static string[] OrderedSearchResultIds(string searchResult) { return ResultIdRegex().Matches(searchResult).Select(m => m.Value).Distinct().ToArray(); From 757a2e61739957ff5bfcedc5a0ec5de4fcb128bf Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 08:22:58 +1000 Subject: [PATCH 6/6] Command aliases header --- src/SeqCli/Cli/Commands/CommandAliases.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/SeqCli/Cli/Commands/CommandAliases.cs b/src/SeqCli/Cli/Commands/CommandAliases.cs index f139393e..02ec9a0d 100644 --- a/src/SeqCli/Cli/Commands/CommandAliases.cs +++ b/src/SeqCli/Cli/Commands/CommandAliases.cs @@ -1,3 +1,17 @@ +// 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.Diagnostics.CodeAnalysis; using System.Linq;